config.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. // Package config 配置管理(12-factor,ADR-015)。
  2. //
  3. // 本包是架构规格 §3.4 的落地样例:
  4. // - 配置结构 Config 与部署形态"单机双实例·预留 K8s"对齐(12-factor:值可被环境变量覆盖,
  5. // K8s 迁移时平移为 Secret 无需改代码);
  6. // - 加载顺序:config.{env}.json(入库模板,**只含非敏感默认值**)← 环境变量(CONFIG_* 前缀,
  7. // 敏感值一律走环境变量/KMS,绝不进代码库)← 启动参数校验;
  8. // - 运行目录幂等:启动时幂等创建 log/、tmp/,日志相对路径锚定运行目录(避免多实例同机
  9. // 双进程写同一日志文件冲突——ADR-015 双实例各自独立 logger 文件名带实例编号)。
  10. //
  11. // 典型使用(cmd/api/main.go):
  12. //
  13. // cfg, err := config.Load("prod") // 读 config/prod.json + 环境变量覆盖
  14. // if err != nil { log.Fatalf("config: %v", err) }
  15. // if err := config.EnsureRuntimeDirs(cfg.Server.InstanceID); err != nil { ... }
  16. // db, err := infra.NewDB(cfg.DB)
  17. //
  18. // 敏感字段清单(必须走环境变量,代码库模板中置空):
  19. // DB.DSN(账密)、Auth.PrivateKeyPEM(JWT RS256 私钥)、OSS.AccessKeySecret、
  20. // WeChat 各 appSecret、Agent.Token。旧系统硬编码值(db.php/params.php 泄露)视为已泄露,迁移前必须轮换。
  21. package config
  22. import (
  23. "encoding/json"
  24. "fmt"
  25. "os"
  26. "path/filepath"
  27. "strings"
  28. )
  29. // Config 根配置结构(所有子结构均为"非敏感字段 + 敏感字段占位"形态)。
  30. //
  31. // 字段一览:
  32. // - Server ServerCfg:HTTP 服务(监听地址、超时、优雅退出、实例编号)。
  33. // - DB DBCfg:PolarDB MySQL(DSN、连接池、事务超时)。
  34. // - Redis RedisCfg:缓存/锁/任务表并发控制(addr、db 编号、池)。
  35. // - Auth AuthCfg:SSO token(RS256 密钥、access/refresh 过期、grace 期、吊销黑名单前缀)。
  36. // - OSS OSSCfg:对象存储(endpoint、按域隔离的 bucket)。
  37. // - WeChat WeChatCfg:微信 per-campus appid/secret(多 appid 映射)。
  38. // - Agent AgentCfg:Python FastAPI Agent OpenAPI(base url、超时、重试)。
  39. // - Task TaskCfg:统一 DB 任务表工作池(并发度、重试上限、轮询间隔)。
  40. // - Log LogCfg:zap 日志(级别、运行目录、采样率)。
  41. type Config struct {
  42. Server ServerCfg
  43. DB DBCfg
  44. Redis RedisCfg
  45. Auth AuthCfg
  46. OSS OSSCfg
  47. WeChat WeChatCfg
  48. Agent AgentCfg
  49. Task TaskCfg
  50. Log LogCfg
  51. }
  52. // ServerCfg HTTP 服务配置。
  53. //
  54. // 字段:
  55. // - Addr string:监听地址(双实例部署:实例 A "0.0.0.0:8080",实例 B "0.0.0.0:8081",ADR-015)。
  56. // - ReadTimeoutSec int:读超时秒数(0=不限;建议 30,SSE 长连接走独立 location 不受限)。
  57. // - GracePeriodSec int:优雅退出等待秒数(滚动重启时 nginx 摘除 upstream 后等待存量请求收尾,建议 30)。
  58. // - InstanceID string:实例编号("a"/"b"),用于双实例日志/临时文件隔离。
  59. type ServerCfg struct {
  60. Addr string `json:"addr"`
  61. ReadTimeoutSec int `json:"readTimeoutSec"`
  62. GracePeriodSec int `json:"gracePeriodSec"`
  63. InstanceID string `json:"instanceId"`
  64. }
  65. // DBCfg PolarDB MySQL 配置。
  66. //
  67. // 字段:
  68. // - DSN string:连接串(**敏感**,走环境变量 CONFIG_DB_DSN;模板中置空)。
  69. // 形如 "user:pass@tcp(polarhost:3306)/unified?charset=utf8mb4&parseTime=true&loc=Local"。
  70. // - MaxOpenConns int:最大连接数(双实例共享 PolarDB,建议单实例 ≤100)。
  71. // - MaxIdleConns int:最大空闲连接(建议 10)。
  72. // - ConnMaxLifetimeSec int:连接最长存活秒数(建议 1800,防连接池漂移)。
  73. // - TxTimeoutSec int:事务超时秒数(SP 重写的事务必须显式超时,建议 30)。
  74. type DBCfg struct {
  75. DSN string `json:"dsn"`
  76. MaxOpenConns int `json:"maxOpenConns"`
  77. MaxIdleConns int `json:"maxIdleConns"`
  78. ConnMaxLifetimeSec int `json:"connMaxLifetimeSec"`
  79. TxTimeoutSec int `json:"txTimeoutSec"`
  80. }
  81. // RedisCfg Redis 配置(缓存 + 分布式锁 + 任务表并发控制,ADR-008 配套)。
  82. //
  83. // 字段:
  84. // - Addr string:addr:port(**敏感**若带密码,走环境变量)。
  85. // - DB int:逻辑库编号(0-15)。
  86. // - PoolSize int:连接池大小(建议 ≥ 工作池并发 + 2)。
  87. type RedisCfg struct {
  88. Addr string `json:"addr"`
  89. DB int `json:"db"`
  90. PoolSize int `json:"poolSize"`
  91. }
  92. // AuthCfg SSO token 配置(ADR-014 最小可行 SSO)。
  93. //
  94. // 字段:
  95. // - Issuer string:token iss 声明(如 "https://unified.example.com")。
  96. // - PrivateKeyPEM string:RS256 私钥 PEM(**敏感**,走环境变量;K8s 期平移为 Secret)。
  97. // - PublicKeysPEM []string:验签公钥列表(支持密钥轮换:新旧公钥并存,grace 期双验)。
  98. // - AccessTokenTTLMin int:access token 有效期分钟数(固定 30,架构规格 §7.2)。
  99. // - RefreshTokenTTLDay int:refresh token 有效期天数(固定 30,架构规格 §7.2)。
  100. // - RevocationKeyPrefix string:Redis 吊销黑名单键前缀(jti 维度)。
  101. type AuthCfg struct {
  102. Issuer string `json:"issuer"`
  103. PrivateKeyPEM string `json:"privateKeyPem"`
  104. PublicKeysPEM []string `json:"publicKeysPem"`
  105. AccessTokenTTLMin int `json:"accessTokenTtlMin"`
  106. RefreshTokenTTLDay int `json:"refreshTokenTtlDay"`
  107. RevocationKeyPrefix string `json:"revocationKeyPrefix"`
  108. }
  109. // OSSCfg 阿里云 OSS 配置(文件资产,按域隔离 bucket)。
  110. //
  111. // 字段:
  112. // - Endpoint string:区域 endpoint(如 "oss-cn-hangzhou.aliyuncs.com")。
  113. // - AccessKeyID string:AK(**敏感**,走环境变量)。
  114. // - AccessKeySecret string:SK(**敏感**,走环境变量)。
  115. // - BucketAcademic string:教务域 bucket(费用凭证/打卡照片)。
  116. // - BucketTeaching string:教学域 bucket(备课素材/作业图片)。
  117. // - BucketAvatar string:头像 bucket(跨域共用)。
  118. type OSSCfg struct {
  119. Endpoint string `json:"endpoint"`
  120. AccessKeyID string `json:"accessKeyId"`
  121. AccessKeySecret string `json:"accessKeySecret"`
  122. BucketAcademic string `json:"bucketAcademic"`
  123. BucketTeaching string `json:"bucketTeaching"`
  124. BucketAvatar string `json:"bucketAvatar"`
  125. }
  126. // WeChatCfg 微信 per-campus appid/secret 配置(多 appid:每个校区可绑独立公众号)。
  127. //
  128. // 字段:
  129. // - Campuses []WeChatCampus:appid 与校区的绑定关系(appid → campus_id 映射,
  130. // 身份绑定的依据:同一手机号在不同校区 appid 下是不同身份实例)。
  131. // - CallbackURL string:微信扫码登录的**对外可达**回调地址(微信重定向落点,
  132. // 须为 https 且已在微信公众号后台登记为"网页授权回调域名";形如
  133. // "https://www.yuansiwei.com/api/auth/wechat/callback",**敏感无关**但环境相关,
  134. // 走环境变量 CONFIG_WECHAT__CALLBACKURL,代码库模板中置空)。
  135. // 未配置时 /auth/wechat/qrcode 返回 4004(第三方未配置),不允许"无回调地址生成二维码"。
  136. type WeChatCfg struct {
  137. Campuses []WeChatCampus `json:"campuses"`
  138. CallbackURL string `json:"callbackUrl"`
  139. }
  140. // WeChatCampus 单个校区的微信应用配置。
  141. //
  142. // 字段:
  143. // - CampusID int64:校区 ID(org 域主键)。
  144. // - AppID string:公众号 appid(非敏感,可入库)。
  145. // - Secret string:appSecret(**敏感**,走环境变量;模板中置空)。
  146. type WeChatCampus struct {
  147. CampusID int64 `json:"campusId"`
  148. AppID string `json:"appId"`
  149. Secret string `json:"secret"`
  150. }
  151. // AgentCfg Python FastAPI Agent 配置(Go 侧为 OpenAPI 消费方,架构规格 §3.7)。
  152. //
  153. // 字段:
  154. // - BaseURL string:Agent 服务 base(如 "http://agent:8000",K8s 期换 Service 名)。
  155. // - TimeoutSec int:单次调用超时秒数(建议 60;批改/向量类长任务改走 t_task 异步,不走同步)。
  156. // - MaxRetries int:同步调用重试上限(建议 2,仅对幂等 GET 类;写操作重试必须带幂等键)。
  157. // - Token string:Agent 鉴权 token(**敏感**,走环境变量)。
  158. type AgentCfg struct {
  159. BaseURL string `json:"baseUrl"`
  160. TimeoutSec int `json:"timeoutSec"`
  161. MaxRetries int `json:"maxRetries"`
  162. Token string `json:"token"`
  163. }
  164. // TaskCfg 统一 DB 任务表工作池配置(架构规格 §3.7)。
  165. //
  166. // 字段:
  167. // - Workers int:工作池并发 goroutine 数(双实例合计不超过 Agent 承载上限;建议单实例 8)。
  168. // - PollSec int:轮询 t_task 间隔秒数(建议 2;任务量大可下调至 1)。
  169. // - MaxRetry int:失败重试上限(超过后任务置 dead,进告警;指数退避基数 2^retry)。
  170. // - ClaimBatch int:单次轮询最多认领的任务数(建议 10,防单实例饿死其它实例)。
  171. type TaskCfg struct {
  172. Workers int `json:"workers"`
  173. PollSec int `json:"pollSec"`
  174. MaxRetry int `json:"maxRetry"`
  175. ClaimBatch int `json:"claimBatch"`
  176. }
  177. // LogCfg zap 日志配置(架构规格 §3.3)。
  178. //
  179. // 字段:
  180. // - Level string:日志级别("info"/"debug"/"warn"/"error";生产 info,联调临时 debug)。
  181. // - Dir string:运行日志目录(相对运行目录,启动时幂等创建,见 EnsureRuntimeDirs)。
  182. // - AccessSampleRate float64:高频只读接口访问日志采样率(0~1,建议 0.1;写操作恒 1 不采样)。
  183. type LogCfg struct {
  184. Level string `json:"level"`
  185. Dir string `json:"dir"`
  186. AccessSampleRate float64 `json:"accessSampleRate"`
  187. }
  188. // Load 加载配置:读 config/{env}.json 模板 + 环境变量覆盖 + 必填校验。
  189. //
  190. // 入参:
  191. // - env string:环境名("dev"/"prod"),决定读取 config/{env}.json;
  192. // 文件不存在时返回错误(禁止"空配置启动"——缺配置必须是显式故障)。
  193. //
  194. // 出参:
  195. // - *Config:解析并校验后的完整配置(敏感字段已按环境变量覆盖)。
  196. // - error:以下场景返回错误:
  197. // - 配置文件不存在或 JSON 非法(包装原始 os/encoding 错误);
  198. // - 必填字段缺失:Server.Addr / DB.DSN / Auth.Issuer / Auth.PrivateKeyPEM /
  199. // Agent.BaseURL(DSN 与私钥通常由环境变量提供,未配置时在此处 fail-fast,
  200. // 避免运行到 DB 连接阶段才暴露"配置没给")。
  201. //
  202. // 环境变量覆盖规则(12-factor):
  203. // - 前缀 CONFIG_,分段用双下划线:CONFIG_DB__DSN → DB.DSN;
  204. // CONFIG_AUTH__PRIVKEYPEM → Auth.PrivateKeyPEM(密钥类字段专用短名,便于 K8s Secret 映射);
  205. // - 环境变量值一律按字符串处理:数值字段内部 strconv 转换,转换失败返回错误。
  206. //
  207. // 异常场景:
  208. // - 同一字段配置文件与环境变量同时存在:环境变量优先(部署期可临时改池大小不重启改文件)。
  209. // - JSON 模板中出现明文密钥:Load 不检测(无法区分"占位空串"与"真明文"),
  210. // 由 CI 门禁 grep 敏感键值兜底(见架构规格 §3.4 加密字段纪律)。
  211. func Load(env string) (*Config, error) {
  212. path := filepath.Join("config", env+".json")
  213. raw, err := os.ReadFile(path)
  214. if err != nil {
  215. return nil, fmt.Errorf("config: 读取 %s 失败: %w", path, err)
  216. }
  217. cfg := &Config{}
  218. if err := json.Unmarshal(raw, cfg); err != nil {
  219. return nil, fmt.Errorf("config: 解析 %s 失败: %w", path, err)
  220. }
  221. // 环境变量覆盖(12-factor:值不入库,部署注入)
  222. setStr("CONFIG_SERVER__ADDR", &cfg.Server.Addr)
  223. setInt("CONFIG_DB__MAXOPENCONNS", &cfg.DB.MaxOpenConns)
  224. setInt("CONFIG_TASK__WORKERS", &cfg.Task.Workers)
  225. setStr("CONFIG_DB__DSN", &cfg.DB.DSN)
  226. setStr("CONFIG_AUTH__PRIVKEYPEM", &cfg.Auth.PrivateKeyPEM)
  227. setStr("CONFIG_AGENT__BASEURL", &cfg.Agent.BaseURL)
  228. setStr("CONFIG_AGENT__TOKEN", &cfg.Agent.Token)
  229. setStr("CONFIG_OSS__ACCESSKEYID", &cfg.OSS.AccessKeyID)
  230. setStr("CONFIG_OSS__ACCESSKEYSECRET", &cfg.OSS.AccessKeySecret)
  231. setStr("CONFIG_WECHAT__CALLBACKURL", &cfg.WeChat.CallbackURL)
  232. // 必填校验(fail-fast:配置不完整不允许启动,双实例任一实例配置错都不该带病上线)
  233. var missing []string
  234. if cfg.Server.Addr == "" {
  235. missing = append(missing, "Server.Addr")
  236. }
  237. if cfg.DB.DSN == "" {
  238. missing = append(missing, "DB.DSN(环境变量 CONFIG_DB__DSN)")
  239. }
  240. if cfg.Auth.Issuer == "" {
  241. missing = append(missing, "Auth.Issuer")
  242. }
  243. if cfg.Auth.PrivateKeyPEM == "" {
  244. missing = append(missing, "Auth.PrivateKeyPEM(环境变量 CONFIG_AUTH__PRIVKEYPEM)")
  245. }
  246. if cfg.Agent.BaseURL == "" {
  247. missing = append(missing, "Agent.BaseURL")
  248. }
  249. if len(missing) > 0 {
  250. return nil, fmt.Errorf("config: 必填字段缺失: %s", strings.Join(missing, ", "))
  251. }
  252. return cfg, nil
  253. }
  254. // setStr 从环境变量覆盖字符串字段(key 未设置或空值时保持原值)。
  255. //
  256. // 入参:
  257. // - key string:环境变量名(CONFIG_ 前缀 + 双下划线分段)。
  258. // - dst *string:目标字段地址(必填,传 nil 会 panic——调用方 bug,单测暴露)。
  259. //
  260. // 出参:无(直接改 dst 指向的内存)。
  261. func setStr(key string, dst *string) {
  262. if v := os.Getenv(key); v != "" {
  263. *dst = v
  264. }
  265. }
  266. // setInt 从环境变量覆盖整数字段(值非法时静默忽略并保留原值;
  267. // 配置错误的暴露依赖 Load 的必填校验 + 启动自检,不做硬失败——
  268. // 池大小类字段配错顶多是性能问题,不该阻断启动)。
  269. //
  270. // 入参:
  271. // - key string:环境变量名。
  272. // - dst *int:目标字段地址。
  273. //
  274. // 出参:无。
  275. func setInt(key string, dst *int) {
  276. v := os.Getenv(key)
  277. if v == "" {
  278. return
  279. }
  280. var n int
  281. if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
  282. *dst = n
  283. }
  284. }
  285. // EnsureRuntimeDirs 幂等创建运行目录(架构规格 §3.4"运行目录幂等")。
  286. //
  287. // 入参:
  288. // - instanceID string:实例编号(双实例同机部署时区分日志/临时目录,ADR-015)。
  289. //
  290. // 出参:
  291. // - error:仅当 MkdirAll 失败(权限/磁盘)时返回;目录已存在视为成功(幂等语义)。
  292. //
  293. // 创建的目录(相对**运行目录**,非源码目录):
  294. // - log/{instanceID}/:zap 滚动日志(双实例各写各的,不共享文件——systemd 场景下
  295. // 也建议改由 journal 统一收集,目录仅用于临时文件)。
  296. // - tmp/{instanceID}/:任务临时产物(导出文件中间态、PDF 渲染缓存)。
  297. //
  298. // 样例:
  299. //
  300. // if err := config.EnsureRuntimeDirs("a"); err != nil { log.Fatal(err) }
  301. func EnsureRuntimeDirs(instanceID string) error {
  302. if instanceID == "" {
  303. instanceID = "default"
  304. }
  305. dirs := []string{
  306. filepath.Join("log", instanceID),
  307. filepath.Join("tmp", instanceID),
  308. }
  309. for _, d := range dirs {
  310. if err := os.MkdirAll(d, 0o755); err != nil {
  311. return fmt.Errorf("config: 创建运行目录 %s 失败: %w", d, err)
  312. }
  313. }
  314. return nil
  315. }