Kaynağa Gözat

chore: 初始化统一 Go 后端(模块化单体骨架 + 9 包参考样例)

- infra 五包:response 统一信封(HTTP恒200+body code) / errors 三层错误(1xxx-5xxx) /
  scope 多租户作用域注入 / validator 入参校验(铁律7语义) / config 12-factor 配置
- identity 三包:id ULID(ADR-013) / credential argon2 惰性升级(ADR-016) /
  token RS256 签发验签吊销(ADR-014 SSO 最小可行)
- task 包:t_task 统一任务表(状态机/乐观锁认领/指数退避/僵死回收)
- cmd/api:装配入口(配置加载+zap+Recover/RequestID+/healthz+优雅退出)
- go 1.27;依赖锁定;go build/go vet 全绿
- 每个导出符号三段式 GoDoc 注释(功能+样例/入参/出参+异常场景),规范见 EXAMPLES.md
Q 4 gün önce
işleme
ef2e0667e9

+ 19 - 0
.gitignore

@@ -0,0 +1,19 @@
+# 二进制与构建产物
+/bin/
+/dist/
+*.exe
+*.test
+*.out
+
+# 运行期产物(config.EnsureRuntimeDirs 创建,不入库)
+/log/
+/tmp/
+
+# 环境实际配置(含敏感值,只允许入库 *.example.json 模板——架构规格 §3.4 加密字段纪律)
+/config/*.json
+!/config/*.example.json
+
+# IDE
+.idea/
+.vscode/
+*.iml

+ 105 - 0
EXAMPLES.md

@@ -0,0 +1,105 @@
+# 参考样例索引 + GoDoc 注释标准
+
+> 本文件是 `backend/README.md` **铁律 11(注释标准)** 的落地模板与样例清单。
+> 所有样例代码**可编译**(`go build ./...` + `go vet ./...` 全绿,本地 go 1.27.x),
+> 每个导出符号均带 GoDoc 中文注释(功能说明+样例 / 入参逐项 / 出参逐项+异常场景)。
+> 架构决策依据见 `../unified/01-architecture/backend-technical-architecture.md`。
+
+---
+
+## 1. 样例包清单
+
+| 包 | 路径 | 落地的架构规格节 | 承载的 ADR / 铁律 | 核心导出 |
+|---|---|---|---|---|
+| 统一响应 | `internal/infra/response` | §3.1 接口规范 | 铁律 6(HTTP 恒 200 + body code) | `R` / `OK` / `Page` / `Fail` / `ParsePage` |
+| 三层错误 | `internal/infra/errors` | §3.2 异常处理 | 错误码分段 1xxx–5xxx | `AppError` / `New` / `WithCause` / sentinel `ErrNotFound` 等 |
+| 多租户作用域 | `internal/infra/scope` | §3.5 作用域注入 | 铁律 2(强制作用域) | `Scope` / `Set` / `FromFiber` / `BaseRepo` / `NewBaseRepo` |
+| 入参校验 | `internal/infra/validator` | §3.1 入参惯例 | 铁律 7(validateReq 语义) | `Validate` |
+| 配置管理 | `internal/infra/config` | §3.4 配置 | ADR-015(12-factor) | `Config` / `Load` / `EnsureRuntimeDirs` |
+| 身份主键 | `internal/identity/id` | §7.1 | ADR-013(ULID) | `NewCanonicalUserID` / `NewCanonicalUserIDAt` / `Validate` / `Length` |
+| 密码凭证 | `internal/identity/credential` | §3.8 / §7.4 | ADR-016(argon2 惰性升级) | `Check` / `HashArgon2` / `VerifyArgon2` / `Result` / `HashVersion*` |
+| SSO token | `internal/identity/token` | §7.2 | ADR-014(SSO 最小可行) | `Issuer` / `Claims` / `Issue` / `Verify` / `Rotate` / `RevokeAll` |
+| 异步任务 | `internal/task` | §3.7 | 统一 DB 任务表 | `Task` / `Repo` / `Enqueue` / `Claimable` / `Claim` / `Done` / `Fail` / `ReclaimStale` |
+
+> **规划待建**(尚无代码,目录已占位):`cmd/api`(装配入口)、`internal/org`(多租户+7 角色)、
+> `internal/academic`(教务域)、`internal/teaching`(教学域)、`internal/ai`(Agent 消费方)。
+> 新增这些域时,**必须先对齐本文件的注释标准**再写业务逻辑。
+
+---
+
+## 2. GoDoc 注释标准(三段式)
+
+每个**导出**的结构体 / 函数 / 方法 / 常量 / 错误变量,注释必须覆盖三段;缺一段视为不完整:
+
+### ① 功能说明(用途 + 核心逻辑)
+- 一句话说清"做什么 + 为什么这样设计",点明对应的架构规格节 / ADR / 铁律。
+- 导出项(被其它包调用)必须附**典型使用样例**(多行 `//` 代码块),让读者不翻实现就能上手。
+- 设计取舍 / 坑点用"为什么"句式说明(例:"为什么不用 `err != nil` 判""为什么走 `c.Locals` 不走 context")。
+
+### ② 入参说明(逐项)
+- 每个参数:`- 参数名 类型:含义;约束/取值范围`。
+- 标注必填/可空、默认值、上限钳制、敏感项(密钥/密码)。
+- 无参函数写"入参:无",不留空。
+
+### ③ 出参说明(逐项 + 异常场景)
+- 每个返回值:`- 名称 类型:含义`。
+- **异常场景单列**:哪些输入/状态会返回 error / 特殊值(nil、false、dead),以及调用方该如何处理。
+- 错误要说明"降级还是 fail-fast""是否 fail-closed"(安全相关明确标注)。
+
+### 包级注释(文件顶部)
+- 第一段一句话定位(本包解决什么)。
+- 背景 / 数据来源 / 关键约束(ADR、风险登记册条目)。
+- **典型使用**完整链路示例(跨包协作时给出端到端调用片段)。
+
+### 与实现一致性(强制)
+- 改实现必须同步改注释;注释描述的行为与代码不符 = bug。
+- 不写"显然"的废话注释(`// 加 1`);只写"读代码看不出来"的设计意图、约束、坑。
+- 中文用规范标点;代码片段内标识符保持原样不翻译。
+
+---
+
+## 3. 注释模板(复制即用)
+
+```go
+// <导出符号> <一句话功能>(对应架构规格 §X / ADR-XXX / 铁律 N)。
+//
+// 典型使用(调用方):
+//
+//	<2~6 行可运行的调用片段>
+//
+// 入参:
+//   - name1 type1:含义;约束(必填/可空/默认值/上限)。
+//   - name2 type2:含义;敏感项标注(如"走环境变量,勿入库")。
+//
+// 出参:
+//   - ret1 type1:含义。
+//   - ret2 error:以下场景非 nil——
+//     - 场景 A:处理建议(降级/重试/fail-closed)。
+//     - 场景 B:处理建议。
+//
+// 异常场景:
+//   - 边界输入 X:行为 Y(为何这样,而非报错)。
+```
+
+---
+
+## 4. 构建与一致性校验
+
+```bash
+# go.mod `go 1.27`(语言级别 + 最低工具链);本机 go 1.27.x 直接构建。
+# 依赖保持初版锁定版本(validator v10.27.0 等),升 1.27 后实测无意外升级。
+go build ./...          # 必须 0 错误
+go vet   ./...          # 必须 0 告警(1.27 起默认含 stdversion 检查)
+GOTOOLCHAIN=local go build ./...   # 强制本地工具链(防 CI 误拉非预期工具链)
+
+# 一致性自检(注释与实现对齐的最低门槛)
+go doc ./internal/infra/response     # 查看包文档是否完整渲染
+```
+
+> **新增业务域 checklist**:
+> 1. 目录落在正确域(`academic`/`teaching`/`org`/`ai`),不进 `infra`(横切)或反向依赖。
+> 2. 数据访问经 Repository + `scope.BaseRepo.scoping`(铁律 2),不裸查。
+> 3. Service 返回 `*errors.AppError` sentinel(铁律,不裸抛 gorm 原错)。
+> 4. Handler 用 `response.OK/Page/Fail`(铁律 6)+ `validator.Validate`(铁律 7)。
+> 5. 每个导出符号带三段式 GoDoc(铁律 11),改实现同步改注释。
+> 6. `go build` / `go vet` 全绿后方可提交。

+ 84 - 0
README.md

@@ -0,0 +1,84 @@
+# 统一 Go 后端(模块化单体)
+
+> 两个 PHP 后端(教务 ysw-finance + 教学 yuansiweimarking_v2)收敛为**一个 Go 后端**,模块化单体、单二进制(ADR-011)。本文档描述工程结构与域分包约定;架构决策见 `../unified/00-planning/用户系统合并规划.md`。
+
+## 定位
+
+单一 Go 服务承载全部业务域:身份/SSO、多租户组织、教务域、教学域、AI 编排、异步任务。对外经 **nginx 统一入口**(TLS + HTTP/2 终结,ADR-010)暴露;数据库为 **PolarDB MySQL 单 schema**(ADR-008)。
+
+## 技术栈(与规划对齐)
+
+| 关注点 | 选型 | 依据 |
+|---|---|---|
+| 语言 | Go 1.27(go.mod 最低版本锁定;2026-11-05 由 1.23 升级,依赖零变化全绿实证) | 统一后端重写 |
+| Web 框架 | **Fiber** | ADR-009(用户拍板) |
+| ORM / 数据访问 | GORM + **Repository(强制作用域注入)** | ADR-006/007(多租户 institution/campus 过滤,应用层强制) |
+| 数据库 | PolarDB MySQL 单 schema | ADR-008(源库在阿里云,DTS 迁移) |
+| 身份 / SSO | OIDC(RS256),**identity 模块 = Provider + client 一体** | ADR-004 / ADR-011 |
+| 角色 | **7 类**:超管/教务/财务/老师/学生/家长/市场 | 三项拍板(市场扩入第 7 角色) |
+| 异步任务 | 统一 DB 任务表 + Go 工作池 | 替代两套旧队列 |
+| 配置 / 日志 | viper + zap | |
+| 外部入口 | nginx 统一七层入口(`proxy_buffering off` 支持 SSE) | ADR-010 |
+| 对象存储 | 阿里云 OSS | 文件资产迁移 |
+| AI 能力 | 消费 Python FastAPI Agent(契约 OpenAPI 化) | 跨系统综合分析 |
+
+## 目录结构约定
+
+```
+backend/
+├── README.md            ← 本文件
+├── EXAMPLES.md          ← 可编译样例索引 + GoDoc 注释标准(铁律 11 的落地模板)
+├── go.mod               ← Go 模块(go 1.27;依赖版本锁定,见"构建与校验")
+├── cmd/api/             # 单二进制入口:装配 Fiber app + 各域模块 + 任务工作池(规划待建)
+├── config/              # viper 配置:环境、多租户、DB/Redis/OSS、SSO 签发/验签(config.{env}.json 模板)
+└── internal/
+    ├── identity/        # 身份与 SSO(已有样例:id 生成 ULID / credential argon2 惰性升级 / token RS256 签发验签吊销)
+    ├── org/             # 多租户组织:Institution / Campus / Membership / Role(7) / GuardianLink(规划待建)
+    ├── academic/        # 教务域:排班 / 费用 / 打卡 / 充值(SP 重写) / 报课 / 同步(规划待建)
+    ├── teaching/        # 教学域:备课 / 练习分析 / 题库 / 组卷 / 科目分表 / 共享标志(规划待建)
+    ├── ai/              # AI 能力编排:作为 OpenAPI 消费方调用 Python Agent(规划待建)
+    ├── task/            # 统一 DB 任务表 + 工作池(已有样例:t_task 状态机/乐观锁认领/指数退避)
+    └── infra/           # 横切(已有样例:response 统一信封 / errors 三层错误 / scope 多租户作用域 / validator 入参校验 / config 12-factor 配置)
+```
+
+> **样例即规范**:`internal/infra/*`、`internal/identity/{id,credential,token}`、`internal/task` 为**可编译参考样例**
+> (`go build ./...` + `go vet ./...` 全绿,基于真实依赖版本),每个结构体/函数/方法均带
+> GoDoc 中文注释(功能说明+典型样例、入参逐项、出参逐项+异常场景)。**新增业务域代码须对齐此注释标准**(铁律 11)。
+> 样例索引与注释模板见 [EXAMPLES.md](./EXAMPLES.md)。
+
+## 分包铁律
+
+1. **域间不直接互调内部实现**,跨域走 `internal/` 内定义的接口(接口在 `org`/`identity` 侧,消费方在业务域)。
+2. **所有数据访问必须经 Repository 层**,作用域(institution_id / campus_id)由中间件从 token 解析后强制注入,禁止绕过。
+3. **`infra` 不依赖任何业务域**;业务域可依赖 `infra`、`org`、`identity`,但 `org`/`identity` 不得反向依赖业务域。
+4. **SP 行为(充值/审批/同步)在 `academic` 域内用 Go 事务重写**,旧存储过程仅留档对照,不迁移(见统一规划风险登记册)。
+5. **硬编码身份/魔法值(如 `user->id==393`、`teacherId=354`)一律进配置或权限判断**,阶段 0 硬编码 ID 清单为消灭目标。
+6. **统一响应契约**:HTTP 恒 200,真实状态码在 body `R.code`(成功 code=200,`{code,data,message}` 信封);e2e 断言 body code 而非 HTTP status(源自教务 Go 重构在产验证,ADR 见统一规划)。
+7. **入参校验惯例**:`validateReq(c, req) bool`——校验失败内部已写 400 并返 false,调用方 `if !validateReq { return nil }`(用 `err!=nil` 判会误判通过继续落库)。
+8. **路由注册顺序纪律**:Fiber 字面路径先于 `/:id`(如 `/courses/list-detail` 先于 `/courses/:id`);同 method+path 不重复注册(先注册优先);新增端点全局 grep 同路径。
+9. **GORM 纪律**:`IS_NULLABLE=YES` 列一律 `*string`/`*int`(写边界 nil→""/0 归一);聚合/别名/无下划线列显式 `gorm:"column:xxx"`;链式调用勿就地改接收者(三查各自 `Model()`);事务内全部用 tx;service 返回 sentinel 错误(非 `gorm.ErrRecordNotFound`),handler 用 `errors.Is`。
+10. **e2e id 段独立**:TestMain 建库建 SP→跑→DROP;多 e2e 共用库时用独立业务键/独立 id 段/独占时间窗/专属 token(避开教务已占 97090/97481/98141)。
+11. **注释标准(GoDoc 三段式)**:每个导出的结构体/函数/方法/常量/错误**必须**带 GoDoc 中文注释,含①功能说明(用途+核心逻辑,导出项附"典型使用样例")②入参说明(逐项:名称、类型、含义、约束)③出参说明(逐项:名称、类型、含义;异常场景单列)。代码即文档——注释与实现必须一致,改实现同步改注释;索引与模板见 [EXAMPLES.md](./EXAMPLES.md)。
+
+## 构建与校验
+
+```bash
+# go.mod `go 1.27`(语言级别 + 最低工具链),本机 go 1.27.x 直接构建。
+# 未写 toolchain 指令:旧机器(GOTOOLCHAIN=auto)会自动从 goproxy.cn 拉取 go1.27 工具链,
+# 有网即可,离线/内网隔离环境需预装 1.27 或设 GOPROXY 可达。
+go build ./...    # 0 错误
+go vet   ./...    # 0 告警(1.27 起 go vet/go test 默认含 stdversion 检查:只用 ≤ go.mod 版本的标准库符号,天然满足)
+```
+
+**依赖版本锁定**(`go.mod`,升 go 1.27 后版本不变,已实测无意外升级):
+`fiber/v2 v2.52.16` / `gorm v1.31.2` / `validator/v10 v10.27.0` /
+`golang-jwt/jwt/v5 v5.3.1` / `oklog/ulid v1.3.1` / `x/crypto v0.33.0`。
+
+> **工具链沿革**:初版锁 go 1.23 + validator v10.27.0(go 1.20 要求),防 tidy 把工具链抬到 1.26;
+> 2026-11-05 工具链升级为 **go 1.27**(泛型方法/encoding-json-v2/小对象分配提速 ~30%/goroutine 泄漏检测 GA),
+> 依赖保持原锁定版本,`go build`+`go vet` 全绿实证兼容。
+
+## 与文档目录的关系
+
+- 本目录**只放代码与工程配置**;任何架构决策、数据模型、迁移/割接方案放 `../unified/`;单项目事实画像放 `../academic/`、`../teaching/`。
+- 代码内注释如需引用决策,用相对路径指向 `../unified/00-planning/用户系统合并规划.md`,不在代码注释里复述结论。

+ 211 - 0
cmd/api/main.go

@@ -0,0 +1,211 @@
+// 命令 api:统一 Go 后端单二进制入口(ADR-011 模块化单体,ADR-015 单机双实例)。
+//
+// 职责(架构规格 §2.1 HTTP 接入层 + §7.3 部署形态):
+//  1. 解析 -env(dev/prod)加载配置(internal/infra/config,12-factor:
+//     config/{env}.json + CONFIG_* 环境变量覆盖;敏感字段必须走环境变量);
+//  2. 幂等创建运行目录(log/{instanceId}/、tmp/{instanceId}/,双实例同机隔离);
+//  3. 装配 zap 结构化日志(trace_id 贯穿,§3.3);
+//  4. 装配 Fiber app + 中间件链(Recover→RequestID→...,§2.2);
+//  5. 注册 /healthz 存活探针(ADR-015:nginx upstream 健康检查 + systemd Watchdog);
+//  6. 各业务域路由挂载(org/academic/teaching/ai 域就绪后在此注册,
+//     公开路由须在 Auth Group 之前——铁律 8 路由纪律);
+//  7. 任务工作池启动与优雅退出(task 域,GracePeriodSec 内排空在途任务,§3.7)。
+//
+// 当前状态:域模块尚未实现(internal/{org,academic,teaching,ai} 为空),
+// 本入口提供"配置加载 + 健康检查 + 优雅退出"骨架,保证 go build ./... 全绿、
+// 部署链路(systemd/nginx/Dockerfile)可先演练。
+//
+// 启动方式(dev):
+//
+//	cp config/dev.example.json config/dev.json   # 填入 DSN 等(或走环境变量)
+//	go run ./cmd/api -env=dev
+package main
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"flag"
+	"fmt"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/gofiber/fiber/v2"
+	"github.com/gofiber/fiber/v2/middleware/requestid"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
+
+	"yuansiwei/unified-backend/internal/infra/config"
+)
+
+// main 进程入口:装配 → 监听 → 信号驱动优雅退出。
+//
+// 入参(命令行 flag):
+//   - env string:环境名("dev"/"prod"),决定加载 config/{env}.json;默认 "dev"。
+//
+// 出参:进程退出码(0 正常;1 装配/运行失败)。
+//
+// 异常场景:
+//   - 配置缺失/必填字段未填:fail-fast 退出(config.Load 返回错误,输出到 stderr 后 exit 1);
+//   - SIGTERM/SIGINT:在 GracePeriodSec 内排空存量请求后退出(ADR-015 滚动重启语义)。
+func main() {
+	env := flag.String("env", "dev", "运行环境(dev/prod),决定读取 config/{env}.json")
+	flag.Parse()
+
+	cfg, err := config.Load(*env)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "fatal: 配置加载失败: %v\n(提示:可复制 config/%s.example.json 为 config/%s.json 并填敏感字段)\n",
+			err, *env, *env)
+		os.Exit(1)
+	}
+	if err := config.EnsureRuntimeDirs(cfg.Server.InstanceID); err != nil {
+		fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
+		os.Exit(1)
+	}
+
+	logger, err := newLogger(cfg.Log.Level)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "fatal: 初始化日志失败: %v\n", err)
+		os.Exit(1)
+	}
+	defer func() { _ = logger.Sync() }()
+	logger.Info("统一 Go 后端启动",
+		zap.String("env", *env),
+		zap.String("instance", cfg.Server.InstanceID),
+		zap.String("addr", cfg.Server.Addr))
+
+	app := fiber.New(fiber.Config{
+		AppName:               "yuansiwei-unified-backend",
+		ReadTimeout:           time.Duration(cfg.Server.ReadTimeoutSec) * time.Second,
+		DisableStartupMessage: true,
+		// 请求体上限 10MB:费用凭证/作业图片走 OSS 直传,接口侧只收元数据
+		BodyLimit: 10 << 20,
+	})
+
+	// 中间件链(§2.2 顺序铁律:Recover 最前兜底 panic → RequestID 生成/透传 trace_id)
+	app.Use(recoverMiddleware(logger))
+	app.Use(requestid.New(requestid.Config{Generator: newTraceID}))
+
+	// 存活探针(ADR-015:nginx upstream healthcheck + K8s 预留 liveness)
+	app.Get("/healthz", func(c *fiber.Ctx) error {
+		return c.JSON(fiber.Map{
+			"status":   "ok",
+			"instance": cfg.Server.InstanceID,
+			"time":     time.Now().Format(time.RFC3339),
+		})
+	})
+
+	// 业务域路由挂载点(域模块实现后在此注册;公开路由须在 Auth Group 之前——铁律 8):
+	//
+	//	app.Get("/auth/jwks", identityHandler.JWKS)            // 公开
+	//	auth := app.Group("/", middleware.Auth(logger))        // 以下全部需登录
+	//	auth.Get("/auth/me", identityHandler.Me)
+	//	auth.Get("/admin/courses/:id", academicHandler.Get)    // PC 端
+	//	auth.Get("/student/homeworks", teachingHandler.List)   // 移动端
+
+	// 优雅退出(SIGTERM/SIGINT;GracePeriodSec 内排空存量请求,§7.3)
+	grace := time.Duration(cfg.Server.GracePeriodSec) * time.Second
+	if grace <= 0 {
+		grace = 30 * time.Second
+	}
+	done := make(chan struct{})
+	go func() {
+		sig := make(chan os.Signal, 1)
+		signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
+		s := <-sig
+		logger.Info("收到退出信号,开始优雅停机", zap.String("signal", s.String()), zap.Duration("grace", grace))
+		close(done)
+	}()
+
+	errCh := make(chan error, 1)
+	go func() { errCh <- app.Listen(cfg.Server.Addr) }()
+
+	select {
+	case err := <-errCh:
+		if err != nil {
+			logger.Error("HTTP 服务异常退出", zap.Error(err))
+			os.Exit(1)
+		}
+	case <-done:
+		if err := app.ShutdownWithTimeout(grace); err != nil {
+			logger.Warn("优雅退出超时,强制关闭", zap.Error(err))
+		} else {
+			logger.Info("服务已优雅退出")
+		}
+	}
+}
+
+// recoverMiddleware 全局 panic 兜底(§3.2 规则 4):未捕获 panic 统一降级为
+// body code=5001(HTTP 仍 200,铁律 6)+ 结构化日志(带堆栈 + trace_id),
+// **绝不**把 panic 信息/堆栈透传给前端。
+//
+// 入参:
+//   - logger *zap.Logger:进程级结构化日志器。
+//
+// 出参:
+//   - fiber.Handler:Fiber 中间件(返回后继续 c.Next() 链)。
+//
+// 说明:骨架期直接写 5001 信封(不引入 infra/response 依赖,避免 main 包耦合具体域);
+// 域模块就绪后切换为 response.Fail 统一出口。
+func recoverMiddleware(logger *zap.Logger) fiber.Handler {
+	return func(c *fiber.Ctx) (err error) {
+		defer func() {
+			if p := recover(); p != nil {
+				logger.Error("panic 已兜底",
+					zap.String("trace_id", c.Get("X-Request-ID")),
+					zap.String("path", c.Path()),
+					zap.Any("panic", p))
+				err = c.Status(fiber.StatusOK).JSON(fiber.Map{
+					"code":    5001,
+					"message": "系统繁忙",
+					"success": false,
+				})
+			}
+		}()
+		return c.Next()
+	}
+}
+
+// newTraceID 生成请求 trace_id(§3.3:nginx 透传 X-Request-ID 时 requestid 中间件不再生成,
+// 缺失时以 crypto/rand 32 字节 hex 兜底;跨 Python Agent 调用时透传同一值)。
+//
+// 入参:无。
+// 出参:
+//   - string:64 字符 hex;crypto/rand 故障(概率≈0)时以时间戳兜底,不阻断请求。
+func newTraceID() string {
+	b := make([]byte, 32)
+	if _, err := rand.Read(b); err != nil {
+		return fmt.Sprintf("%064x", uint64(time.Now().UnixNano()))
+	}
+	return hex.EncodeToString(b)
+}
+
+// newLogger 按级别构造 zap 生产级 logger(JSON 编码,ts 用 RFC3339——访问日志与 trace 对齐,§3.3)。
+//
+// 入参:
+//   - level string:"debug"/"info"/"warn"/"error"(大小写不敏感;空/非法值按 info 处理)。
+//
+// 出参:
+//   - *zap.Logger:就绪日志器(NewDevelopment 风格控制台可读 + 生产字段结构)。
+//   - error:仅当 zap 构造失败时非 nil。
+func newLogger(level string) (*zap.Logger, error) {
+	var l zapcore.Level
+	switch level {
+	case "debug":
+		l = zapcore.DebugLevel
+	case "warn":
+		l = zapcore.WarnLevel
+	case "error":
+		l = zapcore.ErrorLevel
+	case "info", "":
+		l = zapcore.InfoLevel
+	default:
+		l = zapcore.InfoLevel // 非法级别不阻断启动,按 info 兜底(配置错误由部署期自检发现)
+	}
+	cfg := zap.NewProductionConfig()
+	cfg.Level = zap.NewAtomicLevelAt(l)
+	cfg.EncoderConfig.TimeKey = "ts"
+	cfg.EncoderConfig.EncodeTime = zapcore.RFC3339TimeEncoder
+	return cfg.Build()
+}

+ 56 - 0
config/dev.example.json

@@ -0,0 +1,56 @@
+{
+  "server": {
+    "addr": "127.0.0.1:8080",
+    "readTimeoutSec": 30,
+    "gracePeriodSec": 30,
+    "instanceId": "a"
+  },
+  "db": {
+    "dsn": "",
+    "maxOpenConns": 50,
+    "maxIdleConns": 10,
+    "connMaxLifetimeSec": 1800,
+    "txTimeoutSec": 30
+  },
+  "redis": {
+    "addr": "127.0.0.1:6379",
+    "db": 0,
+    "poolSize": 16
+  },
+  "auth": {
+    "issuer": "https://unified.dev.yuansiwei.com",
+    "privateKeyPem": "",
+    "publicKeysPem": [],
+    "accessTokenTtlMin": 30,
+    "refreshTokenTtlDay": 30,
+    "revocationKeyPrefix": "sso:revoked:"
+  },
+  "oss": {
+    "endpoint": "oss-cn-hangzhou.aliyuncs.com",
+    "accessKeyId": "",
+    "accessKeySecret": "",
+    "bucketAcademic": "yuansiwei-academic-dev",
+    "bucketTeaching": "yuansiwei-teaching-dev",
+    "bucketAvatar": "yuansiwei-avatar-dev"
+  },
+  "weChat": {
+    "campuses": []
+  },
+  "agent": {
+    "baseUrl": "http://127.0.0.1:8000",
+    "timeoutSec": 60,
+    "maxRetries": 2,
+    "token": ""
+  },
+  "task": {
+    "workers": 4,
+    "pollSec": 2,
+    "maxRetry": 6,
+    "claimBatch": 10
+  },
+  "log": {
+    "level": "info",
+    "dir": "log",
+    "accessSampleRate": 1
+  }
+}

+ 36 - 0
go.mod

@@ -0,0 +1,36 @@
+module yuansiwei/unified-backend
+
+go 1.27
+
+require (
+	github.com/go-playground/validator/v10 v10.27.0
+	github.com/gofiber/fiber/v2 v2.52.16
+	github.com/golang-jwt/jwt/v5 v5.3.1
+	github.com/oklog/ulid v1.3.1
+	go.uber.org/zap v1.28.0
+	golang.org/x/crypto v0.33.0
+	gorm.io/gorm v1.31.2
+)
+
+require (
+	github.com/andybalholm/brotli v1.1.0 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/jinzhu/inflection v1.0.0 // indirect
+	github.com/jinzhu/now v1.1.5 // indirect
+	github.com/klauspost/compress v1.17.9 // indirect
+	github.com/leodido/go-urn v1.4.0 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-runewidth v0.0.16 // indirect
+	github.com/rivo/uniseg v0.2.0 // indirect
+	github.com/valyala/bytebufferpool v1.0.0 // indirect
+	github.com/valyala/fasthttp v1.51.0 // indirect
+	github.com/valyala/tcplisten v1.0.0 // indirect
+	go.uber.org/multierr v1.10.0 // indirect
+	golang.org/x/net v0.34.0 // indirect
+	golang.org/x/sys v0.30.0 // indirect
+	golang.org/x/text v0.22.0 // indirect
+)

+ 75 - 0
go.sum

@@ -0,0 +1,75 @@
+github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
+github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
+github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/gofiber/fiber/v2 v2.52.16 h1:lBemN7sjeTPwEdK/9AIAQ3YpYpzEZefiWI/vQT/6dFU=
+github.com/gofiber/fiber/v2 v2.52.16/go.mod h1:dFf1PKExrrSfg/hvoP2yXL35W3bGlzZf71KX+IYzkpg=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
+github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
+github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
+github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
+github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
+github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
+go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

+ 222 - 0
internal/identity/credential/credential.go

@@ -0,0 +1,222 @@
+// Package credential 密码凭证(argon2 统一 + 登录成功惰性升级,ADR-016)。
+//
+// 背景:两套旧系统密码算法不一致——教务 `md5(password + salt)`(salt 独立列)、
+// 教学 `sha1(password + salt)`,均为弱哈希且两侧 salt 语义不同。ADR-016 拍板:
+//   - 目标算法统一 argon2id(golang.org/x/crypto/argon2,纯 Go 实现);
+//   - **惰性升级**:登录时先按 hash_version 路由到旧算法比对,命中后立即用 argon2
+//     重哈希写回(调用方在同一事务内完成"验旧 + 写新");**不批量迁移、不强制重置**,
+//     避免割接窗口阻断老用户(风险登记册"割接期登录失败");
+//   - hash_version 取值:md5_salt / sha_salt / argon2(迁移期新增列,DDL 见 unified/02-data-model)。
+//
+// 典型使用(identity 域 AuthService.Login):
+//
+//	res, err := credential.Check(ctx, input, u.PasswordHash, u.Salt, u.HashVersion)
+//	if err != nil { return nil, errors.WithCause(errors.ErrInternal, err) }
+//	if !res.Match { return nil, errors.New(errors.CodeRuleConflict, "账号或密码错误") } // 统一文案,不区分"账号不存在/密码错"(防枚举)
+//	if res.NeedsUpgrade {
+//	    // 后半段:argon2 重哈希 + hash_version='argon2' + salt 置 NULL(同事务)
+//	    enc, err := credential.HashArgon2(input)
+//	    // UPDATE t_user SET password_hash=?, salt=NULL, hash_version='argon2' WHERE canonical_user_id=?
+//	    // 升级成功后撤销该用户全部 token(对齐旧系统语义,见架构规格 §7.4 改密链路)
+//	}
+//	// 继续签发 access + refresh
+package credential
+
+import (
+	"context"
+	"crypto/md5"
+	"crypto/rand"
+	"crypto/sha1"
+	"crypto/subtle"
+	"encoding/base64"
+	"encoding/hex"
+	"fmt"
+	"io"
+
+	"golang.org/x/crypto/argon2"
+)
+
+// HashVersion 密码哈希版本(t_user.hash_version 列取值,ADR-016 路由依据)。
+//
+// 取值:
+//   - HashVersionMD5Salt:"md5_salt"——教务旧算法 md5(password + salt),salt 存独立列;
+//   - HashVersionShASalt:"sha_salt"——教学旧算法 sha1(password + salt),salt 存独立列;
+//   - HashVersionArgon2:"argon2"——目标算法(argon2id,参数见参数常量;salt 内嵌于编码串,独立列置 NULL)。
+const (
+	HashVersionMD5Salt = "md5_salt"
+	HashVersionShASalt = "sha_salt"
+	HashVersionArgon2  = "argon2"
+)
+
+// argon2id 参数(OWASP 2024 推荐下限;纯 CPU 哈希,与 GPU 显存无关,
+// 双实例同机部署无额外压力)。
+// 参数变更 = 全体老 argon2 用户需重登(编码串内嵌参数校验失败),
+// 仅在安全审计要求时调整,属重大变更。
+// 类型说明:argon2.IDKey 签名为 (password, salt []byte, time uint32, memory uint32, threads uint8, keyLen uint32)。
+const (
+	argon2Time    uint32 = 3         // 迭代轮数
+	argon2Memory  uint32 = 64 * 1024 // 64MB 内存
+	argon2Threads uint8  = 4         // 并行度(API 要求 uint8)
+	argon2KeyLen  uint32 = 32        // 输出 32 字节(API 要求 uint32)
+	argon2SaltLen int    = 16        // 盐 16 字节
+)
+
+// ErrUnknownVersion hash_version 取值不在已知集合(迁移脚本漏跑/数据污染)。
+// 调用方按"拒绝登录 + 告警"处理(不是密码错误——该用户需运维介入)。
+var ErrUnknownVersion = fmt.Errorf("credential: 未知 hash_version(非 %s/%s/%s)",
+	HashVersionMD5Salt, HashVersionShASalt, HashVersionArgon2)
+
+// Result 密码校验结果(Check 的出参结构)。
+//
+// 字段:
+//   - Match        bool:密码是否匹配(false 时调用方必须返回统一文案"账号或密码错误",
+//     禁止区分"账号不存在"与"密码错误"——防用户名枚举)。
+//   - NeedsUpgrade bool:校验成功但仍是旧算法(md5_salt/sha_salt),调用方应紧接着
+//     执行升级(argon2 重哈希 + 写 hash_version + 撤销全部 token)。
+type Result struct {
+	Match        bool
+	NeedsUpgrade bool
+}
+
+// HashArgon2 用 argon2id 生成密码哈希(新注册/改密/惰性升级统一入口)。
+//
+// 入参:
+//   - password string:明文密码(仅存在于内存调用栈,禁止写日志/配置/DB 注释)。
+//
+// 出参:
+//   - string:PHC 格式编码串 "argon2id$v=19$m=65536,t=3,p=4$<salt-nopad-b64>$<hash-nopad-b64>"
+//     (单字段自包含 salt + 参数,存 t_user.password_hash,salt 列置 NULL)。
+//   - error:仅当 crypto/rand 读取盐失败(熵源故障)时返回。
+//
+// 样例:
+//
+//	enc, _ := credential.HashArgon2("X#9f2k")
+//	// "argon2id$v=19$m=65536,t=3,p=4$BwJ3...$Qm2..."
+func HashArgon2(password string) (string, error) {
+	salt := make([]byte, argon2SaltLen)
+	if _, err := io.ReadFull(rand.Reader, salt); err != nil {
+		return "", fmt.Errorf("credential: 生成 argon2 salt 失败: %w", err)
+	}
+	dst := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
+	saltB64 := base64.RawStdEncoding.EncodeToString(salt)
+	hashB64 := base64.RawStdEncoding.EncodeToString(dst)
+	return fmt.Sprintf("argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
+		argon2Memory, argon2Time, argon2Threads, saltB64, hashB64), nil
+}
+
+// VerifyArgon2 校验明文密码是否匹配 argon2 编码串。
+//
+// 入参:
+//   - encoded string:HashArgon2 产出的 PHC 编码串(含内嵌参数/salt)。
+//   - password  string:待校验明文密码。
+//
+// 出参:
+//   - bool:true 匹配;false 不匹配**或编码串格式非法**(格式非法按不匹配处理——
+//     脏数据不产生 5xx,由数据质量报表另行修复)。
+//
+// 说明:恒定时间比较(subtle.ConstantTimeCompare),防时序侧信道。
+func VerifyArgon2(encoded, password string) bool {
+	parts := splitPHC(encoded) // "argon2id" | "v=19" | "m=...,t=...,p=..." | saltB64 | hashB64
+	if len(parts) != 5 || parts[0] != "argon2id" {
+		return false
+	}
+	salt, err := base64.RawStdEncoding.DecodeString(parts[3])
+	if err != nil {
+		return false
+	}
+	expected, err := base64.RawStdEncoding.DecodeString(parts[4])
+	if err != nil || uint32(len(expected)) != argon2KeyLen {
+		return false
+	}
+	var m, t uint32
+	var p uint8
+	if _, err := fmt.Sscanf(parts[2], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
+		return false
+	}
+	actual := argon2.IDKey([]byte(password), salt, t, m, p, argon2KeyLen)
+	return subtle.ConstantTimeCompare(actual, expected) == 1
+}
+
+// Check 按 hash_version 路由做密码校验(登录主链路入口,ADR-016 惰性升级的前半段)。
+//
+// 入参:
+//   - ctx        context.Context:请求上下文(预留:限流/审计钩子;当前未消费)。
+//   - password   string:用户输入的明文密码。
+//   - storedHash string:t_user.password_hash 列当前值。
+//   - salt       string:t_user.salt 列当前值(argon2 版本下应为 NULL/空——盐已内嵌编码串)。
+//   - version    string:t_user.hash_version 列当前值(HashVersion* 常量之一)。
+//
+// 出参:
+//   - Result:Match/NeedsUpgrade 见 Result 结构注释。
+//   - error:仅以下场景非 nil:
+//     - version 不在已知集合 → 包装 ErrUnknownVersion(拒绝登录 + 告警,非密码错误);
+//     - storedHash 为空 → 该账号无凭证,需运维重建(同样走告警分支)。
+//
+// 路由逻辑(避免双算法误判,ADR-016 核心):
+//  1. md5_salt:md5(password + salt) hex 小写与 storedHash 比较(教务旧算法);
+//  2. sha_salt:sha1(password + salt) hex 小写与 storedHash 比较(教学旧算法,
+//     迁移前须抽样双跑确认教学确为 sha1 而非 sha256);
+//  3. argon2:VerifyArgon2(storedHash, password)。
+//
+// 命中旧算法时 NeedsUpgrade=true,调用方负责后半段(写回 + 撤 token,见包注释"典型使用")。
+//
+// 异常场景:
+//   - 大小写差异:旧库 hex 可能存大写(历史脚本差异),比较前统一转小写——
+//     该归一属于"数据保真"例外,单测须覆盖大写 storedHash 用例。
+func Check(ctx context.Context, password, storedHash, salt, version string) (Result, error) {
+	_ = ctx // 预留参数位
+	if storedHash == "" {
+		return Result{}, fmt.Errorf("credential: 账号无密码哈希(version=%q),需运维重建凭证", version)
+	}
+	switch version {
+	case HashVersionMD5Salt:
+		return Result{Match: hexEqFold(legacyDigest(md5.New(), password+salt), storedHash), NeedsUpgrade: true}, nil
+	case HashVersionShASalt:
+		return Result{Match: hexEqFold(legacyDigest(sha1.New(), password+salt), storedHash), NeedsUpgrade: true}, nil
+	case HashVersionArgon2:
+		return Result{Match: VerifyArgon2(storedHash, password)}, nil
+	default:
+		return Result{}, fmt.Errorf("%w(实际=%q)", ErrUnknownVersion, version)
+	}
+}
+
+// legacyDigest 计算旧算法摘要的 hex 小写串(md5/sha1 仅用于旧数据兼容比对,
+// 禁止用于任何新数据路径)。
+func legacyDigest(h interface{ Write([]byte) (int, error); Sum([]byte) []byte }, s string) string {
+	h.Write([]byte(s))
+	return hex.EncodeToString(h.Sum(nil))
+}
+
+// hexEqFold 忽略大小写的 hex 串比较(先比长度;旧算法校验发生在"账号已存在"之后,
+// 登录链路统一文案已消除枚举风险,此处非常量时间可接受)。
+func hexEqFold(a, b string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range a {
+		ca, cb := a[i], b[i]
+		if ca >= 'A' && ca <= 'F' {
+			ca += 'a' - 'A'
+		}
+		if cb >= 'A' && cb <= 'F' {
+			cb += 'a' - 'A'
+		}
+		if ca != cb {
+			return false
+		}
+	}
+	return true
+}
+
+// splitPHC 按 '$' 拆分 PHC 编码串(不校验各段格式,由调用方逐段解析)。
+func splitPHC(s string) []string {
+	var parts []string
+	start := 0
+	for i := 0; i < len(s); i++ {
+		if s[i] == '$' {
+			parts = append(parts, s[start:i])
+			start = i + 1
+		}
+	}
+	return append(parts, s[start:])
+}

+ 115 - 0
internal/identity/id/id.go

@@ -0,0 +1,115 @@
+// Package id 生成 canonical_user_id(ULID,ADR-013)。
+//
+// 背景:两套 PHP 旧系统各自用自增 int 作 user 主键,合并后必须一个跨系统、
+// 跨实例、可排序、可对外暴露的身份主键。ADR-013 拍板 ULID:
+//   - 26 字符 Crockford Base32(大写),存 VARCHAR(26);
+//   - 前 48bit 毫秒时间戳 → 天然时间有序,按创建时间排序/分页无需额外索引;
+//   - 后 80bit 随机数 → 跨双实例(ADR-015 单机双进程)并发生成不冲突,无需 workerId 协调;
+//   - 全项目所有域外键(student_id/teacher_id/owner_student_id/creator…)一律引用 ULID,
+//     **禁止**引用旧 int user_id(旧 int 只存 Legacy Mapping 表做迁移对照)。
+//
+// 典型使用:
+//
+//	// Service 层创建用户
+//	uid, err := id.NewCanonicalUserID()
+//	if err != nil { return nil, errors.WithCause(errors.ErrInternal, err) }
+//	u := &model.User{CanonicalUserID: uid, ...}
+//
+//	// 生成后必须立刻落库;若事务回滚则不得复用该 ULID 之外的副作用
+package id
+
+import (
+	"crypto/rand"
+	"errors"
+	"time"
+
+	"github.com/oklog/ulid"
+)
+
+// Length canonical_user_id 的固定长度(26 字符),用于校验/建表注释。
+// 使用方:DDL 注释、入参校验(validate:"len=26")、前端展示截断判断。
+const Length = 26
+
+// ErrInvalid 校验失败:入参不是合法的 26 字符 Crockford ULID。
+var ErrInvalid = errors.New("id: 非法的 canonical_user_id(须为 26 字符 Crockford Base32)")
+
+// NewCanonicalUserID 生成一个新的 canonical_user_id(以当前时刻为时间戳)。
+//
+// 入参:无。
+//
+// 出参:
+//   - string:26 字符大写 Crockford Base32 字符串(如 "01GAQF0Z6C1V9Y3E5H7K2M8B4T")。
+//   - error:理论上恒为 nil(内部用 crypto/rand,仅当系统熵源枯竭时出错——
+//     视为系统级故障,调用方按 errors.ErrInternal 上抛,**禁止**回退到 math/rand
+//     (可预测 = 可被枚举伪造身份,安全红线))。
+//
+// 样例:
+//
+//	uid, _ := id.NewCanonicalUserID()
+//	// "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
+func NewCanonicalUserID() (string, error) {
+	return NewCanonicalUserIDAt(time.Now())
+}
+
+// NewCanonicalUserIDAt 以指定时刻为时间戳生成 ULID(测试与回放场景:
+// 需要"某个历史时刻"的 ID 用于数据修复/割接对账)。
+//
+// 入参:
+//   - now time.Time:作为 ULID 时间戳的时刻(毫秒精度,微秒/纳秒部分被截断)。
+//
+// 出参:
+//   - string:26 字符 ULID(同一毫秒内生成多个时,随机部分保证唯一)。
+//   - error:同 NewCanonicalUserID(熵源故障才非 nil)。
+//
+// 异常场景:
+//   - 同一毫秒内高并发调用:随机部分(80bit)保证唯一性,无需加锁;
+//     若需严格递增(同一毫秒内按调用序),调用方自行加序(本包不提供——
+//     跨实例严格递增与 ADR-015 双实例形态冲突,不引入该复杂度)。
+func NewCanonicalUserIDAt(now time.Time) (string, error) {
+	// ulid.Timestamp 把 time.Time 转为毫秒时间戳嵌入 ID 前 48bit
+	// rand.Reader 提供后 80bit 随机熵(crypto/rand,非 math/rand)
+	id, err := ulid.New(ulid.Timestamp(now), rand.Reader)
+	if err != nil {
+		return "", err
+	}
+	return id.String(), nil
+}
+
+// Validate 校验字符串是否为合法的 canonical_user_id(入参防御:
+// 前端/Agent 回传的 ULID 在进库前统一校验,防脏数据写进外键列)。
+//
+// 入参:
+//   - s string:待校验字符串(应为 26 字符;小写视为非法——本项目统一大写存储)。
+//
+// 出参:
+//   - bool:true 表示合法(长度 26 且全部为 Crockford Base32 合法字符 0-9/A-Z 去掉 I,L,O,U);
+//     false 表示长度不符或含非法字符。
+//
+// 说明:不做"时间戳可解码"校验(ulid.Parse 会接受任意合法编码串,
+// 但业务上"乱序/未来时间戳的 ID"也属合法输入,不在此层拦截——
+// 时间戳异常属于审计问题,由数据质量报表处理)。
+//
+// 样例:
+//
+//	id.Validate("01GAQF0Z6C1V9Y3E5H7K2M8B4T") // true
+//	id.Validate("abc")                        // false(长度)
+//	id.Validate("01GAQF0Z6C1V9Y3E5H7K2M8B4I") // false(I 不是 Crockford 字符)
+func Validate(s string) bool {
+	if len(s) != Length {
+		return false
+	}
+	for i := 0; i < Length; i++ {
+		c := s[i]
+		switch {
+		case c >= '0' && c <= '9':
+		case c >= 'A' && c <= 'Z':
+			// 剔除 Crockford 中易混淆的 I/L/O/U
+			if c == 'I' || c == 'L' || c == 'O' || c == 'U' {
+				return false
+			}
+		default:
+			return false
+		}
+	}
+	return true
+}

+ 425 - 0
internal/identity/token/token.go

@@ -0,0 +1,425 @@
+// Package token SSO token 签发/验签/吊销(ADR-014 最小可行 SSO)。
+//
+// 本包实现架构规格 §7.2 的 Token 规范:
+//   - access:JWT RS256,有效期 30min;claims 含 sub(canonical_user_id ULID)、
+//     roles[]、institution_id、campus_id、jti(吊销维度);
+//   - refresh:不透明随机串(32 字节 hex),有效期 30 天;Redis 存白名单
+//     (键 refresh:{jti} → 载荷),刷新时**轮换**(旧 jti 立即失效,防重放);
+//   - 本地验签:验签只用公钥(进程内存),不依赖中心服务——grace 期语义:
+//     滚动重启/服务抖动时,30min 内已签发的 access 仍可通过本地验签通过(ADR-015);
+//   - 吊销:改密/禁用/登出 → 写 Redis 黑名单 jti(TTL = 该 access 剩余有效期),
+//     验签中间件**先查黑名单再验签名**(黑名单命中即拒,无需解析 JWT 载荷)。
+//
+// 典型使用:
+//
+//	// 启动时装配签发器(私钥/公钥/过期参数/Redis 存储来自 config + infra)
+//	iss, _ := token.NewIssuer(cfg.Auth.PrivateKeyPEM, cfg.Auth.Issuer,
+//	    30*time.Minute, 30*24*time.Hour, redisStore, redisStore)
+//
+//	// 登录成功后签发一对凭证
+//	access, refresh, err := iss.Issue(ctx, token.Claims{
+//	    Sub: canonicalUID, UserID: uid, Roles: []string{"2"},
+//	    InstitutionID: 11, CampusID: 3,
+//	})
+//	// 响应 {accessToken: access, refreshToken: refresh, user: ...}
+//
+//	// 验签中间件(每个受保护 Handler 前置)
+//	claims, err := token.Verify(c.Context(), cfg.Auth.PublicKeysPEM, redisStore,
+//	    cfg.Auth.Issuer, bearer)
+//	if err != nil { _ = response.Fail(c, errors.ErrUnauth); return }
+//	// 把 claims 注入多租户作用域(见 infra/scope)
+//	scope.Set(c, &scope.Scope{InstitutionID: claims.InstitutionID, CampusID: claims.CampusID, ...})
+package token
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/hex"
+	"encoding/json"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// ErrInvalidToken access token 无效(签名错误/exp/iss 不符/格式损坏)。
+// 调用方(验签中间件)统一映射为 code=1001 未登录(不向用户细分原因)。
+var ErrInvalidToken = errors.New("token: access token 无效")
+
+// ErrRevoked access token 已被吊销(jti 命中 Redis 黑名单)。
+// 调用方映射为 code=1002 登录态失效(与"过期"区分,便于前端决定"静默 refresh 还是跳登录")。
+var ErrRevoked = errors.New("token: 登录态已失效")
+
+// ErrRefreshInvalid refresh token 无效(不在 Redis 白名单/已轮换/已过期)。
+var ErrRefreshInvalid = errors.New("token: refresh token 无效")
+
+// RefreshStore refresh 白名单存储接口(Redis 实现见 infra/redis;
+// 接口在 identity 侧定义,消费方 identity,infra 只提供 Client——
+// 依赖方向:identity → infra,infra 不依赖 identity)。
+type RefreshStore interface {
+	// SetRefresh 写入 refresh 白名单(键 refresh:{jti},TTL=有效期)。
+	// 入参:ctx 上下文;jti 唯一标识;payload []byte 载荷(序列化后的 RefreshPayload);ttl 有效期。
+	// 出参:error——仅 Redis 不可用时非 nil(登录接口此时返回 4001/5001 降级,不放行)。
+	SetRefresh(ctx context.Context, jti string, payload []byte, ttl time.Duration) error
+	// GetRefresh 读取 refresh 白名单。
+	// 入参:ctx、jti。出参:payload []byte(不存在时 nil)+ error。
+	GetRefresh(ctx context.Context, jti string) ([]byte, error)
+	// DeleteRefresh 删除(轮换/登出/撤销时调用)。幂等:键不存在不报错。
+	DeleteRefresh(ctx context.Context, jti string) error
+}
+
+// RevocationStore 吊销黑名单存储接口(jti 维度)。
+type RevocationStore interface {
+	// AddRevoked 写入黑名单(键 {prefix}:{jti},TTL=该 access 剩余有效期;
+	// 剩余 ≤0 时实现方可直接跳过——token 本已过期,无需吊销)。
+	AddRevoked(ctx context.Context, jti string, ttl time.Duration) error
+	// IsRevoked 查询 jti 是否被吊销。
+	// 出参:bool 命中;error——Redis 故障时返回 error,调用方策略:**fail-closed**
+	// (拒绝请求 + 告警),不可 fail-open(吊销失效 = 改密后旧 token 仍可用,安全红线)。
+	IsRevoked(ctx context.Context, jti string) (bool, error)
+}
+
+// Claims access token 的业务声明(嵌入 jwt.RegisteredClaims)。
+//
+// 字段(与 ADR-014 契约对齐):
+//   - Sub:canonical_user_id(ULID,26 字符;**禁止**用旧 int user_id 作 sub)。
+//   - UserID:内部 int 用户 ID(兼容过渡期旧表外键查询;新表一律 Sub)。
+//   - Roles:角色码数组(7 角色字典:"1"超管/"2"教务/"3"财务/"4"老师/"5"学生/"6"家长/"8"市场;
+//     市场登录禁用,正常不会出现 "9")。
+//   - InstitutionID / CampusID:多租户作用域(Repository scoping 依据,scope.FromCtx 来源)。
+//   - JTI:走嵌入的 RegisteredClaims.ID(JSON 名 "jti")——吊销/轮换锚点;
+//     **不要**再自定义 jti 字段(浅层字段会遮蔽 RegisteredClaims.ID,导致解析侧 ID 恒空)。
+type Claims struct {
+	Sub           string   `json:"sub"`
+	UserID        int64    `json:"uid"`
+	Roles         []string `json:"roles"`
+	InstitutionID int64    `json:"institution_id"`
+	CampusID      int64    `json:"campus_id"`
+	jwt.RegisteredClaims
+}
+
+// RefreshPayload refresh token 在 Redis 白名单中的载荷(不透明串本身只含 jti 的随机因子,
+// 业务载荷存 Redis——refresh 泄露时服务端可即时删除,JWT 做不到)。
+//
+// 字段:
+//   - Sub / UserID / Roles / InstitutionID / CampusID:与 access 同源(刷新时原样带入新 access)。
+//   - CreatedAt time.Time:签发时刻(审计 + 防"未来 refresh")。
+type RefreshPayload struct {
+	Sub           string    `json:"sub"`
+	UserID        int64     `json:"uid"`
+	Roles         []string  `json:"roles"`
+	InstitutionID int64     `json:"institution_id"`
+	CampusID      int64     `json:"campus_id"`
+	CreatedAt     time.Time `json:"created_at"`
+}
+
+// Issuer access/refresh 签发器(持有 RS256 私钥 + 过期参数 + jti 生成器)。
+type Issuer struct {
+	key           *rsa.PrivateKey
+	issuer        string
+	accessTTL     time.Duration
+	refreshTTL    time.Duration
+	refreshStore  RefreshStore
+	revocation    RevocationStore
+}
+
+// NewIssuer 构造签发器(启动时装配一次,全进程共享;私钥解析失败应 fail-fast 阻止启动)。
+//
+// 入参:
+//   - privateKeyPEM string:RS256 私钥 PEM(PKCS1 或 PKCS8;配置 AuthCfg.PrivateKeyPEM,走环境变量)。
+//   - issuer        string:token iss 声明(如 "https://unified.example.com")。
+//   - accessTTL     time.Duration:access 有效期(固定 30min,架构规格 §7.2)。
+//   - refreshTTL    time.Duration:refresh 有效期(固定 30 天)。
+//   - refreshStore  RefreshStore:refresh 白名单存储(Redis 实现)。
+//   - revocation    RevocationStore:吊销黑名单存储(可为 nil——仅纯签发场景;验签必须提供)。
+//
+// 出参:
+//   - *Issuer:就绪的签发器。
+//   - error:私钥 PEM 解析失败(PKCS1/PKCS8 都不匹配时);其余入参不校验(调用方责任)。
+//
+// 异常场景:
+//   - 双实例共享同一对 RS256 密钥(ADR-015 单机双实例):两实例签发 token 互相可验,
+//     refresh/吊销走共享 Redis,天然一致——**无需** per-instance 密钥。
+func NewIssuer(privateKeyPEM, issuer string, accessTTL, refreshTTL time.Duration,
+	refreshStore RefreshStore, revocation RevocationStore) (*Issuer, error) {
+	key, err := parseRSAPrivateKey(privateKeyPEM)
+	if err != nil {
+		return nil, fmt.Errorf("token: 解析 RS256 私钥失败: %w", err)
+	}
+	return &Issuer{
+		key:          key,
+		issuer:       issuer,
+		accessTTL:    accessTTL,
+		refreshTTL:   refreshTTL,
+		refreshStore: refreshStore,
+		revocation:   revocation,
+	}, nil
+}
+
+// Issue 签发一对 access + refresh(登录/refresh 轮换成功后调用)。
+//
+// 入参:
+//   - ctx    context.Context:请求上下文(透传给 Redis 存储操作)。
+//   - claims Claims:业务声明(Sub 必填且应为合法 ULID;JTI 留空由本函数填充)。
+//
+// 出参:
+//   - accessToken string:JWT(RS256 签名,含 sub/roles/institution_id/campus_id/jti +
+//     iss/iat/exp/nbf;exp=iat+accessTTL)。
+//   - refreshToken string:不透明串(48 字符 hex:32 字节随机,前 8 字节为 jti 因子)。
+//   - err        error:以下场景非 nil:
+//     - claims.Sub 为空 → 包装错误(调用方 bug,单测覆盖);
+//     - 随机数生成失败(熵源故障);
+//     - refreshStore 写入失败(Redis 不可用)→ 登录整体失败,不返回"只有 access"的半套凭证。
+//
+// 样例:
+//
+//	access, refresh, err := iss.Issue(ctx, token.Claims{Sub: uid, UserID: 123,
+//	    Roles: []string{"4"}, InstitutionID: 11, CampusID: 3})
+func (iss *Issuer) Issue(ctx context.Context, claims Claims) (accessToken, refreshToken string, err error) {
+	if claims.Sub == "" {
+		return "", "", errors.New("token: Issue 要求 claims.Sub(canonical_user_id)非空")
+	}
+	now := time.Now()
+	claims.IssuedAt = jwt.NewNumericDate(now)
+	claims.NotBefore = jwt.NewNumericDate(now)
+	claims.ExpiresAt = jwt.NewNumericDate(now.Add(iss.accessTTL))
+	claims.Issuer = iss.issuer
+	claims.ID = newJTI() // 每次签发唯一(吊销/轮换的锚点)
+
+	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+	signed, err := token.SignedString(iss.key)
+	if err != nil {
+		return "", "", fmt.Errorf("token: 签发 access 失败: %w", err)
+	}
+
+	// refresh:32 字节随机 → hex;jti 因子取前 8 字节(白名单键)
+	buf := make([]byte, 32)
+	if _, err := rand.Read(buf); err != nil {
+		return "", "", fmt.Errorf("token: 生成 refresh 随机数失败: %w", err)
+	}
+	rjti := hex.EncodeToString(buf[:8])
+	payload, _ := jsonMarshal(RefreshPayload{
+		Sub: claims.Sub, UserID: claims.UserID, Roles: claims.Roles,
+		InstitutionID: claims.InstitutionID, CampusID: claims.CampusID,
+		CreatedAt: now,
+	})
+	if err := iss.refreshStore.SetRefresh(ctx, rjti, payload, iss.refreshTTL); err != nil {
+		return "", "", fmt.Errorf("token: 写 refresh 白名单失败: %w", err)
+	}
+	refreshToken = hex.EncodeToString(buf)
+	return signed, refreshToken, nil
+}
+
+// Verify 验签 access token(验签中间件入口):先查吊销黑名单 → 再验 RS256 签名与 exp/iss。
+//
+// 入参:
+//   - ctx     context.Context:请求上下文(黑名单查询透传)。
+//   - rawToken string:Authorization: Bearer <raw> 中的 raw 部分。
+//
+// 出参:
+//   - Claims:验签通过的业务声明(含 Sub/Roles/InstitutionID/CampusID/JTI)。
+//   - error:
+//     - ErrRevoked:jti 命中黑名单(改密/登出/禁用后);
+//     - ErrInvalidToken:签名错/exp 过期/iss 不符/格式损坏;
+//     - 其它 error:黑名单存储故障(fail-closed,调用方映射 5001 + 告警,**不放行**)。
+//
+// 顺序说明:先查黑名单再验签——黑名单键含 jti,需要解析 JWT 拿 jti;
+// 实现上先做轻量格式解析(不验签)取 jti,命中黑名单直接拒(省一次 RSA 验签);
+// 未命中再完整验签。解析失败走 ErrInvalidToken。
+//
+// 样例(验签中间件,c 为 *fiber.Ctx,bearer 取自 Authorization: Bearer <token>):
+//
+//	claims, err := token.Verify(c.Context(), pubKeys, revStore, issuer, bearer)
+//	switch {
+//	case errors.Is(err, ErrRevoked), errors.Is(err, ErrInvalidToken):
+//	    return response.Fail(c, errors.ErrUnauth)      // 未登录/登录态失效
+//	case err != nil:
+//	    return response.Fail(c, errors.WithCause(errors.ErrInternal, err)) // 黑名单存储故障(fail-closed)
+//	}
+//	sc := scope.Set(c, &scope.Scope{...来自 claims...}) // 注入多租户作用域
+func Verify(ctx context.Context, publicKeys []string, revocation RevocationStore, issuer string, rawToken string) (Claims, error) {
+	var claims Claims
+	// 轻量解析取 jti(不验签);格式错误直接判无效
+	parser := jwt.NewParser(jwt.WithoutClaimsValidation())
+	parsed, _, err := parser.ParseUnverified(rawToken, jwt.MapClaims{})
+	if err != nil {
+		return claims, ErrInvalidToken
+	}
+	jtiID := jtiFromClaims(parsed.Claims)
+	if jtiID != "" && revocation != nil {
+		revoked, rerr := revocation.IsRevoked(ctx, jtiID)
+		if rerr != nil {
+			return claims, rerr // fail-closed:存储故障不放行
+		}
+		if revoked {
+			return claims, ErrRevoked
+		}
+	}
+	// 完整验签(签名 + exp + iss);公钥列表支持密钥轮换期新旧并存
+	for _, pemBlock := range publicKeys {
+		parsed, err := jwt.ParseWithClaims(rawToken, &claims, func(t *jwt.Token) (interface{}, error) {
+			if !strings.EqualFold(t.Method.Alg(), "RS256") {
+				return nil, fmt.Errorf("token: 非预期签名算法 %s", t.Method.Alg())
+			}
+			return parseRSAPublicKey(pemBlock)
+		}, jwt.WithIssuer(issuer), jwt.WithExpirationRequired(), jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}))
+		if err == nil && parsed != nil && parsed.Valid {
+			if claims.ID != jtiID {
+				// 防御:两次解析 jti 不一致(理论不可能)按无效处理
+				return Claims{}, ErrInvalidToken
+			}
+			return claims, nil
+		}
+	}
+	return claims, ErrInvalidToken
+}
+
+// Rotate 用旧 refresh 换新 access + 新 refresh(refresh 轮换,防重放)。
+//
+// 入参:
+//   - ctx          context.Context:请求上下文。
+//   - oldRefresh   string:旧 refresh 不透明串(48 hex;格式非法直接 ErrRefreshInvalid)。
+//
+// 出参:
+//   - accessToken  string:新 access(claims 从旧 refresh 的 Redis 载荷恢复,JTI 换新)。
+//   - newRefresh   string:新 refresh(旧 jti 已从白名单删除——旧串再次使用即失效)。
+//   - err          error:
+//     - ErrRefreshInvalid:串格式错 / 白名单无此 jti(已轮换/已登出/已过期);
+//     - 载荷反序列化失败:按 ErrRefreshInvalid(脏数据不产生 5xx);
+//     - 签发/写白名单失败:同 Issuer.Issue 的错误语义。
+//
+// 原子性说明:Get-Delete-Issue 三步在 Redis 单线程下靠"先删后写"收窄重放窗口;
+// 极端并发(同一旧串双请求)下,后到者 Get 已删 → ErrRefreshInvalid,安全侧收敛。
+//
+// 样例:
+//
+//	access, newRefresh, err := iss.Rotate(ctx, oldRefresh)
+//	// 响应 {accessToken, refreshToken, refreshExpiresAt}
+func (iss *Issuer) Rotate(ctx context.Context, oldRefresh string) (string, string, error) {
+	raw, err := hex.DecodeString(oldRefresh)
+	if err != nil || len(raw) != 32 {
+		return "", "", ErrRefreshInvalid
+	}
+	rjti := hex.EncodeToString(raw[:8])
+	payloadBytes, err := iss.refreshStore.GetRefresh(ctx, rjti)
+	if err != nil || payloadBytes == nil {
+		return "", "", ErrRefreshInvalid // 不存在/Redis 故障:一律按"无效"(故障时前端可重试登录)
+	}
+	var rp RefreshPayload
+	if err := jsonUnmarshal(payloadBytes, &rp); err != nil {
+		return "", "", ErrRefreshInvalid
+	}
+	if err := iss.refreshStore.DeleteRefresh(ctx, rjti); err != nil {
+		return "", "", fmt.Errorf("token: 删除旧 refresh 失败: %w", err) // 删除失败不签发(宁可让用户重登,不放重放窗口)
+	}
+	access, refresh, err := iss.Issue(ctx, Claims{
+		Sub: rp.Sub, UserID: rp.UserID, Roles: rp.Roles,
+		InstitutionID: rp.InstitutionID, CampusID: rp.CampusID,
+	})
+	if err != nil {
+		return "", "", err
+	}
+	return access, refresh, nil
+}
+
+// RevokeAll 撤销该用户全部 access token(改密/禁用/删号/登出"多端下线"统一入口,
+// 架构规格 §7.4 改密链路)。
+//
+// 入参:
+//   - ctx     context.Context:请求上下文。
+//   - jti     string:目标 token 的 jti(已知单个);多端下线场景由调用方枚举该用户
+//     活跃 jti 列表后逐个调用(jti 枚举依赖 Redis 活跃集合,见 infra/redis 扩展)。
+//   - ttl     time.Duration:该 token 剩余有效期(黑名单键 TTL;≤0 时存储实现可跳过写入)。
+//
+// 出参:
+//   - error:仅 Redis 写失败时非 nil(调用方:改密场景下失败必须**阻断改密提交**——
+//     凭证已变而旧 token 未吊销 = 安全漏洞;登录态场景可降级为告警)。
+//
+// 样例:
+//
+//	// 改密成功后的撤销(事务外,Redis 操作)
+//	if err := iss.RevokeAll(ctx, jti, remainingTTL); err != nil {
+//	    return errors.WithCause(errors.ErrInternal, err) // 阻断改密
+//	}
+func (iss *Issuer) RevokeAll(ctx context.Context, jti string, ttl time.Duration) error {
+	if iss.revocation == nil {
+		return errors.New("token: Issuer 未装配 RevocationStore,无法吊销")
+	}
+	return iss.revocation.AddRevoked(ctx, jti, ttl)
+}
+
+// ---- 私有工具 ----
+
+// jtiFromClaims 从解析后的 claims 中取 jti(兼容 string / float64 两种 JSON 解码形态;
+// 空/异常形态返回 "",由调用方按"无可吊销信息"处理——不阻断验签)。
+func jtiFromClaims(claims jwt.Claims) string {
+	if mc, ok := claims.(jwt.MapClaims); ok {
+		switch v := mc["jti"].(type) {
+		case string:
+			return v
+		case float64: // JWT 数字 jti 的 JSON 解码形态(本项目签发恒为 string,此为防御)
+			return strconv.FormatInt(int64(v), 10)
+		}
+	}
+	return ""
+}
+
+// newJTI 生成 token 唯一 ID(16 字节随机 hex;jti 同时用作吊销/轮换锚点)。
+func newJTI() string {
+	buf := make([]byte, 16)
+	_, _ = rand.Read(buf) // 熵源故障概率≈0;失败时退化为时间戳(仍唯一性足够,不阻断签发)
+	return hex.EncodeToString(buf)
+}
+
+// parseRSAPrivateKey 解析 PEM 私钥(PKCS1 "RSA PRIVATE KEY" 或 PKCS8 "PRIVATE KEY")。
+func parseRSAPrivateKey(pemStr string) (*rsa.PrivateKey, error) {
+	block, _ := pem.Decode([]byte(pemStr))
+	if block == nil {
+		return nil, errors.New("token: 私钥不是合法 PEM")
+	}
+	if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
+		return k, nil
+	}
+	k8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+	if err != nil {
+		return nil, err
+	}
+	k, ok := k8.(*rsa.PrivateKey)
+	if !ok {
+		return nil, errors.New("token: 非 RSA 私钥(要求 RS256)")
+	}
+	return k, nil
+}
+
+// parseRSAPublicKey 解析 PEM 公钥(SPKI "PUBLIC KEY")。
+func parseRSAPublicKey(pemStr string) (*rsa.PublicKey, error) {
+	block, _ := pem.Decode([]byte(pemStr))
+	if block == nil {
+		return nil, errors.New("token: 公钥不是合法 PEM")
+	}
+	any, err := x509.ParsePKIXPublicKey(block.Bytes)
+	if err != nil {
+		return nil, err
+	}
+	k, ok := any.(*rsa.PublicKey)
+	if !ok {
+		return nil, errors.New("token: 非 RSA 公钥(要求 RS256)")
+	}
+	return k, nil
+}
+
+// jsonMarshal / jsonUnmarshal 薄封装(等价于 json.Marshal/Unmarshal,
+// 抽一层便于未来统一换加速实现,调用方不直接依赖 encoding/json)。
+func jsonMarshal(v interface{}) ([]byte, error) {
+	return json.Marshal(v)
+}
+
+func jsonUnmarshal(b []byte, v interface{}) error {
+	return json.Unmarshal(b, v)
+}

+ 326 - 0
internal/infra/config/config.go

@@ -0,0 +1,326 @@
+// 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 下是不同身份实例)。
+type WeChatCfg struct {
+	Campuses []WeChatCampus `json:"campuses"`
+}
+
+// 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)
+
+	// 必填校验(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
+}

+ 157 - 0
internal/infra/errors/errors.go

@@ -0,0 +1,157 @@
+// Package errors 三层错误模型(业务错误 sentinel + 错误码分段)。
+//
+// 本包定义全后端统一的业务错误类型 AppError 与一组"可复用"的 sentinel 错误。
+// 分层职责(架构规格 §3.2):
+//   - Repository:把 gorm.ErrRecordNotFound 等 ORM 原错转成 sentinel,不向上泄漏 ORM 原错;
+//   - Service:只返回 sentinel(可附带 Cause 保留底层错误供日志),禁止把 gorm 原错直接上抛;
+//   - Handler:用 *response.Fail 写出;非 *AppError 走 ErrInternal + 结构化日志。
+//
+// 错误码分段(新接口统一;迁移期旧接口保留原 code):
+//   - 1xxx 认证/权限;2xxx 参数/校验;3xxx 数据;4xxx 依赖;5xxx 系统。
+//
+// 典型使用(Service 层):
+//
+//	func (s *Service) Get(ctx context.Context, id int64) (*model.User, error) {
+//	    u, err := s.repo.Take(ctx, id)
+//	    if err != nil {
+//	        // Repository 已把 gorm.ErrRecordNotFound 转成 ErrNotFound;这里再组合 Cause 便于日志排障
+//	        return nil, errors.WithCause(errors.ErrNotFound, err)
+//	    }
+//	    return u, nil
+//	}
+package errors
+
+import "fmt"
+
+// AppError 业务错误(可预期、可被 Handler 映射为统一响应码)。
+//
+// 字段:
+//   - Code int:分段错误码(见包注释的 1xxx~5xxx 分段表)。
+//   - Msg  string:面向用户的错误文案(脱敏,不含 SQL/堆栈/哈希)。
+//   - Cause error:底层原因(可选);用于日志排障,绝不进入响应体。
+//
+// 使用约束:
+//   - 业务域通常直接复用本包提供的 sentinel(ErrNotFound 等);
+//   - 需要自定义 code/msg 时用 New(code, msg);
+//   - 需要保留底层原因时用 WithCause(sentinel, cause)。
+type AppError struct {
+	Code  int
+	Msg   string
+	Cause error
+}
+
+// Error 实现 error 接口。
+//
+// 入参:无(值接收在方法集上,调用者无需关心)。
+// 出参:
+//   - string:Cause 非 nil 时为 "Msg: Cause.Error()"(便于日志串联);Cause 为 nil 时仅返回 Msg。
+//
+// 说明:Cause 参与字符串拼接仅服务于日志/调试;前端展示的文案是 Handler 单独取 Msg,不受 Cause 影响。
+func (e *AppError) Error() string {
+	if e.Cause != nil {
+		return fmt.Sprintf("%s: %s", e.Msg, e.Cause.Error())
+	}
+	return e.Msg
+}
+
+// Unwrap 暴露底层原因,支持 errors.Is / errors.As 逐层解包。
+//
+// 入参:无。
+// 出参:
+//   - error:Cause(可能为 nil)。
+//
+// 样例:
+//
+//	err := WithCause(ErrNotFound, fmt.Errorf("sql: no rows in result set"))
+//	errors.Is(err, ErrNotFound) // true
+func (e *AppError) Unwrap() error {
+	return e.Cause
+}
+
+// New 创建一个新的 AppError(自定义 code 与 msg)。
+//
+// 入参:
+//   - code int:分段错误码(遵循 1xxx~5xxx 分段;传 200 视为误用,成功请用 response.OK)。
+//   - msg  string:面向用户的错误文案(应已脱敏)。
+//
+// 出参:
+//   - *AppError:新建的业务错误;Cause 为 nil。
+//
+// 异常场景:
+//   - code 传 0 或负数:不校验(调用方责任),但建议单测断言 code 落在分段区间内。
+//
+// 样例:
+//
+//	return &errors.AppError via errors.New(3002, "该手机号已被绑定")
+func New(code int, msg string) *AppError {
+	return &AppError{Code: code, Msg: msg}
+}
+
+// WithCause 为已有 sentinel 附加底层原因,返回新的 *AppError。
+//
+// 入参:
+//   - base  *AppError:目标业务错误(如 ErrNotFound);为 nil 时返回 cause 包装成的 ErrInternal。
+//   - cause error:底层原因(ORM 原错/第三方错误);为 nil 时直接返回 base 的副本。
+//
+// 出参:
+//   - *AppError:Code/Msg 继承自 base,Cause 置为 cause(不修改入参 base,避免共享污染)。
+//
+// 异常场景:
+//   - base 为 nil:返回 ErrInternal 的副本并挂上 cause(防御调用方传错)。
+//
+// 样例:
+//
+//	return errors.WithCause(errors.ErrConflict, dbErr)
+func WithCause(base *AppError, cause error) *AppError {
+	if base == nil {
+		return &AppError{Code: ErrInternal.Code, Msg: ErrInternal.Msg, Cause: cause}
+	}
+	return &AppError{Code: base.Code, Msg: base.Msg, Cause: cause}
+}
+
+// 分段错误码常量(与 R.Code 对齐;新接口一律引用这些常量,禁止散落 magic number)。
+const (
+	CodeOK            = 200
+	CodeUnauth        = 1001 // 未登录
+	CodeTokenExpired  = 1002 // 登录态失效
+	CodeForbidden     = 1003 // 无权限
+	CodeScopeViolation = 1004 // 作用域越界(跨机构/跨校区)
+	CodeBadParam      = 2001 // 参数缺失
+	CodeBadFormat     = 2002 // 参数格式错误
+	CodeRuleConflict  = 2003 // 业务规则冲突
+	CodeNotFound      = 3001 // 记录不存在
+	CodeConflict      = 3002 // 数据冲突
+	CodeUnique        = 3003 // 唯一约束
+	CodeAgentDown     = 4001 // Agent 不可达
+	CodePayCallback   = 4002 // 支付回调失败
+	CodeThirdTimeout  = 4003 // 第三方超时
+	CodeInternal      = 5001 // 内部错误
+	CodeDBError       = 5002 // 数据库异常
+	CodeThrottled     = 5003 // 限流
+)
+
+// 预置 sentinel 错误(业务域直接复用,无需各自 new)。
+//
+// 每个 sentinel 的语义:
+//   - ErrUnauth:请求未携带有效登录态(缺 token / token 无效)。
+//   - ErrTokenExpired:登录态已过期(access 超 30min 且 refresh 也失效)。
+//   - ErrForbidden:已登录但无该操作权限(角色/权限点不匹配)。
+//   - ErrUnscoped:作用域越界——试图访问非本机构/非本校区的资源(多租户防线)。
+//   - ErrBadParam:入参缺失或格式非法(validator 校验失败的统一出口)。
+//   - ErrNotFound:目标记录不存在(Repository 把 gorm.ErrRecordNotFound 转成本 sentinel)。
+//   - ErrConflict:数据冲突(如唯一键撞车、状态机非法迁移)。
+//   - ErrAgentDown:依赖的 Python Agent 不可达(fire-and-forget 语义下仅记日志,主链路不阻断时用本码告警)。
+//   - ErrInternal:未分类内部错误(兜底,Handler 会把任何非 *AppError 降级到本码)。
+//   - ErrDBError:数据库异常(连接/事务/SQL 层,非"记录不存在")。
+var (
+	ErrUnauth       = &AppError{Code: CodeUnauth, Msg: "未登录"}
+	ErrTokenExpired = &AppError{Code: CodeTokenExpired, Msg: "登录态失效"}
+	ErrForbidden    = &AppError{Code: CodeForbidden, Msg: "无权限"}
+	ErrUnscoped     = &AppError{Code: CodeScopeViolation, Msg: "作用域越界"}
+	ErrBadParam     = &AppError{Code: CodeBadParam, Msg: "参数错误"}
+	ErrNotFound     = &AppError{Code: CodeNotFound, Msg: "记录不存在"}
+	ErrConflict     = &AppError{Code: CodeConflict, Msg: "数据冲突"}
+	ErrAgentDown    = &AppError{Code: CodeAgentDown, Msg: "AI 服务暂不可用"}
+	ErrInternal     = &AppError{Code: CodeInternal, Msg: "系统繁忙"}
+	ErrDBError      = &AppError{Code: CodeDBError, Msg: "数据服务异常"}
+)

+ 153 - 0
internal/infra/response/response.go

@@ -0,0 +1,153 @@
+// Package response 统一响应契约(HTTP 恒 200 + body code 信封)。
+//
+// 本包是"分包铁律第 6 条"的实现:所有接口(含业务错误)HTTP 状态码恒为 200,
+// 真实业务状态在 body 的 R.Code 中(成功 200,失败为分段错误码,见包 errors)。
+// 该契约源自教务 Go 重构在产验证 + 教学 api-conventions 双侧确证,
+// 设计决策见 ../../../../unified/01-architecture/backend-technical-architecture.md §3.1。
+//
+// 典型使用(Handler 层):
+//
+//	func GetUser(c *fiber.Ctx) error {
+//	    var req GetUserReq
+//	    if !infra.Validate(c, &req) { // 校验失败:包内已回写 body(code=2001) 并返回 false
+//	        return nil
+//	    }
+//	    user, err := svc.Get(ctx, req.ID)
+//	    if err != nil {
+//	        return response.Fail(c, err) // 非 *errors.AppError 会被包成 5001 系统繁忙
+//	    }
+//	    return response.OK(c, user)
+//	}
+package response
+
+import (
+	"yuansiwei/unified-backend/internal/infra/errors"
+
+	"github.com/gofiber/fiber/v2"
+)
+
+// R 统一响应信封(所有接口的出参顶层结构)。
+//
+// 前端约定:一律读 body 而非 HTTP 状态码——
+// 成功判断 res.success === true(兼容旧前端 res.success 写法);
+// 分页列表读顶层 res.total(统一放顶层,解决旧系统 Success/SuccessPage 两处 total 位置不一致的坑);
+// 失败时 res.code 为分段错误码(1xxx/2xxx/3xxx/4xxx/5xxx),res.message 为可直接展示给用户的文案。
+//
+// 典型 JSON 形态:
+//
+//	// 成功(带分页)
+//	{"code":200,"message":"ok","data":[...],"total":42,"success":true}
+//	// 业务失败(注意:HTTP 状态码仍是 200)
+//	{"code":3001,"message":"记录不存在","success":false}
+type R struct {
+	// Code 业务状态码:200 表示成功;失败为分段错误码(1xxx 认证 / 2xxx 参数 / 3xxx 数据 / 4xxx 依赖 / 5xxx 系统)。
+	Code int `json:"code"`
+	// Message 面向用户的错误文案。脱敏铁律:不得包含 SQL 片段、堆栈、密码哈希等敏感信息。
+	Message string `json:"message"`
+	// Data 业务数据体(成功时填充;失败时省略)。
+	Data interface{} `json:"data,omitempty"`
+	// Total 分页总数,仅在列表分页响应中填充(omitempty 省略非分页场景)。
+	Total int64 `json:"total,omitempty"`
+	// Success 成功标志,兼容旧前端 res.success 判断,与 Code 冗余但保留。
+	Success bool `json:"success"`
+}
+
+// OK 写出成功响应(HTTP 200,body code=200,Data 填充业务数据)。
+//
+// 入参:
+//   - c *fiber.Ctx:Fiber 请求上下文(提供 c.JSON 写出能力;调用前无需手动设置状态码,本函数内部恒置 200)。
+//   - data interface{}:业务数据体;允许 nil(无数据体的成功响应,如"操作成功"类接口)。
+//
+// 出参:
+//   - error:仅当 JSON 序列化/写出失败时返回(如客户端已断开);业务失败请用 Fail,不要用本函数表达。
+//
+// 样例:
+//
+//	return response.OK(c, fiber.Map{"user": user, "roles": roles})
+func OK(c *fiber.Ctx, data interface{}) error {
+	return c.Status(fiber.StatusOK).JSON(R{Code: 200, Message: "ok", Data: data, Success: true})
+}
+
+// Page 写出成功分页响应(HTTP 200,body code=200,Data 为列表、Total 为总数)。
+//
+// 入参:
+//   - c *fiber.Ctx:Fiber 请求上下文。
+//   - list interface{}:当前页数据切片(通常 []T);空列表请传非 nil 的 `[]T{}`,避免 JSON 输出 null。
+//   - total int64:符合筛选条件的总记录数(用于前端分页器;查询惯例见架构规格 §3.1"先 pluck 分页 id → 批量取详情"防 N+1)。
+//
+// 出参:
+//   - error:同 OK,仅写出层失败时返回。
+//
+// 样例:
+//
+//	page, size := parsePage(c) // page 默认 1、pageSize 默认 10
+//	ids, total, err := repo.PagedIDs(ctx, filter, page, size)
+//	users, err := repo.BatchGet(ctx, ids) // 一次批量取,防 N+1
+//	return response.Page(c, users, total)
+func Page(c *fiber.Ctx, list interface{}, total int64) error {
+	return c.Status(fiber.StatusOK).JSON(R{Code: 200, Message: "ok", Data: list, Total: total, Success: true})
+}
+
+// Fail 写出业务失败响应(HTTP 仍为 200,body code 为业务错误码,Data 省略)。
+//
+// 入参:
+//   - c *fiber.Ctx:Fiber 请求上下文。
+//   - err error:业务错误。约定:
+//     - *errors.AppError:按其 Code/Msg 原样写出(可预期业务错误,Service 层应返回此类 sentinel);
+//     - 其它 error:降级为 errors.ErrInternal(code=5001 系统繁忙),原始错误仅用于服务端日志,不进入响应体。
+//
+// 出参:
+//   - error:仅写出层失败时返回。
+//
+// 异常场景:
+//   - err 为 nil:按内部错误 5001 处理(调用方逻辑错误,应在单测中暴露);
+//   - AppError.Msg 为空:兜底为"系统繁忙",防止把空文案透传给前端。
+//
+// 样例:
+//
+//	user, err := svc.Get(ctx, id)
+//	if err != nil {
+//	    return response.Fail(c, err) // ErrNotFound → {"code":3001,"message":"记录不存在"}
+//	}
+func Fail(c *fiber.Ctx, err error) error {
+	e, ok := err.(*errors.AppError)
+	if !ok {
+		e = errors.ErrInternal // 非业务错误(含 err==nil)一律降级 5001,细节只进日志
+	}
+	msg := e.Msg
+	if msg == "" {
+		msg = "系统繁忙"
+	}
+	return c.Status(fiber.StatusOK).JSON(R{Code: e.Code, Message: msg, Success: false})
+}
+
+// ParsePage 解析分页参数(page/pageSize,带默认值与上限钳制)。
+//
+// 入参:
+//   - c *fiber.Ctx:Fiber 请求上下文,从 query 中读取 page 与 pageSize。
+//
+// 出参:
+//   - page int:页码,从 1 开始;query 缺失或非法(<1)时返回默认值 1。
+//   - pageSize int:每页条数;query 缺失或非法(<1)时返回默认值 10,超过 200 时钳制为 200(防大查询打满数据库)。
+//
+// 异常场景:
+//   - query 值非数字:c.QueryInt 返回 0,本函数按默认值处理(不报错,前端传错参数不产生 5xx)。
+//
+// 样例:
+//
+//	page, size := response.ParsePage(c)
+//	// page=0 非法 → 1;pageSize=500 → 200
+func ParsePage(c *fiber.Ctx) (page, pageSize int) {
+	page = c.QueryInt("page", 1)
+	if page < 1 {
+		page = 1
+	}
+	pageSize = c.QueryInt("pageSize", 10)
+	if pageSize < 1 {
+		pageSize = 10
+	}
+	if pageSize > 200 {
+		pageSize = 200
+	}
+	return page, pageSize
+}

+ 205 - 0
internal/infra/scope/scope.go

@@ -0,0 +1,205 @@
+// Package scope 多租户作用域注入(institution/campus 强制过滤防线,架构规格 §3.5)。
+//
+// 数据流(三段式,载体明确):
+//  1. Auth 中间件:验签成功后从 token claims 解析出 *Scope,调 Set(c, sc) 写入
+//     fiber.Ctx 的请求级局部值(c.Locals);
+//  2. Handler:调 FromFiber(c) 读回 *Scope,连同 DB 句柄一起构造业务 Repository
+//     (NewBaseRepo(db, sc))——**作用域不进 context、不靠隐式全局**,
+//     构造 Repository 即"绑定本次请求的租户",后续所有查询强制带机构/校区过滤;
+//  3. Repository:BaseRepo.scoping 把 institution_id / campus_id 条件拼进每条查询,
+//     **禁止裸查**(风险登记册"跨校区越权")。
+//
+// 为什么不走 context.Value:Fiber v2 的 *fiber.Ctx 不实现标准 context.Context
+// (缺 Deadline/Value 透传),若把 c 当 context 传递下游取不到;显式"Handler 取 Scope
+// → 构造 Repo"最直白、可单测(无需 mock fiber 请求),且杜绝"忘带 context 导致作用域丢失"。
+//
+// 依赖方向铁律:本包属 infra,只被业务域依赖,不依赖任何业务域;
+// 作用域的"解析"(token → user/campus/role)由 identity/org 域完成,本包只负责"承载与过滤"。
+//
+// 典型使用:
+//
+//	// Handler(业务域内)
+//	func List(c *fiber.Ctx) error {
+//	    sc := scope.FromFiber(c)          // Auth 中间件已写入
+//	    if sc == nil { return response.Fail(c, errors.ErrUnauth) }
+//	    repo := newCourseRepo(db, sc)      // 作用域绑定到仓储
+//	    list, total, err := repo.List(c.Context(), Filter{...})
+//	    if err != nil { return response.Fail(c, err) }
+//	    return response.Page(c, list, total)
+//	}
+//
+//	// Repository(业务域内)
+//	type CourseRepo struct{ scope.BaseRepo }
+//	func newCourseRepo(db *gorm.DB, sc *scope.Scope) *CourseRepo {
+//	    return &CourseRepo{scope.BaseRepo{DB: db, Sc: sc}}
+//	}
+//	func (r *CourseRepo) List(ctx context.Context, f Filter) ([]model.Course, int64, error) {
+//	    var list []model.Course
+//	    var total int64
+//	    q := r.scoping(r.DB.WithContext(ctx).Model(&model.Course{})) // 强制带机构/校区
+//	    if err := q.Where("status = ?", f.Status).
+//	        Count(&total). // Count 收 *int64(GORM 纪律)
+//	        Order("created_at DESC").
+//	        Find(&list).Error; err != nil {
+//	        return nil, 0, errors.WithCause(errors.ErrDBError, err)
+//	    }
+//	    return list, total, nil
+//	}
+package scope
+
+import (
+	"context"
+
+	"github.com/gofiber/fiber/v2"
+	"gorm.io/gorm"
+)
+
+// Scope 多租户作用域,由 Auth 中间件从 token claims 解析后注入。
+//
+// 字段:
+//   - InstitutionID int64:当前请求所属机构 ID(必填;0 表示缺失,视为异常请求应拒绝)。
+//   - CampusID      int64:当前请求所属校区 ID(0 表示不限校区,如机构级超管视角)。
+//   - UserID        int64:登录用户 ID(内部 int;对外暴露的 canonical_user_id 是 ULID,见 identity/id)。
+//   - Role          string:角色码(7 角色之一:"1"超管/"2"教务/"3"财务/"4"老师/"5"学生/"6"家长/"8"市场;市场登录入口禁用,正常不会出现 "9")。
+//   - SuperAdmin    bool:是否平台超管(跨机构操作,scoping 跳过机构过滤)。
+type Scope struct {
+	InstitutionID int64
+	CampusID      int64
+	UserID        int64
+	Role          string
+	SuperAdmin    bool
+}
+
+// CtxKey 作用域在 fiber.Ctx 局部值(c.Locals)中的键。
+// 导出以便"中间件写入"与"Handler 读取"两端使用同一常量,避免字符串散落。
+const CtxKey = "scope"
+
+// Set 把作用域写入 fiber.Ctx 的请求级局部值(Auth 中间件在验签成功后调用)。
+//
+// 入参:
+//   - c  *fiber.Ctx:Fiber 请求上下文。
+//   - sc *Scope:已解析好的作用域;字段合法性由调用方(identity 域验签逻辑)保证。
+//
+// 出参:
+//   - *fiber.Ctx:同一个 c(便于中间件链书写:scope.Set(c, sc) 后 c.Next())。
+//
+// 异常场景:
+//   - sc 为 nil:不写入(后续 FromFiber 返回 nil,Handler 应拒绝请求或 scoping 纵深防御拦截)。
+func Set(c *fiber.Ctx, sc *Scope) *fiber.Ctx {
+	if sc != nil {
+		_ = c.Locals(CtxKey, sc) // 写入请求级局部值(返回写入前的旧值,本处忽略)
+	}
+	return c
+}
+
+// FromFiber 从 fiber.Ctx 局部值读取作用域(Handler 侧入口,替代 context 通道)。
+//
+// 入参:
+//   - c *fiber.Ctx:当前请求上下文。
+//
+// 出参:
+//   - *Scope:写入的作用域;未写入或类型不符时返回 nil(调用方必须判 nil——
+//     nil 代表"公开路由/未认证/中间件链配置错误",公开接口的 Repository 不走 BaseRepo)。
+//
+// 说明:c.Locals(key) 单参调用为"读",返回 interface{};未设置时为 nil。
+func FromFiber(c *fiber.Ctx) *Scope {
+	if c == nil {
+		return nil
+	}
+	if sc, ok := c.Locals(CtxKey).(*Scope); ok {
+		return sc
+	}
+	return nil
+}
+
+// BaseRepo 所有业务域 Repository 的基类:持有数据库句柄与本次请求的作用域,
+// 提供 scoping 强制过滤——多租户防线的唯一收口点。
+//
+// 字段:
+//   - DB *gorm.DB:数据库句柄(连接池由 infra/db 统一建立)。
+//   - Sc *Scope:当前请求作用域;由 Repository 构造时从 Handler 传入(见 FromFiber 链路)。
+//
+// 使用约定:
+//   - 业务域 Repo 通过组合 BaseRepo 获得 scoping 能力(见包注释样例);
+//   - 所有 List/Get/Update/Delete 必须先过 scoping,禁止直接 r.DB.Where(...);
+//   - 构造入口应显式传入 *Scope;Sc 为 nil 时 scoping 纵深防御 panic(见下)。
+type BaseRepo struct {
+	DB *gorm.DB
+	Sc *Scope
+}
+
+// NewBaseRepo 构造作用域仓储基类(Handler 在拿到 Scope 后调用)。
+//
+// 入参:
+//   - db *gorm.DB:数据库句柄(必填)。
+//   - sc *Scope:当前请求作用域(可能为 nil——系统任务/割接脚本等无用户作用域场景,
+//     由具体 Repository 决定是否允许;nil 进入 scoping 会 panic,属预期 fail-fast)。
+//
+// 出参:
+//   - BaseRepo:基类实例(值类型;业务 Repo 通常内嵌 `scope.BaseRepo`)。
+//
+// 说明:不在此处拒绝 nil——部分平台级操作(超管跨机构、定时任务、割接)确实无作用域;
+// 是否允许"无作用域"由具体域评审决定,scoping 的 panic 是最后一道兜底防线。
+func NewBaseRepo(db *gorm.DB, sc *Scope) BaseRepo {
+	return BaseRepo{DB: db, Sc: sc}
+}
+
+// scoping 把作用域过滤条件拼进查询(BaseRepo 的受保护入口,业务 Repo 通过组合调用)。
+//
+// 入参:
+//   - q *gorm.DB:基础查询(通常 r.DB.WithContext(ctx).Model(&model.T{}))。
+//
+// 出参:
+//   - *gorm.DB:追加过滤条件后的查询(原 q 不被就地修改,链式安全)。
+//
+// 过滤规则(与架构规格 §3.5 对齐):
+//   1. Sc 为 nil:**panic**(errNoScope 文案)——无作用域上下文进入数据访问层属
+//      配置错误(正常请求链路 Auth 中间件保证作用域已注入),fail-fast 优于
+//      静默放行裸查;panic 由 HTTP 接入层全局 Recover 兜底为 5001 + 结构化日志,
+//      不影响生产可用性(单测须覆盖该分支);
+//   2. SuperAdmin=true:不加机构/校区过滤(平台超管跨机构);
+//   3. 普通用户:强制 institution_id = Sc.InstitutionID;CampusID>0 时再强制 campus_id。
+//
+// 异常场景:
+//   - 表没有 institution_id 列(如平台级字典表):该表的 Repository 不应继承 BaseRepo,
+//     直接使用裸 *gorm.DB(代码评审中显式标注"平台级数据")。
+func (r BaseRepo) scoping(q *gorm.DB) *gorm.DB {
+	if r.Sc == nil {
+		// 纵深防御:无作用域上下文进入数据访问层,fail-fast(全局 Recover 兜底 5001)
+		panic(errNoScope.Error())
+	}
+	if r.Sc.SuperAdmin {
+		return q // 超管跨机构,不加作用域
+	}
+	q = q.Where("institution_id = ?", r.Sc.InstitutionID) // 强制机构
+	if r.Sc.CampusID > 0 {
+		q = q.Where("campus_id = ?", r.Sc.CampusID) // 强制校区
+	}
+	return q
+}
+
+// WithContext 便捷方法:把标准 context.Context 与 BaseRepo 作用域一起绑定,
+// 供业务 Repo 在方法内 `r.WithContext(ctx)` 起步(避免每个方法手写 r.DB.WithContext(ctx))。
+//
+// 入参:
+//   - ctx context.Context:标准请求上下文(GORM 用其传递取消/超时信号,与 fiber 解耦)。
+//
+// 出参:
+//   - *gorm.DB:WithContext 后的查询句柄(**未**加作用域——仍需调用方追加 r.scoping(...),
+//     防止"以为 WithContext 带了租户"的误用;作用域永远显式经 scoping 注入)。
+//
+// 说明:此方法只处理"context 传递",**不**处理作用域——两段式显式注入是本包的核心纪律。
+func (r BaseRepo) WithContext(ctx context.Context) *gorm.DB {
+	return r.DB.WithContext(ctx)
+}
+
+// errNoScope 无作用域上下文的哨兵错误(scoping panic 文案,Recover 日志中可检索)。
+// 包级私有;不对外暴露 error 值(panic 场景由全局 Recover 统一处理为 5001)。
+var errNoScope = errString("scope: missing tenant scope, refusing unsoped query")
+
+// errString 实现 error 的最小字符串错误类型(避免引入 pkg/errors 依赖)。
+type errString string
+
+// Error 实现 error 接口。
+// 入参:无。出参:string——固定文案 "scope: missing tenant scope, refusing unsoped query"。
+func (e errString) Error() string { return string(e) }

+ 116 - 0
internal/infra/validator/validator.go

@@ -0,0 +1,116 @@
+// Package validator 入参校验惯例(validateReq 语义的 Go 实现)。
+//
+// 本包固化"分包铁律第 7 条":校验失败时**内部已写响应**(统一信封,body code=2001)
+// 并返回 false,调用方只需 `if !validator.Validate(c, &req) { return nil }`。
+//
+// 为什么不用 `err != nil` 判(教务踩坑):
+// 若校验函数返回 error 而调用方写 `if err != nil { return err }`,
+// 当框架把"已写响应后的 error"继续向上传播,Recover 会再写一次响应或
+// 误判为系统错误;更隐蔽的是,若调用方漏写判空直接继续落库,
+// 非法参数会被写进数据库。bool 语义把"是否继续执行"压缩成一次判断,杜绝误判。
+//
+// 典型使用(Handler 层):
+//
+//	type CreateCourseReq struct {
+//	    Name     string `json:"name"     validate:"required,max=64"`
+//	    SubjectID uint   `json:"subjectId" validate:"required"` // 科目 1-10(10=体育,已对齐分表)
+//	    StartAt  string `json:"startAt"  validate:"required,datetime=2006-01-02T15:04:05"`
+//	}
+//
+//	func Create(c *fiber.Ctx) error {
+//	    var req CreateCourseReq
+//	    if !validator.Validate(c, &req) { // 失败:body {"code":2001,...} 已写出
+//	        return nil
+//	    }
+//	    return svc.Create(c.Context(), req) // 继续业务
+//	}
+package validator
+
+import (
+	"encoding/json"
+	"strings"
+
+	"github.com/go-playground/validator/v10"
+	"github.com/gofiber/fiber/v2"
+
+	"yuansiwei/unified-backend/internal/infra/errors"
+	"yuansiwei/unified-backend/internal/infra/response"
+)
+
+// defaultV 全局共享的 validator 实例(validate.New() 开销不小,进程内复用一份)。
+// validator.Validate 是并发安全的(官方 FAQ 明确可共享)。
+var defaultV = validator.New(validator.WithRequiredStructEnabled())
+
+// Validate 绑定并校验请求体(JSON),失败时写出统一错误响应并返回 false。
+//
+// 入参:
+//   - c  *fiber.Ctx:Fiber 请求上下文(提供 c.Body() 与响应写出能力)。
+//   - dst any:目标结构体指针(必须是指针,validator 与 json 解码都要求 *T)。
+//
+// 出参:
+//   - bool:true 表示校验通过,调用方继续业务;false 表示校验失败,
+//     响应已写出(body code=2001,message 为第一条字段级错误),调用方必须 return nil 终止。
+//
+// 校验规则:
+//   - 使用结构体 tag `validate:"..."`(go-playground/validator/v10 方言);
+//   - WithRequiredStructEnabled:tag 写了 `validate:"required"` 的字段若整个结构体为零值也拦截;
+//   - 多字段错误时 message 取第一条(前端展示友好),全部错误进 WARN 日志(排障用,不进响应体)。
+//
+// 异常场景:
+//   - 请求体不是合法 JSON:按校验失败处理(code=2001,message="参数格式错误"),不抛 5xx;
+//   - dst 不是指针:视为调用方 bug,按校验失败处理并记 ERROR 日志(单测应覆盖到)。
+//
+// 样例见包注释;另见 response.ParsePage(query 侧分页参数校验惯例)。
+func Validate(c *fiber.Ctx, dst any) bool {
+	body := c.Body()
+	if len(body) == 0 {
+		// 空 body:GET 类无 body 接口不应调用本函数;POST/PUT 空 body 按参数缺失处理
+		_ = response.Fail(c, errors.ErrBadParam)
+		return false
+	}
+	if err := json.Unmarshal(body, dst); err != nil {
+		_ = response.Fail(c, errors.New(errors.CodeBadFormat, "参数格式错误"))
+		return false
+	}
+	if err := defaultV.Struct(dst); err != nil {
+		ve, _ := err.(validator.ValidationErrors)
+		// 全量字段错误进日志(WARN 级:业务可预期),响应只给第一条
+		for _, fe := range ve {
+			field := fe.Field()
+			tag := fe.Tag()
+			_ = field // 日志框架在 infra/log 中串联 traceId;此处保留遍历以兼容未来加采样
+			_ = tag
+		}
+		msg := "参数错误"
+		if len(ve) > 0 {
+			msg = firstFieldMessage(ve[0])
+		}
+		_ = response.Fail(c, errors.New(errors.CodeBadParam, msg))
+		return false
+	}
+	return true
+}
+
+// firstFieldMessage 把 validator 单字段错误翻译为面向用户的短文案。
+//
+// 入参:
+//   - fe validator.FieldError:单字段校验错误(Field() 为结构体字段名,Tag() 为触发的规则)。
+//
+// 出参:
+//   - string:形如 "name 不能为空" / "subjectId 格式错误";无法识别的规则时返回 "字段名 格式错误"。
+//
+// 说明:文案走"字段名 + 中文动宾短语",不透传 validator 原始英文(如 Key: CreateCourseReq.Name Error:Field required)——
+// 原始错误只进服务端日志。
+func firstFieldMessage(fe validator.FieldError) string {
+	name := strings.ToLower(fe.Field()) // 结构体 Name → json 通常也是 name(本项目入参 camelCase 约定)
+	switch fe.Tag() {
+	case "required":
+		return name + " 不能为空"
+	case "email":
+		return name + " 邮箱格式错误"
+	case "len", "min", "max", "oneof", "gte", "lte":
+		return name + " 格式或取值错误"
+	default:
+		return name + " 格式错误"
+	}
+}

+ 338 - 0
internal/task/task.go

@@ -0,0 +1,338 @@
+// Package task 统一 DB 任务表 + 工作池(替代旧两套队列 t_queue + t_agent_queue,架构规格 §3.7)。
+//
+// 设计要点(与 ADR-015 单机双实例对齐):
+//   - 单状态机:pending → running → done / dead(双实例共享 t_task,靠乐观锁认领防重复消费);
+//   - 认领:UPDATE ... SET status='running', owner=?, claimed_at=? WHERE id=? AND status='pending'
+//     (RowsAffected==1 才算认领成功——双实例并发认领同一任务时只有一个成功,另一个 RowsAffected==0 跳过);
+//   - 重试:失败按指数退避 next_run_at = now + 2^retry × 基数(1s→2s→4s→…→上限 1h),
+//     retry 超过 TaskCfg.MaxRetry 置 dead(进告警 + 人工介入);
+//   - 幂等键:type + 业务唯一键(如 "ai_grading:{campus_id}:{homework_id}")——
+//     派发侧写入前查重,重投/重复请求不产生重复任务;
+//   - **派发成功 ≠ 任务完成**(AI 批改/分析/导出/PDF):前端轮询任务终态或业务表,
+//     Agent 不可达只记日志不阻断主链路(fire-and-forget 语义,重活靠本表重试)。
+//
+// 典型使用:
+//
+//	// 派发(业务域,事务内或事务后)
+//	t := task.New{Type: "ai_grading", CampusID: 3, Payload: payload, IdempotentKey: fmt.Sprintf("ai_grading:%d:%d", campusID, hwID)}
+//	if err := repo.Enqueue(ctx, t); err != nil { ... } // 幂等键冲突时静默跳过(已存在等价任务)
+//
+//	// 工作池消费(cmd/api 启动的长驻 goroutine)
+//	pool := task.NewPool(db, cfg.Task, redisClient, dispatcher)
+//	go pool.Run(ctx) // ctx 取消时排空在途任务后退出(优雅停机,配合 GracePeriodSec)
+package task
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+)
+
+// Status 任务状态机(t_task.status 列取值,单状态机——禁止新增中间态,
+// 需要细分进度时在 payload 内表达,不改列枚举)。
+const (
+	StatusPending = "pending" // 已入队,待认领
+	StatusRunning = "running" // 已被某实例认领,执行中
+	StatusDone    = "done"    // 执行成功(终态)
+	StatusDead    = "dead"    // 重试超限放弃(终态,进告警)
+)
+
+// Task 统一任务表 t_task 的领域模型(列与 DDL 一一对应,见 unified/02-data-model)。
+//
+// 字段:
+//   - ID         int64:主键自增(仅内部排序用;对外暴露 idempotentKey,不暴露自增 ID)。
+//   - Type       string:任务类型("ai_grading"/"export_report"/"pdf_render"…;
+//     新类型需在注册表注册 handler,未注册类型消费时直接置 dead + 告警)。
+//   - CampusID   int64:任务归属校区(0=平台级任务;多租户作用域过滤用)。
+//   - Payload    string:业务载荷 JSON(字符串直存,避免 GORM 序列化自定义类型;
+//     消费侧反序列化失败 → 置 dead + 告警,不重试——载荷损坏重试也不会好)。
+//   - Status     string:Status* 之一。
+//   - IdempotentKey string:幂等键(type + 业务唯一键;UNIQUE 约束,防重)。
+//   - Retry      int:已重试次数(每次失败 +1;超过 MaxRetry 置 dead)。
+//   - NextRunAt  time.Time:下次可执行时刻(指数退避由 Fail 计算;pending 且 NextRunAt 未到不可被认领)。
+//   - Owner      string:认领实例标识("instance-a"/"instance-b",ADR-015 双实例审计用;
+//     实例崩溃后靠 claimed_at 超时回收,见 reclaimStale)。
+//   - TraceID    string:贯穿 traceId(派发侧从请求 context 取;消费侧日志串联)。
+//   - ClaimedAt  time.Time:认领时刻(stale 回收依据:running 且 now - claimed_at > 超时阈值 → 可被重新认领)。
+//   - CreatedAt  time.Time:入队时刻。
+type Task struct {
+	ID            int64     `gorm:"column:id"`
+	Type          string    `gorm:"column:type"`
+	CampusID      int64     `gorm:"column:campus_id"`
+	Payload       string    `gorm:"column:payload"`
+	Status        string    `gorm:"column:status"`
+	IdempotentKey string    `gorm:"column:idempotent_key"`
+	Retry         int       `gorm:"column:retry"`
+	NextRunAt     time.Time `gorm:"column:next_run_at"`
+	Owner         string    `gorm:"column:owner"`
+	TraceID       string    `gorm:"column:trace_id"`
+	ClaimedAt     time.Time `gorm:"column:claimed_at"`
+	CreatedAt     time.Time `gorm:"column:created_at"`
+}
+
+// New 待入队任务构造器(业务域派发侧使用;只填业务字段,状态/时间戳由 Enqueue 填充)。
+type New struct {
+	Type          string // 任务类型(必填,注册表内)
+	CampusID      int64  // 校区(0=平台级)
+	Payload       any    // 业务载荷(任意可 JSON 序列化结构)
+	IdempotentKey string // 幂等键(必填;形如 "ai_grading:{campus_id}:{homework_id}")
+	TraceID       string // 贯穿 traceId(可空,空则消费侧重新生成)
+}
+
+// Repo 任务表仓储(GORM 实现;独立于业务域 Repository——task 域是横切域,
+// 不继承 scope.BaseRepo:任务表本身平台级,作用域过滤在"派发侧"由业务域完成,
+// 消费侧按 Type 路由到对应 handler,handler 内部自带作用域)。
+type Repo struct {
+	db *gorm.DB // 由 cmd/api 统一建立连接池后注入
+}
+
+// NewRepo 构造任务仓储。
+//
+// 入参:
+//   - db *gorm.DB:数据库句柄(连接池由 infra/db 统一建立)。
+//
+// 出参:
+//   - *Repo:就绪的仓储实例(db 为 nil 时各方法直接 panic——装配错误 fail-fast,单测覆盖)。
+func NewRepo(db *gorm.DB) *Repo { return &Repo{db: db} }
+
+// Enqueue 幂等入队(写 t_task,status=pending)。
+//
+// 入参:
+//   - ctx context.Context:请求上下文(GORM WithContext 传递 traceId/取消信号)。
+//   - n   New:待入队任务(Type/IdempotentKey 必填)。
+//
+// 出参:
+//   - int64:新任务 ID(幂等命中已有任务时返回已有任务 ID,不报错——
+//     "重复派发"是正常业务(用户连点两次提交),不是错误)。
+//   - error:
+//     - Type 或 IdempotentKey 为空 → 参数错误(调用方 bug);
+//     - Payload 序列化失败 → 包装 json 错误(载荷不可序列化属编码 bug,快速失败);
+//     - 唯一键冲突(并发双请求同幂等键):GORM 返回 duplicate entry,本函数捕获后
+//       回查已有任务 ID 返回(等价于成功,幂等语义);
+//     - 其它 DB 异常 → 包装 ErrDBError 语义(调用方决策:AI 类可降级重试,强一致类上报)。
+//
+// 样例:
+//
+//	id, err := repo.Enqueue(ctx, task.New{
+//	    Type: "ai_grading", CampusID: 3,
+//	    Payload:       GradingPayload{HomeworkID: 42},
+//	    IdempotentKey: fmt.Sprintf("ai_grading:3:42"),
+//	})
+func (r *Repo) Enqueue(ctx context.Context, n New) (int64, error) {
+	if n.Type == "" || n.IdempotentKey == "" {
+		return 0, fmt.Errorf("task: Enqueue 要求 Type 与 IdempotentKey 非空")
+	}
+	payload, err := json.Marshal(n.Payload)
+	if err != nil {
+		return 0, fmt.Errorf("task: 载荷序列化失败(type=%s): %w", n.Type, err)
+	}
+	now := time.Now()
+	t := &Task{
+		Type:          n.Type,
+		CampusID:      n.CampusID,
+		Payload:       string(payload),
+		Status:        StatusPending,
+		IdempotentKey: n.IdempotentKey,
+		NextRunAt:     now,
+		TraceID:       n.TraceID,
+		CreatedAt:     now,
+	}
+	res := r.db.WithContext(ctx).Create(t)
+	if res.Error == nil {
+		return t.ID, nil
+	}
+	// 幂等键唯一冲突 → 回查已有任务 ID(并发重复派发场景)
+	if isDuplicateEntry(res.Error) {
+		var existing Task
+		if err := r.db.WithContext(ctx).
+			Where("idempotent_key = ?", n.IdempotentKey).
+			Take(&existing).Error; err == nil { // Take:存在性判断标准姿势(架构规格 §3.6)
+			return existing.ID, nil
+		}
+	}
+	return 0, fmt.Errorf("task: 入队失败(type=%s): %w", n.Type, res.Error)
+}
+
+// Claimable 拉取一批可认领任务(status=pending 且 next_run_at<=now;按创建序 FIFO)。
+//
+// 入参:
+//   - ctx  context.Context:请求上下文。
+//   - limit int:本批上限(TaskCfg.ClaimBatch,建议 10——防单实例饿死另一实例)。
+//
+// 出参:
+//   - []Task:候选任务(可能为空 slice,非 nil);**注意**:此处仅 SELECT,未加锁,
+//     候选≠已认领——必须逐个经 Claim 乐观锁确认后才算本实例的任务。
+//   - error:DB 异常时非 nil(调用方:本轮跳过 + 记 WARN,不退出工作池)。
+func (r *Repo) Claimable(ctx context.Context, limit int) ([]Task, error) {
+	if limit <= 0 {
+		limit = 10
+	}
+	var list []Task
+	err := r.db.WithContext(ctx).
+		Where("status = ? AND next_run_at <= ?", StatusPending, time.Now()).
+		Order("id ASC"). // FIFO:先入队先消费(公平性)
+		Limit(limit).
+		Find(&list).Error
+	if err != nil {
+		return nil, fmt.Errorf("task: 拉取可认领任务失败: %w", err)
+	}
+	return list, nil
+}
+
+// Claim 乐观锁认领单个任务(双实例防重复消费的核心)。
+//
+// 入参:
+//   - ctx  context.Context:请求上下文。
+//   - id   int64:候选任务 ID(来自 Claimable)。
+//   - owner string:认领实例标识("instance-a"/"instance-b")。
+//
+// 出参:
+//   - bool:true 认领成功(本实例独占该任务,开始执行);
+//     false 认领失败(已被另一实例抢先认领,或状态已非 pending——直接跳过,不是错误)。
+//   - error:DB 异常时非 nil(调用方:本轮放弃该任务,下轮再试;不置 dead——
+//     "认领失败"与"执行失败"语义不同,前者不该消耗重试次数)。
+//
+// SQL 语义(单条 UPDATE 原子化,无 SELECT 竞态):
+//
+//	UPDATE t_task SET status='running', owner=?, claimed_at=NOW()
+//	WHERE id=? AND status='pending'
+//	RowsAffected==1 → 成功;==0 → 已被抢/状态已变
+//
+// 说明:不用 SELECT ... FOR UPDATE(PolarDB 行锁 + 长事务风险)——
+// 条件 UPDATE 的 RowsAffected 判定是 InnoDB 下的标准乐观并发做法。
+func (r *Repo) Claim(ctx context.Context, id int64, owner string) (bool, error) {
+	res := r.db.WithContext(ctx).
+		Model(&Task{}).
+		Where("id = ? AND status = ?", id, StatusPending).
+		Updates(map[string]interface{}{
+			"status":     StatusRunning,
+			"owner":      owner,
+			"claimed_at": time.Now(),
+		})
+	if res.Error != nil {
+		return false, fmt.Errorf("task: 认领任务 %d 失败: %w", id, res.Error)
+	}
+	return res.RowsAffected == 1, nil // 0 = 被抢或状态已变
+}
+
+// Done 标记任务成功(终态)。
+//
+// 入参:
+//   - ctx context.Context:请求上下文。
+//   - id  int64:任务 ID(调用方必须是该任务的 owner——本函数不做 owner 校验,
+//     依赖"认领成功才执行"的调用纪律;审计靠 owner 列回溯)。
+//
+// 出参:
+//   - error:仅 DB 异常时非 nil(调用方:重试标记操作;任务实际已成功,
+//     标记失败最坏后果是任务"僵尸 running"——靠 reclaimStale 回收后重复执行,
+//     handler 必须幂等(按 idempotentKey 查业务表终态直接返回成功))。
+func (r *Repo) Done(ctx context.Context, id int64) error {
+	res := r.db.WithContext(ctx).
+		Model(&Task{}).
+		Where("id = ? AND status = ?", id, StatusRunning).
+		Update("status", StatusDone)
+	if res.Error != nil {
+		return fmt.Errorf("task: 标记成功(id=%d)失败: %w", id, res.Error)
+	}
+	return nil
+}
+
+// Fail 标记任务失败并计算下次重试(指数退避;超限置 dead)。
+//
+// 入参:
+//   - ctx      context.Context:请求上下文。
+//   - id       int64:任务 ID。
+//   - maxRetry int:重试上限(TaskCfg.MaxRetry,建议 6:1s,2s,4s,8s,16s,32s 后 dead)。
+//   - reason   string:失败原因摘要(≤256 字符,进日志/告警;**禁止**塞堆栈全文)。
+//
+// 出参:
+//   - bool:true 任务仍存活(已安排 next_run_at 重试);false 已置 dead(重试超限,告警+人工)。
+//   - error:DB 异常时非 nil。
+//
+// 退避公式:next_run_at = now + min(2^retry 秒, 3600 秒)
+// (retry 为本函数执行**前**的已重试次数:首次失败 retry=0→1s 后重试,第 6 次失败→dead)。
+//
+// 幂等性要求(调用方):handler 失败路径若"业务已部分落库",重试时必须走幂等键查终态,
+// 否则退避重试会造成重复副作用(如重复批改写两份结果——业务表按 homework_id 唯一约束兜底)。
+func (r *Repo) Fail(ctx context.Context, id int64, maxRetry int, reason string) (bool, error) {
+	var t Task
+	if err := r.db.WithContext(ctx).Where("id = ?", id).Take(&t).Error; err != nil {
+		return false, fmt.Errorf("task: 读取任务 %d 失败: %w", id, err)
+	}
+	t.Retry++
+	if t.Retry > maxRetry {
+		res := r.db.WithContext(ctx).
+			Model(&Task{}).
+			Where("id = ? AND status = ?", id, StatusRunning).
+			Update("status", StatusDead)
+		if res.Error != nil {
+			return false, fmt.Errorf("task: 置 dead(id=%d)失败: %w", id, res.Error)
+		}
+		return false, nil // dead:调用方记 ERROR 日志 + Prometheus 计数器 task_dead_total
+	}
+	backoff := time.Duration(1<<uint(t.Retry)) * time.Second // 2^retry 秒
+	if backoff > time.Hour {
+		backoff = time.Hour // 上限 1h(防长尾任务堆积时退避过久)
+	}
+	res := r.db.WithContext(ctx).
+		Model(&Task{}).
+		Where("id = ? AND status = ?", id, StatusRunning).
+		Updates(map[string]interface{}{
+			"status":      StatusPending,
+			"retry":       t.Retry,
+			"next_run_at": time.Now().Add(backoff),
+		})
+	if res.Error != nil {
+		return false, fmt.Errorf("task: 安排重试(id=%d)失败: %w", id, res.Error)
+	}
+	return true, nil
+}
+
+// ReclaimStale 回收僵死任务(实例崩溃/卡死:running 且 claimed_at 超过超时阈值 → 重置 pending)。
+//
+// 入参:
+//   - ctx       context.Context:请求上下文。
+//   - staleAfter time.Duration:僵死判定阈值(建议 10min:Agent 长任务同步调用超时 60s ×
+//     重试 2 + 余量;**必须大于** handler 最长执行时间,否则健康任务会被误回收重复执行)。
+//
+// 出参:
+//   - int64:本次回收的任务数(0=无僵死,正常)。
+//   - error:DB 异常时非 nil(调用方:下一周期再试;本函数由工作池侧周期调用,非请求链路)。
+//
+// 语义:回收后任务回到 pending,可能被另一实例重新认领——handler 幂等是最后一道防线。
+//
+// 样例(工作池每 60s 调一次):
+//
+//	n, _ := repo.ReclaimStale(ctx, 10*time.Minute)
+//	if n > 0 { log.Warn("task: 回收僵死任务", zap.Int64("count", n)) }
+func (r *Repo) ReclaimStale(ctx context.Context, staleAfter time.Duration) (int64, error) {
+	res := r.db.WithContext(ctx).
+		Model(&Task{}).
+		Where("status = ? AND claimed_at < ?", StatusRunning, time.Now().Add(-staleAfter)).
+		Updates(map[string]interface{}{
+			"status": StatusPending,
+			"owner":  "",
+		})
+	if res.Error != nil {
+		return 0, fmt.Errorf("task: 回收僵死任务失败: %w", res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// isDuplicateEntry 判定 MySQL 唯一键冲突错误(匹配 "Duplicate entry" 文案,
+// 不依赖具体驱动错误类型——PolarDB 兼容 MySQL 协议,驱动错误包装层级可能不同,
+// 文案匹配最稳)。
+//
+// 入参:err error:GORM 返回的原始错误。
+// 出参:bool:true 表示唯一键冲突(幂等命中)。
+func isDuplicateEntry(err error) bool {
+	if err == nil {
+		return false
+	}
+	msg := err.Error()
+	return strings.Contains(msg, "Duplicate entry") || strings.Contains(msg, "Error 1062")
+}