config.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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. type WeChatCfg struct {
  132. Campuses []WeChatCampus `json:"campuses"`
  133. }
  134. // WeChatCampus 单个校区的微信应用配置。
  135. //
  136. // 字段:
  137. // - CampusID int64:校区 ID(org 域主键)。
  138. // - AppID string:公众号 appid(非敏感,可入库)。
  139. // - Secret string:appSecret(**敏感**,走环境变量;模板中置空)。
  140. type WeChatCampus struct {
  141. CampusID int64 `json:"campusId"`
  142. AppID string `json:"appId"`
  143. Secret string `json:"secret"`
  144. }
  145. // AgentCfg Python FastAPI Agent 配置(Go 侧为 OpenAPI 消费方,架构规格 §3.7)。
  146. //
  147. // 字段:
  148. // - BaseURL string:Agent 服务 base(如 "http://agent:8000",K8s 期换 Service 名)。
  149. // - TimeoutSec int:单次调用超时秒数(建议 60;批改/向量类长任务改走 t_task 异步,不走同步)。
  150. // - MaxRetries int:同步调用重试上限(建议 2,仅对幂等 GET 类;写操作重试必须带幂等键)。
  151. // - Token string:Agent 鉴权 token(**敏感**,走环境变量)。
  152. type AgentCfg struct {
  153. BaseURL string `json:"baseUrl"`
  154. TimeoutSec int `json:"timeoutSec"`
  155. MaxRetries int `json:"maxRetries"`
  156. Token string `json:"token"`
  157. }
  158. // TaskCfg 统一 DB 任务表工作池配置(架构规格 §3.7)。
  159. //
  160. // 字段:
  161. // - Workers int:工作池并发 goroutine 数(双实例合计不超过 Agent 承载上限;建议单实例 8)。
  162. // - PollSec int:轮询 t_task 间隔秒数(建议 2;任务量大可下调至 1)。
  163. // - MaxRetry int:失败重试上限(超过后任务置 dead,进告警;指数退避基数 2^retry)。
  164. // - ClaimBatch int:单次轮询最多认领的任务数(建议 10,防单实例饿死其它实例)。
  165. type TaskCfg struct {
  166. Workers int `json:"workers"`
  167. PollSec int `json:"pollSec"`
  168. MaxRetry int `json:"maxRetry"`
  169. ClaimBatch int `json:"claimBatch"`
  170. }
  171. // LogCfg zap 日志配置(架构规格 §3.3)。
  172. //
  173. // 字段:
  174. // - Level string:日志级别("info"/"debug"/"warn"/"error";生产 info,联调临时 debug)。
  175. // - Dir string:运行日志目录(相对运行目录,启动时幂等创建,见 EnsureRuntimeDirs)。
  176. // - AccessSampleRate float64:高频只读接口访问日志采样率(0~1,建议 0.1;写操作恒 1 不采样)。
  177. type LogCfg struct {
  178. Level string `json:"level"`
  179. Dir string `json:"dir"`
  180. AccessSampleRate float64 `json:"accessSampleRate"`
  181. }
  182. // Load 加载配置:读 config/{env}.json 模板 + 环境变量覆盖 + 必填校验。
  183. //
  184. // 入参:
  185. // - env string:环境名("dev"/"prod"),决定读取 config/{env}.json;
  186. // 文件不存在时返回错误(禁止"空配置启动"——缺配置必须是显式故障)。
  187. //
  188. // 出参:
  189. // - *Config:解析并校验后的完整配置(敏感字段已按环境变量覆盖)。
  190. // - error:以下场景返回错误:
  191. // - 配置文件不存在或 JSON 非法(包装原始 os/encoding 错误);
  192. // - 必填字段缺失:Server.Addr / DB.DSN / Auth.Issuer / Auth.PrivateKeyPEM /
  193. // Agent.BaseURL(DSN 与私钥通常由环境变量提供,未配置时在此处 fail-fast,
  194. // 避免运行到 DB 连接阶段才暴露"配置没给")。
  195. //
  196. // 环境变量覆盖规则(12-factor):
  197. // - 前缀 CONFIG_,分段用双下划线:CONFIG_DB__DSN → DB.DSN;
  198. // CONFIG_AUTH__PRIVKEYPEM → Auth.PrivateKeyPEM(密钥类字段专用短名,便于 K8s Secret 映射);
  199. // - 环境变量值一律按字符串处理:数值字段内部 strconv 转换,转换失败返回错误。
  200. //
  201. // 异常场景:
  202. // - 同一字段配置文件与环境变量同时存在:环境变量优先(部署期可临时改池大小不重启改文件)。
  203. // - JSON 模板中出现明文密钥:Load 不检测(无法区分"占位空串"与"真明文"),
  204. // 由 CI 门禁 grep 敏感键值兜底(见架构规格 §3.4 加密字段纪律)。
  205. func Load(env string) (*Config, error) {
  206. path := filepath.Join("config", env+".json")
  207. raw, err := os.ReadFile(path)
  208. if err != nil {
  209. return nil, fmt.Errorf("config: 读取 %s 失败: %w", path, err)
  210. }
  211. cfg := &Config{}
  212. if err := json.Unmarshal(raw, cfg); err != nil {
  213. return nil, fmt.Errorf("config: 解析 %s 失败: %w", path, err)
  214. }
  215. // 环境变量覆盖(12-factor:值不入库,部署注入)
  216. setStr("CONFIG_SERVER__ADDR", &cfg.Server.Addr)
  217. setInt("CONFIG_DB__MAXOPENCONNS", &cfg.DB.MaxOpenConns)
  218. setInt("CONFIG_TASK__WORKERS", &cfg.Task.Workers)
  219. setStr("CONFIG_DB__DSN", &cfg.DB.DSN)
  220. setStr("CONFIG_AUTH__PRIVKEYPEM", &cfg.Auth.PrivateKeyPEM)
  221. setStr("CONFIG_AGENT__BASEURL", &cfg.Agent.BaseURL)
  222. setStr("CONFIG_AGENT__TOKEN", &cfg.Agent.Token)
  223. setStr("CONFIG_OSS__ACCESSKEYID", &cfg.OSS.AccessKeyID)
  224. setStr("CONFIG_OSS__ACCESSKEYSECRET", &cfg.OSS.AccessKeySecret)
  225. // 必填校验(fail-fast:配置不完整不允许启动,双实例任一实例配置错都不该带病上线)
  226. var missing []string
  227. if cfg.Server.Addr == "" {
  228. missing = append(missing, "Server.Addr")
  229. }
  230. if cfg.DB.DSN == "" {
  231. missing = append(missing, "DB.DSN(环境变量 CONFIG_DB__DSN)")
  232. }
  233. if cfg.Auth.Issuer == "" {
  234. missing = append(missing, "Auth.Issuer")
  235. }
  236. if cfg.Auth.PrivateKeyPEM == "" {
  237. missing = append(missing, "Auth.PrivateKeyPEM(环境变量 CONFIG_AUTH__PRIVKEYPEM)")
  238. }
  239. if cfg.Agent.BaseURL == "" {
  240. missing = append(missing, "Agent.BaseURL")
  241. }
  242. if len(missing) > 0 {
  243. return nil, fmt.Errorf("config: 必填字段缺失: %s", strings.Join(missing, ", "))
  244. }
  245. return cfg, nil
  246. }
  247. // setStr 从环境变量覆盖字符串字段(key 未设置或空值时保持原值)。
  248. //
  249. // 入参:
  250. // - key string:环境变量名(CONFIG_ 前缀 + 双下划线分段)。
  251. // - dst *string:目标字段地址(必填,传 nil 会 panic——调用方 bug,单测暴露)。
  252. //
  253. // 出参:无(直接改 dst 指向的内存)。
  254. func setStr(key string, dst *string) {
  255. if v := os.Getenv(key); v != "" {
  256. *dst = v
  257. }
  258. }
  259. // setInt 从环境变量覆盖整数字段(值非法时静默忽略并保留原值;
  260. // 配置错误的暴露依赖 Load 的必填校验 + 启动自检,不做硬失败——
  261. // 池大小类字段配错顶多是性能问题,不该阻断启动)。
  262. //
  263. // 入参:
  264. // - key string:环境变量名。
  265. // - dst *int:目标字段地址。
  266. //
  267. // 出参:无。
  268. func setInt(key string, dst *int) {
  269. v := os.Getenv(key)
  270. if v == "" {
  271. return
  272. }
  273. var n int
  274. if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
  275. *dst = n
  276. }
  277. }
  278. // EnsureRuntimeDirs 幂等创建运行目录(架构规格 §3.4"运行目录幂等")。
  279. //
  280. // 入参:
  281. // - instanceID string:实例编号(双实例同机部署时区分日志/临时目录,ADR-015)。
  282. //
  283. // 出参:
  284. // - error:仅当 MkdirAll 失败(权限/磁盘)时返回;目录已存在视为成功(幂等语义)。
  285. //
  286. // 创建的目录(相对**运行目录**,非源码目录):
  287. // - log/{instanceID}/:zap 滚动日志(双实例各写各的,不共享文件——systemd 场景下
  288. // 也建议改由 journal 统一收集,目录仅用于临时文件)。
  289. // - tmp/{instanceID}/:任务临时产物(导出文件中间态、PDF 渲染缓存)。
  290. //
  291. // 样例:
  292. //
  293. // if err := config.EnsureRuntimeDirs("a"); err != nil { log.Fatal(err) }
  294. func EnsureRuntimeDirs(instanceID string) error {
  295. if instanceID == "" {
  296. instanceID = "default"
  297. }
  298. dirs := []string{
  299. filepath.Join("log", instanceID),
  300. filepath.Join("tmp", instanceID),
  301. }
  302. for _, d := range dirs {
  303. if err := os.MkdirAll(d, 0o755); err != nil {
  304. return fmt.Errorf("config: 创建运行目录 %s 失败: %w", d, err)
  305. }
  306. }
  307. return nil
  308. }