健康检查功能添加
ci-userbase-service / build-and-push (push) Successful in 2m47s

This commit is contained in:
chenxing
2026-09-01 11:50:49 +08:00
parent bf4e1552b8
commit 71adaf6acb
8 changed files with 268 additions and 6 deletions
@@ -0,0 +1,51 @@
package com.yunhai.userbase.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* {@code GET /api/env-config} 免登录放行(方案:前端动态配置方案-env-config接入落地.md §3.3)。
* <p>
* 前端 SPA 启动阶段(用户尚未登录)就要拉运行时配置,而 yh-auth 的 CAS 过滤器
* (HIGHEST+4..+9,url-pattern /api/*)会把未登录请求 302 到 CAS 登录页,导致配置拉取失败。
* 本过滤器注册在所有 CAS 过滤器之前(HIGHEST+3),精确匹配该端点路径后直接 forward:
* forward 属 FORWARD 派发,只经过为 FORWARD 注册的过滤器(CAS 四件套均仅注册 REQUEST 派发),
* 请求直达 DispatcherServlet 的 {@link com.yunhai.userbase.controller.api.EnvConfigController}。
* 其余 /api/** 请求完全不受影响,登录态拦截行为逐字节不变。
*/
@Configuration
public class EnvConfigPublicConfig {
/** 免登录端点的 context 相对路径(与 EnvConfigController 的 /{platform}/env-config 中 platform=api 对应) */
public static final String PUBLIC_PATH = "/api/env-config";
@Bean
public FilterRegistrationBean<EnvConfigSsoBypassFilter> envConfigSsoBypassFilter() {
FilterRegistrationBean<EnvConfigSsoBypassFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new EnvConfigSsoBypassFilter());
// 精确 URL 匹配,只作用于这一个端点;须早于 CasSsoApiRedirectFilter(HIGHEST+4)
bean.addUrlPatterns(PUBLIC_PATH);
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 3);
return bean;
}
static class EnvConfigSsoBypassFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// forward 同一 URI(query 参数随原请求保留);本过滤器仅注册 REQUEST 派发,不会被再次触发
((HttpServletRequest) request).getRequestDispatcher(PUBLIC_PATH).forward(request, response);
// 不调用 chain.doFilter:REQUEST 链上排在后面的 CAS 过滤器因此不会执行
}
}
}
@@ -2,6 +2,7 @@ package com.yunhai.userbase.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springdoc.core.GroupedOpenApi;
import org.springdoc.core.customizers.GlobalOperationCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -10,7 +11,14 @@ import org.springframework.web.method.HandlerMethod;
/**
* 接口文档(springdoc-openapi,替代旧 Springfox)。<br><br>
* 提供统一文档元信息;并通过全局定制器隐藏由切面注入的 {@code BoaUser authUserWithDept} 参数,
* 避免其作为请求体出现在文档中(该参数不参与 HTTP 入参,由 {@code BoaUserArgumentResolver} 解析)。
* 避免其作为请求体出现在文档中(该参数不参与 HTTP 入参,由 {@code BoaUserArgumentResolver} 解析)。<br><br>
* 文档分组:
* <ul>
* <li>{@code oapi}——开放接口(/oapi/**,不含 /oapi/internal/**),是双端 SDK(yh-platform / yh-platform-dotnet)
* 契约的权威源,可经 {@code GET /v3/api-docs/oapi} 导出 OpenAPI 3 快照入库对拍;</li>
* <li>{@code api}——业务接口(/api/**,含会话登录态,供前端联调)。</li>
* </ul>
* 生产环境建议关闭文档入口(见 application.yml 的 springdoc 注释)。
*/
@Configuration
public class SwaggerConfig {
@@ -24,6 +32,28 @@ public class SwaggerConfig {
.version("0.0.1"));
}
/**
* 开放接口分组:/oapi/** 但排除 /oapi/internal/**(内部密钥分发端点,仅内网 + IP 白名单,不对外暴露契约)。
* 导出快照:GET /v3/api-docs/oapi
*/
@Bean
public GroupedOpenApi oapiGroup() {
return GroupedOpenApi.builder()
.group("oapi")
.pathsToMatch("/oapi/**")
.pathsToExclude("/oapi/internal/**")
.build();
}
/** 业务接口分组:/api/**(会话鉴权的面向前端接口,与开放接口契约隔离)。 */
@Bean
public GroupedOpenApi apiGroup() {
return GroupedOpenApi.builder()
.group("api")
.pathsToMatch("/api/**")
.build();
}
/** 隐藏切面注入的登录用户参数,保持文档整洁。 */
@Bean
public GlobalOperationCustomizer hideAuthUserParam() {
@@ -0,0 +1,35 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.service.impl.EnvConfigService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 前端运行时配置端点(免登录)。
* <p>
* 返回 projman 配置管理维护、双写 Nacos 的 env-config-{front}.json 原文(JSON),
* 前端 SPA 启动时请求 {@code GET /userbase/api/env-config?front=front},替代 config/index.js
* 与构建期 VITE_* 环境变量,实现接口地址等配置在线改、免发版生效。<br>
* {@code /api/*} 前缀是 Caddy 反代到后端的既有路由,免登录放行由
* {@link com.yunhai.userbase.config.EnvConfigPublicConfig} 的旁路过滤器实现。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/env-config")
public class EnvConfigController {
@Autowired
private EnvConfigService envConfigService;
/** 配置内容为非敏感信息(前端 JS 最终也会持有),按方案约定不做登录鉴权 */
@Operation(summary = "前端运行时配置(projman 双写 Nacos,免登录)", operationId = "envConfig")
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public String get(@RequestParam(value = "front", defaultValue = "front") String front) {
return envConfigService.get(front);
}
}
@@ -0,0 +1,92 @@
package com.yunhai.userbase.service.impl;
import com.alibaba.cloud.nacos.NacosConfigManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 前端运行时配置读取(方案:Projman/前端动态配置方案-env-config接入落地.md)。
* <p>
* projman 配置管理中的 env-config-{前端目标名}.json 会双写到 Nacos
* (dataId = front-{应用名}-{目标名},group = FRONT_GROUP,type = json),
* 本服务按前端目标名读取并原样返回 JSON 串,经 {@code GET /api/env-config?front=xxx} 提供给前端 SPA,
* 替代构建期写死的 VITE_* 环境变量与 config/index.js 静态文件。
* <p>
* 容错口径:读不到(本地未开 Nacos / dataId 不存在 / 超时)一律返回 {@code "{}"},
* 前端拿空对象后走 import.meta.env 构建期兜底,不影响启动。
*/
@Service
public class EnvConfigService {
private static final Logger log = LoggerFactory.getLogger(EnvConfigService.class);
/** 前端目标名白名单:projman 前端目标创建时即限制为简单标识符,此处同口径校验,防 dataId 拼接注入 */
private static final String FRONT_NAME_PATTERN = "^[A-Za-z0-9_-]{1,64}$";
/** Nacos dataId 前缀:front-{应用名}-,与 projman AppConfigFileService 双写规则一致 */
@Value("${yh.env-config.data-id-prefix:front-userbase-}")
private String dataIdPrefix;
/** Nacos group,与 projman 双写侧 FRONT_GROUP 常量一致 */
@Value("${yh.env-config.group:FRONT_GROUP}")
private String group;
/** 结果缓存 TTL:前端 SPA 每次打开页面都会拉一次,缓存削峰避免轮询打穿 Nacos */
private static final long CACHE_TTL_MS = 30_000L;
private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();
private static class CacheEntry {
final String content;
final long ts;
CacheEntry(String content, long ts) {
this.content = content;
this.ts = ts;
}
}
/** 本地 profile 未启用 Nacos 时为 null(NACOS_CONFIG_ENABLED=false 时不注册该 bean) */
@Autowired(required = false)
private NacosConfigManager nacosConfigManager;
/** 读取前端目标配置 JSON;任何失败返回 "{}"(前端走构建期兜底) */
public String get(String front) {
if (front == null || !front.trim().matches(FRONT_NAME_PATTERN)) {
return "{}";
}
String key = front.trim();
CacheEntry cached = cache.get(key);
long now = System.currentTimeMillis();
if (cached != null && now - cached.ts < CACHE_TTL_MS) {
return cached.content;
}
String content = readFromNacos(key);
cache.put(key, new CacheEntry(content, now));
return content;
}
private String readFromNacos(String front) {
if (nacosConfigManager == null) {
// 本地开发未接 Nacos:无运行时配置,前端走 import.meta.env 兜底
return "{}";
}
try {
String content = nacosConfigManager.getConfigService()
.getConfig(dataIdPrefix + front, group, 3000);
if (content == null || content.trim().isEmpty()) {
return "{}";
}
return content.trim();
} catch (Exception e) {
// Nacos 不可达时 getConfig 自身有本地快照兜底;仍失败则返回空对象,不抛错不影响前端启动
log.warn("[env-config] 读取 Nacos 配置失败 dataId={} group={}: {}", dataIdPrefix + front, group, e.getMessage());
return "{}";
}
}
}