// 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=Local"。 // - 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 }