| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335 |
- // Package config 配置管理(12-factor,ADR-015)。
- //
- // 本包是架构规格 §3.4 的落地样例:
- // - 配置结构 Config 与部署形态"单机双实例·预留 K8s"对齐(12-factor:值可被环境变量覆盖,
- // K8s 迁移时平移为 Secret 无需改代码);
- // - 加载顺序:config.{env}.json(入库模板,**只含非敏感默认值**)← 环境变量(CONFIG_* 前缀,
- // 敏感值一律走环境变量/KMS,绝不进代码库)← 启动参数校验;
- // - 运行目录幂等:启动时幂等创建 log/、tmp/,日志相对路径锚定运行目录(避免多实例同机
- // 双进程写同一日志文件冲突——ADR-015 双实例各自独立 logger 文件名带实例编号)。
- //
- // 典型使用(cmd/api/main.go):
- //
- // cfg, err := config.Load("prod") // 读 config/prod.json + 环境变量覆盖
- // if err != nil { log.Fatalf("config: %v", err) }
- // if err := config.EnsureRuntimeDirs(cfg.Server.InstanceID); err != nil { ... }
- // db, err := infra.NewDB(cfg.DB)
- //
- // 敏感字段清单(必须走环境变量,代码库模板中置空):
- // DB.DSN(账密)、Auth.PrivateKeyPEM(JWT RS256 私钥)、OSS.AccessKeySecret、
- // WeChat 各 appSecret、Agent.Token。旧系统硬编码值(db.php/params.php 泄露)视为已泄露,迁移前必须轮换。
- package config
- import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- )
- // Config 根配置结构(所有子结构均为"非敏感字段 + 敏感字段占位"形态)。
- //
- // 字段一览:
- // - Server ServerCfg:HTTP 服务(监听地址、超时、优雅退出、实例编号)。
- // - DB DBCfg:PolarDB MySQL(DSN、连接池、事务超时)。
- // - Redis RedisCfg:缓存/锁/任务表并发控制(addr、db 编号、池)。
- // - Auth AuthCfg:SSO token(RS256 密钥、access/refresh 过期、grace 期、吊销黑名单前缀)。
- // - OSS OSSCfg:对象存储(endpoint、按域隔离的 bucket)。
- // - WeChat WeChatCfg:微信 per-campus appid/secret(多 appid 映射)。
- // - Agent AgentCfg:Python FastAPI Agent OpenAPI(base url、超时、重试)。
- // - Task TaskCfg:统一 DB 任务表工作池(并发度、重试上限、轮询间隔)。
- // - Log LogCfg:zap 日志(级别、运行目录、采样率)。
- type Config struct {
- Server ServerCfg
- DB DBCfg
- Redis RedisCfg
- Auth AuthCfg
- OSS OSSCfg
- WeChat WeChatCfg
- Agent AgentCfg
- Task TaskCfg
- Log LogCfg
- }
- // ServerCfg HTTP 服务配置。
- //
- // 字段:
- // - Addr string:监听地址(双实例部署:实例 A "0.0.0.0:8080",实例 B "0.0.0.0:8081",ADR-015)。
- // - ReadTimeoutSec int:读超时秒数(0=不限;建议 30,SSE 长连接走独立 location 不受限)。
- // - GracePeriodSec int:优雅退出等待秒数(滚动重启时 nginx 摘除 upstream 后等待存量请求收尾,建议 30)。
- // - InstanceID string:实例编号("a"/"b"),用于双实例日志/临时文件隔离。
- type ServerCfg struct {
- Addr string `json:"addr"`
- ReadTimeoutSec int `json:"readTimeoutSec"`
- GracePeriodSec int `json:"gracePeriodSec"`
- InstanceID string `json:"instanceId"`
- }
- // DBCfg PolarDB MySQL 配置。
- //
- // 字段:
- // - DSN string:连接串(**敏感**,走环境变量 CONFIG_DB_DSN;模板中置空)。
- // 形如 "user:pass@tcp(polarhost:3306)/unified?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai"。
- // **不要写 `loc=Local`**(它会跟随机器时区漂移 → 开发机自测绿、UTC 容器整体错 8 小时);
- // 即便写了也会被 infra/db 经 internal/infra/tz.NormalizeDSN 强制改写为 loc=Asia%2FShanghai。
- // - MaxOpenConns int:最大连接数(双实例共享 PolarDB,建议单实例 ≤100)。
- // - MaxIdleConns int:最大空闲连接(建议 10)。
- // - ConnMaxLifetimeSec int:连接最长存活秒数(建议 1800,防连接池漂移)。
- // - TxTimeoutSec int:事务超时秒数(SP 重写的事务必须显式超时,建议 30)。
- type DBCfg struct {
- DSN string `json:"dsn"`
- MaxOpenConns int `json:"maxOpenConns"`
- MaxIdleConns int `json:"maxIdleConns"`
- ConnMaxLifetimeSec int `json:"connMaxLifetimeSec"`
- TxTimeoutSec int `json:"txTimeoutSec"`
- }
- // RedisCfg Redis 配置(缓存 + 分布式锁 + 任务表并发控制,ADR-008 配套)。
- //
- // 字段:
- // - Addr string:addr:port(**敏感**若带密码,走环境变量)。
- // - DB int:逻辑库编号(0-15)。
- // - PoolSize int:连接池大小(建议 ≥ 工作池并发 + 2)。
- type RedisCfg struct {
- Addr string `json:"addr"`
- DB int `json:"db"`
- PoolSize int `json:"poolSize"`
- }
- // AuthCfg SSO token 配置(ADR-014 最小可行 SSO)。
- //
- // 字段:
- // - Issuer string:token iss 声明(如 "https://unified.example.com")。
- // - PrivateKeyPEM string:RS256 私钥 PEM(**敏感**,走环境变量;K8s 期平移为 Secret)。
- // - PublicKeysPEM []string:验签公钥列表(支持密钥轮换:新旧公钥并存,grace 期双验)。
- // - AccessTokenTTLMin int:access token 有效期分钟数(固定 30,架构规格 §7.2)。
- // - RefreshTokenTTLDay int:refresh token 有效期天数(固定 30,架构规格 §7.2)。
- // - RevocationKeyPrefix string:Redis 吊销黑名单键前缀(jti 维度)。
- type AuthCfg struct {
- Issuer string `json:"issuer"`
- PrivateKeyPEM string `json:"privateKeyPem"`
- PublicKeysPEM []string `json:"publicKeysPem"`
- AccessTokenTTLMin int `json:"accessTokenTtlMin"`
- RefreshTokenTTLDay int `json:"refreshTokenTtlDay"`
- RevocationKeyPrefix string `json:"revocationKeyPrefix"`
- }
- // OSSCfg 阿里云 OSS 配置(文件资产,按域隔离 bucket)。
- //
- // 字段:
- // - Endpoint string:区域 endpoint(如 "oss-cn-hangzhou.aliyuncs.com")。
- // - AccessKeyID string:AK(**敏感**,走环境变量)。
- // - AccessKeySecret string:SK(**敏感**,走环境变量)。
- // - BucketAcademic string:教务域 bucket(费用凭证/打卡照片)。
- // - BucketTeaching string:教学域 bucket(备课素材/作业图片)。
- // - BucketAvatar string:头像 bucket(跨域共用)。
- type OSSCfg struct {
- Endpoint string `json:"endpoint"`
- AccessKeyID string `json:"accessKeyId"`
- AccessKeySecret string `json:"accessKeySecret"`
- BucketAcademic string `json:"bucketAcademic"`
- BucketTeaching string `json:"bucketTeaching"`
- BucketAvatar string `json:"bucketAvatar"`
- }
- // WeChatCfg 微信 per-campus appid/secret 配置(多 appid:每个校区可绑独立公众号)。
- //
- // 字段:
- // - Campuses []WeChatCampus:appid 与校区的绑定关系(appid → campus_id 映射,
- // 身份绑定的依据:同一手机号在不同校区 appid 下是不同身份实例)。
- // - CallbackURL string:微信扫码登录的**对外可达**回调地址(微信重定向落点,
- // 须为 https 且已在微信公众号后台登记为"网页授权回调域名";形如
- // "https://www.yuansiwei.com/api/auth/wechat/callback",**敏感无关**但环境相关,
- // 走环境变量 CONFIG_WECHAT__CALLBACKURL,代码库模板中置空)。
- // 未配置时 /auth/wechat/qrcode 返回 4004(第三方未配置),不允许"无回调地址生成二维码"。
- type WeChatCfg struct {
- Campuses []WeChatCampus `json:"campuses"`
- CallbackURL string `json:"callbackUrl"`
- }
- // WeChatCampus 单个校区的微信应用配置。
- //
- // 字段:
- // - CampusID int64:校区 ID(org 域主键)。
- // - AppID string:公众号 appid(非敏感,可入库)。
- // - Secret string:appSecret(**敏感**,走环境变量;模板中置空)。
- type WeChatCampus struct {
- CampusID int64 `json:"campusId"`
- AppID string `json:"appId"`
- Secret string `json:"secret"`
- }
- // AgentCfg Python FastAPI Agent 配置(Go 侧为 OpenAPI 消费方,架构规格 §3.7)。
- //
- // 字段:
- // - BaseURL string:Agent 服务 base(如 "http://agent:8000",K8s 期换 Service 名)。
- // - TimeoutSec int:单次调用超时秒数(建议 60;批改/向量类长任务改走 t_task 异步,不走同步)。
- // - MaxRetries int:同步调用重试上限(建议 2,仅对幂等 GET 类;写操作重试必须带幂等键)。
- // - Token string:Agent 鉴权 token(**敏感**,走环境变量)。
- type AgentCfg struct {
- BaseURL string `json:"baseUrl"`
- TimeoutSec int `json:"timeoutSec"`
- MaxRetries int `json:"maxRetries"`
- Token string `json:"token"`
- }
- // TaskCfg 统一 DB 任务表工作池配置(架构规格 §3.7)。
- //
- // 字段:
- // - Workers int:工作池并发 goroutine 数(双实例合计不超过 Agent 承载上限;建议单实例 8)。
- // - PollSec int:轮询 t_task 间隔秒数(建议 2;任务量大可下调至 1)。
- // - MaxRetry int:失败重试上限(超过后任务置 dead,进告警;指数退避基数 2^retry)。
- // - ClaimBatch int:单次轮询最多认领的任务数(建议 10,防单实例饿死其它实例)。
- type TaskCfg struct {
- Workers int `json:"workers"`
- PollSec int `json:"pollSec"`
- MaxRetry int `json:"maxRetry"`
- ClaimBatch int `json:"claimBatch"`
- }
- // LogCfg zap 日志配置(架构规格 §3.3)。
- //
- // 字段:
- // - Level string:日志级别("info"/"debug"/"warn"/"error";生产 info,联调临时 debug)。
- // - Dir string:运行日志目录(相对运行目录,启动时幂等创建,见 EnsureRuntimeDirs)。
- // - AccessSampleRate float64:高频只读接口访问日志采样率(0~1,建议 0.1;写操作恒 1 不采样)。
- type LogCfg struct {
- Level string `json:"level"`
- Dir string `json:"dir"`
- AccessSampleRate float64 `json:"accessSampleRate"`
- }
- // Load 加载配置:读 config/{env}.json 模板 + 环境变量覆盖 + 必填校验。
- //
- // 入参:
- // - env string:环境名("dev"/"prod"),决定读取 config/{env}.json;
- // 文件不存在时返回错误(禁止"空配置启动"——缺配置必须是显式故障)。
- //
- // 出参:
- // - *Config:解析并校验后的完整配置(敏感字段已按环境变量覆盖)。
- // - error:以下场景返回错误:
- // - 配置文件不存在或 JSON 非法(包装原始 os/encoding 错误);
- // - 必填字段缺失:Server.Addr / DB.DSN / Auth.Issuer / Auth.PrivateKeyPEM /
- // Agent.BaseURL(DSN 与私钥通常由环境变量提供,未配置时在此处 fail-fast,
- // 避免运行到 DB 连接阶段才暴露"配置没给")。
- //
- // 环境变量覆盖规则(12-factor):
- // - 前缀 CONFIG_,分段用双下划线:CONFIG_DB__DSN → DB.DSN;
- // CONFIG_AUTH__PRIVKEYPEM → Auth.PrivateKeyPEM(密钥类字段专用短名,便于 K8s Secret 映射);
- // - 环境变量值一律按字符串处理:数值字段内部 strconv 转换,转换失败返回错误。
- //
- // 异常场景:
- // - 同一字段配置文件与环境变量同时存在:环境变量优先(部署期可临时改池大小不重启改文件)。
- // - JSON 模板中出现明文密钥:Load 不检测(无法区分"占位空串"与"真明文"),
- // 由 CI 门禁 grep 敏感键值兜底(见架构规格 §3.4 加密字段纪律)。
- func Load(env string) (*Config, error) {
- path := filepath.Join("config", env+".json")
- raw, err := os.ReadFile(path)
- if err != nil {
- return nil, fmt.Errorf("config: 读取 %s 失败: %w", path, err)
- }
- cfg := &Config{}
- if err := json.Unmarshal(raw, cfg); err != nil {
- return nil, fmt.Errorf("config: 解析 %s 失败: %w", path, err)
- }
- // 环境变量覆盖(12-factor:值不入库,部署注入)
- setStr("CONFIG_SERVER__ADDR", &cfg.Server.Addr)
- setInt("CONFIG_DB__MAXOPENCONNS", &cfg.DB.MaxOpenConns)
- setInt("CONFIG_TASK__WORKERS", &cfg.Task.Workers)
- setStr("CONFIG_DB__DSN", &cfg.DB.DSN)
- setStr("CONFIG_AUTH__PRIVKEYPEM", &cfg.Auth.PrivateKeyPEM)
- setStr("CONFIG_AGENT__BASEURL", &cfg.Agent.BaseURL)
- setStr("CONFIG_AGENT__TOKEN", &cfg.Agent.Token)
- setStr("CONFIG_OSS__ACCESSKEYID", &cfg.OSS.AccessKeyID)
- setStr("CONFIG_OSS__ACCESSKEYSECRET", &cfg.OSS.AccessKeySecret)
- setStr("CONFIG_WECHAT__CALLBACKURL", &cfg.WeChat.CallbackURL)
- // 必填校验(fail-fast:配置不完整不允许启动,双实例任一实例配置错都不该带病上线)
- var missing []string
- if cfg.Server.Addr == "" {
- missing = append(missing, "Server.Addr")
- }
- if cfg.DB.DSN == "" {
- missing = append(missing, "DB.DSN(环境变量 CONFIG_DB__DSN)")
- }
- if cfg.Auth.Issuer == "" {
- missing = append(missing, "Auth.Issuer")
- }
- if cfg.Auth.PrivateKeyPEM == "" {
- missing = append(missing, "Auth.PrivateKeyPEM(环境变量 CONFIG_AUTH__PRIVKEYPEM)")
- }
- if cfg.Agent.BaseURL == "" {
- missing = append(missing, "Agent.BaseURL")
- }
- if len(missing) > 0 {
- return nil, fmt.Errorf("config: 必填字段缺失: %s", strings.Join(missing, ", "))
- }
- return cfg, nil
- }
- // setStr 从环境变量覆盖字符串字段(key 未设置或空值时保持原值)。
- //
- // 入参:
- // - key string:环境变量名(CONFIG_ 前缀 + 双下划线分段)。
- // - dst *string:目标字段地址(必填,传 nil 会 panic——调用方 bug,单测暴露)。
- //
- // 出参:无(直接改 dst 指向的内存)。
- func setStr(key string, dst *string) {
- if v := os.Getenv(key); v != "" {
- *dst = v
- }
- }
- // setInt 从环境变量覆盖整数字段(值非法时静默忽略并保留原值;
- // 配置错误的暴露依赖 Load 的必填校验 + 启动自检,不做硬失败——
- // 池大小类字段配错顶多是性能问题,不该阻断启动)。
- //
- // 入参:
- // - key string:环境变量名。
- // - dst *int:目标字段地址。
- //
- // 出参:无。
- func setInt(key string, dst *int) {
- v := os.Getenv(key)
- if v == "" {
- return
- }
- var n int
- if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
- *dst = n
- }
- }
- // EnsureRuntimeDirs 幂等创建运行目录(架构规格 §3.4"运行目录幂等")。
- //
- // 入参:
- // - instanceID string:实例编号(双实例同机部署时区分日志/临时目录,ADR-015)。
- //
- // 出参:
- // - error:仅当 MkdirAll 失败(权限/磁盘)时返回;目录已存在视为成功(幂等语义)。
- //
- // 创建的目录(相对**运行目录**,非源码目录):
- // - log/{instanceID}/:zap 滚动日志(双实例各写各的,不共享文件——systemd 场景下
- // 也建议改由 journal 统一收集,目录仅用于临时文件)。
- // - tmp/{instanceID}/:任务临时产物(导出文件中间态、PDF 渲染缓存)。
- //
- // 样例:
- //
- // if err := config.EnsureRuntimeDirs("a"); err != nil { log.Fatal(err) }
- func EnsureRuntimeDirs(instanceID string) error {
- if instanceID == "" {
- instanceID = "default"
- }
- dirs := []string{
- filepath.Join("log", instanceID),
- filepath.Join("tmp", instanceID),
- }
- for _, d := range dirs {
- if err := os.MkdirAll(d, 0o755); err != nil {
- return fmt.Errorf("config: 创建运行目录 %s 失败: %w", d, err)
- }
- }
- return nil
- }
|