ソースを参照

feat(academic): AC-01 模型全量 27 表 + AC-02 共享内核

AC-01(model ↔ DDL 逐列对齐):
- internal/academic/model:models_fund / models_activity / models_divide /
  models_course / models_payroll + tables.go(AllTables 27 表,返回新切片)
- 每字段显式 column/size/type tag(金额 string+type:decimal(12,2)、时间 datetime(3)、
  tinyint(1)→bool、tinyint→int8、int→int32、smallint→int16、date→type:date、LONGTEXT→type:longtext)
- 每字段五维中文注释(语义与单位|枚举|写入时机|关联|红线)
- 补齐 DDL 声明的索引/唯一键(uk_request_type 幂等键、uk_biz_no×7、uk_user_activity 等)
- org model t_campus 增 self_support int8(compatibility:core-model-ddl.sql + field-spec 三处同步)
- 单测:27 表 ↔ DDL 列名/类型/可空逐列比对(go:embed fixture)+ 五维注释检查;
  真库门禁测试(ACADEMIC_MIGRATE_CHECK=1)AutoMigrate + information_schema 370 列 + 11 唯一键核对通过

AC-02(共享内核):
- money.Amount(int64 分):NewFromYuanString/String/Add/Sub/MulInt/Neg/Cmp/Validate/
  MarshalJSON/UnmarshalJSON/Value/Scan/GormDataType,全程无 float64 路径;29 组单测
- apperr:31xx 段 19 个错误码(设计 §10.1 全量;3114 仅内部日志)
- repository.BaseRepo(Scoped 强制作用域 + 超管放行)+ TxRunner(WithinTx 已在本事务则透传:
  ctx/tx 绑定两通道)+ 分页钳制 + IsDupKey
- port:CampusReader(含 GetByID/GetSchoolID/IsSelfSupport)+ TxCampusReader 扩展 +
  MembershipChecker + org 只读适配实现(不跨库、不写 org 表)

验证:go build ./... / go vet ./... / go test ./... -short 全绿
Kou 20 時間 前
親
コミット
bc3c970a70

+ 151 - 0
internal/academic/apperr/codes.go

@@ -0,0 +1,151 @@
+// Package apperr academic(教务)域业务错误码(**31xx 段**,跨任务唯一定义点)。
+//
+// 依据:unified/03-migration/academic-domain-design.md §10.1「错误码段位(academic = 31xx)」。
+//
+// 设计约束:
+//   - 31xx 段业务码全部在 **3100~3199** 内,禁止越界(单测断言,CI 门禁);
+//   - 通用语义复用 infra/errors 的 sentinel:1001 未登录 / 1003 无权限 / 1004 作用域越界 /
+//     2001 参数缺失 / 2002 格式错误 / 3001 不存在 / 5002 DB 异常——**本包不重复定义**;
+//   - 3103(可退金额不足)为 **warn 级**:不阻断流程,响应体带 badDebt 字段;
+//   - 3114(幂等重复)**仅内部日志用**,对外 code 仍返回 200 + data.repeated=true。
+//
+// 使用约定(Service 层):
+//
+//	return nil, apperr.ErrGiftAmountShort                       // 纯业务错误
+//	return nil, infraerrors.WithCause(apperr.ErrBalanceInsufficient, err) // 需保留底层原因排障
+package apperr
+
+import infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+
+// academic 业务错误码常量(31xx 段,取值与设计 §10.1 逐条对应)。
+const (
+	// CodeBalanceInsufficient 余额不足(消费/转结时可用余额 < 需要)。
+	CodeBalanceInsufficient = 3101
+	// CodeNoBalanceToRefund 账户余额为 0,不可退费(custom_refund / cleared_balance 前置校验)。
+	CodeNoBalanceToRefund = 3102
+	// CodeRefundableShort 可退金额不足,差额计坏账(**warn 级,不阻断**)。
+	CodeRefundableShort = 3103
+	// CodeCampusNotFound 校区不存在或未归属机构(跨域读 t_campus 失败 / school_id<=0)。
+	CodeCampusNotFound = 3104
+	// CodeActivityAlreadySigned 已报名该活动(signed_add 重复)。
+	CodeActivityAlreadySigned = 3105
+	// CodeRecommendLimited 老带新活动每人限参加一次(signed_add,活动 type=4)。
+	CodeRecommendLimited = 3106
+	// CodeActivitySignedAudited 活动已审核通过,不可取消(signed_cancel,type∈{2,3} 且已审核)。
+	CodeActivitySignedAudited = 3107
+	// CodeRecommendUsed 老带新已产生消费,不可取消(signed_cancel,type=4 且有 used_log)。
+	CodeRecommendUsed = 3108
+	// CodeGiftAmountShort 可用赠送金额不足(user_discount_use)。
+	CodeGiftAmountShort = 3109
+	// CodeActivityUnavailable 活动已过期或不可用(活动 status/时间窗校验)。
+	CodeActivityUnavailable = 3110
+	// CodeDivideQuotaExceeded 分账层级超限或额度超上限(log_add 深度>100 / update_quota 超上线)。
+	CodeDivideQuotaExceeded = 3111
+	// CodePayrollPlanMissing 课酬方案未配置(get_teacher_detail 无可用方案)。
+	CodePayrollPlanMissing = 3112
+	// CodeArrearsCounteractFailed 欠费核销失败(counteract_arrears 冲突)。
+	CodeArrearsCounteractFailed = 3113
+	// CodeRepeated 幂等重复请求(**仅内部日志**;对外仍 code=200 + data.repeated=true)。
+	CodeRepeated = 3114
+	// CodeClearBalanceRejected 余额清零前置条件不满足(余额≤0 / 校区余额>总余额)。
+	CodeClearBalanceRejected = 3120
+	// CodeRecordStateInvalid 打卡/上课状态非法(record start/finish 状态机)。
+	CodeRecordStateInvalid = 3121
+	// CodeScheduleConflict 排班冲突(record generate 同 courses+start_time 重复)。
+	CodeScheduleConflict = 3122
+	// CodeBusinessParam 多参数联合校验失败(campusId<=0、金额<=0 等;单参数优先 2001/2002)。
+	CodeBusinessParam = 3140
+	// CodeActivityFull 报名人数已满(signed_add:number_sign >= number_max)。
+	CodeActivityFull = 3141
+)
+
+// 金额/申请/活动/分账/课酬/课程 业务错误的预置 sentinel(业务域直接复用,禁止散落 magic message)。
+//
+// 文案口径:面向终端用户的中文短句,**不含 SQL/堆栈/内部编码**(脱敏纪律,架构规格 §3.3)。
+var (
+	// ErrBalanceInsufficient 余额不足(3101)。
+	ErrBalanceInsufficient = infraerrors.New(CodeBalanceInsufficient, "余额不足")
+	// ErrNoBalanceToRefund 余额为 0 不可退费(3102)。
+	ErrNoBalanceToRefund = infraerrors.New(CodeNoBalanceToRefund, "账户余额为 0,不可退费")
+	// ErrRefundableShort 可退金额不足(3103,warn 级:差额转坏账,不阻断主流程)。
+	ErrRefundableShort = infraerrors.New(CodeRefundableShort, "可退金额不足,差额将计入坏账")
+	// ErrCampusNotFound 校区不存在或未归属该机构(3104;跨域 port.CampusReader 失败的统一出口)。
+	ErrCampusNotFound = infraerrors.New(CodeCampusNotFound, "校区不存在或未归属该机构")
+	// ErrActivityAlreadySigned 已报名该活动(3105)。
+	ErrActivityAlreadySigned = infraerrors.New(CodeActivityAlreadySigned, "已报名该活动")
+	// ErrRecommendLimited 老带新每人限参加一次(3106)。
+	ErrRecommendLimited = infraerrors.New(CodeRecommendLimited, "老带新活动每人限参加一次")
+	// ErrActivitySignedAudited 活动已审核通过不可取消(3107)。
+	ErrActivitySignedAudited = infraerrors.New(CodeActivitySignedAudited, "活动已审核通过,不可取消")
+	// ErrRecommendUsed 老带新已产生消费不可取消(3108)。
+	ErrRecommendUsed = infraerrors.New(CodeRecommendUsed, "老带新已产生消费,不可取消")
+	// ErrGiftAmountShort 可用赠送金额不足(3109)。
+	ErrGiftAmountShort = infraerrors.New(CodeGiftAmountShort, "可用赠送金额不足")
+	// ErrActivityUnavailable 活动已过期或不可用(3110)。
+	ErrActivityUnavailable = infraerrors.New(CodeActivityUnavailable, "活动已过期或不可用")
+	// ErrDivideQuotaExceeded 分账层级超限或额度超上限(3111)。
+	ErrDivideQuotaExceeded = infraerrors.New(CodeDivideQuotaExceeded, "分账层级超限或额度超上限")
+	// ErrPayrollPlanMissing 课酬方案未配置(3112)。
+	ErrPayrollPlanMissing = infraerrors.New(CodePayrollPlanMissing, "课酬方案未配置")
+	// ErrArrearsCounteractFailed 欠费核销失败(3113)。
+	ErrArrearsCounteractFailed = infraerrors.New(CodeArrearsCounteractFailed, "欠费核销失败")
+	// ErrRepeated 幂等重复请求(3114;对外 code 仍 200,仅内部日志/指标使用)。
+	ErrRepeated = infraerrors.New(CodeRepeated, "重复请求(幂等命中)")
+	// ErrClearBalanceRejected 余额清零前置条件不满足(3120)。
+	ErrClearBalanceRejected = infraerrors.New(CodeClearBalanceRejected, "余额清零前置条件不满足")
+	// ErrRecordStateInvalid 打卡/上课状态非法(3121)。
+	ErrRecordStateInvalid = infraerrors.New(CodeRecordStateInvalid, "上课状态非法")
+	// ErrScheduleConflict 排班冲突(3122)。
+	ErrScheduleConflict = infraerrors.New(CodeScheduleConflict, "排班冲突(同一课程同一上课时间已存在)")
+	// ErrBusinessParam 多参数联合校验失败(3140)。
+	ErrBusinessParam = infraerrors.New(CodeBusinessParam, "业务参数错误")
+	// ErrActivityFull 报名人数已满(3141)。
+	ErrActivityFull = infraerrors.New(CodeActivityFull, "报名人数已满")
+)
+
+// CodeLowerBound / CodeUpperBound academic 业务码段位上下界(含端点,单测与门禁断言用)。
+const (
+	// CodeLowerBound 段位下界。
+	CodeLowerBound = 3100
+	// CodeUpperBound 段位上界。
+	CodeUpperBound = 3199
+)
+
+// all 本包定义的全部业务 sentinel(顺序与设计 §10.1 表格一致;返回新切片,调用方可安全 append)。
+var all = []*infraerrors.AppError{
+	ErrBalanceInsufficient,
+	ErrNoBalanceToRefund,
+	ErrRefundableShort,
+	ErrCampusNotFound,
+	ErrActivityAlreadySigned,
+	ErrRecommendLimited,
+	ErrActivitySignedAudited,
+	ErrRecommendUsed,
+	ErrGiftAmountShort,
+	ErrActivityUnavailable,
+	ErrDivideQuotaExceeded,
+	ErrPayrollPlanMissing,
+	ErrArrearsCounteractFailed,
+	ErrRepeated,
+	ErrClearBalanceRejected,
+	ErrRecordStateInvalid,
+	ErrScheduleConflict,
+	ErrBusinessParam,
+	ErrActivityFull,
+}
+
+// All 返回本包定义的 academic 业务错误全集(供单测断言段位/唯一性、供 Handler 生成错误码文档)。
+//
+// 出参:[]*infraerrors.AppError——新切片(每次调用重新分配,调用方 append 不影响内部)。
+func All() []*infraerrors.AppError {
+	out := make([]*infraerrors.AppError, len(all))
+	copy(out, all)
+	return out
+}
+
+// IsAcademicCode 判断错误码是否落在 academic 31xx 段内(3100~3199,含端点)。
+//
+// 入参:code int——任意错误码。出参:true 表示属 academic 业务码段。
+func IsAcademicCode(code int) bool {
+	return code >= CodeLowerBound && code <= CodeUpperBound
+}

+ 97 - 0
internal/academic/apperr/codes_test.go

@@ -0,0 +1,97 @@
+// academic 31xx 业务错误码单测(纯断言,不连库)。
+//
+// 覆盖 AC-02 验收②:31xx 码全落 3100~3199 段、无重复、文案非空、
+// 且与 infra/errors 通用码段(1xxx/2xxx/3xxx/5xxx)不重叠。
+package apperr
+
+import "testing"
+
+// TestCodesInAcademicRange 断言全部业务码落在 3100~3199 且唯一。
+func TestCodesInAcademicRange(t *testing.T) {
+	allCodes := All()
+	if len(allCodes) == 0 {
+		t.Fatal("apperr.All() 不应为空")
+	}
+	seen := make(map[int]string, len(allCodes))
+	for _, e := range allCodes {
+		if e == nil {
+			t.Fatal("apperr.All() 含 nil")
+		}
+		if !IsAcademicCode(e.Code) {
+			t.Fatalf("错误码 %d(%s)越出 academic 段位 3100~3199", e.Code, e.Msg)
+		}
+		if prev, dup := seen[e.Code]; dup {
+			t.Fatalf("错误码 %d 重复定义:%q 与 %q", e.Code, prev, e.Msg)
+		}
+		seen[e.Code] = e.Msg
+		if e.Msg == "" {
+			t.Fatalf("错误码 %d 文案为空(禁止无文案 sentinel)", e.Code)
+		}
+	}
+}
+
+// TestKnownCodes 逐条断言设计 §10.1 的码值(防误改错位,造成前端契约漂移)。
+func TestKnownCodes(t *testing.T) {
+	pairs := []struct {
+		name string
+		got  int
+		want int
+	}{
+		{"3101 余额不足", ErrBalanceInsufficient.Code, 3101},
+		{"3102 余额为0不可退费", ErrNoBalanceToRefund.Code, 3102},
+		{"3103 可退金额不足(warn)", ErrRefundableShort.Code, 3103},
+		{"3104 校区不存在", ErrCampusNotFound.Code, 3104},
+		{"3105 已报名", ErrActivityAlreadySigned.Code, 3105},
+		{"3106 老带新限一次", ErrRecommendLimited.Code, 3106},
+		{"3107 已审核不可取消", ErrActivitySignedAudited.Code, 3107},
+		{"3108 老带新已消费", ErrRecommendUsed.Code, 3108},
+		{"3109 赠送金额不足", ErrGiftAmountShort.Code, 3109},
+		{"3110 活动不可用", ErrActivityUnavailable.Code, 3110},
+		{"3111 分账额度超限", ErrDivideQuotaExceeded.Code, 3111},
+		{"3112 课酬方案未配置", ErrPayrollPlanMissing.Code, 3112},
+		{"3113 欠费核销失败", ErrArrearsCounteractFailed.Code, 3113},
+		{"3114 幂等重复(内部)", ErrRepeated.Code, 3114},
+		{"3120 清零前置不满足", ErrClearBalanceRejected.Code, 3120},
+		{"3121 上课状态非法", ErrRecordStateInvalid.Code, 3121},
+		{"3122 排班冲突", ErrScheduleConflict.Code, 3122},
+		{"3140 业务参数错", ErrBusinessParam.Code, 3140},
+		{"3141 报名人数已满", ErrActivityFull.Code, 3141},
+	}
+	for _, p := range pairs {
+		if p.got != p.want {
+			t.Fatalf("%s:code = %d,期望 %d", p.name, p.got, p.want)
+		}
+	}
+	if len(All()) != len(pairs) {
+		t.Fatalf("All() 数量 = %d,期望 %d(新增码须同步本测试与设计 §10.1)", len(All()), len(pairs))
+	}
+}
+
+// TestAllReturnsCopy All() 返回新切片(调用方 append 不得污染内部全集)。
+func TestAllReturnsCopy(t *testing.T) {
+	a := All()
+	a = append(a, ErrBalanceInsufficient)
+	if len(All()) != len(a)-1 {
+		t.Fatal("All() 未返回新切片:内部集合被调用方污染")
+	}
+}
+
+// TestIsAcademicCode 段位边界(含端点/邻接非法值)。
+func TestIsAcademicCode(t *testing.T) {
+	cases := []struct {
+		code int
+		want bool
+	}{
+		{3099, false},
+		{3100, true},
+		{3199, true},
+		{3200, false},
+		{1004, false},
+		{2001, false},
+	}
+	for _, tc := range cases {
+		if got := IsAcademicCode(tc.code); got != tc.want {
+			t.Fatalf("IsAcademicCode(%d) = %v,期望 %v", tc.code, got, tc.want)
+		}
+	}
+}

+ 168 - 0
internal/academic/model/migrate_dev_check_test.go

@@ -0,0 +1,168 @@
+// academic 27 表 **真库** AutoMigrate + information_schema 逐列核对(默认 skip)。
+//
+// 运行方式(仅 dev 环境,且已获得授权时执行):
+//
+//	ACADEMIC_MIGRATE_CHECK=1 go test ./internal/academic/model/ -run TestDevDBAutoMigrate -v
+//
+// 前置:`config/dev.json` 存在(DSN 指向 dev 库)。
+// 未设置 ACADEMIC_MIGRATE_CHECK 时本测试直接 skip,保证 `go test ./...`
+// 在无数据库的 CI/容器环境恒绿(与 internal/migrate 的 e2e 门禁同一纪律)。
+//
+// 核对口径(AC-01 验收①):对 27 表逐列比对 `information_schema.COLUMNS` 的
+// COLUMN_NAME / COLUMN_TYPE(含 size/precision)/ IS_NULLABLE 与 DDL fixture 一致。
+package model
+
+import (
+	"os"
+	"strings"
+	"testing"
+
+	"yuansiwei/unified-backend/internal/infra/config"
+	infradb "yuansiwei/unified-backend/internal/infra/db"
+)
+
+// TestDevDBAutoMigrate 对 dev 库执行 AutoMigrate 并用 information_schema 逐列核对(需显式开启)。
+func TestDevDBAutoMigrate(t *testing.T) {
+	if os.Getenv("ACADEMIC_MIGRATE_CHECK") != "1" {
+		t.Skip("跳过真库核对:设置 ACADEMIC_MIGRATE_CHECK=1 且 config/dev.json 指向 dev 库时执行")
+	}
+	chdirRepoRoot(t) // config.Load 按仓库根相对路径读 config/dev.json
+	cfg, err := config.Load("dev")
+	if err != nil {
+		t.Skipf("config.Load(dev) 失败,跳过真库核对: %v", err)
+	}
+	gdb, err := infradb.NewDB(cfg.DB)
+	if err != nil {
+		t.Skipf("连接 dev 库失败,跳过真库核对: %v", err)
+	}
+	t.Cleanup(func() { _ = infradb.Close(gdb) })
+
+	// 1) AutoMigrate 27 表(幂等:已存在则只补缺列,不删列)
+	if err := infradb.Migrate(gdb, AllTables()...); err != nil {
+		t.Fatalf("AutoMigrate 27 表失败: %v", err)
+	}
+
+	// 2) 读 information_schema 实际列
+	type colRow struct {
+		TableName  string `gorm:"column:TABLE_NAME"`
+		ColumnName string `gorm:"column:COLUMN_NAME"`
+		ColumnType string `gorm:"column:COLUMN_TYPE"`
+		IsNullable string `gorm:"column:IS_NULLABLE"`
+	}
+	ddl := parseDDL(t, ddlSQL)
+	names := make([]any, 0, len(ddl))
+	for table := range ddl {
+		names = append(names, table)
+	}
+	var rows []colRow
+	if err := gdb.Raw(
+		`SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE
+		   FROM information_schema.COLUMNS
+		  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ?`, names,
+	).Scan(&rows).Error; err != nil {
+		t.Fatalf("查询 information_schema 失败: %v", err)
+	}
+
+	actual := make(map[string]map[string]colRow, len(ddl))
+	for _, r := range rows {
+		if actual[r.TableName] == nil {
+			actual[r.TableName] = make(map[string]colRow, 16)
+		}
+		actual[r.TableName][r.ColumnName] = r
+	}
+
+	checked := 0
+	for table, cols := range ddl {
+		got, ok := actual[table]
+		if !ok {
+			t.Fatalf("dev 库缺少表 %s(AutoMigrate 未建?)", table)
+		}
+		if len(got) != len(cols) {
+			t.Fatalf("表 %s 列数 = %d,DDL = %d", table, len(got), len(cols))
+		}
+		for _, want := range cols {
+			r, ok := got[want.Name]
+			if !ok {
+				t.Fatalf("表 %s 缺少列 %s", table, want.Name)
+			}
+			if gotType := strings.ToUpper(r.ColumnType); gotType != want.Type {
+				t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s", table, want.Name, gotType, want.Type)
+			}
+			wantNullable := "YES"
+			if want.NotNull {
+				wantNullable = "NO"
+			}
+			if r.IsNullable != wantNullable {
+				t.Fatalf("表 %s 列 %s IS_NULLABLE = %s,DDL = %s", table, want.Name, r.IsNullable, wantNullable)
+			}
+			checked++
+		}
+	}
+	if checked < 250 {
+		t.Fatalf("仅核对 %d 列,疑似漏表", checked)
+	}
+
+	// 3) 唯一键核对(7 张 biz_no + 幂等键 uk_request_type + 账户/报名/课酬唯一键)
+	type idxRow struct {
+		TableName string `gorm:"column:TABLE_NAME"`
+		IndexName string `gorm:"column:INDEX_NAME"`
+	}
+	var idxRows []idxRow
+	if err := gdb.Raw(
+		`SELECT DISTINCT TABLE_NAME, INDEX_NAME
+		   FROM information_schema.STATISTICS
+		  WHERE TABLE_SCHEMA = DATABASE() AND NON_UNIQUE = 0 AND TABLE_NAME IN ?`, names,
+	).Scan(&idxRows).Error; err != nil {
+		t.Fatalf("查询 information_schema.STATISTICS 失败: %v", err)
+	}
+	gotIdx := make(map[string]bool, len(idxRows))
+	for _, r := range idxRows {
+		gotIdx[r.TableName+"."+r.IndexName] = true
+	}
+	wantIdx := []string{
+		"t_user_balance_request.uk_biz_no",
+		"t_order_pay.uk_biz_no",
+		"t_payment_log.uk_biz_no",
+		"t_courses_activity_request.uk_biz_no",
+		"t_courses_student.uk_biz_no",
+		"t_courses_record.uk_biz_no",
+		"t_courses_record_student.uk_biz_no",
+		"t_user_expense_log.uk_request_type",
+		"t_user_expense_school.uk_user_inst_campus",
+		"t_courses_activity_signed.uk_user_activity",
+		"t_teacher_courses_percentage.uk_type_teacher",
+	}
+	for _, k := range wantIdx {
+		if !gotIdx[k] {
+			t.Fatalf("dev 库缺少唯一键 %s(迁移幂等/并发防线依赖它)", k)
+		}
+	}
+
+	t.Logf("dev 库 27 表 %d 列 + %d 个唯一键核对通过", checked, len(wantIdx))
+}
+
+// chdirRepoRoot 把工作目录切到仓库根(向上查找 go.mod),测试结束自动还原。
+//
+// 原因:`config.Load` 以"仓库根相对路径"读取 config/dev.json,而 `go test` 的工作目录是包目录。
+func chdirRepoRoot(t *testing.T) {
+	t.Helper()
+	wd, err := os.Getwd()
+	if err != nil {
+		t.Fatalf("Getwd 失败: %v", err)
+	}
+	dir := wd
+	for {
+		if _, err := os.Stat(dir + string(os.PathSeparator) + "go.mod"); err == nil {
+			break
+		}
+		parent := dir[:strings.LastIndexAny(dir, `/\`)]
+		if parent == "" || parent == dir {
+			t.Skipf("未找到仓库根 go.mod,跳过真库核对(wd=%s)", wd)
+		}
+		dir = parent
+	}
+	if err := os.Chdir(dir); err != nil {
+		t.Fatalf("切换到仓库根失败: %v", err)
+	}
+	t.Cleanup(func() { _ = os.Chdir(wd) })
+}

+ 208 - 0
internal/academic/model/models_activity.go

@@ -0,0 +1,208 @@
+// 活动域 7 表 model(t_courses_activity / _signed / _user_discount / _user_discount_log /
+// _used_log / _request / _visit)——与 academic-model-ddl.sql「第二部分」逐列对齐。
+//
+// 业务主线(设计 §3.2,覆盖 9 个 SP):
+//
+//	活动方案(type 2 充值赠送/3 代金券/4 老带新) → 报名(signed, 状态机 0~4) → 赠送金额池(user_discount)
+//	→ 使用/退回流水(user_discount_log) → 老带新结算(used_log 写负值) → 优惠申请(request 审批) → 回访(visit)
+//
+// 字段注释五维口径:语义与单位 | 枚举全量取值 | 写入时机 | 主外键/关联语义 | 安全红线。
+package model
+
+import "time"
+
+// t_courses_activity.type 活动类型编码(1 试听为旧遗留,不参与 SP 逻辑)。
+const (
+	ActivityTypeTrial     int8 = 1 // 试听活动(旧遗留,不参与 SP 逻辑)
+	ActivityTypeRecharge  int8 = 2 // 充值赠送
+	ActivityTypeVoucher   int8 = 3 // 代金券
+	ActivityTypeRecommend int8 = 4 // 老带新推荐
+)
+
+// t_courses_activity.status 活动状态编码(旧 COMMENT 自相矛盾,以 PHP 判断口径为准,设计 §11-5)。
+const (
+	ActivityStatusDisabled int8 = 0 // 停用
+	ActivityStatusEnabled  int8 = 1 // 开启
+	ActivityStatusEnded    int8 = 2 // 已结束
+)
+
+// t_courses_activity_signed.status 报名状态机(0 取消报名 → 1 报名 → 2 参加活动 → 3 报课成交 / 4 过期)。
+const (
+	ActivitySignedCancelled int32 = 0 // 取消报名(旧 DELETE+INSERT 幂等会清掉的行状态)
+	ActivitySignedSigned    int32 = 1 // 报名
+	ActivitySignedAttended  int32 = 2 // 参加活动(老带新结算后置为此态)
+	ActivitySignedDeal      int32 = 3 // 报课(成交)
+	ActivitySignedExpired   int32 = 4 // 过期不能使用
+)
+
+// t_courses_activity_request.approved 审批状态编码。
+const (
+	ActivityRequestApprovedRejected int8 = -1 // 不通过
+	ActivityRequestApprovedPending  int8 = 0  // 未处理
+	ActivityRequestApprovedPassed   int8 = 1  // 通过
+)
+
+// t_courses_activity.toped 置顶标记编码。
+const (
+	ActivityTopedNo  int8 = 0 // 不置顶
+	ActivityTopedYes int8 = 1 // 置顶(置顶有效至活动结束)
+)
+
+// CoursesActivity 活动表 t_courses_activity(充值赠送/代金券/老带新)。
+//
+// 作用域:institution_id + campus_id(campus_id=0 表示"机构所有**自营**校区",
+// 是 custom_refund/cleared_balance 的 `campus_id IN (x,0)` 判据来源,设计 §2「7.」)。
+type CoursesActivity struct {
+	ID            int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                               // 活动 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:被 signed/discount/used_log/request 引用|红线:报名表已落库后不得物理删除
+	Type          int8       `gorm:"index:idx_inst_campus_type_status,priority:3;column:type;not null;default:1" json:"type"`                    // 活动类型(1 试听/2 充值赠送/3 代金券/4 老带新推荐)|枚举:ActivityType*|写入:创建活动时|FK:无|红线:决定结算分支(type=3 一次全退、type=4 双边入账),运行期禁改
+	Toped         int8       `gorm:"column:toped;not null;default:0" json:"toped"`                                                               // 置顶(0 否/1 是,置顶有效至活动结束)|枚举:ActivityToped*|写入:后台编辑|FK:无|红线:仅展示序,不影响资金
+	Thumbnail     *string    `gorm:"column:thumbnail;size:500" json:"thumbnail,omitempty"`                                                       // 缩略图 URL|枚举:无|写入:后台编辑|FK:无|红线:外部 URL,渲染需校验域名白名单
+	SubjectID     int32      `gorm:"column:subject_id;not null;default:0" json:"subjectId"`                                                      // 科目 ID(0 混合)|枚举:科目字典(跨域)|写入:创建活动时|FK:→ 教学域科目(无物理 FK)|红线:不参与金额计算
+	GradeID       int32      `gorm:"column:grade_id;not null;default:0" json:"gradeId"`                                                          // 年级 ID(0 混合)|枚举:年级字典(跨域)|写入:创建活动时|FK:→ 教学域年级|红线:同上
+	InstitutionID int64      `gorm:"index:idx_inst_campus_type_status,priority:1;column:institution_id;not null;default:0" json:"institutionId"` // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:创建活动时锁定|FK:→ t_institution|红线:scoping 强依赖,禁 0 值
+	CampusID      int64      `gorm:"index:idx_inst_campus_type_status,priority:2;column:campus_id;not null;default:0" json:"campusId"`           // 校区 ID(原 campusid;0=机构所有自营校区;作用域列)|枚举:无|写入:创建活动时锁定|FK:→ t_campus|红线:0 的活动失效范围依赖自营判定(设计 §11-2)
+	StartTime     *time.Time `gorm:"index:idx_time,priority:1;column:start_time" json:"startTime,omitempty"`                                     // 活动开始时间(datetime(3))|枚举:无|写入:创建/编辑|FK:无|红线:报名须校验时间窗(3110)
+	EndTime       *time.Time `gorm:"index:idx_time,priority:2;column:end_time" json:"endTime,omitempty"`                                         // 活动结束时间(datetime(3))|枚举:无|写入:创建/编辑|FK:无|红线:须 >= start_time
+	NumberMax     int32      `gorm:"column:number_max;not null;default:0" json:"numberMax"`                                                      // 可报名人数(0=不限)|枚举:无|写入:创建/编辑|FK:无|红线:与 number_sign 比较须在事务内(防超卖,3141)
+	NumberSign    int32      `gorm:"column:number_sign;not null;default:0" json:"numberSign"`                                                    // 当前报名人数(报名+1/取消-1)|枚举:无|写入:signed_add/cancel(原子增减)|FK:无|红线:取消时下限保护(不得为负)
+	Title         *string    `gorm:"column:title;size:500" json:"title,omitempty"`                                                               // 标题|枚举:无|写入:创建/编辑|FK:无|红线:用户输入,展示需转义
+	Content       *string    `gorm:"column:content;type:longtext" json:"content,omitempty"`                                                      // 活动内容(HTML)|枚举:无|写入:创建/编辑|FK:无|红线:**富文本 XSS 风险**,输出须 sanitize
+	Money         string     `gorm:"column:money;type:decimal(12,2);not null;default:0.00" json:"money"`                                         // 金额/额度(元;充值赠送额/代金券面额/老带新奖励额)|枚举:无|写入:创建/编辑|FK:无|红线:报名时快照进 signed.activity_money,事后改活动不影响存量
+	Status        int8       `gorm:"index:idx_inst_campus_type_status,priority:4;column:status;not null;default:0" json:"status"`                // 状态(0 停用/1 开启/2 已结束)|枚举:ActivityStatus*|写入:后台编辑/到期任务|FK:无|红线:与报名表 status 同名不同义(设计 §11-5),勿混用
+	View          int32      `gorm:"column:view;not null;default:0" json:"view"`                                                                 // 浏览量(后台可见)|枚举:无|写入:详情页访问自增|FK:无|红线:非资金字段,可异步
+	Memo          *string    `gorm:"column:memo;size:500" json:"memo,omitempty"`                                                                 // 备注|枚举:无|写入:创建/编辑|FK:无|红线:脱敏
+	CreateUserID  *string    `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                                                // 创建人(ULID;原 create_userid)|枚举:无|写入:创建时|FK:→ t_user(逻辑)|红线:禁止旧 int id
+	CreateTime    time.Time  `gorm:"column:create_time;not null" json:"createTime"`                                                              // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt     time.Time  `gorm:"column:updated_at;not null" json:"updatedAt"`                                                                // 更新时间(datetime(3),ON UPDATE;新增列)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
+}
+
+// TableName 指定表名。
+func (CoursesActivity) TableName() string { return "t_courses_activity" }
+
+// CoursesActivitySigned 活动报名/结算表 t_courses_activity_signed(UK(user_id, activity_id))。
+//
+// 幂等语义演进(设计 §1.5):旧 SP `signed_add` 用 DELETE+INSERT 保证"同一 user+activity 只留一条",
+// 目标态改为 `clause.OnConflict{DoUpdates: 覆盖 status/activity_money/recommend_user_id}` + UK 兜底。
+// institution_id/campus_id 为**冗余作用域列**(取活动上的值),便于 scoping 查询(迁移后一次性回填)。
+type CoursesActivitySigned struct {
+	ID                int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                               // 报名记录 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:注销不等于删除(status=0 保留审计)
+	ActivityID        int64      `gorm:"uniqueIndex:uk_user_activity,priority:2;index:idx_activity_status,priority:1;column:activity_id;not null" json:"activityId"` // 活动 ID(原 activityid)|枚举:无|写入:报名时|FK:→ t_courses_activity.id|红线:UK 第 2 列,禁止改绑
+	UserID            string     `gorm:"uniqueIndex:uk_user_activity,priority:1;index:idx_user_status,priority:1;column:user_id;size:26;not null" json:"userId"`     // 报名用户(ULID;原 userid)|枚举:无|写入:报名时|FK:→ t_user(逻辑)|红线:UK 第 1 列,一活动一人一行
+	InstitutionID     int64      `gorm:"index:idx_inst_campus,priority:1;column:institution_id;not null;default:0" json:"institutionId"`                             // 机构 ID(冗余作用域列,取活动值;新增)|枚举:无|写入:报名时冗余|FK:→ t_institution|红线:不得与活动表值冲突(迁移回填后一次性冻结)
+	CampusID          int64      `gorm:"index:idx_inst_campus,priority:2;column:campus_id;not null;default:0" json:"campusId"`                                       // 校区 ID(冗余作用域列,取活动 campus_id;新增)|枚举:无|写入:报名时冗余|FK:→ t_campus|红线:同上
+	Status            int32      `gorm:"index:idx_activity_status,priority:2;index:idx_user_status,priority:2;column:status;not null;default:0" json:"status"`       // 报名状态(0 取消报名/1 报名/2 参加活动/3 报课成交/4 过期不能使用)|枚举:ActivitySigned*|写入:报名/结算/取消|FK:无|红线:状态机单向推进(2→1 仅取消路径);type=4 限一次判定按 dump 用**活动** status(§11-5)
+	RechargeAmount    string     `gorm:"column:recharge_amount;type:decimal(12,2);not null;default:0.00" json:"rechargeAmount"`                                      // 充值金额(元;copy_activity_discount_to_signed 回填)|枚举:无|写入:充值赠送结算/迁移重放|FK:无|红线:只读回填值,勿参与实时计算
+	ActivityMoney     string     `gorm:"column:activity_money;type:decimal(12,2);not null;default:0.00" json:"activityMoney"`                                        // 活动金额(元;报名时取 t_courses_activity.money 快照)|枚举:无|写入:报名时|FK:无|红线:取消报名时归零(status=0 同行清 0)
+	ActivityMoneyUsed string     `gorm:"column:activity_money_used;type:decimal(12,2);not null;default:0.00" json:"activityMoneyUsed"`                               // 已使用活动金额(元;老带新=已充值到账户)|枚举:无|写入:结算|FK:无|红线:取消报名时归零;与 used_log 累计须一致
+	RecommendUserID   *string    `gorm:"column:recommend_user_id;size:26" json:"recommendUserId,omitempty"`                                                          // 推荐人(ULID;原 recommend_userid;0→NULL)|枚举:无|写入:报名时|FK:→ t_user(逻辑)|红线:双边入账的对手方,禁止自荐(业务校验)
+	CreateUserID      *string    `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                                                                // 创建人(原 create_userid)|枚举:无|写入:报名时|FK:→ t_user(逻辑)|红线:禁止旧 int id
+	CreateTime        time.Time  `gorm:"column:create_time;not null" json:"createTime"`                                                                              // 报名时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改(对账依据)
+	Imgs              *string    `gorm:"column:imgs;size:255" json:"imgs,omitempty"`                                                                                 // 图片备注(JSON 字符串)|枚举:无|写入:报名时|FK:无|红线:含个人信息,输出按需裁剪
+	Memo              *string    `gorm:"column:memo;size:500" json:"memo,omitempty"`                                                                                 // 备注|枚举:无|写入:报名/审核|FK:无|红线:脱敏
+	RequestNo         *string    `gorm:"index:idx_request_no;column:request_no;size:100" json:"requestNo,omitempty"`                                                 // 申请单号(原 requestid)|枚举:无|写入:报名时|FK:弱关联 t_user_balance_request.biz_no|红线:字符串保留(非 int64 FK),勿 JOIN id
+	ResponseUserID    *string    `gorm:"column:response_user_id;size:26" json:"responseUserId,omitempty"`                                                            // 审核人(ULID;原 response_userid;非空表示已审核通过→不可取消)|枚举:无|写入:审核动作|FK:→ t_user(逻辑)|红线:**取消报名的核心判据**(3107)
+	ResponseTime      *time.Time `gorm:"column:response_time" json:"responseTime,omitempty"`                                                                         // 审核时间(datetime(3))|枚举:无|写入:审核动作|FK:无|红线:与 response_user_id 同事务写
+	ResponseMsg       *string    `gorm:"column:response_msg;size:500" json:"responseMsg,omitempty"`                                                                  // 审核意见|枚举:无|写入:审核动作|FK:无|红线:用户输入,展示转义
+}
+
+// TableName 指定表名。
+func (CoursesActivitySigned) TableName() string { return "t_courses_activity_signed" }
+
+// CoursesActivityUserDiscount 活动学生优惠表 t_courses_activity_user_discount(赠送金额池)。
+//
+// 语义:discount_amount = 赠送总额(**负数是减、正数是赠送**),discount_amount_used = 已使用;
+// 可用 = discount_amount − discount_amount_used(user_discount_usable 仅累加 >0 的行,设计 §3.2 #14)。
+type CoursesActivityUserDiscount struct {
+	ID                 int64     `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                           // 优惠账户 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:被 discount_log 引用|红线:资金池行,禁止物理删除(审计)
+	ActivityID         int64     `gorm:"index:idx_user_activity,priority:2;column:activity_id;not null" json:"activityId"`                                       // 活动 ID(原 activityid)|枚举:无|写入:充值赠送入池时|FK:→ t_courses_activity.id|红线:决定赠送口径
+	InstitutionID      int64     `gorm:"index:idx_inst_campus_user,priority:1;column:institution_id;not null;default:0" json:"institutionId"`                    // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:入池时锁定|FK:→ t_institution|红线:scoping 依赖,禁 0 值
+	CampusID           int64     `gorm:"index:idx_inst_campus_user,priority:2;column:campus_id;not null;default:0" json:"campusId"`                              // 校区 ID(原 campusid;0=机构所有自营校区)|枚举:无|写入:入池时锁定|FK:→ t_campus|红线:usable 汇总按此过滤
+	UserID             string    `gorm:"index:idx_user_activity,priority:1;index:idx_inst_campus_user,priority:3;column:user_id;size:26;not null" json:"userId"` // 学生(ULID;原 userid)|枚举:无|写入:入池时|FK:→ t_user(逻辑)|红线:池归属人,禁止跨人使用
+	BalanceRequestID   *int64    `gorm:"column:balance_request_id" json:"balanceRequestId,omitempty"`                                                            // 来源充值/退费工单 ID(原 balance_requestid varchar(32) → 迁移 JOIN biz_no 重映射)|枚举:无|写入:入池时|FK:→ t_user_balance_request.id|红线:NULL=历史/手工入池,不得据此反推金额
+	Income             string    `gorm:"column:income;type:decimal(12,2);not null;default:0.00" json:"income"`                                                   // 充值金额(元;触发赠送的充值额)|枚举:无|写入:入池时|FK:无|红线:审计口径列,只写不改
+	DiscountAmount     string    `gorm:"column:discount_amount;type:decimal(12,2);not null;default:0.00" json:"discountAmount"`                                  // 赠送金额(元;负数=扣减/正数=赠送)|枚举:无|写入:入池时|FK:无|红线:可用额=本列−已用,禁止直接改本列做消费
+	DiscountAmountUsed string    `gorm:"column:discount_amount_used;type:decimal(12,2);not null;default:0.00" json:"discountAmountUsed"`                         // 已使用赠送金额(元;<= discount_amount)|枚举:无|写入:使用/退回(原子增减)|FK:无|红线:须与 discount_log 累计一致(对账恒等式)
+	CreateTime         time.Time `gorm:"column:create_time;not null" json:"createTime"`                                                                          // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+}
+
+// TableName 指定表名。
+func (CoursesActivityUserDiscount) TableName() string { return "t_courses_activity_user_discount" }
+
+// CoursesActivityUserDiscountLog 活动学生优惠使用记录表 t_courses_activity_user_discount_log(只增)。
+//
+// 迁移说明:`courses_studentid`(旧 varchar(32) UUID)→ courses_student_id(int64 FK),
+// 同时保留 courses_student_no 旧 UUID 供迁移/对账(回填完成后可停用,设计 §2「10.」)。
+type CoursesActivityUserDiscountLog struct {
+	ID                     int64     `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                   // 流水 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:只增不改(审计底线)
+	ActivityUserDiscountID int64     `gorm:"index:idx_discount;column:activity_user_discount_id;not null" json:"activityUserDiscountId"`     // 优惠账户 ID(原 activity_user_discountid)|枚举:无|写入:使用/退回时|FK:→ t_courses_activity_user_discount.id|红线:汇总须按本列分组核对
+	CoursesStudentID       *int64    `gorm:"index:idx_student;column:courses_student_id" json:"coursesStudentId,omitempty"`                  // 报课记录 ID(原 courses_studentid UUID → t_courses_student.id;未回填前 NULL)|枚举:无|写入:使用/退回时|FK:→ t_courses_student.id|红线:NULL 期间用 courses_student_no 回溯
+	CoursesStudentNo       *string   `gorm:"index:idx_student_no;column:courses_student_no;size:32" json:"coursesStudentNo,omitempty"`       // 报课记录旧单号(迁移期保留旧 UUID)|枚举:无|写入:迁移搬入|FK:弱关联 t_courses_student.biz_no|红线:回填完成后停用,禁止新写入
+	DiscountAmountUsed     string    `gorm:"column:discount_amount_used;type:decimal(12,2);not null;default:0.00" json:"discountAmountUsed"` // 变动金额(元;负=消费/正=退回)|枚举:无|写入:使用/退回时|FK:无|红线:符号方向固定,退回按 ABS 等额回冲
+	CreateTime             time.Time `gorm:"column:create_time;not null" json:"createTime"`                                                  // 使用时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:同秒多行用 id 排序(退回游标 DESC)
+	Memo                   *string   `gorm:"column:memo;size:500" json:"memo,omitempty"`                                                     // 备注(默认文案"使用充值赠送金额")|枚举:无|写入:使用/退回时|FK:无|红线:脱敏
+}
+
+// TableName 指定表名。
+func (CoursesActivityUserDiscountLog) TableName() string {
+	return "t_courses_activity_user_discount_log"
+}
+
+// CoursesActivityUsedLog 老带新活动获得/使用记录表 t_courses_activity_used_log(只增)。
+//
+// 语义:老带新结算写**负值**(activity_money_used = −金额,表示"从活动池取出并充值到账户");
+// `copy_activity_discountLog_to_usedLog` 全量重放按 discount_log 原值写入(可能为正)。
+// 撤销前置校验:老带新有本表记录 → 不可取消报名(3108,设计 §3.2 #20)。
+type CoursesActivityUsedLog struct {
+	ID                int64     `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                    // 记录 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:只增不改
+	ActivityID        int64     `gorm:"index:idx_activity_user,priority:1;column:activity_id;not null" json:"activityId"`                // 活动 ID(原 activityid)|枚举:无|写入:结算/重放|FK:→ t_courses_activity.id|红线:撤销校验按 (activity,user) 判存在
+	UserID            string    `gorm:"index:idx_activity_user,priority:2;index:idx_user;column:user_id;size:26;not null" json:"userId"` // 参与用户(ULID;原 userid)|枚举:无|写入:结算/重放|FK:→ t_user(逻辑)|红线:与 signed.user_id 一致
+	RecommendUserID   *string   `gorm:"column:recommend_user_id;size:26" json:"recommendUserId,omitempty"`                               // 推荐人(ULID;原 recommend_userid;0→NULL)|枚举:无|写入:结算|FK:→ t_user(逻辑)|红线:双边入账对手方
+	OrderNo           *string   `gorm:"index:idx_order_no;column:order_no;size:100" json:"orderNo,omitempty"`                            // 订单号(原 orderid:老带新=报课信息 id;copy 重放=courses_studentid)|枚举:无|写入:结算/重放|FK:弱关联 t_courses_student.biz_no|红线:字符串保留,勿当 int64 FK
+	ActivityMoneyUsed string    `gorm:"column:activity_money_used;type:decimal(12,2);not null;default:0.00" json:"activityMoneyUsed"`    // 活动金额变动(元;老带新结算=负值)|枚举:无|写入:结算/重放|FK:无|红线:符号方向 ★ 结算负值,重放可能正值(双跑重点比对)
+	CreateTime        time.Time `gorm:"column:create_time;not null" json:"createTime"`                                                   // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	Memo              *string   `gorm:"column:memo;size:255" json:"memo,omitempty"`                                                      // 备注|枚举:无|写入:结算/重放|FK:无|红线:脱敏
+}
+
+// TableName 指定表名。
+func (CoursesActivityUsedLog) TableName() string { return "t_courses_activity_used_log" }
+
+// CoursesActivityRequest 活动优惠申请表 t_courses_activity_request(旧 UUID 主键 → int64 + biz_no)。
+//
+// 审批:approved −1 不通过 / 0 未处理 / 1 通过;`copy_activity_discount_to_signed` 只取
+// `approved=1 AND type=100`(设计 §3.6 #31)。
+type CoursesActivityRequest struct {
+	ID             int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                            // 申请 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:对外单号是 biz_no
+	BizNo          string     `gorm:"uniqueIndex:uk_biz_no;column:biz_no;size:32;not null" json:"bizNo"`                                       // 业务单号(旧 UUID 原样保留;UNIQUE)|枚举:无|写入:申请时|FK:幂等/回滚锚点|红线:不可变
+	ActivityID     int64      `gorm:"index:idx_activity_user,priority:1;column:activity_id;not null" json:"activityId"`                        // 活动 ID(原 activityid)|枚举:无|写入:申请时|FK:→ t_courses_activity.id|红线:审批通过后不可改绑
+	InstitutionID  int64      `gorm:"index:idx_inst_campus_approved,priority:1;column:institution_id;not null;default:0" json:"institutionId"` // 适用机构 ID(原 schoolid;作用域列)|枚举:无|写入:申请时|FK:→ t_institution|红线:scoping 依赖
+	CampusID       int64      `gorm:"index:idx_inst_campus_approved,priority:2;column:campus_id;not null;default:0" json:"campusId"`           // 适用校区 ID(原 campusid;作用域列)|枚举:无|写入:申请时|FK:→ t_campus|红线:同上
+	UserID         string     `gorm:"index:idx_activity_user,priority:2;column:user_id;size:26;not null;default:''" json:"userId"`             // 用户(ULID)|枚举:无|写入:申请时|FK:→ t_user(逻辑)|红线:与请求人可不同(代申请场景需审计)
+	GiveAmount     string     `gorm:"column:give_amount;type:decimal(12,2);not null;default:0.00" json:"giveAmount"`                           // 优惠金额(元;旧 float(10,2))|枚举:无|写入:申请时|FK:无|红线:审批通过才入池,未通过不得预占
+	Imgs           *string    `gorm:"column:imgs;type:longtext" json:"-"`                                                                      // 图片备注(JSON 直存)|枚举:无|写入:申请时|FK:无|红线:**不外泄**(json:"-")
+	Memo           *string    `gorm:"column:memo;type:longtext" json:"memo,omitempty"`                                                         // 备注|枚举:无|写入:申请/审批|FK:无|红线:用户输入,展示转义
+	RequestUserID  string     `gorm:"column:request_user_id;size:26;not null;default:''" json:"requestUserId"`                                 // 申请人(ULID)|枚举:无|写入:申请时|FK:→ t_user(逻辑)|红线:禁止旧 int id
+	RequestTime    *time.Time `gorm:"column:request_time" json:"requestTime,omitempty"`                                                        // 申请时间(datetime(3))|枚举:无|写入:提交时|FK:无|红线:只写一次
+	ResponseUserID *string    `gorm:"column:response_user_id;size:26" json:"responseUserId,omitempty"`                                         // 审批人(ULID;0→NULL)|枚举:无|写入:审批动作|FK:→ t_user(逻辑)|红线:申请人不得自审(Service 校验)
+	ResponseTime   *time.Time `gorm:"column:response_time" json:"responseTime,omitempty"`                                                      // 审批时间(datetime(3))|枚举:无|写入:审批动作|FK:无|红线:与 approved 同事务
+	ResponseMsg    *string    `gorm:"column:response_msg;type:longtext" json:"responseMsg,omitempty"`                                          // 审批意见|枚举:无|写入:审批动作|FK:无|红线:用户输入,转义
+	Approved       int8       `gorm:"index:idx_inst_campus_approved,priority:3;column:approved;not null;default:0" json:"approved"`            // 审批状态(-1 不通过/0 未处理/1 通过;原 aproved 错拼修正)|枚举:ActivityRequestApproved*|写入:审批动作|FK:无|红线:0→1 才可入池,重复审批幂等
+	CreatedAt      time.Time  `gorm:"column:created_at;not null" json:"createdAt"`                                                             // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt      time.Time  `gorm:"column:updated_at;not null" json:"updatedAt"`                                                             // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
+}
+
+// TableName 指定表名。
+func (CoursesActivityRequest) TableName() string { return "t_courses_activity_request" }
+
+// CoursesActivityVisit 活动回访记录表 t_courses_activity_visit。
+//
+// 来源:旧库基本空表(AUTO_INCREMENT=1),字符集由 latin1 收敛 utf8mb4。
+type CoursesActivityVisit struct {
+	ID               int64     `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                 // 回访 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:只增不改
+	ActivitySignedID *int64    `gorm:"index:idx_signed;column:activity_signed_id" json:"activitySignedId,omitempty"` // 报名记录 ID(原 activity_signedid)|枚举:无|写入:回访登记时|FK:→ t_courses_activity_signed.id|红线:NULL=历史脏数据,勿据此做业务判断
+	CreateUserID     *string   `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                  // 回访人(ULID;原 create_userid)|枚举:无|写入:回访登记时|FK:→ t_user(逻辑)|红线:禁止旧 int id
+	CreateTime       time.Time `gorm:"column:create_time;not null" json:"createTime"`                                // 回访时间(datetime(3))|枚举:无|写入:DB 默认/录入|FK:无|红线:勿手改
+	Memo             *string   `gorm:"column:memo;size:500" json:"memo,omitempty"`                                   // 回访内容|枚举:无|写入:回访登记时|FK:无|红线:含学员沟通内容,按权限裁剪输出
+}
+
+// TableName 指定表名。
+func (CoursesActivityVisit) TableName() string { return "t_courses_activity_visit" }

+ 260 - 0
internal/academic/model/models_course.go

@@ -0,0 +1,260 @@
+// 课程域 6 表 model(t_courses / t_courses_classify / t_courses_class / t_courses_student /
+// t_courses_record / t_courses_record_student)——与 academic-model-ddl.sql「第四部分」逐列对齐。
+//
+// 本期范围(设计 §11-3 复盘结论):只做「排班生成 + 打卡 + 结束扣课时」最小闭环;
+// 调课/插班/结课申请/上课通知/学员档案列 P2(表未建)。
+//
+// 表语义分层:
+//
+//	t_courses(课程模板:周规则+次数+价格+时长)
+//	  → t_courses_class(排班规则:某课在某班由某老师按 week+时分 排课)
+//	  → t_courses_record(排班实例:一次具体上课,含 income/teacher_reward)
+//	      → t_courses_record_student(打卡/考勤/扣课时:一次打卡一行)
+//	  → t_courses_student(报课订单 = 学费账户/课次账户)
+//
+// ⚠️ 时间列语义特例:t_courses_class.start_time/end_time 旧库为"时分"(date 部分无意义),
+// 目标态保留 datetime(3);新写入统一以 1970-01-01 日期 + 时分承载(设计 §11-3)。
+//
+// 字段注释五维口径:语义与单位 | 枚举全量取值 | 写入时机 | 主外键/关联语义 | 安全红线。
+package model
+
+import "time"
+
+// t_courses.status 课程状态编码。
+const (
+	CoursesStatusUnpublished int32 = 0 // 未发布
+	CoursesStatusPublished   int32 = 1 // 发布未开始
+	CoursesStatusOngoing     int32 = 2 // 进行中
+	CoursesStatusFinished    int32 = 3 // 已结束
+)
+
+// t_courses_class.status / t_courses_class.confirm 编码。
+const (
+	CoursesClassStatusOngoing int32 = 1 // 进行中
+	CoursesClassStatusEnded   int32 = 2 // 结束
+
+	CoursesClassConfirmNo  int8 = 0 // 未确认
+	CoursesClassConfirmYes int8 = 1 // 已确认
+)
+
+// t_courses_student.type / status 编码(报课类型与状态)。
+const (
+	CoursesStudentTypeNormal   int8 = 1 // 正常上课
+	CoursesStudentTypeTransfer int8 = 2 // 插班
+
+	CoursesStudentStatusNormal int8 = 1 // 正常
+	CoursesStudentStatusEnded  int8 = 2 // 结束
+)
+
+// t_courses_record.status / 调课标记 编码。
+const (
+	CoursesRecordStatusNotStarted int32 = 0 // 未开始
+	CoursesRecordStatusOngoing    int32 = 1 // 正在进行
+	CoursesRecordStatusFinished   int32 = 2 // 已结束
+
+	CoursesRecordChangingCourseNo  int8 = 0 // 非调课
+	CoursesRecordChangingCourseYes int8 = 1 // 调课
+
+	CoursesRecordTransferStudentNo  int8 = 0 // 无插班生
+	CoursesRecordTransferStudentYes int8 = 1 // 有插班生
+
+	CoursesRecordRewardTypeNormal    int8 = 1 // 正常结算
+	CoursesRecordRewardTypeNoMinimum int8 = 2 // 按原课程老师课酬无最低课酬
+)
+
+// t_courses_record_student.type / pay 编码。
+const (
+	CoursesRecordStudentTypeNormal   int32 = 1 // 正常
+	CoursesRecordStudentTypeTransfer int32 = 2 // 插班
+	CoursesRecordStudentTypeGive     int32 = 3 // 赠送
+
+	CoursesRecordStudentPayUnpaid int8 = 0 // 未支付
+	CoursesRecordStudentPayPaid   int8 = 1 // 已支付
+
+	CoursesWeekAll string = "-1" // week 列的特殊取值:全部周
+)
+
+// Courses 课程信息表 t_courses(课程模板:价格/次数/周规则/时长)。
+//
+// week 语义(字符串原样保留,不做位图化,保迁移稳定):
+// "-1" 全部周 / "0" 周日 / "1".."6" 周一..周六 / 多值英文逗号分隔(如 "1,3,5")。
+type Courses struct {
+	ID            int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                          // 课程 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:被 classify/class/student/record 引用|红线:已排课的课程禁止物理删除
+	SubjectID     int32      `gorm:"index:idx_subject_grade,priority:1;column:subject_id;not null;default:0" json:"subjectId"`              // 科目 ID(0 混合科)|枚举:科目字典(跨域)|写入:创建/编辑|FK:→ 教学域科目(无物理 FK)|红线:仅分类维度
+	GradeID       int32      `gorm:"index:idx_subject_grade,priority:2;column:grade_id;not null;default:0" json:"gradeId"`                  // 年级 ID(0 混合年级)|枚举:年级字典(跨域)|写入:创建/编辑|FK:→ 教学域年级|红线:仅分类维度
+	Type          int32      `gorm:"column:type;not null;default:1" json:"type"`                                                            // 类型(1000 集体班/2000 VIP…)|枚举:班型编码(与 classify.class_type 对应)|写入:创建/编辑|FK:无|红线:影响课酬匹配,改动须评审
+	InstitutionID int64      `gorm:"index:idx_inst_campus_status,priority:1;column:institution_id;not null;default:0" json:"institutionId"` // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:创建时锁定|FK:→ t_institution|红线:scoping 强依赖
+	CampusID      int64      `gorm:"index:idx_inst_campus_status,priority:2;column:campus_id;not null;default:0" json:"campusId"`           // 校区 ID(原 campusid;作用域列)|枚举:无|写入:创建/回填(campus_id=0 的多校区课程不更新)|FK:→ t_campus|红线:backfill 仅唯一校区时回填(设计 §3.5 #29)
+	ClassifyID    int64      `gorm:"index:idx_classify;column:classify_id;not null;default:0" json:"classifyId"`                            // 课程分类 ID(原 classifyid)|枚举:无|写入:创建/编辑|FK:→ t_courses_classify.id|红线:回填 max_people 依赖本列
+	Status        int32      `gorm:"index:idx_inst_campus_status,priority:3;column:status;not null;default:1" json:"status"`                // 状态(0 未发布/1 发布未开始/2 进行中/3 已结束)|枚举:CoursesStatus*|写入:发布/结课任务|FK:无|红线:状态机单向推进
+	Name          string     `gorm:"column:name;size:500;not null" json:"name"`                                                             // 课程名称|枚举:无|写入:创建/编辑|FK:无|红线:用户输入,展示转义
+	Number        int32      `gorm:"column:number;not null;default:0" json:"number"`                                                        // 上课次数(课次)|枚举:无|写入:创建/编辑|FK:无|红线:与 record 生成条数关联,改动须校验已开课
+	Duration      string     `gorm:"column:duration;type:decimal(5,1);not null;default:0.0" json:"duration"`                                // 单节课时长(课时,1 位小数,保留旧精度)|枚举:无|写入:创建/编辑|FK:无|红线:精度例外列(非 12,2)
+	Week          string     `gorm:"column:week;size:50;not null;default:'-1'" json:"week"`                                                 // 周规则("-1" 全部/"0" 周日/"1".."6"/多值逗号分隔)|枚举:见上方 week 语义|写入:创建/编辑|FK:无|红线:解析逻辑须与旧 PHP 一致(排班展开依赖)
+	StartTime     *time.Time `gorm:"column:start_time" json:"startTime,omitempty"`                                                          // 课程开始时间(datetime(3))|枚举:无|写入:创建/编辑|FK:无|红线:<= end_time
+	EndTime       *time.Time `gorm:"column:end_time" json:"endTime,omitempty"`                                                              // 课程结束时间(datetime(3))|枚举:无|写入:创建/编辑|FK:无|红线:同上
+	Address       *string    `gorm:"column:address;type:longtext" json:"address,omitempty"`                                                 // 上课地址|枚举:无|写入:创建/编辑|FK:无|红线:展示转义
+	OriginalCost  string     `gorm:"column:original_cost;type:decimal(12,2);not null;default:0.00" json:"originalCost"`                     // 原价(元;旧 float(10,3) → 2 位收敛)|枚举:无|写入:创建/编辑|FK:无|红线:报课快照进 courses_student,事后改不影响存量
+	RealPrice     string     `gorm:"column:real_price;type:decimal(12,2);not null;default:0.00" json:"realPrice"`                           // 实价(元)|枚举:无|写入:创建/编辑|FK:无|红线:同上
+	DiscountCash  string     `gorm:"column:discount_cash;type:decimal(12,2);not null;default:0.00" json:"discountCash"`                     // 现金折扣(元)|枚举:无|写入:创建/编辑|FK:无|红线:可为负(加价场景),勿强校验非负
+	DiscountRatio int32      `gorm:"column:discount_ratio;not null;default:0" json:"discountRatio"`                                         // 折扣率(百分比数字部分,如 85 表示 85%)|枚举:整数百分比|写入:创建/编辑|FK:无|红线:仅展示/试算,实际金额以 real_price 为准
+	Remark        *string    `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                                   // 备注|枚举:无|写入:创建/编辑|FK:无|红线:脱敏
+	MaxPeople     int32      `gorm:"column:max_people;not null;default:0" json:"maxPeople"`                                                 // 最多人数(0 时由 courses_update 从 classify 继承)|枚举:无|写入:创建/编辑/修复 SP|FK:无|红线:回填幂等(仅 max_people=0 行)
+	UpdateUserID  *string    `gorm:"column:update_user_id;size:26" json:"updateUserId,omitempty"`                                           // 最后修改人(ULID;原 update_userid)|枚举:无|写入:每次编辑|FK:→ t_user(逻辑)|红线:禁止旧 int id
+	UpdateTime    *time.Time `gorm:"column:update_time" json:"updateTime,omitempty"`                                                        // 最后修改时间(datetime(3))|枚举:无|写入:每次编辑|FK:无|红线:与 update_user_id 同写
+	CreatedAt     time.Time  `gorm:"column:created_at;not null" json:"createdAt"`                                                           // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+}
+
+// TableName 指定表名。
+func (Courses) TableName() string { return "t_courses" }
+
+// CoursesClassify 课程分类表 t_courses_classify(班级类型/单价/人数区间 + 课酬方案绑定)。
+type CoursesClassify struct {
+	ID              int64  `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                   // 分类 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:被 t_courses.classify_id 引用|红线:被引用后禁删
+	InstitutionID   int64  `gorm:"index:idx_inst_campus,priority:1;column:institution_id;not null;default:0" json:"institutionId"` // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:创建时锁定|FK:→ t_institution|红线:scoping 强依赖
+	CampusID        int64  `gorm:"index:idx_inst_campus,priority:2;column:campus_id;not null;default:0" json:"campusId"`           // 校区 ID(原 campusid;作用域列)|枚举:无|写入:创建时锁定|FK:→ t_campus|红线:同上
+	ClassType       int32  `gorm:"column:class_type;not null;default:1000" json:"classType"`                                       // 班级类型(1000 集体班/1001 小班…)|枚举:班型编码(与 course_payment_detail.class_type 对应)|写入:创建/编辑|FK:无|红线:课酬匹配维度
+	CoursePaymentID int64  `gorm:"index:idx_payment;column:course_payment_id;not null;default:0" json:"coursePaymentId"`           // 课酬方案 ID(原 course_paymentid)|枚举:无|写入:创建/编辑|FK:→ t_course_payment.id|红线:0=未绑定方案(课酬试算回退通用档)
+	GradeID         int32  `gorm:"column:grade_id;not null;default:0" json:"gradeId"`                                              // 年级 ID(0 混合)|枚举:年级字典|写入:创建/编辑|FK:→ 教学域年级|红线:仅分类维度
+	SubjectID       int32  `gorm:"column:subject_id;not null;default:0" json:"subjectId"`                                          // 科目 ID(0 混合)|枚举:科目字典|写入:创建/编辑|FK:→ 教学域科目|红线:同上
+	Name            string `gorm:"column:name;size:500;not null" json:"name"`                                                      // 分类名称|枚举:无|写入:创建/编辑|FK:无|红线:展示转义
+	UnitPrice       string `gorm:"column:unit_price;type:decimal(12,2);not null;default:0.00" json:"unitPrice"`                    // 单价(元/一次课;旧 float(10,3) 收敛)|枚举:无|写入:创建/编辑|FK:无|红线:报课计费依据,改动须评估存量课程
+	MinPeople       int32  `gorm:"column:min_people;not null;default:1" json:"minPeople"`                                          // 最少人数|枚举:无|写入:创建/编辑|FK:无|红线:<= max_people
+	MaxPeople       int32  `gorm:"column:max_people;not null;default:1000" json:"maxPeople"`                                       // 最多人数|枚举:无|写入:创建/编辑|FK:无|红线:同上
+}
+
+// TableName 指定表名。
+func (CoursesClassify) TableName() string { return "t_courses_classify" }
+
+// CoursesClass 课程班级关系表 t_courses_class(排班规则:某课在某班由某老师按 week+时分 排课)。
+//
+// ⚠️ start_time/end_time 旧库仅"时分"语义;目标态保留 datetime(3) 兼容旧数据,
+// 新写入统一 1970-01-01 + 时分(设计 §11-3)。
+type CoursesClass struct {
+	ID                 int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                   // 关系 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:排班规则行,被 record 生成依赖
+	Status             int32      `gorm:"column:status;not null;default:1" json:"status"`                                                 // 状态(1 进行中/2 结束)|枚举:CoursesClassStatus*|写入:创建/结课|FK:无|红线:结束后不再生成 record
+	CoursesID          int64      `gorm:"index:idx_courses;column:courses_id;not null" json:"coursesId"`                                  // 课程 ID(原 coursesid)|枚举:无|写入:创建|FK:→ t_courses.id|红线:禁止改绑
+	ClassID            int64      `gorm:"index:idx_class;column:class_id;not null" json:"classId"`                                        // 班级 ID(原 classid;旧库为外部班级体系 id,本期仅存值)|枚举:无|写入:创建|FK:外部班级体系(无物理 FK)|红线:不做业务联查
+	TeacherID          *string    `gorm:"index:idx_teacher;column:teacher_id;size:26" json:"teacherId,omitempty"`                         // 老师(ULID;原 teacherid;0→NULL)|枚举:无|写入:排课时|FK:→ t_user(逻辑)|红线:课酬计算输入,禁止旧 int id
+	InstitutionID      int64      `gorm:"index:idx_inst_campus,priority:1;column:institution_id;not null;default:0" json:"institutionId"` // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:创建时锁定|FK:→ t_institution|红线:scoping 强依赖
+	CampusID           int64      `gorm:"index:idx_inst_campus,priority:2;column:campus_id;not null;default:0" json:"campusId"`           // 校区 ID(原 campusid;作用域列)|枚举:无|写入:创建时锁定|FK:→ t_campus|红线:同上
+	StandardLessonCost string     `gorm:"column:standard_lesson_cost;type:decimal(12,2);not null;default:0.00" json:"standardLessonCost"` // 课程标准单价(元;旧 float(10,3) 收敛)|枚举:无|写入:排课时|FK:无|红线:与 classify.unit_price 口径区分(本列=排班时快照)
+	Number             int32      `gorm:"column:number;not null;default:0" json:"number"`                                                 // 开课总量(课次)|枚举:无|写入:排课时|FK:无|红线:与 finished_number 比较判结课
+	LessonCost         string     `gorm:"column:lesson_cost;type:decimal(12,2);not null;default:0.00" json:"lessonCost"`                  // 一次课的课费(元)|枚举:无|写入:排课时|FK:无|红线:打卡扣课时依据
+	DiscountCash       string     `gorm:"column:discount_cash;type:decimal(12,2);not null;default:0.00" json:"discountCash"`              // 课程优惠(元)|枚举:无|写入:排课时|FK:无|红线:可为负
+	FinishedNumber     int32      `gorm:"column:finished_number;not null;default:0" json:"finishedNumber"`                                // 已上课量(课次)|枚举:无|写入:结束上课时 +1|FK:无|红线:不得超过 number
+	Confirm            int8       `gorm:"column:confirm;not null;default:0" json:"confirm"`                                               // 是否确认(0 未确认/1 确认)|枚举:CoursesClassConfirm*|写入:确认动作|FK:无|红线:未确认不得开始上课(业务校验)
+	Week               string     `gorm:"column:week;size:50;not null;default:'-1'" json:"week"`                                          // 周规则("-1" 全部/"0" 周日/"1".."6"/多值逗号分隔)|枚举:同 t_courses.week|写入:排课时|FK:无|红线:排班展开依据
+	StartTime          *time.Time `gorm:"column:start_time" json:"startTime,omitempty"`                                                   // 上课时间(时分语义;旧 NOT NULL,目标态放宽)|枚举:无|写入:排课时(1970-01-01+时分)|FK:无|红线:date 部分无业务含义,勿用于区间查询
+	EndTime            *time.Time `gorm:"column:end_time" json:"endTime,omitempty"`                                                       // 下课时间(时分语义)|枚举:无|写入:排课时|FK:无|红线:同上
+	Address            *string    `gorm:"column:address;type:longtext" json:"address,omitempty"`                                          // 上课地址|枚举:无|写入:排课时|FK:无|红线:展示转义
+	Remark             *string    `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                            // 备注|枚举:无|写入:排课时|FK:无|红线:脱敏
+	CreatedAt          time.Time  `gorm:"column:created_at;not null" json:"createdAt"`                                                    // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+}
+
+// TableName 指定表名。
+func (CoursesClass) TableName() string { return "t_courses_class" }
+
+// CoursesStudent 学生报课信息表 t_courses_student(学费账户/课次账户;旧 UUID 主键 → int64 + biz_no)。
+//
+// 该表同时是"报课订单"载体:t_courses_activity_user_discount_log.courses_student_id /
+// t_courses_activity_used_log.order_no 均指向本表;旧 UUID 必须保留在 biz_no(设计 §2「19.」)。
+//
+// ⚠️ activity_ids 旧为"多活动英文逗号串":目标态保留 varchar(200) 原样,**不拆关联表**
+// (拆表引入不可逆迁移风险且旧报表依赖该列,设计 §2.3)。
+// ⚠️ confirm_discount_user_id 为空/NULL 表示"不可充值"(业务语义,设计 §4.2)。
+type CoursesStudent struct {
+	ID                    int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                        // 报课记录 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:被 activity 两张 log 引用|红线:对外单号是 biz_no
+	BizNo                 string     `gorm:"uniqueIndex:uk_biz_no;column:biz_no;size:32;not null" json:"bizNo"`                                                   // 业务单号(旧 UUID 原样保留;UNIQUE)|枚举:无|写入:报课时|FK:迁移幂等/回滚锚点|红线:不可变(旧 UUID 引用列靠它重映射)
+	CoursesID             int64      `gorm:"index:idx_courses;column:courses_id;not null" json:"coursesId"`                                                       // 课程 ID(原 coursesid)|枚举:无|写入:报课时|FK:→ t_courses.id|红线:与课程作用域须一致
+	ClassID               int64      `gorm:"column:class_id;not null;default:0" json:"classId"`                                                                   // 班级 ID(原 classid)|枚举:无|写入:报课时|FK:外部/本域班级(无物理 FK)|红线:仅存值
+	InstitutionID         int64      `gorm:"index:idx_inst_campus,priority:1;column:institution_id;not null;default:0" json:"institutionId"`                      // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:报课时|FK:→ t_institution|红线:scoping 强依赖
+	CampusID              int64      `gorm:"index:idx_inst_campus,priority:2;column:campus_id;not null;default:0" json:"campusId"`                                // 校区 ID(原 campusid;作用域列)|枚举:无|写入:报课时|FK:→ t_campus|红线:同上
+	StudentID             string     `gorm:"index:idx_student_status,priority:1;index:idx_unpaid,priority:1;column:student_id;size:26;not null" json:"studentId"` // 学生(ULID;原 studentid)|枚举:无|写入:报课时|FK:→ t_user(逻辑)|红线:欠费核销按本列过滤
+	Type                  int8       `gorm:"column:type;not null;default:1" json:"type"`                                                                          // 上课类型(1 正常/2 插班)|枚举:CoursesStudentType*|写入:报课时|FK:无|红线:影响扣课时规则
+	Status                int8       `gorm:"index:idx_student_status,priority:2;column:status;not null;default:1" json:"status"`                                  // 状态(1 正常/2 结束)|枚举:CoursesStudentStatus*|写入:报课/结课|FK:无|红线:结束后不再参与打卡
+	Receivable            string     `gorm:"column:receivable;type:decimal(12,2);not null;default:0.00" json:"receivable"`                                        // 应收(元)|枚举:无|写入:报课时|FK:无|红线:= net_receipts + unpaid(对账恒等式)
+	NetReceipts           string     `gorm:"column:net_receipts;type:decimal(12,2);not null;default:0.00" json:"netReceipts"`                                     // 实收(元;学生实际缴纳)|枚举:无|写入:缴费/退费|FK:无|红线:与资金流水累计一致(对账)
+	Unpaid                string     `gorm:"index:idx_unpaid,priority:2;column:unpaid;type:decimal(12,2);not null;default:0.00" json:"unpaid"`                    // 欠费(元)|枚举:无|写入:缴费/核销|FK:无|红线:>0 即进入欠费核销候选(idx_unpaid)
+	LessonCost            string     `gorm:"column:lesson_cost;type:decimal(12,2);not null;default:0.00" json:"lessonCost"`                                       // 一节课的费用(元)|枚举:无|写入:报课时快照|FK:无|红线:打卡扣费依据,扣课时不得改本列
+	Number                int32      `gorm:"column:number;not null;default:0" json:"number"`                                                                      // 总课次|枚举:无|写入:报课时|FK:无|红线:含 give_number(赠送)口径差异见旧 PHP
+	FinishedNumber        int32      `gorm:"column:finished_number;not null;default:0" json:"finishedNumber"`                                                     // 已上课次|枚举:无|写入:打卡 +1|FK:无|红线:不得超过 number+give_number
+	DiscountCash          string     `gorm:"column:discount_cash;type:decimal(12,2);not null;default:0.00" json:"discountCash"`                                   // 现金折扣(元)|枚举:无|写入:报课时|FK:无|红线:可为负
+	ActivityIDs           *string    `gorm:"column:activity_ids;size:200" json:"activityIds,omitempty"`                                                           // 报课使用的活动 ID(英文逗号分隔;原 activityid)|枚举:活动 ID 列表串|写入:报课时|FK:弱关联 t_courses_activity.id(不拆表)|红线:勿当 JSON 解析,按英文逗号 split
+	GiveNumber            int32      `gorm:"column:give_number;not null;default:0" json:"giveNumber"`                                                             // 赠送课次|枚举:无|写入:报课时|FK:无|红线:扣课时先扣赠送还是实付须按旧口径
+	GiveCost              string     `gorm:"column:give_cost;type:decimal(12,2);not null;default:0.00" json:"giveCost"`                                           // 使用的赠送金额(元)|枚举:无|写入:报课时|FK:无|红线:与 discount_log 累计一致
+	ChangingCourseNumber  int32      `gorm:"column:changing_course_number;not null;default:0" json:"changingCourseNumber"`                                        // 调课次数|枚举:无|写入:调课(P2)|FK:无|红线:本期只读
+	RemainderNumber       int32      `gorm:"column:remainder_number;not null;default:0" json:"remainderNumber"`                                                   // 剩余课次|枚举:无|写入:报课/打卡|FK:无|红线:>=0(负数=数据异常,须修复)
+	RemainderCost         string     `gorm:"column:remainder_cost;type:decimal(12,2);not null;default:0.00" json:"remainderCost"`                                 // 剩余费用(元;缴费扣除每次课费后余额)|枚举:无|写入:报课/打卡|FK:无|红线:与 net_receipts−已扣课费 一致(对账)
+	RemainderReceivable   string     `gorm:"column:remainder_receivable;type:decimal(12,2);not null;default:0.00" json:"remainderReceivable"`                     // 剩余应收(元)|枚举:无|写入:报课/打卡|FK:无|红线:<= receivable
+	BadDebt               string     `gorm:"column:bad_debt;type:decimal(12,2);not null;default:0.00" json:"badDebt"`                                             // 坏账(元)|枚举:无|写入:退费差额/坏账处理|FK:无|红线:与 special_expenses 同源口径
+	ConfirmDiscountUserID *string    `gorm:"column:confirm_discount_user_id;size:26" json:"confirmDiscountUserId,omitempty"`                                      // 确认学生折扣的操作人(原 confirm_discount_userid;空/NULL=不可充值)|枚举:无|写入:折扣确认动作|FK:→ t_user(逻辑)|红线:**NULL 语义=不可充值**,勿用 0 替代
+	ConfirmTime           *time.Time `gorm:"column:confirm_time" json:"confirmTime,omitempty"`                                                                    // 确认时间(datetime(3))|枚举:无|写入:折扣确认|FK:无|红线:与 confirm_discount_user_id 同写
+	RecommenderUserID     *string    `gorm:"column:recommender_user_id;size:26" json:"recommenderUserId,omitempty"`                                               // 推荐人(原 recommender_userid)|枚举:无|写入:报课时|FK:→ t_user(逻辑)|红线:分账树上行起点(设计 §3.3)
+	RecommenderTime       *time.Time `gorm:"column:recommender_time" json:"recommenderTime,omitempty"`                                                            // 推荐时间(datetime(3))|枚举:无|写入:报课时|FK:无|红线:勿手改
+	CreateTime            time.Time  `gorm:"column:create_time;not null" json:"createTime"`                                                                       // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:欠费核销排序键(create_time ASC)
+	Remark                *string    `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                                                 // 备注|枚举:无|写入:报课/编辑|FK:无|红线:脱敏
+}
+
+// TableName 指定表名。
+func (CoursesStudent) TableName() string { return "t_courses_student" }
+
+// CoursesRecord 课程记录 t_courses_record(课表/排班实例;旧 UUID 主键 → int64 + biz_no)。
+//
+// 流转(设计 §7.3 流程外延):开始上课写 actual_start_time;结束上课写 actual_end_time + income
+// + teacher_reward,并联动 t_courses_record_student / 余额 spend / 分账 / 课酬(同事务)。
+type CoursesRecord struct {
+	ID                    int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                       // 上课记录 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:被 record_student.record_id 引用|红线:对外单号是 biz_no
+	BizNo                 string     `gorm:"uniqueIndex:uk_biz_no;column:biz_no;size:32;not null" json:"bizNo"`                                                  // 业务单号(旧 UUID 原样保留;UNIQUE)|枚举:无|写入:排班生成时|FK:迁移/回滚锚点|红线:不可变
+	CoursesID             int64      `gorm:"index:idx_courses_start,priority:1;column:courses_id;not null" json:"coursesId"`                                     // 课程 ID(原 coursesid)|枚举:无|写入:排班生成|FK:→ t_courses.id|红线:与 start_time 组排班唯一性判据(3122)
+	ClassID               int64      `gorm:"column:class_id;not null" json:"classId"`                                                                            // 班级 ID(原 classid)|枚举:无|写入:排班生成|FK:弱关联 t_courses_class.class_id|红线:仅存值
+	TeacherID             *string    `gorm:"index:idx_teacher_start,priority:1;column:teacher_id;size:26" json:"teacherId,omitempty"`                            // 老师(ULID;原 teacherid)|枚举:无|写入:排班/调课|FK:→ t_user(逻辑)|红线:课酬归属人,禁止改绑已结束记录
+	InstitutionID         int64      `gorm:"index:idx_inst_campus,priority:1;column:institution_id;not null;default:0" json:"institutionId"`                     // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:排班生成|FK:→ t_institution|红线:scoping 强依赖
+	CampusID              int64      `gorm:"index:idx_inst_campus,priority:2;column:campus_id;not null;default:0" json:"campusId"`                               // 校区 ID(原 campusid;作用域列)|枚举:无|写入:排班生成|FK:→ t_campus|红线:同上
+	Status                int32      `gorm:"index:idx_status;column:status;not null;default:0" json:"status"`                                                    // 状态(0 未开始/1 正在进行/2 已结束)|枚举:CoursesRecordStatus*|写入:开始/结束上课|FK:无|红线:状态机校验(非法迁移 → 3121)
+	ChangingCourse        int8       `gorm:"column:changing_course;not null;default:0" json:"changingCourse"`                                                    // 是否调课(1 调课)|枚举:CoursesRecordChangingCourse*|写入:调课(P2)|FK:无|红线:本期只读
+	StudentChangingCourse int32      `gorm:"column:student_changing_course;not null;default:0" json:"studentChangingCourse"`                                     // 单学生调课数|枚举:无|写入:调课(P2)|FK:无|红线:本期只读
+	TransferStudent       int8       `gorm:"column:transfer_student;not null;default:0" json:"transferStudent"`                                                  // 是否有插班生(1 有)|枚举:CoursesRecordTransferStudent*|写入:插班(P2)|FK:无|红线:本期只读
+	OriginalRecordNo      *string    `gorm:"column:original_record_no;size:32" json:"originalRecordNo,omitempty"`                                                // 原上课记录单号(原 original_recordid UUID)|枚举:无|写入:调课生成|FK:弱关联本表 biz_no|红线:字符串保留(非 int64 FK)
+	StartTime             *time.Time `gorm:"index:idx_courses_start,priority:2;index:idx_teacher_start,priority:2;column:start_time" json:"startTime,omitempty"` // 上课时间(datetime(3))|枚举:无|写入:排班生成|FK:无|红线:排班唯一性判据之一
+	EndTime               *time.Time `gorm:"column:end_time" json:"endTime,omitempty"`                                                                           // 下课时间(datetime(3))|枚举:无|写入:排班生成|FK:无|红线:>= start_time
+	Address               *string    `gorm:"column:address;type:longtext" json:"address,omitempty"`                                                              // 上课地点|枚举:无|写入:排班生成/编辑|FK:无|红线:展示转义
+	ActualStartTime       *time.Time `gorm:"column:actual_start_time" json:"actualStartTime,omitempty"`                                                          // 实际上课时间(datetime(3))|枚举:无|写入:开始上课|FK:无|红线:只写一次(重复开始 → 3121)
+	ActualEndTime         *time.Time `gorm:"column:actual_end_time" json:"actualEndTime,omitempty"`                                                              // 实际下课时间(datetime(3))|枚举:无|写入:结束上课|FK:无|红线:结束上课的幂等判据(非空即已结束)
+	StudentNumber         int32      `gorm:"column:student_number;not null;default:0" json:"studentNumber"`                                                      // 上课人数|枚举:无|写入:结束上课汇总|FK:无|红线:与 record_student 行数一致
+	Income                string     `gorm:"column:income;type:decimal(12,2);not null;default:0.00" json:"income"`                                               // 课程收入(元)|枚举:无|写入:结束上课|FK:无|红线:= Σ record_student.cost(对账)
+	TeacherReward         string     `gorm:"column:teacher_reward;type:decimal(12,2);not null;default:0.00" json:"teacherReward"`                                // 老师课酬(元)|枚举:无|写入:结束上课(课酬试算)|FK:无|红线:= Σ record_student.teacher_reward(对账)
+	TeacherRewardType     int8       `gorm:"column:teacher_reward_type;not null;default:1" json:"teacherRewardType"`                                             // 课酬结算方式(1 正常/2 按原课程老师课酬无最低课酬)|枚举:CoursesRecordRewardType*|写入:结束上课|FK:无|红线:影响试算分支
+	ExpectStudentIDs      *string    `gorm:"column:expect_student_ids;size:500" json:"expectStudentIds,omitempty"`                                               // 要求上课的学生 id 列表(逗号分隔;原 expect_studentids)|枚举:ULID 列表串|写入:排班生成|FK:弱关联 t_user|红线:字符串保留,勿当数组入 SQL
+	Remark                *string    `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                                                // 备注|枚举:无|写入:编辑|FK:无|红线:脱敏
+	CreatedAt             time.Time  `gorm:"column:created_at;not null" json:"createdAt"`                                                                        // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+}
+
+// TableName 指定表名。
+func (CoursesRecord) TableName() string { return "t_courses_record" }
+
+// CoursesRecordStudent 课程上课记录 t_courses_record_student(打卡/考勤/扣课时;旧 UUID → int64 + biz_no)。
+//
+// 语义:一次打卡 = 一行;cost 为本次扣除课费;give_cost 为本次使用的赠送金额;
+// `divide_into_log_delete` 用本表"首次 cost>0 且 end_time 非空"的记录判定分成归属(设计 §3.3 #23)。
+type CoursesRecordStudent struct {
+	ID            int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                   // 记录 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:对外单号是 biz_no
+	BizNo         string     `gorm:"uniqueIndex:uk_biz_no;column:biz_no;size:32;not null" json:"bizNo"`                              // 业务单号(旧 UUID 原样保留;UNIQUE)|枚举:无|写入:打卡时|FK:迁移/回滚锚点|红线:不可变
+	CoursesID     int64      `gorm:"column:courses_id;not null" json:"coursesId"`                                                    // 课程 ID(原 coursesid)|枚举:无|写入:打卡时冗余|FK:→ t_courses.id|红线:与 record 的课程一致
+	ClassID       int64      `gorm:"column:class_id;not null;default:0" json:"classId"`                                              // 班级 ID(原 classid)|枚举:无|写入:打卡时冗余|FK:弱关联|红线:仅存值
+	InstitutionID int64      `gorm:"index:idx_inst_campus,priority:1;column:institution_id;not null;default:0" json:"institutionId"` // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:打卡时冗余|FK:→ t_institution|红线:scoping 强依赖
+	CampusID      int64      `gorm:"index:idx_inst_campus,priority:2;column:campus_id;not null;default:0" json:"campusId"`           // 校区 ID(原 campusid;作用域列)|枚举:无|写入:打卡时冗余|FK:→ t_campus|红线:同上
+	RecordID      int64      `gorm:"index:idx_record;column:record_id;not null" json:"recordId"`                                     // 上课记录 ID(原 recordid UUID → 迁移 JOIN biz_no 重映射)|枚举:无|写入:打卡时|FK:→ t_courses_record.id|红线:分账/课酬汇总按本列分组
+	StudentID     string     `gorm:"index:idx_student_time,priority:1;column:student_id;size:26;not null" json:"studentId"`          // 学生(ULID;原 studentid)|枚举:无|写入:打卡时|FK:→ t_user(逻辑)|红线:首次扣费判定按 (student, start_time)
+	Type          int32      `gorm:"column:type;not null;default:1" json:"type"`                                                     // 上课类型(1 正常/2 插班/3 赠送)|枚举:CoursesRecordStudentType*|写入:打卡时|FK:无|红线:赠送课次不动余额(只动 give_cost)
+	StartTime     time.Time  `gorm:"index:idx_student_time,priority:2;column:start_time;not null" json:"startTime"`                  // 上课时间(datetime(3))|枚举:无|写入:打卡时|FK:无|红线:只增不改(考勤凭证)
+	EndTime       *time.Time `gorm:"column:end_time" json:"endTime,omitempty"`                                                       // 下课时间(datetime(3))|枚举:无|写入:结束上课|FK:无|红线:非空 = 已完成(分成归属判据)
+	Cost          string     `gorm:"column:cost;type:decimal(12,2);not null;default:0.00" json:"cost"`                               // 本次课程费用(元;扣课时金额)|枚举:无|写入:结束上课|FK:无|红线:Σ cost = record.income(对账)
+	GiveCost      string     `gorm:"column:give_cost;type:decimal(12,2);not null;default:0.00" json:"giveCost"`                      // 本次使用的赠送金额(元)|枚举:无|写入:结束上课|FK:无|红线:与 discount_log 累计一致
+	TeacherReward string     `gorm:"column:teacher_reward;type:decimal(12,2);not null;default:0.00" json:"teacherReward"`            // 本次课老师课酬(元)|枚举:无|写入:结束上课(课酬试算)|FK:无|红线:Σ = record.teacher_reward(对账)
+	Pay           int8       `gorm:"column:pay;not null;default:0" json:"pay"`                                                       // 是否已支付(0 未/1 已支付)|枚举:CoursesRecordStudentPay*|写入:扣费成功/欠费核销|FK:无|红线:未支付即欠费候选
+	Remark        *string    `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                            // 备注|枚举:无|写入:打卡/结束|FK:无|红线:脱敏
+}
+
+// TableName 指定表名。
+func (CoursesRecordStudent) TableName() string { return "t_courses_record_student" }

+ 64 - 0
internal/academic/model/models_divide.go

@@ -0,0 +1,64 @@
+// 分账域 2 表 model(t_user_divide_into / t_user_divide_into_log)——Nested Set 左右值树。
+//
+// 与 academic-model-ddl.sql「第三部分」逐列对齐;覆盖 4 个 SP:
+// divide_into_add(加入下线)/ divide_into_log_add(上行分成,递归→迭代 BFS)/
+// divide_into_log_delete(撤销/改绑)/ divide_into_update_quota(额度级联降)。
+//
+// 作用域说明(设计 §1.7 例外二 + §11.1 拍板 11-12):本域 2 表**无** institution_id/campus_id
+// (旧库为全局推广体系),**不继承 BaseRepo**;越权防线 = Handler 角色白名单(super_admin/academic)
+// + 显式数据范围参数,代码评审须在此显式标注"平台级/全局数据"。
+//
+// 字段注释五维口径:语义与单位 | 枚举全量取值 | 写入时机 | 主外键/关联语义 | 安全红线。
+package model
+
+import "time"
+
+// t_user_divide_into.type / t_user_divide_into_log.type 分成类型编码。
+const (
+	DivideTypeOneOff    int8 = 1 // 一次付清(同 user+student 只生成一条)
+	DivideTypePerLesson int8 = 2 // 按课次(同 user+student+order_no 只生成一条)
+)
+
+// UserDivideInto 用户分账表 t_user_divide_into(Nested Set 左右值树·一人一节点·自然键 user_id)。
+//
+// 树不变式:root_user_id 标识所属树;left_val/right_val 为嵌套集区间(left < right);
+// layer 为层级(根=1)。下线 quota 不得超过上线(update_quota 级联约束,超限 → 3111)。
+type UserDivideInto struct {
+	UserID           string    `gorm:"column:user_id;primaryKey;size:26;not null" json:"userId"`                                                    // 节点用户(ULID;原 userid,主键)|枚举:无|写入:加入分账体系时|FK:→ t_user(逻辑)|红线:一人一节点,禁止重复建节点
+	Type             int8      `gorm:"column:type;not null;default:1" json:"type"`                                                                  // 分成类型(1 一次付清/2 按课次)|枚举:DivideType*|写入:加入/调整时|FK:无|红线:决定幂等维度(per student / per order),运行期改须评审
+	Quota            string    `gorm:"column:quota;type:decimal(12,2);not null;default:0.00" json:"quota"`                                          // 分成额度(元/次)|枚举:无|写入:加入时配置/update_quota|FK:无|红线:子树 quota 不得高于上线(级联降)
+	TotalIncome      string    `gorm:"column:total_income;type:decimal(12,2);not null;default:0.00" json:"totalIncome"`                             // 分成总收入(元,累积)|枚举:无|写入:上行分成累加/撤销回滚|FK:无|红线:须与 divide_into_log.money 累计一致(撤销是回滚本列)
+	TotalIncomePayed string    `gorm:"column:total_income_payed;type:decimal(12,2);not null;default:0.00" json:"totalIncomePayed"`                  // 已支付的分成收入(元)|枚举:无|写入:结算打款时|FK:无|红线:<= total_income
+	RootUserID       *string   `gorm:"index:idx_root;index:idx_root_left_right,priority:1;column:root_user_id;size:26" json:"rootUserId,omitempty"` // 树根用户(ULID;自身为根时=user_id)|枚举:无|写入:加入时|FK:→ 本表 user_id(自引用)|红线:★旧 SP 在 subid 已存在分支未初始化 root → NULL,Go 侧须显式取父 root
+	ParentUserID     *string   `gorm:"index:idx_parent;column:parent_user_id;size:26" json:"parentUserId,omitempty"`                                // 上级用户(ULID;根为 NULL)|枚举:无|写入:加入时|FK:→ 本表 user_id(自引用)|红线:BFS 上行链依据,禁止成环
+	LeftVal          int32     `gorm:"index:idx_root_left_right,priority:2;column:left_val;not null;default:1" json:"leftVal"`                      // 左值(Nested Set)|枚举:无|写入:加入/插入新节点时右值扩张|FK:无|红线:区间扩张须同事务(left>=parentRight 全体 +2)
+	RightVal         int32     `gorm:"index:idx_root_left_right,priority:3;column:right_val;not null;default:2" json:"rightVal"`                    // 右值(Nested Set)|枚举:无|写入:同上|FK:无|红线:必须恒有 left < right
+	Layer            int32     `gorm:"column:layer;not null;default:1" json:"layer"`                                                                // 层级(根=1)|枚举:无|写入:加入时=父+1|FK:无|红线:深度上限 100(超限 → 3111,防递归栈溢出)
+	CreatedAt        time.Time `gorm:"column:created_at;not null" json:"createdAt"`                                                                 // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt        time.Time `gorm:"column:updated_at;not null" json:"updatedAt"`                                                                 // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
+}
+
+// TableName 指定表名。
+func (UserDivideInto) TableName() string { return "t_user_divide_into" }
+
+// UserDivideIntoLog 分账明细表 t_user_divide_into_log(只增;两级幂等)。
+//
+// 幂等口径(divide_into_log_add,设计 §3.3 #22):
+//   - type=1(一次付清):同 user+student 只生成一条(不区分 order);
+//   - type=2(按课次)  :同 user+student+order_no 只生成一条。
+//
+// 撤销语义(log_delete,设计 §3.3 #23):学生已有首次扣费上课记录 → 改 order_no 为首次 record
+// (保留分成);否则回滚 total_income 并物理删除该行。
+type UserDivideIntoLog struct {
+	ID         int64     `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                                  // 分成记录 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:撤销路径允许物理删除(旧语义),须与 total_income 回滚同事务
+	Type       int8      `gorm:"index:idx_user_student_type,priority:3;column:type;not null;default:1" json:"type"`                                             // 分成类型(1 一次付清/2 按课次;旧 tinyint ZEROFILL → tinyint)|枚举:DivideType*|写入:上行分成时|FK:无|红线:决定幂等维度,与节点 type 一致
+	UserID     string    `gorm:"index:idx_user_student_type,priority:1;column:user_id;size:26;not null" json:"userId"`                                          // 获得分成的用户(ULID;原 userid)|枚举:无|写入:上行分成时|FK:→ t_user_divide_into.user_id|红线:必须是分账树节点(否则不入账)
+	StudentID  string    `gorm:"index:idx_user_student_type,priority:2;index:idx_student_order,priority:1;column:student_id;size:26;not null" json:"studentId"` // 报课学生(ULID;原 studentid)|枚举:无|写入:上行分成时|FK:→ t_user(逻辑)|红线:幂等键组成列
+	OrderNo    *string   `gorm:"index:idx_student_order,priority:2;column:order_no;size:50" json:"orderNo,omitempty"`                                           // 订单号(原 orderid:type=2 时=上课记录 id)|枚举:无|写入:上行分成/撤销改绑|FK:弱关联 t_courses_record.id|红线:幂等键组成列(type=2),改绑即改幂等语义,须审计
+	Money      string    `gorm:"column:money;type:decimal(12,2);not null;default:0.00" json:"money"`                                                            // 分成金额(元;旧 float(10,2))|枚举:无|写入:上行分成时取节点 quota|FK:无|红线:与 total_income 累加同事务
+	CreateTime time.Time `gorm:"index:idx_create_time;column:create_time;not null" json:"createTime"`                                                           // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改(对账依据)
+	Remark     *string   `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                                                           // 备注(撤销改绑时写"推广分成")|枚举:无|写入:上行/撤销|FK:无|红线:脱敏
+}
+
+// TableName 指定表名。
+func (UserDivideIntoLog) TableName() string { return "t_user_divide_into_log" }

+ 211 - 0
internal/academic/model/models_fund.go

@@ -0,0 +1,211 @@
+// 资金账户与流水 6 表 model(t_user_expense / _school / _log / t_user_balance_request /
+// t_order_pay / t_payment_log)——与 unified/02-data-model/academic-model-ddl.sql「第一部分」逐列对齐。
+//
+// 字段注释五维口径(本项目 discipline,逐字段必填):
+//
+//	语义与单位 | 枚举全量取值 | 写入时机 | 主外键/关联语义 | 安全红线
+//
+// GORM 类型映射纪律(backend-model-field-spec.md §1.4,T2 实证漏 size → longtext 漂移):
+//   - varchar(N) → string/*string + **必带 size:N**(N 严格等于 DDL);
+//   - decimal(12,2) → **string + type:decimal(12,2)**(本批不直接落 money.Amount:
+//     model 层保结构稳定,金额运算在 service 层用 money.Amount 完成,AC-02 共享内核);
+//   - datetime(3) → time.Time/*time.Time(GORM MySQL 驱动默认 datetime(3));
+//   - date → *time.Time + type:date(对齐 DDL 的 DATE 列);
+//   - tinyint(1) → bool;tinyint → int8;smallint → *int16;int → int32;bigint → int64;
+//   - LONGTEXT(JSON/HTML)→ *string + type:longtext(string 直存,规避自定义类型序列化坑)。
+//
+// 作用域说明(设计 §1.7 两条例外):
+//   - t_user_expense / t_user_divide_into(_log) **无** institution_id/campus_id:
+//     全局钱包 / 全局推广体系,**不继承 BaseRepo**,越权断言在 Service 层经 MembershipChecker;
+//   - t_user_expense_school 等带作用域列的表 → Repository 继承 BaseRepo 强制 Scoped。
+package model
+
+import "time"
+
+// t_user_expense_log.type 编码(设计 §10.2 权威口径,方向以 SP dump 实现为准)。
+//
+// 方向标注:+ 表示入账方向(balance 增加),− 表示出账方向;**106/206 为 ★dump 口径漂移项**
+// (与 P0-04 规格相反,见设计 §11-6 #2),后续 service 实现不得"按规格顺手修正"。
+const (
+	ExpenseLogTypeRecharge          int32 = 100 // 充值(+)
+	ExpenseLogTypeCourseCampus      int32 = 101 // 课程消费·校区(−)
+	ExpenseLogTypeCourseSchoolA     int32 = 104 // 课程消费·学校(−)
+	ExpenseLogTypeCourseSchoolB     int32 = 105 // 课程消费·学校(−)
+	ExpenseLogTypeTransferOut       int32 = 106 // 转结出(★dump 口径:+money)
+	ExpenseLogTypeRecommend         int32 = 107 // 老带新推荐(+)
+	ExpenseLogTypeBadDebt           int32 = 108 // 坏账处理(+)
+	ExpenseLogTypeHistoryDeduct     int32 = 200 // 历史扣费(−)
+	ExpenseLogTypeCourseCampusB     int32 = 201 // 课程消费·校区(−)
+	ExpenseLogTypeCourseSchoolC     int32 = 204 // 课程消费·学校(−)
+	ExpenseLogTypeCourseSchoolD     int32 = 205 // 课程消费·学校(−)
+	ExpenseLogTypeTransferIn        int32 = 206 // 转结入(★dump 口径:−money)
+	ExpenseLogTypeRefund            int32 = 207 // 余额退费(−,全额含坏账部分)
+	ExpenseLogTypeClearBalance      int32 = 208 // 余额清零(−)
+	ExpenseLogTypeCancelRechargeAct int32 = 209 // 取消充值活动(−)
+)
+
+// t_user_balance_request.type / approved 编码(设计 §2「4. t_user_balance_request」)。
+const (
+	BalanceRequestTypeRecharge       int32 = 100 // 普通充值
+	BalanceRequestTypeBadDebt        int32 = 101 // 坏账
+	BalanceRequestTypeRefund         int32 = 202 // 退费
+	BalanceRequestTypeCancelRecharge int32 = 203 // 取消充值活动
+	BalanceRequestTypeTransfer       int32 = 300 // 转结
+
+	BalanceRequestApprovedCancelled int8 = -2 // 取消
+	BalanceRequestApprovedRejected  int8 = -1 // 不通过
+	BalanceRequestApprovedPending   int8 = 0  // 未处理
+	BalanceRequestApprovedPassed    int8 = 1  // 已通过(资金变动触发点)
+)
+
+// t_user_balance_request.transfer_accounts_type 收款方式编码。
+const (
+	TransferAccountsNone   int8 = 0 // 无
+	TransferAccountsCash   int8 = 1 // 现金
+	TransferAccountsWechat int8 = 2 // 微信
+	TransferAccountsAlipay int8 = 3 // 支付宝
+)
+
+// t_order_pay.status 状态编码。
+const (
+	OrderPayStatusFailed    int8 = -2 // 失败
+	OrderPayStatusCancelled int8 = -1 // 取消
+	OrderPayStatusUnpaid    int8 = 0  // 未支付
+	OrderPayStatusSucceeded int8 = 1  // 成功
+)
+
+// UserExpense 用户费用表 t_user_expense(**全局维度**账户·自然键 user_id·无作用域列)。
+//
+// 主键:user_id(canonical_user_id ULID,D1)。
+// 作用域:**无** institution_id/campus_id——跨机构共享的钱包视图,作用域断言由 Service 层完成
+// (设计 §1.7 例外一,代码评审须逐处确认"未绕过 Scoped 就代表了它不需要 scoped")。
+type UserExpense struct {
+	UserID                 string `gorm:"column:user_id;primaryKey;size:26;not null" json:"userId"`                                               // 用户(ULID 26 位,D1;原 userid int 经 t_legacy_mapping 重映射)|枚举:无|写入:首次资金操作 UPSERT|FK:→ t_user.canonical_user_id(无物理 FK)|红线:旧 int userid 只进映射表,禁止在此落 int
+	Balance                string `gorm:"column:balance;type:decimal(12,2);not null;default:0.00" json:"balance"`                                 // 余额(元,±;负数=欠费)|枚举:无|写入:充值/消费/退费/清零事务内更新|FK:无|红线:只允许 `balance = balance + ?` 原子表达式,禁止读-改-写(防丢更新)
+	Spend                  string `gorm:"column:spend;type:decimal(12,2);not null;default:0.00" json:"spend"`                                     // 上课消费课费单价之和(元;只增)|枚举:无|写入:spend SP(FundService.AccumulateSpend)|FK:无|红线:本列不动 balance,勿混用为扣费
+	TotalIncome            string `gorm:"column:total_income;type:decimal(12,2);not null;default:0.00" json:"totalIncome"`                        // 总充值(元)|枚举:无|写入:充值+/退费-/清零不动/转结双向|FK:无|红线:报表口径列,禁止业务分支改写
+	TotalExpenditure       string `gorm:"column:total_expenditure;type:decimal(12,2);not null;default:0.00" json:"totalExpenditure"`              // 总消费(元;报课学费之和+历史欠费)|枚举:无|写入:消费+/退费-/转结双向调整|FK:无|红线:与 t_user_expense_school 同名列须同事务同步
+	TotalRecommendedAmount string `gorm:"column:total_recommended_amount;type:decimal(12,2);not null;default:0.00" json:"totalRecommendedAmount"` // 累计推荐金额(元)|枚举:无|写入:老带新双边入账累加|FK:无|红线:双边各加一次,勿重复累加(双跑重点比对项)
+	SpecialExpenses        string `gorm:"column:special_expenses;type:decimal(12,2);not null;default:0.00" json:"specialExpenses"`                // 特殊费用(元;充值活动提前退费/坏账/清零累加)|枚举:无|写入:BadDebt/ClearBalance/退费差额|FK:无|红线:只增不减(对账口径)
+}
+
+// TableName 指定表名(GORM 默认复数化,与 DDL 的 t_ 前缀不符)。
+func (UserExpense) TableName() string { return "t_user_expense" }
+
+// UserExpenseSchool 用户费用表 t_user_expense_school(**校区维度**账户·多租户)。
+//
+// UK(user_id, institution_id, campus_id):旧库仅 PK id 无唯一键,目标态补 UK 以支撑
+// "COUNT>0 则 UPDATE 否则 INSERT"(旧 SP)→ `clause.OnConflict{DoNothing}` 的 UPSERT 语义(设计 §1.5)。
+// campus_id=0 表示机构级账户(跨校区汇总)。
+type UserExpenseSchool struct {
+	ID                     int64  `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                                                                        // 账户记录 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:被流水/工单逻辑引用(无物理 FK)|红线:不可复用/手工指定
+	UserID                 string `gorm:"uniqueIndex:uk_user_inst_campus,priority:1;column:user_id;size:26;not null" json:"userId"`                                                                            // 用户(ULID;原 userid)|枚举:无|写入:账户创建时|FK:→ t_user(逻辑)|红线:不得跨界改写
+	InstitutionID          int64  `gorm:"uniqueIndex:uk_user_inst_campus,priority:2;index:idx_inst_campus,priority:1;column:institution_id;not null" json:"institutionId"`                                     // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:账户创建时锁定,永不更新|FK:→ t_institution|红线:scoping 强依赖,禁止 0 值写入
+	CampusID               int64  `gorm:"uniqueIndex:uk_user_inst_campus,priority:3;index:idx_inst_campus,priority:2;index:idx_campus_balance,priority:1;column:campus_id;not null;default:0" json:"campusId"` // 校区 ID(原 campusid;0=机构级)|枚举:无|写入:账户创建时锁定|FK:→ t_campus|红线:UK 第 3 列,改动即账户错位
+	Balance                string `gorm:"index:idx_campus_balance,priority:2;column:balance;type:decimal(12,2);not null;default:0.00" json:"balance"`                                                          // 校区余额(元,±;负数=欠费)|枚举:无|写入:资金事务内原子增减|FK:无|红线:与全局 balance 同事务更新(固定顺序:全局→校区,防死锁)
+	Spend                  string `gorm:"column:spend;type:decimal(12,2);not null;default:0.00" json:"spend"`                                                                                                  // 校区上课消费课费单价之和(元)|枚举:无|写入:spend SP|FK:无|红线:只增
+	TotalIncome            string `gorm:"column:total_income;type:decimal(12,2);not null;default:0.00" json:"totalIncome"`                                                                                     // 校区总充值(元)|枚举:无|写入:充值/退费/转结|FK:无|红线:与全局表同事务
+	TotalExpenditure       string `gorm:"column:total_expenditure;type:decimal(12,2);not null;default:0.00" json:"totalExpenditure"`                                                                           // 校区总消费(元)|枚举:无|写入:消费/退费/转结|FK:无|红线:与全局表同事务
+	TotalRecommendedAmount string `gorm:"column:total_recommended_amount;type:decimal(12,2);not null;default:0.00" json:"totalRecommendedAmount"`                                                              // 校区累计推荐金额(元)|枚举:无|写入:老带新结算|FK:无|红线:双边累计勿重复
+	SpecialExpenses        string `gorm:"column:special_expenses;type:decimal(12,2);not null;default:0.00" json:"specialExpenses"`                                                                             // 校区特殊费用(元;坏账/清零/提前退费)|枚举:无|写入:BadDebt/ClearBalance|FK:无|红线:只增不减
+}
+
+// TableName 指定表名。
+func (UserExpenseSchool) TableName() string { return "t_user_expense_school" }
+
+// UserExpenseLog 用户费用变更流水表 t_user_expense_log(**只增不改**·幂等键 request_id+type)。
+//
+// 主键:id(D3 自增);幂等:UNIQUE(request_id, type)——Go 侧写库撞键即视为重复请求,
+// 返回首次结果(Repeated=true,code 仍 200,设计 §1.5)。历史行 request_id 为 NULL(MySQL 唯一键对 NULL 放行)。
+type UserExpenseLog struct {
+	ID           int64     `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                     // 流水 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:只增不改不删(审计底线)
+	UserID       string    `gorm:"index:idx_user_time,priority:1;index:idx_user_type,priority:1;column:user_id;size:26;not null" json:"userId"`      // 用户(ULID;原 userid)|枚举:无|写入:资金方法内|FK:→ t_user(逻辑)|红线:与账户行 user_id 必须一致
+	Type         int32     `gorm:"uniqueIndex:uk_request_type,priority:2;index:idx_user_type,priority:2;column:type;not null;default:0" json:"type"` // 类型编码(见 ExpenseLogType*:100/101/104/105/106/107/108/200/201/204/205/206/207/208/209)|枚举:全量见常量块|写入:资金方法内按动作固定传值|FK:无|红线:type 参与幂等键,禁止复用不同语义
+	LogID        *string   `gorm:"index:idx_log_id;column:log_id;size:64" json:"logId,omitempty"`                                                    // 业务单号(原 logid:报课 id/缴费申请 id/结课申请 id/充值单号;无则 NULL)|枚举:无|写入:资金方法内|FK:弱关联 t_user_balance_request.biz_no 等|红线:非唯一,勿当幂等键
+	RequestID    *string   `gorm:"uniqueIndex:uk_request_type,priority:1;column:request_id;size:64" json:"requestId,omitempty"`                      // 幂等请求号(原 SP inputRequestid;NULL=历史数据)|枚举:无|写入:调用方传入(工单 biz_no/支付单 biz_no)|FK:无|红线:与 type 组唯一键,重复提交必须命中而非重复入账
+	Money        string    `gorm:"column:money;type:decimal(12,2);not null;default:0.00" json:"money"`                                               // 变更金额(元;正负号表方向,见 ExpenseLogType* 注释)|枚举:无|写入:资金方法内|FK:无|红线:符号方向须与 type 口径一致(106/206 ★反向)
+	BalanceAfter *string   `gorm:"column:balance_after;type:decimal(12,2)" json:"balanceAfter,omitempty"`                                            // 变更后校区余额快照(元;新增列,便于对账/双跑)|枚举:无|写入:同事务内 UPDATE 后回读|FK:无|红线:历史行为 NULL,不得回填猜测值
+	CreateUserID *string   `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                                                      // 创建人(ULID;原 create_userid int,0/系统 → NULL)|枚举:无|写入:Service 从 scope/user 上下文取|FK:→ t_user(逻辑)|红线:不得写入旧 int id
+	CreateTime   time.Time `gorm:"index:idx_user_time,priority:2;column:create_time;not null" json:"createTime"`                                     // 创建时间(datetime(3))|枚举:无|写入:DB 默认 CURRENT_TIMESTAMP(3)|FK:无|红线:只增不改(勿 UPDATE)
+	Remark       *string   `gorm:"column:remark;size:1000" json:"remark,omitempty"`                                                                  // 备注(原 text → varchar(1000),超长截断见迁移报告)|枚举:无|写入:资金方法内|FK:无|红线:不得写入敏感信息(手机号/证件号)
+}
+
+// TableName 指定表名。
+func (UserExpenseLog) TableName() string { return "t_user_expense_log" }
+
+// UserBalanceRequest 余额变更申请表 t_user_balance_request(充值/退费/坏账/取消活动/转结 工单·审批驱动)。
+//
+// 旧 UUID 主键处置(设计 §2.2 / §11-10 拍板):id int64 自增 + biz_no varchar(32) UNIQUE 保留旧 UUID。
+// 资金变动触发点:approved 由 0 → 1 的审批动作(Service 同事务内先调资金方法、后回写工单并加幂等 request_id)。
+type UserBalanceRequest struct {
+	ID                   int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                                    // 工单 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:被流水 log_id/优惠池 balance_request_id 引用|红线:对外暴露的业务单号是 biz_no,不是本列
+	BizNo                string     `gorm:"uniqueIndex:uk_biz_no;column:biz_no;size:32;not null" json:"bizNo"`                                                               // 业务单号(旧 UUID 主键原样保留;UNIQUE)|枚举:无|写入:创建工单时生成/迁移搬入|FK:唯一键锚点(迁移幂等/回滚/双跑反查)|红线:不可变(割接回滚依赖)
+	Type                 int32      `gorm:"index:idx_type_approved,priority:1;column:type;not null;default:100" json:"type"`                                                 // 类型(100 普通充值/101 坏账/202 退费/203 取消充值活动/300 转结)|枚举:见 BalanceRequestType*|写入:创建工单时|FK:无|红线:决定调用哪个资金方法,禁止运行期变更
+	UserID               string     `gorm:"index:idx_user;column:user_id;size:26;not null;default:''" json:"userId"`                                                         // 用户(ULID;原 userid)|枚举:无|写入:创建工单时|FK:→ t_user(逻辑)|红线:与资金方法入参必须一致
+	InstitutionID        int64      `gorm:"index:idx_inst_campus_approved,priority:1;column:institution_id;not null;default:0" json:"institutionId"`                         // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:创建工单时|FK:→ t_institution|红线:scoping 依赖,禁止 0 值落库(除平台级)
+	CampusID             int64      `gorm:"index:idx_inst_campus_approved,priority:2;column:campus_id;not null;default:0" json:"campusId"`                                   // 校区 ID(原 campusid;作用域列)|枚举:无|写入:创建工单时|FK:→ t_campus|红线:同上
+	ToInstitutionID      *int64     `gorm:"column:to_institution_id" json:"toInstitutionId,omitempty"`                                                                       // 转入机构 ID(仅 type=300 转结;新增列)|枚举:无|写入:转结申请时|FK:→ t_institution|红线:转出≠转入时须校验两侧均可见
+	ToCampusID           *int64     `gorm:"column:to_campus_id" json:"toCampusId,omitempty"`                                                                                 // 转入校区 ID(仅 type=300)|枚举:无|写入:转结申请时|FK:→ t_campus|红线:同上
+	Money                string     `gorm:"column:money;type:decimal(12,2);not null;default:0.00" json:"money"`                                                              // 金额(元;旧 decimal(10,3) 收敛)|枚举:无|写入:创建工单时|FK:无|红线:审批通过后不可改(改须新建工单)
+	TransferAccountsType int8       `gorm:"column:transfer_accounts_type;not null;default:0" json:"transferAccountsType"`                                                    // 收款方式(0 无/1 现金/2 微信/3 支付宝)|枚举:TransferAccounts*|写入:创建工单时|FK:无|红线:仅记账口径,不代表已对账
+	TransferAccountsTime *time.Time `gorm:"column:transfer_accounts_time" json:"transferAccountsTime,omitempty"`                                                             // 收款时间(datetime(3))|枚举:无|写入:财务确认收款时|FK:无|红线:不得早于 request_time
+	OrderNo              *string    `gorm:"column:order_no;size:50" json:"orderNo,omitempty"`                                                                                // 交易单号(原 orderid;第三方流水号/报课单号)|枚举:无|写入:支付回调/财务录入|FK:弱关联 t_order_pay.pay_order|红线:非唯一
+	RequestUserID        string     `gorm:"column:request_user_id;size:26;not null;default:''" json:"requestUserId"`                                                         // 申请人(ULID;原 request_userid)|枚举:无|写入:创建工单时|FK:→ t_user(逻辑)|红线:禁止旧 int id
+	RequestTime          *time.Time `gorm:"column:request_time" json:"requestTime,omitempty"`                                                                                // 申请时间(datetime(3))|枚举:无|写入:提交时|FK:无|红线:只写一次
+	ResponseUserID       *string    `gorm:"column:response_user_id;size:26" json:"responseUserId,omitempty"`                                                                 // 审批人(ULID;原 response_userid;0→NULL)|枚举:无|写入:审批动作|FK:→ t_user(逻辑)|红线:申请人不得自审(业务规则在 Service 校验)
+	ResponseTime         *time.Time `gorm:"column:response_time" json:"responseTime,omitempty"`                                                                              // 审批时间(datetime(3))|枚举:无|写入:审批动作|FK:无|红线:与 approved 同事务写
+	ResponseMsg          *string    `gorm:"column:response_msg;size:1000" json:"responseMsg,omitempty"`                                                                      // 审批意见|枚举:无|写入:审批动作|FK:无|红线:用户输入,禁止拼接进 SQL
+	Approved             int8       `gorm:"index:idx_inst_campus_approved,priority:3;index:idx_type_approved,priority:2;column:approved;not null;default:0" json:"approved"` // 审批状态(-2 取消/-1 不通过/0 未处理/1 已通过;原 aproved 错拼修正)|枚举:BalanceRequestApproved*|写入:审批动作|FK:无|红线:0→1 是资金变动唯一触发点,重复调用须幂等
+	Item                 *string    `gorm:"column:item;size:1000" json:"item,omitempty"`                                                                                     // 项目(报课/充值课程说明)|枚举:无|写入:创建工单时|FK:无|红线:展示用,不参与计算
+	Imgs                 *string    `gorm:"column:imgs;type:longtext" json:"-"`                                                                                              // 凭证图片(JSON 字符串直存)|枚举:无|写入:创建工单时|FK:无|红线:**API 不回显原始报文**(json:"-"),防凭证泄露
+	Remark               *string    `gorm:"column:remark;size:1000" json:"remark,omitempty"`                                                                                 // 备注|枚举:无|写入:创建/审批|FK:无|红线:脱敏
+	CreatedAt            time.Time  `gorm:"column:created_at;not null" json:"createdAt"`                                                                                     // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt            time.Time  `gorm:"column:updated_at;not null" json:"updatedAt"`                                                                                     // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
+}
+
+// TableName 指定表名。
+func (UserBalanceRequest) TableName() string { return "t_user_balance_request" }
+
+// OrderPay 支付订单表 t_order_pay(旧 UUID 主键 → int64 + biz_no)。
+//
+// 本期仅承接"创建/取消/回调"三动作;支付通道(JSAPI/W02 银联)适配层 P1(设计 §11-11)。
+type OrderPay struct {
+	ID            int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                    // 订单 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:被 t_payment_log.order_id 引用|红线:对外单号是 biz_no
+	BizNo         string     `gorm:"uniqueIndex:uk_biz_no;column:biz_no;size:32;not null" json:"bizNo"`               // 业务单号(旧 UUID 原样保留;UNIQUE;对外暴露给前端/支付网关)|枚举:无|写入:下单时|FK:幂等/回滚锚点|红线:不可变(支付网关侧已登记)
+	Status        int8       `gorm:"index:idx_status;column:status;not null;default:0" json:"status"`                 // 状态(-2 失败/-1 取消/0 未支付/1 成功)|枚举:OrderPayStatus*|写入:下单/取消/回调|FK:无|红线:状态机单向(成功不可回退),回调重复须幂等
+	Type          *string    `gorm:"column:type;size:100" json:"type,omitempty"`                                      // 订单类型(充值/报课…,旧字符串原样)|枚举:旧字符串枚举(不收敛)|写入:下单时|FK:无|红线:仅展示/分流,不参与金额计算
+	InstitutionID *int64     `gorm:"column:institution_id" json:"institutionId,omitempty"`                            // 机构 ID(原 schoolid;作用域列;可空)|枚举:无|写入:下单时|FK:→ t_institution|红线:NULL ≠ 0,勿混判
+	CampusID      *int64     `gorm:"column:campus_id" json:"campusId,omitempty"`                                      // 校区 ID(原 campusid;作用域列;可空)|枚举:无|写入:下单时|FK:→ t_campus|红线:NULL ≠ 0
+	UserID        *string    `gorm:"index:idx_user_create,priority:1;column:user_id;size:26" json:"userId,omitempty"` // 付款用户(ULID;原 userid)|枚举:无|写入:下单时|FK:→ t_user(逻辑)|红线:家长代付场景 ≠ create_user_id
+	PayOrder      *string    `gorm:"index:idx_pay_order;column:pay_order;size:50" json:"payOrder,omitempty"`          // 交易单号(第三方流水号)|枚举:无|写入:支付回调|FK:弱关联 t_payment_log|红线:非唯一,索引仅用于排障
+	PayType       *string    `gorm:"column:pay_type;size:10" json:"payType,omitempty"`                                // 支付方式(JSAPI 微信 / W02 银联 AllinPay)|枚举:JSAPI/W02|写入:下单时|FK:无|红线:新增通道须评审(对账口径)
+	PayAccount    *string    `gorm:"column:pay_account;size:100" json:"-"`                                            // 支付平台用户标识(原 pay_acct;openid/卡号)|枚举:无|写入:下单时|FK:无|红线:**json:"-" 不外泄**(openid/卡号属个人信息)
+	PayMoney      string     `gorm:"column:pay_money;type:decimal(12,2);not null;default:0.00" json:"payMoney"`       // 交易金额(元;旧 float(10,2) 收敛)|枚举:无|写入:下单时锁定|FK:无|红线:回调金额与订单金额不符 → 拒绝入账
+	PayTime       *time.Time `gorm:"column:pay_time" json:"payTime,omitempty"`                                        // 支付时间(datetime(3))|枚举:无|写入:回调成功|FK:无|红线:成功态必须非 NULL
+	PayReturnData *string    `gorm:"column:pay_return_data;type:longtext" json:"-"`                                   // 支付返回数据(JSON 直存;含敏感信息)|枚举:无|写入:回调|FK:无|红线:**API 与日志均不得回显**(json:"-")
+	CreateUserID  *string    `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                     // 下单人(ULID)|枚举:无|写入:下单时|FK:→ t_user(逻辑)|红线:家长代付场景与 user_id 不同
+	CreateTime    time.Time  `gorm:"index:idx_user_create,priority:2;column:create_time;not null" json:"createTime"`  // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	Remark        *string    `gorm:"column:remark;size:500" json:"remark,omitempty"`                                  // 用户备注|枚举:无|写入:下单时|FK:无|红线:用户输入,展示需转义
+}
+
+// TableName 指定表名。
+func (OrderPay) TableName() string { return "t_order_pay" }
+
+// PaymentLog 支付网关日志表 t_payment_log(**只读历史**;旧 UUID 主键 → int64 + biz_no)。
+//
+// 本期纳管为只读:迁移保留、不新增写入(新支付回调日志写 t_audit_log + t_order_pay.pay_return_data,
+// 设计 §11-9)。是否重建新通道日志表列 P2。
+type PaymentLog struct {
+	ID          int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                       // 日志 ID(D3 自增)|枚举:无|写入:仅迁移期|FK:无|红线:只读历史,禁止新写入
+	BizNo       string     `gorm:"uniqueIndex:uk_biz_no;column:biz_no;size:100;not null" json:"bizNo"` // 对外订单号(旧主键原样保留;UNIQUE)|枚举:无|写入:迁移搬入|FK:弱关联 t_order_pay.biz_no|红线:与 t_order_pay.biz_no 长度不同(100 vs 32),勿混用
+	UserID      *string    `gorm:"index:idx_user;column:user_id;size:26" json:"userId,omitempty"`      // 用户(ULID)|枚举:无|写入:迁移搬入|FK:→ t_user(逻辑)|红线:历史可能缺失
+	OrderID     *int64     `gorm:"index:idx_order;column:order_id" json:"orderId,omitempty"`           // 订单 ID(原 order_id int → t_order_pay.id;迁移期 NULL 待回填)|枚举:无|写入:迁移回填|FK:→ t_order_pay.id|红线:NULL 期间不得据此做资金判断
+	PaymentType int32      `gorm:"column:payment_type;not null;default:0" json:"paymentType"`          // 支付网关类型(旧 int 编码原样)|枚举:旧编码(不收敛,需对照 PHP 常量)|写入:迁移搬入|FK:无|红线:语义未定前不做分支
+	ReceiveTime *time.Time `gorm:"column:receive_time" json:"receiveTime,omitempty"`                   // 收到支付消息时间(datetime(3))|枚举:无|写入:迁移搬入|FK:无|红线:只读
+	ReturnData  *string    `gorm:"column:return_data;type:longtext" json:"-"`                          // 网关原始数据(JSON 直存)|枚举:无|写入:迁移搬入|FK:无|红线:**敏感,API/日志不得回显**
+	CreateTime  time.Time  `gorm:"column:create_time;not null" json:"createTime"`                      // 生成时间(datetime(3))|枚举:无|写入:DB 默认/迁移|FK:无|红线:只读
+	Result      int32      `gorm:"index:idx_result;column:result;not null;default:0" json:"result"`    // 处理结果(旧编码原样)|枚举:旧编码(不收敛)|写入:迁移搬入|FK:无|红线:只读
+}
+
+// TableName 指定表名。
+func (PaymentLog) TableName() string { return "t_payment_log" }

+ 146 - 0
internal/academic/model/models_payroll.go

@@ -0,0 +1,146 @@
+// 课酬与收支 6 表 model(t_course_payment / _detail / t_teacher_information /
+// t_teacher_courses_percentage / t_expenditure / t_expenditure_log)
+// —— 与 academic-model-ddl.sql「第五部分」逐列对齐。
+//
+// 覆盖 SP:course_payment_detail_get_teacher_detail(课酬多级回退匹配),
+// 匹配顺序(设计 §3.4 #26):
+//  1. 方案两级回退:campus_id 命中 → 回退 campus_id=0(机构级);
+//  2. 规则三级回退:个人(teacher_id=uid)→ 持证通用(teacher_id 空 + teacher_cert=1)→ 无证通用(teacher_cert=0);
+//  3. grade_id / subject_id 支持 −1 通配;教龄区间用 BETWEEN(teacher_exp_min..max)。
+//
+// 无可用方案 → 3112(apperr.ErrPayrollPlanMissing)。
+//
+// 字段注释五维口径:语义与单位 | 枚举全量取值 | 写入时机 | 主外键/关联语义 | 安全红线。
+package model
+
+import "time"
+
+// t_teacher_courses_percentage.payment_method 结算方式编码。
+const (
+	PayrollPaymentMethodPercentage int8 = 1 // 提成结算
+	PayrollPaymentMethodCash       int8 = 2 // 现金结算
+)
+
+// t_expenditure.type 收支类型编码。
+const (
+	ExpenditureTypeExpense int32 = 1 // 支出
+	ExpenditureTypeIncome  int32 = 2 // 收入
+)
+
+// CoursePayment 课酬方案表 t_course_payment(机构/校区级,按生效日期取最新)。
+type CoursePayment struct {
+	ID            int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                  // 方案 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:被 course_payment_detail / classify 引用|红线:被规则引用后禁删
+	InstitutionID int64      `gorm:"index:idx_inst_campus_actived_effect,priority:1;column:institution_id;not null;default:0" json:"institutionId"` // 机构 ID(原 schoolid;0=所有机构;作用域列)|枚举:无|写入:创建方案时|FK:→ t_institution|红线:0 表示通用方案,scoping 时需显式放行(评审确认)
+	CampusID      int64      `gorm:"index:idx_inst_campus_actived_effect,priority:2;column:campus_id;not null;default:0" json:"campusId"`           // 校区 ID(原 campusid;0=机构所有校区)|枚举:无|写入:创建方案时|FK:→ t_campus|红线:两级回退的第二级依据
+	Name          *string    `gorm:"column:name;size:200" json:"name,omitempty"`                                                                    // 方案名称|枚举:无|写入:创建/编辑|FK:无|红线:展示转义
+	EffectDate    *time.Time `gorm:"index:idx_inst_campus_actived_effect,priority:4;column:effect_date" json:"effectDate,omitempty"`                // 生效日期(datetime(3);取 effect_date<=当前 最新一条)|枚举:无|写入:创建/编辑|FK:无|红线:同机构校区多方案按本列 DESC 取首条
+	Actived       bool       `gorm:"index:idx_inst_campus_actived_effect,priority:3;column:actived;not null;default:0" json:"actived"`              // 是否生效(bit(1)→tinyint(1)/bool)|枚举:false/true|写入:启用/停用|FK:无|红线:仅 actived=true 参与匹配
+	CreateUserID  *string    `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                                                   // 创建人(ULID;原 create_userid)|枚举:无|写入:创建|FK:→ t_user(逻辑)|红线:禁止旧 int id
+	CreateTime    time.Time  `gorm:"column:create_time;not null" json:"createTime"`                                                                 // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	Memo          *string    `gorm:"column:memo;type:longtext" json:"memo,omitempty"`                                                               // 备注|枚举:无|写入:创建/编辑|FK:无|红线:脱敏
+}
+
+// TableName 指定表名。
+func (CoursePayment) TableName() string { return "t_course_payment" }
+
+// CoursePaymentDetail 课酬明细规则表 t_course_payment_detail(个人→持证通用→无证通用 三级回退)。
+//
+// grade_id / subject_id 支持 −1 通配(匹配逻辑保留旧口径,设计 §3.4 #26);
+// percentage 为 1 位小数的百分比数字部分(如 65.5 表示 65.5%)。
+type CoursePaymentDetail struct {
+	ID              int64   `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                                      // 明细 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:试算按 id ASC 取首条命中(回退优先级靠查询顺序)
+	CoursePaymentID int64   `gorm:"index:idx_payment;column:course_payment_id;not null" json:"coursePaymentId"`                                        // 课酬方案 ID(原 course_paymentid)|枚举:无|写入:创建规则时|FK:→ t_course_payment.id|红线:方案停用即整组失效
+	GradeID         int32   `gorm:"column:grade_id;not null;default:-1" json:"gradeId"`                                                                // 年级 ID(-1 所有/0 混合)|枚举:−1 通配 + 年级字典|写入:创建规则|FK:→ 教学域年级|红线:−1 通配逻辑不得写成 `= -1` 强制条件
+	SubjectID       int32   `gorm:"column:subject_id;not null;default:-1" json:"subjectId"`                                                            // 科目 ID(-1 所有/0 混合)|枚举:−1 通配 + 科目字典|写入:创建规则|FK:→ 教学域科目|红线:同上
+	ClassType       int32   `gorm:"index:idx_teacher_classify,priority:2;column:class_type;not null;default:0" json:"classType"`                       // 班级类型(对应 t_courses_classify.class_type)|枚举:班型编码|写入:创建规则|FK:弱关联 t_courses_classify.class_type|红线:匹配维度
+	ClassifyID      int64   `gorm:"index:idx_teacher_classify,priority:3;column:classify_id;not null;default:0" json:"classifyId"`                     // 课程分类 ID(原 classifyid;个人配置维度)|枚举:无|写入:创建规则|FK:→ t_courses_classify.id|红线:0=不限定分类
+	TeacherID       *string `gorm:"index:idx_teacher_classify,priority:1;column:teacher_id;size:26" json:"teacherId,omitempty"`                        // 老师(ULID;NULL/空=通用规则)|枚举:无|写入:个人规则时|FK:→ t_user(逻辑)|红线:三级回退的第一级判据(个人优先)
+	Name            *string `gorm:"column:name;size:300" json:"name,omitempty"`                                                                        // 计算方式名称|枚举:无|写入:创建规则|FK:无|红线:展示转义
+	Percentage      string  `gorm:"column:percentage;type:decimal(5,1);not null;default:0.0" json:"percentage"`                                        // 课酬比例(百分比数字部分,1 位小数;旧 float(10,1))|枚举:0~100 常见|写入:创建规则|FK:无|红线:精度例外列;参与计算须走整数运算(分×比例→四舍五入)
+	MinPayment      string  `gorm:"column:min_payment;type:decimal(12,2);not null;default:0.00" json:"minPayment"`                                     // 最低课酬标准(元)|枚举:无|写入:创建规则|FK:无|红线:<= max_payment(0 表示不限制)
+	MaxPayment      string  `gorm:"column:max_payment;type:decimal(12,2);not null;default:0.00" json:"maxPayment"`                                     // 最高课酬标准(元)|枚举:无|写入:创建规则|FK:无|红线:0 表示不限制
+	Reward          string  `gorm:"column:reward;type:decimal(12,2);not null;default:0.00" json:"reward"`                                              // 额外课酬(元)|枚举:无|写入:创建规则|FK:无|红线:在比例基础上追加
+	TeacherCert     bool    `gorm:"index:idx_cert_exp,priority:1;column:teacher_cert;not null;default:0" json:"teacherCert"`                           // 是否持证(bit(1)→tinyint(1)/bool)|枚举:false 无证/true 持证|写入:创建规则|FK:弱关联 t_teacher_information.teacher_cert|红线:第二级回退判据(持证通用)
+	TeacherExpMin   string  `gorm:"index:idx_cert_exp,priority:2;column:teacher_exp_min;type:decimal(6,2);not null;default:0.00" json:"teacherExpMin"` // 教龄下限(年;旧 float(10,3) → 2 位)|枚举:无|写入:创建规则|FK:无|红线:精度例外列;BETWEEN 匹配
+	TeacherExpMax   string  `gorm:"index:idx_cert_exp,priority:3;column:teacher_exp_max;type:decimal(6,2);not null;default:0.00" json:"teacherExpMax"` // 教龄上限(年)|枚举:无|写入:创建规则|FK:无|红线:>= teacher_exp_min
+	Actived         bool    `gorm:"column:actived;not null;default:0" json:"actived"`                                                                  // 是否生效(bit(1)→tinyint(1)/bool)|枚举:false/true|写入:启用/停用|FK:无|红线:仅 true 参与匹配
+	Memo            *string `gorm:"column:memo;type:longtext" json:"memo,omitempty"`                                                                   // 备注|枚举:无|写入:创建/编辑|FK:无|红线:脱敏
+}
+
+// TableName 指定表名。
+func (CoursePaymentDetail) TableName() string { return "t_course_payment_detail" }
+
+// TeacherInformation 教师扩展信息表 t_teacher_information(资格证/教龄·自然键 teacher_id)。
+//
+// 作用域:**无** institution_id/campus_id(教师是平台级人员属性,跨机构共享)——
+// Repository 不继承 BaseRepo,越权断言在 Service 层(设计 §1.7 例外一)。
+type TeacherInformation struct {
+	TeacherID   string     `gorm:"column:teacher_id;primaryKey;size:26;not null" json:"teacherId"` // 老师(ULID;原 teacherid,主键)|枚举:无|写入:首次填资质时|FK:→ t_user(逻辑)|红线:一人一行
+	TeacherCert bool       `gorm:"column:teacher_cert;not null;default:0" json:"teacherCert"`      // 教师资格证(bit(1)→tinyint(1)/bool;0 无/1 有)|枚举:false/true|写入:资质维护|FK:无|红线:课酬第二级回退判据
+	TeacherExp  *time.Time `gorm:"column:teacher_exp;type:date" json:"teacherExp,omitempty"`       // 教龄起算日(date)|枚举:无|写入:资质维护|FK:无|红线:教龄年数由本列与试算日期差算出,勿存冗余年数
+	CreatedAt   time.Time  `gorm:"column:created_at;not null" json:"createdAt"`                    // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt   time.Time  `gorm:"column:updated_at;not null" json:"updatedAt"`                    // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
+}
+
+// TableName 指定表名。
+func (TeacherInformation) TableName() string { return "t_teacher_information" }
+
+// TeacherCoursesPercentage 老师课酬提成表 t_teacher_courses_percentage(提成/固定两制·UK(type, teacher_id))。
+//
+// `*_extend` 列为按年级/科目分档的 JSON/逗号扩展串,目标态**原样保留字符串**不结构化
+// (保结构稳定;解析在 Service 层,设计 §11-3 待补盘点)。
+type TeacherCoursesPercentage struct {
+	ID               int64      `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                               // 记录 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:UK(type,teacher_id) 保证一人一类型一行
+	Type             int32      `gorm:"uniqueIndex:uk_type_teacher,priority:1;column:type;not null;default:1000" json:"type"`       // 课程类型(class_type)|枚举:班型编码(1000 集体班…)|写入:配置时|FK:弱关联 t_courses_classify.class_type|红线:UK 第 1 列
+	PaymentMethod    int8       `gorm:"column:payment_method;not null;default:1" json:"paymentMethod"`                              // 结算方式(1 提成结算/2 现金结算)|枚举:PayrollPaymentMethod*|写入:配置时|FK:无|红线:决定试算分支
+	TeacherID        string     `gorm:"uniqueIndex:uk_type_teacher,priority:2;column:teacher_id;size:26;not null" json:"teacherId"` // 老师(ULID;原 teacherid)|枚举:无|写入:配置时|FK:→ t_user(逻辑)|红线:UK 第 2 列
+	Percentage       int32      `gorm:"column:percentage;not null;default:0" json:"percentage"`                                     // 提成比例(%,整数)|枚举:0~100|写入:配置时|FK:无|红线:与 extend 分档冲突时以 extend 为准(旧口径)
+	Cost             string     `gorm:"column:cost;type:decimal(12,2);not null;default:0.00" json:"cost"`                           // 一次课课酬(元)|枚举:无|写入:配置时|FK:无|红线:现金结算基数
+	AddedCost        string     `gorm:"column:added_cost;type:decimal(12,2);not null;default:0.00" json:"addedCost"`                // 额外费用(元;比例基础上追加)|枚举:无|写入:配置时|FK:无|红线:追加项须可追溯
+	PercentageExtend *string    `gorm:"column:percentage_extend;size:500" json:"percentageExtend,omitempty"`                        // 提成比例扩展(按年级/科目分档,原样保留)|枚举:旧扩展串格式|写入:配置时|FK:无|红线:**不结构化**(解析在 Service),勿在此做 JSON 校验
+	CostExtend       *string    `gorm:"column:cost_extend;size:500" json:"costExtend,omitempty"`                                    // 固定课酬扩展(原样保留)|枚举:旧扩展串格式|写入:配置时|FK:无|红线:同上
+	MaxReward        string     `gorm:"column:max_reward;type:decimal(12,2);not null;default:0.00" json:"maxReward"`                // 最大课酬(元/一次课)|枚举:无|写入:配置时|FK:无|红线:0=不限制
+	MaxRewardExtend  *string    `gorm:"column:max_reward_extend;type:longtext" json:"maxRewardExtend,omitempty"`                    // 最大课酬扩展(按年级分档)|枚举:旧扩展串格式|写入:配置时|FK:无|红线:同上
+	MinReward        string     `gorm:"column:min_reward;type:decimal(12,2);not null;default:0.00" json:"minReward"`                // 最小课酬(元/一次课)|枚举:无|写入:配置时|FK:无|红线:0=不限制
+	MinRewardExtend  *string    `gorm:"column:min_reward_extend;size:500" json:"minRewardExtend,omitempty"`                         // 最小课酬扩展(原样保留)|枚举:旧扩展串格式|写入:配置时|FK:无|红线:同上
+	EffectiveDate    *time.Time `gorm:"column:effective_date;type:date" json:"effectiveDate,omitempty"`                             // 课酬生效日期(date)|枚举:无|写入:配置时|FK:无|红线:取 <= 试算日期的最新一条
+	CreatedAt        time.Time  `gorm:"column:created_at;not null" json:"createdAt"`                                                // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt        time.Time  `gorm:"column:updated_at;not null" json:"updatedAt"`                                                // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
+}
+
+// TableName 指定表名。
+func (TeacherCoursesPercentage) TableName() string { return "t_teacher_courses_percentage" }
+
+// Expenditure 收支类型字典表 t_expenditure(自引用树·支出/收入)。
+type Expenditure struct {
+	ID            int64   `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                 // 类型 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:被 expenditure_log.expenditure_id 引用|红线:被引用后禁删(改 disabled 语义由 status 缺列限制,本期只禁删)
+	Type          int32   `gorm:"index:idx_inst_type,priority:2;column:type;not null;default:1" json:"type"`                    // 类型(1 支出/2 收入)|枚举:ExpenditureType*|写入:创建字典项|FK:无|红线:决定报表方向(正负)
+	ParentID      int64   `gorm:"index:idx_parent;column:parent_id;not null;default:0" json:"parentId"`                         // 父类型 ID(原 parentid;0=顶级)|枚举:无|写入:创建字典项|FK:→ 本表 id(自引用)|红线:禁止成环
+	Name          string  `gorm:"column:name;size:500;not null" json:"name"`                                                    // 名称|枚举:无|写入:创建/编辑|FK:无|红线:展示转义
+	Remark        *string `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                          // 备注|枚举:无|写入:创建/编辑|FK:无|红线:脱敏
+	InstitutionID int64   `gorm:"index:idx_inst_type,priority:1;column:institution_id;not null;default:0" json:"institutionId"` // 机构 ID(新增作用域列;0=平台通用字典)|枚举:无|写入:创建时|FK:→ t_institution|红线:0 为平台字典(scoping 需放行 0 行,评审确认)
+}
+
+// TableName 指定表名。
+func (Expenditure) TableName() string { return "t_expenditure" }
+
+// ExpenditureLog 收支记录表 t_expenditure_log(旧 UUID 主键 → int64 + biz_no)。
+//
+// year/month 为报表分桶冗余列(原 year year 类型 / month tinyint),保留以避免月度报表改 SQL。
+type ExpenditureLog struct {
+	ID            int64     `gorm:"column:id;primaryKey;autoIncrement" json:"id"`                                                        // 记录 ID(D3 自增)|枚举:无|写入:INSERT 自动|FK:无|红线:对外单号是 biz_no
+	BizNo         string    `gorm:"uniqueIndex:uk_biz_no;column:biz_no;size:32;not null" json:"bizNo"`                                   // 业务单号(旧 UUID 原样保留;UNIQUE)|枚举:无|写入:记账时|FK:迁移/回滚锚点|红线:不可变
+	InstitutionID int64     `gorm:"index:idx_inst_campus_time,priority:1;column:institution_id;not null;default:0" json:"institutionId"` // 机构 ID(原 schoolid;作用域列)|枚举:无|写入:记账时|FK:→ t_institution|红线:scoping 强依赖
+	CampusID      int64     `gorm:"index:idx_inst_campus_time,priority:2;column:campus_id;not null;default:0" json:"campusId"`           // 校区 ID(原 campusid;作用域列)|枚举:无|写入:记账时|FK:→ t_campus|红线:同上
+	ExpenditureID int64     `gorm:"index:idx_expenditure;column:expenditure_id;not null;default:0" json:"expenditureId"`                 // 收支类型 ID(原 expenditureid)|枚举:无|写入:记账时|FK:→ t_expenditure.id|红线:决定支出/收入方向
+	Cost          string    `gorm:"column:cost;type:decimal(12,2);not null;default:0.00" json:"cost"`                                    // 费用(元;旧 float(10,3) 收敛)|枚举:无|写入:记账时|FK:无|红线:符号由 type 决定,本列存正数
+	CreateUserID  *string   `gorm:"column:create_user_id;size:26" json:"createUserId,omitempty"`                                         // 创建人(ULID;原 create_userid)|枚举:无|写入:记账时|FK:→ t_user(逻辑)|红线:禁止旧 int id
+	CreateTime    time.Time `gorm:"index:idx_inst_campus_time,priority:3;column:create_time;not null" json:"createTime"`                 // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	Year          *int16    `gorm:"index:idx_year_month,priority:1;column:year" json:"year,omitempty"`                                   // 收支年份(报表分桶冗余列,原 year 类型)|枚举:如 2026|写入:记账时冗余|FK:无|红线:与 create_time 年份须一致(一致性校验)
+	Month         *int8     `gorm:"index:idx_year_month,priority:2;column:month" json:"month,omitempty"`                                 // 收支月份(报表分桶冗余列,原 month tinyint)|枚举:1~12|写入:记账时冗余|FK:无|红线:同上
+	Remark        *string   `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                                 // 备注|枚举:无|写入:记账时|FK:无|红线:脱敏
+	Imgs          *string   `gorm:"column:imgs;type:longtext" json:"-"`                                                                  // 图片地址(JSON 直存)|枚举:无|写入:记账时|FK:无|红线:**凭证可能含敏感信息,json:"-"**(按需授权下载)
+}
+
+// TableName 指定表名。
+func (ExpenditureLog) TableName() string { return "t_expenditure_log" }

+ 333 - 0
internal/academic/model/models_test.go

@@ -0,0 +1,333 @@
+// academic model 与 DDL 逐列对账单测(**纯单测,不连库**)。
+//
+// 权威基准:testdata/academic-model-ddl.sql(`02-data-model/academic-model-ddl.sql` 的逐字节副本,
+// 由 AC-01 建立时 cp 而来;DDL 变更须同步刷新该 fixture,AC-17 会再用 information_schema 做真库核对)。
+//
+// 覆盖 4 项硬要求(AC-01 验收②③④):
+//  1. `AllTables()` 长度 = 27、无重复、每表均有 TableName();
+//  2. 27 表 ↔ DDL CREATE TABLE 集合一致(无遗漏/无多余);
+//  3. 每表列名集合、列类型(VARCHAR(N)/DECIMAL(p,s)/BIGINT/INT/TINYINT(1)/TINYINT/SMALLINT/
+//     DATETIME(3)/DATE/LONGTEXT)、可空性(NULL ↔ 指针)逐列一致;
+//  4. 每个字段都有 gorm `column:` tag + 五维中文注释(含 4 个"|"分隔符)。
+package model
+
+import (
+	_ "embed"
+	"fmt"
+	"go/ast"
+	"go/parser"
+	"go/token"
+	"os"
+	"reflect"
+	"strings"
+	"sync"
+	"testing"
+
+	"gorm.io/gorm/schema"
+)
+
+// ddlSQL academic 目标态 DDL 全文(fixture 副本,见文件头说明)。
+//
+//go:embed testdata/academic-model-ddl.sql
+var ddlSQL string
+
+// expectedTableCount 目标态表数(设计 §2.1:30 旧表 → 27 建 + 3 不建)。
+const expectedTableCount = 27
+
+// ddlColumn DDL 单列信息(解析产物)。
+type ddlColumn struct {
+	Name    string // 列名(DDL 内的 snake_case)
+	Type    string // 归一化列类型(大写,如 "VARCHAR(26)" / "DECIMAL(12,2)" / "DATETIME(3)")
+	NotNull bool   // 是否 NOT NULL
+}
+
+// TestAllTablesShape 断言 AllTables 的形状:27 项、无重复、TableName 齐全且返回新切片。
+func TestAllTablesShape(t *testing.T) {
+	tables := AllTables()
+	if len(tables) != expectedTableCount {
+		t.Fatalf("AllTables() 长度 = %d,期望 %d", len(tables), expectedTableCount)
+	}
+	seen := make(map[string]bool, len(tables))
+	for _, m := range tables {
+		if m == nil {
+			t.Fatal("AllTables() 含 nil")
+		}
+		p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{})
+		if err != nil {
+			t.Fatalf("schema.Parse(%T) 失败: %v", m, err)
+		}
+		if p.Table == "" {
+			t.Fatalf("%T 缺少 TableName()", m)
+		}
+		if seen[p.Table] {
+			t.Fatalf("表名重复: %s", p.Table)
+		}
+		seen[p.Table] = true
+	}
+
+	// 返回新切片:改动第一次返回值不得影响第二次(backend-model-field-spec.md §1.8)
+	first := AllTables()
+	first[0] = nil
+	if second := AllTables(); second[0] == nil {
+		t.Fatal("AllTables() 返回了共享底层数组(调用方可安全 append 的前提被破坏)")
+	}
+}
+
+// TestDDLTableCoverage DDL 中的 CREATE TABLE 集合与 AllTables 完全一致。
+func TestDDLTableCoverage(t *testing.T) {
+	ddl := parseDDL(t, ddlSQL)
+	if len(ddl) != expectedTableCount {
+		t.Fatalf("DDL 解析到 %d 张表,期望 %d(fixture 是否最新?)", len(ddl), expectedTableCount)
+	}
+	structTables := make(map[string]bool, expectedTableCount)
+	for _, m := range AllTables() {
+		p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{})
+		if err != nil {
+			t.Fatalf("schema.Parse 失败: %v", err)
+		}
+		structTables[p.Table] = true
+	}
+	for table := range ddl {
+		if !structTables[table] {
+			t.Fatalf("DDL 表 %s 在 model.AllTables() 中缺失", table)
+		}
+	}
+	for table := range structTables {
+		if _, ok := ddl[table]; !ok {
+			t.Fatalf("model 表 %s 在 DDL 中不存在", table)
+		}
+	}
+}
+
+// TestModelsColumnByColumn 27 表逐列比对(列名/类型/可空性)。
+func TestModelsColumnByColumn(t *testing.T) {
+	ddl := parseDDL(t, ddlSQL)
+	checkedColumns := 0
+	for _, m := range AllTables() {
+		p, err := schema.Parse(m, &sync.Map{}, schema.NamingStrategy{})
+		if err != nil {
+			t.Fatalf("schema.Parse(%T) 失败: %v", m, err)
+		}
+		cols, ok := ddl[p.Table]
+		if !ok {
+			t.Fatalf("表 %s 无 DDL 定义", p.Table)
+		}
+
+		// 结构体列索引(DBName → 字段)
+		structCols := make(map[string]*schema.Field, len(p.Fields))
+		for _, f := range p.Fields {
+			structCols[f.DBName] = f
+		}
+		if len(structCols) != len(p.Fields) {
+			t.Fatalf("表 %s 存在重复 column 映射(DB 列名冲突)", p.Table)
+		}
+
+		seen := make(map[string]bool, len(cols))
+		for _, c := range cols {
+			seen[c.Name] = true
+			f, ok := structCols[c.Name]
+			if !ok {
+				t.Fatalf("表 %s 缺少列 %s(DDL 有、model 无)", p.Table, c.Name)
+			}
+			gotType := gormTypeSig(f)
+			if gotType != c.Type {
+				t.Fatalf("表 %s 列 %s 类型 = %s,DDL = %s", p.Table, c.Name, gotType, c.Type)
+			}
+			isPtr := f.FieldType.Kind() == reflect.Ptr
+			if c.NotNull && isPtr {
+				t.Fatalf("表 %s 列 %s 为 NOT NULL,model 却是指针(可空)", p.Table, c.Name)
+			}
+			if !c.NotNull && !isPtr {
+				t.Fatalf("表 %s 列 %s 可空,model 必须指针化(backend-model-field-spec.md §1.5)", p.Table, c.Name)
+			}
+			checkedColumns++
+		}
+		for name := range structCols {
+			if !seen[name] {
+				t.Fatalf("表 %s 多出列 %s(model 有、DDL 无)", p.Table, name)
+			}
+		}
+	}
+	// 27 表合计约 280+ 列;低于 250 说明解析或映射异常(防御性下限断言)
+	if checkedColumns < 250 {
+		t.Fatalf("仅比对 %d 列,疑似 DDL 解析或列映射不全", checkedColumns)
+	}
+}
+
+// TestFieldTagsAndComments 每个字段必须有 gorm column tag + 五维中文注释(4 个"|"分隔符)。
+//
+// 说明:注释无法经反射读取,故用 go/parser 直接解析本包源文件(同一目录下的 *.go)。
+func TestFieldTagsAndComments(t *testing.T) {
+	entries, err := os.ReadDir(".")
+	if err != nil {
+		t.Fatalf("读取包目录失败: %v", err)
+	}
+	fset := token.NewFileSet()
+	files := 0
+	for _, e := range entries {
+		name := e.Name()
+		if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
+			continue
+		}
+		f, err := parser.ParseFile(fset, name, nil, parser.ParseComments)
+		if err != nil {
+			t.Fatalf("解析 %s 失败: %v", name, err)
+		}
+		files++
+		for _, decl := range f.Decls {
+			gd, ok := decl.(*ast.GenDecl)
+			if !ok || gd.Tok != token.TYPE {
+				continue
+			}
+			for _, spec := range gd.Specs {
+				ts, ok := spec.(*ast.TypeSpec)
+				if !ok {
+					continue
+				}
+				st, ok := ts.Type.(*ast.StructType)
+				if !ok {
+					continue
+				}
+				for _, field := range st.Fields.List {
+					if len(field.Names) == 0 {
+						continue // 内嵌字段(本包 model 无)
+					}
+					name := field.Names[0].Name
+					tag := ""
+					if field.Tag != nil {
+						tag = field.Tag.Value
+					}
+					if !strings.Contains(tag, "column:") {
+						t.Fatalf("%s.%s 缺少 gorm column tag", ts.Name.Name, name)
+					}
+					comment := ""
+					if field.Comment != nil {
+						comment += field.Comment.Text()
+					}
+					if field.Doc != nil {
+						comment += field.Doc.Text()
+					}
+					if comment == "" {
+						t.Fatalf("%s.%s 缺少五维中文注释", ts.Name.Name, name)
+					}
+					if got := strings.Count(comment, "|"); got < 4 {
+						t.Fatalf("%s.%s 注释维度不足(应含 语义|枚举|写入|关联|红线 共 4 个分隔符,实得 %d):%s",
+							ts.Name.Name, name, got, comment)
+					}
+				}
+			}
+		}
+	}
+	if files < 6 {
+		t.Fatalf("仅解析到 %d 个源文件,期望 >= 6(models_*.go + tables.go)", files)
+	}
+}
+
+// gormTypeSig 把 GORM schema.Field 归一为与 DDL 同形的类型签名。
+//
+// 归一规则(与 backend-model-field-spec.md §1.4 的类型映射互为逆运算):
+//   - `type:decimal(12,2)` / `type:decimal(5,1)` → "DECIMAL(12,2)" / "DECIMAL(5,1)"(原样大写);
+//   - `type:date` → "DATE";`type:longtext` → "LONGTEXT";
+//   - string + size:N → "VARCHAR(N)";string 无 size → "LONGTEXT"(GORM 默认,正是历史踩坑点);
+//   - bool → "TINYINT(1)";int8/16/32/64 → TINYINT/SMALLINT/INT/BIGINT;
+//   - time.Time / *time.Time → "DATETIME(3)"(MySQL 驱动默认毫秒精度,与 DDL datetime(3) 一致)。
+func gormTypeSig(f *schema.Field) string {
+	dt := strings.ToLower(string(f.DataType))
+	switch {
+	case strings.HasPrefix(dt, "decimal"):
+		if dt == "decimal" {
+			return fmt.Sprintf("DECIMAL(%d,%d)", f.Precision, f.Scale)
+		}
+		return strings.ToUpper(dt) // 已含精度,如 "decimal(12,2)"
+	case dt == "date":
+		return "DATE"
+	case dt == "longtext":
+		return "LONGTEXT"
+	case dt == "datetime(3)":
+		return "DATETIME(3)"
+	}
+	switch f.DataType {
+	case schema.String:
+		if f.Size > 0 {
+			return fmt.Sprintf("VARCHAR(%d)", f.Size)
+		}
+		return "LONGTEXT"
+	case schema.Bool:
+		return "TINYINT(1)"
+	case schema.Int, schema.Uint:
+		switch f.Size {
+		case 8:
+			return "TINYINT"
+		case 16:
+			return "SMALLINT"
+		case 32:
+			return "INT"
+		case 64:
+			return "BIGINT"
+		}
+		return fmt.Sprintf("INT(%d)", f.Size)
+	case schema.Time:
+		if f.Precision > 0 {
+			return fmt.Sprintf("DATETIME(%d)", f.Precision)
+		}
+		return "DATETIME(3)"
+	}
+	return strings.ToUpper(string(f.DataType))
+}
+
+// parseDDL 解析 DDL 全文,返回「表名 → 列定义列表」(仅列定义,索引/约束行跳过)。
+//
+// 识别规则:
+//   - `CREATE TABLE \`t_x\` (` 行开启一张表;
+//   - 以 ')' 开头的行结束当前表;
+//   - 以反引号开头的行是列定义(`PRIMARY KEY` / `UNIQUE KEY` / `KEY` 开头行自动跳过)。
+func parseDDL(t *testing.T, src string) map[string][]ddlColumn {
+	t.Helper()
+	out := make(map[string][]ddlColumn)
+	var cur string
+	for _, raw := range strings.Split(src, "\n") {
+		line := strings.TrimSpace(raw)
+		if line == "" || strings.HasPrefix(line, "--") {
+			continue
+		}
+		if strings.HasPrefix(line, "CREATE TABLE") {
+			start := strings.Index(line, "`")
+			end := strings.LastIndex(line, "`")
+			if start < 0 || end <= start {
+				t.Fatalf("CREATE TABLE 行缺少反引号表名: %s", line)
+			}
+			cur = line[start+1 : end]
+			out[cur] = nil
+			continue
+		}
+		if cur == "" {
+			continue
+		}
+		if strings.HasPrefix(line, ")") {
+			cur = ""
+			continue
+		}
+		if !strings.HasPrefix(line, "`") {
+			continue // PRIMARY KEY / UNIQUE KEY / KEY 等
+		}
+		rest := line[1:]
+		idx := strings.Index(rest, "`")
+		if idx < 0 {
+			t.Fatalf("列定义行缺少结束反引号: %s", line)
+		}
+		name := rest[:idx]
+		rest = strings.TrimSuffix(strings.TrimSpace(rest[idx+1:]), ",")
+		parts := strings.SplitN(rest, " ", 2)
+		typ := strings.ToUpper(parts[0])
+		head := rest
+		if i := strings.Index(rest, "COMMENT"); i >= 0 {
+			head = rest[:i] // 排除 COMMENT 文案里出现 "NOT NULL" 造成的误判
+		}
+		out[cur] = append(out[cur], ddlColumn{
+			Name:    name,
+			Type:    typ,
+			NotNull: strings.Contains(head, "NOT NULL"),
+		})
+	}
+	return out
+}

+ 67 - 0
internal/academic/model/tables.go

@@ -0,0 +1,67 @@
+// 表清单 + 枚举/表名总览(academic 域 27 表装配入口)。
+//
+// 用途:装配层(cmd/api/main.go,AC-14 接线)与集成测试把本域全表合并进
+// `infra/db.Migrate`(AutoMigrate 幂等建表,guarded by UNIFIED_AUTO_MIGRATE=1)。
+//
+// 表清单与 unified/02-data-model/academic-model-ddl.sql 一一对应(顺序与该 DDL 的建表顺序一致):
+//
+//	第一部分 资金账户与流水(6):t_user_expense / t_user_expense_school / t_user_expense_log /
+//	    t_user_balance_request / t_order_pay / t_payment_log
+//	第二部分 活动(7):t_courses_activity / _signed / _user_discount / _user_discount_log /
+//	    _used_log / _request / _visit
+//	第三部分 分账(2):t_user_divide_into / t_user_divide_into_log
+//	第四部分 课程/排班/打卡(6):t_courses / _classify / _class / _student / _record / _record_student
+//	第五部分 课酬与支出(6):t_course_payment / _detail / t_teacher_information /
+//	    t_teacher_courses_percentage / t_expenditure / t_expenditure_log
+//
+// 7 张旧 UUID 主键表(设计 §2.2 / §11.1 拍板 11-10):一律 int64 自增主键 + biz_no varchar(32) UNIQUE
+// 保留旧 UUID —— t_user_balance_request / t_order_pay / t_payment_log / t_courses_activity_request /
+// t_courses_student / t_courses_record / t_courses_record_student。
+package model
+
+// AllTables 返回 academic 域全部 model 指针切片(27 张表,供装配层传入 infra/db.Migrate)。
+//
+// 出参:[]any——27 个 *struct 指针;**每次调用返回新切片**(调用方可安全 append,
+// 不会污染其他调用方持有的切片底层数组,backend-model-field-spec.md §1.8)。
+//
+// 说明:本函数不读取任何全局状态、无副作用;重复调用结果等价(顺序固定,便于测试断言)。
+func AllTables() []any {
+	return []any{
+		// 第一部分:资金账户与流水(6 表)
+		&UserExpense{},
+		&UserExpenseSchool{},
+		&UserExpenseLog{},
+		&UserBalanceRequest{},
+		&OrderPay{},
+		&PaymentLog{},
+
+		// 第二部分:活动(7 表)
+		&CoursesActivity{},
+		&CoursesActivitySigned{},
+		&CoursesActivityUserDiscount{},
+		&CoursesActivityUserDiscountLog{},
+		&CoursesActivityUsedLog{},
+		&CoursesActivityRequest{},
+		&CoursesActivityVisit{},
+
+		// 第三部分:分账(2 表)
+		&UserDivideInto{},
+		&UserDivideIntoLog{},
+
+		// 第四部分:课程 / 班级 / 排班 / 打卡(6 表)
+		&Courses{},
+		&CoursesClassify{},
+		&CoursesClass{},
+		&CoursesStudent{},
+		&CoursesRecord{},
+		&CoursesRecordStudent{},
+
+		// 第五部分:课酬与支出(6 表)
+		&CoursePayment{},
+		&CoursePaymentDetail{},
+		&TeacherInformation{},
+		&TeacherCoursesPercentage{},
+		&Expenditure{},
+		&ExpenditureLog{},
+	}
+}

+ 958 - 0
internal/academic/model/testdata/academic-model-ddl.sql

@@ -0,0 +1,958 @@
+-- ============================================================================
+-- 目标态 academic(教务)域 数据模型 DDL(v0.1 Proposed)
+-- 产出:P1-06 教务域 Go 重写 · 阶段 2 架构设计(2026-11-09)
+-- 配套:03-migration/academic-domain-design.md(§2 映射总表 / §3 SP 映射 / §11 待明确)
+--
+-- 定位:academic 域**目标态运行态**表结构 —— 新 Go 后端(internal/academic)直接读写的表。
+-- 与既有 DDL 的分工(三套并存,不可混用):
+--   · 02-data-model/core-model-ddl.sql        机构/身份/org/审计 13 表(已落地,本文件不重复)
+--   · 02-data-model/unified-identity-ddl.sql  迁移源"清洗视图"(int 自增,仅迁移工具读取)
+--   · 本文件 academic-model-ddl.sql           academic 目标态 27 表(本文件)
+--
+-- 设计依据:
+--   · 03-migration/stage0/finance-sp-behavior-spec.md(31 SP 行为规格)
+--   · 02-data-model/backend-model-field-spec.md §4(财务表字段方向 + 收敛规则)
+--   · 03-migration/stage0/schema-reconciliation-report.md(P0-11 双侧字段级对比)
+--   · 01-architecture/backend-technical-architecture.md §3(命名/类型/作用域/指针化)
+--   · 旧库实表结构:yuansiwei_finance.sql(44 表 dump,只取本域 27 表)
+--
+-- 统一约定(对齐 core-model-ddl.sql):
+--   · 字符集 utf8mb4 / COLLATE utf8mb4_general_ci;ENGINE=InnoDB;ROW_FORMAT=Dynamic
+--   · **不建物理外键**(应用层 Repository 强制作用域 + Service 事务保证),仅保留索引
+--   · 时间列 datetime(3)(毫秒);业务时间列沿用旧列名(create_time / start_time ...)
+--   · 金额列 **decimal(12,2)**(旧 float(10,2/3) / double(10,3) / decimal(10,3) 全收敛,P0-11 规则 2)
+--   · 列名 snake_case(旧 userid→user_id / schoolid→institution_id / campusid→campus_id /
+--     create_userid→create_user_id / logid→log_id / activityid→activity_id / ...
+--     唯一例外:**表名保持 t_xxx 原样**,不做 t_→t_academic_ 前缀,降低迁移与对账成本)
+--   · 拼写修正:frosen→frozen、aproved→approved(本文件不涉 frosen,t_user_money_summary* 未纳入)
+--
+-- 主键铁律(D1/D2/D3,2026-11-06 拍板):
+--   · 引用人列 = canonical_user_id VARCHAR(26)(ULID)—— 本域所有 user_id / student_id /
+--     teacher_id / recommend_user_id / create_user_id / request_user_id / response_user_id
+--   · 机构/校区列 = institution_id / campus_id BIGINT(int64)
+--   · 业务明细/日志/关系表主键 = BIGINT AUTO_INCREMENT(int64,D3)
+--   · 纯汇总/自然键表(t_user_expense / t_user_divide_into)= 自然键(user_id)作主键
+--
+-- 旧 UUID 主键的处置(**关键迁移决策**,详见设计 §2.2):
+--   旧库 7 张表主键为 varchar(32) UUID(t_order_pay / t_payment_log / t_user_balance_request /
+--   t_expenditure_log / t_courses_student / t_courses_record / t_courses_record_student)。
+--   目标态一律 int64 自增主键 + **biz_no varchar(32) UNIQUE 保留旧 UUID**:
+--     - 迁移幂等(重跑按 biz_no 去重)
+--     - 旧表间引用列(t_courses_activity_signed.requestid、user_discount.balance_requestid、
+--       courses_student. activityid 逗号串 …)由迁移脚本 JOIN biz_no 重映射为 int64 外键
+--     - 割接期双写/回滚可反查
+--
+-- 执行顺序:第一部分 → 第五部分(无物理外键,顺序不强制;按资金→活动→分账→课程→课酬排列便于阅读)
+-- 建表:DDL 先行纪律 + main.go AutoMigrate 守卫(model.AllTables());本文件为结构权威,
+--       AutoMigrate 只补不删(列变更须同步本文件 + models.go + backend-model-field-spec.md 三处)
+-- ============================================================================
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ============================================================================
+-- 第一部分:资金账户与流水(6 表)
+-- 覆盖 SP:recharge / school_recharge / school_expenditure / school_refund /
+--           get_refundable_amount / school_transfer_expenditure / baddebt /
+--           cancel_recharge_activity / custom_refund / school_cleared_balance / school_spend
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 1. t_user_expense(用户费用·**全局维度**账户,自然键 user_id)
+-- 迁移源:t_user_expense(PK userid int,float 金额)
+-- 说明:全局账本,**无 institution_id/campus_id**(跨机构共享的钱包视图),不继承 BaseRepo;
+--       作用域校验在 Service 层经 MembershipChecker 完成(设计 §1.7)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_user_expense`;
+CREATE TABLE `t_user_expense` (
+  `user_id`                 VARCHAR(26)   NOT NULL COMMENT '用户ID(canonical_user_id ULID,原 userid int → t_legacy_mapping.legacy_academic_user_id 重映射)',
+  `balance`                 DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '用户余额(±数,负数为欠费部分;旧 float(10,3) −> decimal(12,2))',
+  `spend`                   DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '上课消费的课费单价之和(spend SP 只加此列,不动 balance)',
+  `total_income`            DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '总充值(充值+/退费-/清零不动)',
+  `total_expenditure`       DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '总消费(报课学费之和+历史欠费;消费+/退费-/转结双向调整)',
+  `total_recommended_amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '累计推荐金额(老带新双边入账累加)',
+  `special_expenses`        DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '特殊费用(充值活动后提前退费/坏账/清零时累加)',
+  PRIMARY KEY (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户费用表(全局账户·自然键·金额 decimal(12,2))' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 2. t_user_expense_school(用户费用·**校区维度**账户,UK(user_id, institution_id, campus_id))
+-- 迁移源:t_user_expense_school(id int AI + userid/schoolid/campusid,decimal(10,3))
+-- 说明:多租户表(带 institution_id/campus_id)→ Repository 继承 BaseRepo 强制 scoping,
+--       跨作用域访问 → 1004。旧库**无唯一键**(仅 PK id),目标态补 UK 防重复账户(SP 里
+--       "COUNT>0 则 UPDATE 否则 INSERT" 的 UPSERT 语义迁到 Go 后依赖该唯一键 + OnConflict)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_user_expense_school`;
+CREATE TABLE `t_user_expense_school` (
+  `id`                      BIGINT        NOT NULL AUTO_INCREMENT COMMENT '账户记录ID(D3 自增)',
+  `user_id`                 VARCHAR(26)   NOT NULL COMMENT '用户ID(canonical_user_id ULID,原 userid)',
+  `institution_id`          BIGINT        NOT NULL COMMENT '机构ID(原 schoolid → institution_id;作用域列)',
+  `campus_id`               BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;作用域列,0=机构级)',
+  `balance`                 DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '校区余额(±数,负数为欠费)',
+  `spend`                   DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '上课消费课费单价之和',
+  `total_income`            DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '校区总充值',
+  `total_expenditure`       DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '校区总消费',
+  `total_recommended_amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '校区累计推荐金额',
+  `special_expenses`        DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '校区特殊费用(坏账/清零/提前退费)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_user_inst_campus` (`user_id`, `institution_id`, `campus_id`),
+  KEY `idx_inst_campus` (`institution_id`, `campus_id`),
+  KEY `idx_campus_balance` (`campus_id`, `balance`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户费用表(校区账户·多租户·UK(user,institution,campus))' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 3. t_user_expense_log(费用变更流水,**只增不改**)
+-- 迁移源:t_user_expense_log(id int AI,type int,logid varchar(32),decimal(10,3))
+-- type 编码(规格 §1.1,**权威口径见设计 §10.2**):
+--   100 充值 / 101·201 课程消费(校区) / 104·105·204·205 课程消费(学校) /
+--   106 转结出 / 206 转结入 / 107 老带新推荐 / 108 坏账处理 / 200 历史扣费 /
+--   207 余额退费 / 208 余额清零 / 209 取消充值活动
+-- 幂等键:request_id + type 唯一(MySQL 唯一键对 NULL 放行 → 历史数据 request_id NULL 安全);
+--         Go 侧写入撞键即视为重复请求,返回首次结果(设计 §1.5)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_user_expense_log`;
+CREATE TABLE `t_user_expense_log` (
+  `id`             BIGINT        NOT NULL AUTO_INCREMENT COMMENT '流水ID(D3 自增)',
+  `user_id`        VARCHAR(26)   NOT NULL COMMENT '用户ID(canonical_user_id ULID,原 userid)',
+  `type`           INT           NOT NULL DEFAULT 0 COMMENT '类型编码(100充值/107老带新/108坏账/200历史扣费/201·101·104·105·204·205课程消费/206转结入/106转结出/207退费/208清零/209取消充值活动)',
+  `log_id`         VARCHAR(64)   DEFAULT NULL COMMENT '业务单号(原 logid:报课id/缴费申请id/结课申请id/充值单号;无则为 NULL)',
+  `request_id`     VARCHAR(64)   DEFAULT NULL COMMENT '幂等请求号(原 SP inputRequestid;NULL=历史数据;与 type 组唯一键)',
+  `money`          DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '变更金额(正负号表方向;转结 106=+金额 / 206=金额,见 §11 漂移项)',
+  `balance_after`  DECIMAL(12,2) DEFAULT NULL COMMENT '变更后校区余额快照(新增列,便于对账与双跑比对;历史行 NULL)',
+  `create_user_id` VARCHAR(26)   DEFAULT NULL COMMENT '创建人(canonical_user_id ULID;原 create_userid int,0/系统 → NULL)',
+  `create_time`    DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `remark`         VARCHAR(1000) DEFAULT NULL COMMENT '备注(原 text → varchar(1000),旧数据超长截断记录见迁移报告)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_request_type` (`request_id`, `type`),
+  KEY `idx_user_time` (`user_id`, `create_time`),
+  KEY `idx_user_type` (`user_id`, `type`),
+  KEY `idx_log_id` (`log_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户费用变更流水表(只增不改·幂等键 request_id+type)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 4. t_user_balance_request(余额变更申请/审批 · 充值/退费/坏账/取消活动/转结 的工单)
+-- 迁移源:t_user_balance_request(PK id varchar(32) UUID → int64 + biz_no)
+-- type 编码:100 普通充值 / 101 坏账 / 202 退费 / 203 取消充值活动 / 300 转结
+-- approved:-2 取消 / -1 不通过 / 0 未处理 / 1 已通过(原 aproved 错拼修正)
+-- 说明:审批"通过"动作才是资金变动触发点(Service 在同一事务内先更新工单再调资金方法,
+--       或先调资金方法再回写工单——**设计取后者并加幂等 request_id**,见设计 §3 备注)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_user_balance_request`;
+CREATE TABLE `t_user_balance_request` (
+  `id`                   BIGINT        NOT NULL AUTO_INCREMENT COMMENT '工单ID(D3 自增)',
+  `biz_no`               VARCHAR(32)   NOT NULL COMMENT '业务单号(旧 UUID 主键原样保留,UNIQUE,迁移幂等键)',
+  `type`                 INT           NOT NULL DEFAULT 100 COMMENT '类型(100普通充值/101坏账/202退费/203取消充值活动/300转结)',
+  `user_id`              VARCHAR(26)   NOT NULL DEFAULT '' COMMENT '用户ID(canonical_user_id ULID,原 userid)',
+  `institution_id`       BIGINT        NOT NULL DEFAULT 0 COMMENT '机构ID(原 schoolid;作用域列)',
+  `campus_id`            BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;作用域列)',
+  `to_institution_id`    BIGINT        DEFAULT NULL COMMENT '转入机构ID(仅 type=300 转结使用,新增列)',
+  `to_campus_id`         BIGINT        DEFAULT NULL COMMENT '转入校区ID(仅 type=300 转结使用,新增列)',
+  `money`                DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '金额(旧 decimal(10,3) 收敛)',
+  `transfer_accounts_type` TINYINT     NOT NULL DEFAULT 0 COMMENT '收款方式(0无/1现金/2微信/3支付宝)',
+  `transfer_accounts_time` DATETIME(3) DEFAULT NULL COMMENT '收款时间',
+  `order_no`             VARCHAR(50)   DEFAULT NULL COMMENT '交易单号(原 orderid)',
+  `request_user_id`      VARCHAR(26)   NOT NULL DEFAULT '' COMMENT '申请人(canonical_user_id ULID,原 request_userid)',
+  `request_time`         DATETIME(3)   DEFAULT NULL COMMENT '申请时间',
+  `response_user_id`     VARCHAR(26)   DEFAULT NULL COMMENT '审批人(canonical_user_id ULID,原 response_userid;0→NULL)',
+  `response_time`        DATETIME(3)   DEFAULT NULL COMMENT '审批时间',
+  `response_msg`         VARCHAR(1000) DEFAULT NULL COMMENT '审批意见',
+  `approved`             TINYINT       NOT NULL DEFAULT 0 COMMENT '审批状态(-2取消/-1不通过/0未处理/1已通过;原 aproved 错拼修正)',
+  `item`                 VARCHAR(1000) DEFAULT NULL COMMENT '项目(报课/充值课程说明)',
+  `imgs`                 LONGTEXT      DEFAULT NULL COMMENT '凭证图片(JSON 字符串,GORM *string 直存)',
+  `remark`               VARCHAR(1000) DEFAULT NULL COMMENT '备注',
+  `created_at`           DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `updated_at`           DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_biz_no` (`biz_no`),
+  KEY `idx_user` (`user_id`),
+  KEY `idx_inst_campus_approved` (`institution_id`, `campus_id`, `approved`),
+  KEY `idx_type_approved` (`type`, `approved`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='余额变更申请表(充值/退费/坏账/取消活动/转结 工单·审批驱动)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 5. t_order_pay(支付订单,旧 UUID 主键 → int64 + biz_no)
+-- 迁移源:t_order_pay(PK id varchar(32) UUID,pay_money float(10,2))
+-- 说明:本期仅承接"创建/取消/回调"三动作(Go 侧重写 PayController 的 Addpaymentorder /
+--       Cancelpaymentorder + NotifyController 回调),支付通道(JSAPI/W02 银联)适配层 P1。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_order_pay`;
+CREATE TABLE `t_order_pay` (
+  `id`             BIGINT        NOT NULL AUTO_INCREMENT COMMENT '订单ID(D3 自增)',
+  `biz_no`         VARCHAR(32)   NOT NULL COMMENT '业务单号(旧 UUID 主键原样保留,UNIQUE,对外暴露给前端/支付网关)',
+  `status`         TINYINT       NOT NULL DEFAULT 0 COMMENT '状态(-2失败/-1取消/0未支付/1成功)',
+  `type`           VARCHAR(100)  DEFAULT NULL COMMENT '订单类型(充值/报课 …,旧 type 字符串原样)',
+  `institution_id` BIGINT        DEFAULT NULL COMMENT '机构ID(原 schoolid;作用域列)',
+  `campus_id`      BIGINT        DEFAULT NULL COMMENT '校区ID(原 campusid;作用域列)',
+  `user_id`        VARCHAR(26)   DEFAULT NULL COMMENT '付款用户(canonical_user_id ULID,原 userid)',
+  `pay_order`      VARCHAR(50)   DEFAULT NULL COMMENT '交易单号(第三方流水号)',
+  `pay_type`       VARCHAR(10)   DEFAULT NULL COMMENT '支付方式(JSAPI 微信 / W02 银联 AllinPay)',
+  `pay_account`    VARCHAR(100)  DEFAULT NULL COMMENT '支付平台用户标识(原 pay_acct,openid/卡号)',
+  `pay_money`      DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '交易金额(旧 float(10,2) 收敛)',
+  `pay_time`       DATETIME(3)   DEFAULT NULL COMMENT '支付时间',
+  `pay_return_data` LONGTEXT     DEFAULT NULL COMMENT '支付返回数据(JSON 字符串,*string 直存;含敏感信息,API 层不外泄)',
+  `create_user_id` VARCHAR(26)   DEFAULT NULL COMMENT '下单人(canonical_user_id ULID;家长代付场景≠user_id)',
+  `create_time`    DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `remark`         VARCHAR(500)  DEFAULT NULL COMMENT '用户备注',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_biz_no` (`biz_no`),
+  KEY `idx_user_create` (`user_id`, `create_time`),
+  KEY `idx_status` (`status`),
+  KEY `idx_pay_order` (`pay_order`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='支付订单表(旧 UUID→int64+biz_no·充值/报课支付)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 6. t_payment_log(支付网关回调日志,只增;旧 UUID 主键 → int64 + biz_no)
+-- 迁移源:t_payment_log(PK id varchar(100) 对外订单号;PHP 全仓 0 引用 → 旧支付网关遗留)
+-- 说明:本期**纳管为只读历史**(迁移保留,不新增写入);新支付回调日志统一写 t_audit_log
+--       + t_order_pay.pay_return_data。是否重建新通道日志表见设计 §11-9。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_payment_log`;
+CREATE TABLE `t_payment_log` (
+  `id`             BIGINT       NOT NULL AUTO_INCREMENT COMMENT '日志ID(D3 自增)',
+  `biz_no`         VARCHAR(100) NOT NULL COMMENT '对外订单号(旧主键原样保留,UNIQUE)',
+  `user_id`        VARCHAR(26)  DEFAULT NULL COMMENT '用户ID(canonical_user_id ULID)',
+  `order_id`       BIGINT       DEFAULT NULL COMMENT '订单ID(原 order_id int → t_order_pay.id;迁移期 NULL 待回填)',
+  `payment_type`   INT          NOT NULL DEFAULT 0 COMMENT '支付网关类型(旧 int 编码原样)',
+  `receive_time`   DATETIME(3)  DEFAULT NULL COMMENT '收到支付消息时间',
+  `return_data`    LONGTEXT     DEFAULT NULL COMMENT '网关原始数据(JSON 字符串)',
+  `create_time`    DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生成时间',
+  `result`         INT          NOT NULL DEFAULT 0 COMMENT '处理结果(旧编码原样)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_biz_no` (`biz_no`),
+  KEY `idx_order` (`order_id`),
+  KEY `idx_user` (`user_id`),
+  KEY `idx_result` (`result`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='支付网关日志表(只读历史·P2 不新增写入)' ROW_FORMAT=Dynamic;
+
+-- ============================================================================
+-- 第二部分:活动(7 表)
+-- 覆盖 SP:use_activity_recharge / refund_activity_recharge / user_discount_usable /
+--           user_discount_use / user_discount_refund / user_discount_refund_user_activity /
+--           use_activity_recommend / signed_add / signed_cancel
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 7. t_courses_activity(活动方案)
+-- 迁移源:t_courses_activity(id int AI;money float(10,2))
+-- type:2 充值赠送 / 3 代金券 / 4 老带新推荐(1 试听活动为旧遗留,不参与 SP 逻辑)
+-- status:0 停用 / 1 开启 / 2 已结束(旧 COMMENT 自相矛盾,以 PHP 判断口径为准,见 §11-5)
+-- campus_id=0 表示"机构所有自营校区"(custom_refund/cleared_balance 的 campusid IN (x,0) 判据)
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_activity`;
+CREATE TABLE `t_courses_activity` (
+  `id`             BIGINT        NOT NULL AUTO_INCREMENT COMMENT '活动ID(D3 自增)',
+  `type`           TINYINT       NOT NULL DEFAULT 1 COMMENT '活动类型(1试听/2充值赠送/3代金券/4老带新推荐)',
+  `toped`          TINYINT       NOT NULL DEFAULT 0 COMMENT '置顶(0否/1是,置顶有效至活动结束)',
+  `thumbnail`      VARCHAR(500)  DEFAULT NULL COMMENT '缩略图 URL',
+  `subject_id`     INT           NOT NULL DEFAULT 0 COMMENT '科目ID(0混合)',
+  `grade_id`       INT           NOT NULL DEFAULT 0 COMMENT '年级ID(0混合)',
+  `institution_id` BIGINT        NOT NULL DEFAULT 0 COMMENT '机构ID(原 schoolid;作用域列)',
+  `campus_id`      BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;0=机构所有自营校区;作用域列)',
+  `start_time`     DATETIME(3)   DEFAULT NULL COMMENT '活动开始时间',
+  `end_time`       DATETIME(3)   DEFAULT NULL COMMENT '活动结束时间',
+  `number_max`     INT           NOT NULL DEFAULT 0 COMMENT '可报名人数(0=不限)',
+  `number_sign`    INT           NOT NULL DEFAULT 0 COMMENT '当前报名人数(报名+1/取消-1)',
+  `title`          VARCHAR(500)  DEFAULT NULL COMMENT '标题',
+  `content`        LONGTEXT      DEFAULT NULL COMMENT '活动内容(HTML)',
+  `money`          DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '金额/额度(充值赠送额/代金券面额/老带新奖励额;旧 float(10,2))',
+  `status`         TINYINT       NOT NULL DEFAULT 0 COMMENT '状态(0停用/1开启/2已结束)',
+  `view`           INT           NOT NULL DEFAULT 0 COMMENT '浏览量(后台可见)',
+  `memo`           VARCHAR(500)  DEFAULT NULL COMMENT '备注',
+  `create_user_id` VARCHAR(26)   DEFAULT NULL COMMENT '创建人(canonical_user_id ULID,原 create_userid)',
+  `create_time`    DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `updated_at`     DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间(新增列)',
+  PRIMARY KEY (`id`),
+  KEY `idx_inst_campus_type_status` (`institution_id`, `campus_id`, `type`, `status`),
+  KEY `idx_time` (`start_time`, `end_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='活动表(充值赠送/代金券/老带新)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 8. t_courses_activity_signed(活动报名/结算记录,UK(user_id, activity_id) 由业务保证)
+-- 迁移源:t_courses_activity_signed(id int AI;decimal(10,3))
+-- status:0 取消报名 / 1 报名 / 2 参加活动 / 3 报课(成交) / 4 过期不能使用
+-- 关键:signed_add 用 **DELETE+INSERT 幂等**(同一 user+activity 只留一条,含 status=0 的旧行被清);
+--       目标态改为 OnConflict(更新) 语义,保留唯一键 uk_user_activity 兜底(设计 §1.6)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_activity_signed`;
+CREATE TABLE `t_courses_activity_signed` (
+  `id`                BIGINT        NOT NULL AUTO_INCREMENT COMMENT '报名记录ID(D3 自增)',
+  `activity_id`       BIGINT        NOT NULL COMMENT '活动ID(原 activityid,→ t_courses_activity.id)',
+  `user_id`           VARCHAR(26)   NOT NULL COMMENT '报名用户(canonical_user_id ULID,原 userid)',
+  `institution_id`    BIGINT        NOT NULL DEFAULT 0 COMMENT '机构ID(冗余作用域列,便于 scoping;新增)',
+  `campus_id`         BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(冗余作用域列,取活动 campus_id;新增)',
+  `status`            INT           NOT NULL DEFAULT 0 COMMENT '状态(0取消报名/1报名/2参加活动/3报课成交/4过期不能使用)',
+  `recharge_amount`   DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '充值金额(copy_activity_discount_to_signed 回填)',
+  `activity_money`    DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '活动金额(报名时取 t_courses_activity.money)',
+  `activity_money_used` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '已使用活动金额(老带新=已充值到账户)',
+  `recommend_user_id` VARCHAR(26)   DEFAULT NULL COMMENT '推荐人(canonical_user_id ULID,原 recommend_userid;0→NULL)',
+  `create_user_id`    VARCHAR(26)   DEFAULT NULL COMMENT '创建人(原 create_userid)',
+  `create_time`       DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '报名时间',
+  `imgs`              VARCHAR(255)  DEFAULT NULL COMMENT '图片备注(JSON 字符串)',
+  `memo`              VARCHAR(500)  DEFAULT NULL COMMENT '备注',
+  `request_no`        VARCHAR(100)  DEFAULT NULL COMMENT '申请单号(原 requestid,关联 t_user_balance_request.biz_no)',
+  `response_user_id`  VARCHAR(26)   DEFAULT NULL COMMENT '审核人(canonical_user_id ULID,原 response_userid;>0 表示已审核通过→不可取消)',
+  `response_time`     DATETIME(3)   DEFAULT NULL COMMENT '审核时间',
+  `response_msg`      VARCHAR(500)  DEFAULT NULL COMMENT '审核意见',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`),
+  KEY `idx_activity_status` (`activity_id`, `status`),
+  KEY `idx_user_status` (`user_id`, `status`),
+  KEY `idx_inst_campus` (`institution_id`, `campus_id`),
+  KEY `idx_request_no` (`request_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='活动报名/结算表(UK user+activity·status 状态机)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 9. t_courses_activity_user_discount(活动学生优惠账户 = 赠送金额池)
+-- 迁移源:t_courses_activity_user_discount(id int AI;float(10,2)/float(10,3))
+-- 语义:discount_amount=赠送总额(负数是减、正数是赠送),discount_amount_used=已使用;
+--       可用 = discount_amount - discount_amount_used(user_discount_usable 汇总裁剪口径:
+--       仅累加 discount_amount > discount_amount_used 的行)
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_activity_user_discount`;
+CREATE TABLE `t_courses_activity_user_discount` (
+  `id`                   BIGINT        NOT NULL AUTO_INCREMENT COMMENT '优惠账户ID(D3 自增)',
+  `activity_id`          BIGINT        NOT NULL COMMENT '活动ID(原 activityid)',
+  `institution_id`       BIGINT        NOT NULL DEFAULT 0 COMMENT '机构ID(原 schoolid;作用域列)',
+  `campus_id`            BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;0=机构所有自营校区)',
+  `user_id`              VARCHAR(26)   NOT NULL COMMENT '学生(canonical_user_id ULID,原 userid)',
+  `balance_request_id`   BIGINT        DEFAULT NULL COMMENT '来源充值/退费工单ID(原 balance_requestid varchar(32) → t_user_balance_request.id)',
+  `income`               DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '充值金额(触发赠送的充值额)',
+  `discount_amount`      DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '赠送金额(负数减/正数赠送)',
+  `discount_amount_used` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '已使用赠送金额(<=discount_amount)',
+  `create_time`          DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_user_activity` (`user_id`, `activity_id`),
+  KEY `idx_inst_campus_user` (`institution_id`, `campus_id`, `user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='活动学生优惠表(赠送金额池·可用=总额-已用)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 10. t_courses_activity_user_discount_log(赠送金额使用流水,只增)
+-- 迁移源:t_courses_activity_user_discount_log(id int AI;discount_amount_used float(10,3) 负=消费 正=退回)
+-- courses_student_id:旧 varchar(32) UUID(报课信息 id)→ int64 FK(t_courses_student.id),
+--                     同时保留 courses_student_no 旧 UUID 供迁移与对账。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_activity_user_discount_log`;
+CREATE TABLE `t_courses_activity_user_discount_log` (
+  `id`                      BIGINT        NOT NULL AUTO_INCREMENT COMMENT '流水ID(D3 自增)',
+  `activity_user_discount_id` BIGINT      NOT NULL COMMENT '优惠账户ID(原 activity_user_discountid → t_courses_activity_user_discount.id)',
+  `courses_student_id`      BIGINT        DEFAULT NULL COMMENT '报课记录ID(原 courses_studentid varchar(32) UUID → t_courses_student.id;未回填前 NULL)',
+  `courses_student_no`      VARCHAR(32)   DEFAULT NULL COMMENT '报课记录旧单号(迁移期间保留旧 UUID,回填完成后可停用)',
+  `discount_amount_used`    DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '变动金额(负=消费/正=退回)',
+  `create_time`             DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '使用时间',
+  `memo`                    VARCHAR(500)  DEFAULT NULL COMMENT '备注',
+  PRIMARY KEY (`id`),
+  KEY `idx_discount` (`activity_user_discount_id`),
+  KEY `idx_student` (`courses_student_id`),
+  KEY `idx_student_no` (`courses_student_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='活动学生优惠使用记录表(负=消费/正=退回)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 11. t_courses_activity_used_log(活动金额获得/使用记录,只增)
+-- 迁移源:t_courses_activity_used_log(id int AI;activity_money_used decimal(10,3))
+-- 语义:老带新结算写 **负值**(activity_money_used = -金额,表示"从活动池取出并充值到账户");
+--       copy_activity_discountLog_to_usedLog 全量重放时按 discount_log 原值写入(可能为正)。
+--       撤销充值活动前置校验:老带新有本表记录 → 不可取消(signed_cancel)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_activity_used_log`;
+CREATE TABLE `t_courses_activity_used_log` (
+  `id`                 BIGINT        NOT NULL AUTO_INCREMENT COMMENT '记录ID(D3 自增)',
+  `activity_id`        BIGINT        NOT NULL COMMENT '活动ID(原 activityid)',
+  `user_id`            VARCHAR(26)   NOT NULL COMMENT '参与用户(canonical_user_id ULID,原 userid)',
+  `recommend_user_id`  VARCHAR(26)   DEFAULT NULL COMMENT '推荐人(canonical_user_id ULID,原 recommend_userid;0→NULL)',
+  `order_no`           VARCHAR(100)  DEFAULT NULL COMMENT '订单号(原 orderid:老带新=报课信息 id;copy 重放=courses_studentid)',
+  `activity_money_used` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '活动金额变动(老带新结算=负值)',
+  `create_time`        DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `memo`               VARCHAR(255)  DEFAULT NULL COMMENT '备注',
+  PRIMARY KEY (`id`),
+  KEY `idx_activity_user` (`activity_id`, `user_id`),
+  KEY `idx_user` (`user_id`),
+  KEY `idx_order_no` (`order_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='老带新活动获得/使用记录表(结算写负值)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 12. t_courses_activity_request(活动优惠申请/审批,旧 UUID 主键 → int64 + biz_no)
+-- 迁移源:t_courses_activity_request(PK id varchar(32) UUID;give_amount float(10,2))
+-- approved:-1 不通过 / 0 未处理 / 1 通过
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_activity_request`;
+CREATE TABLE `t_courses_activity_request` (
+  `id`               BIGINT        NOT NULL AUTO_INCREMENT COMMENT '申请ID(D3 自增)',
+  `biz_no`           VARCHAR(32)   NOT NULL COMMENT '业务单号(旧 UUID 主键原样保留,UNIQUE)',
+  `activity_id`      BIGINT        NOT NULL COMMENT '活动ID(原 activityid)',
+  `institution_id`   BIGINT        NOT NULL DEFAULT 0 COMMENT '适用机构ID(原 schoolid;作用域列)',
+  `campus_id`        BIGINT        NOT NULL DEFAULT 0 COMMENT '适用校区ID(原 campusid;作用域列)',
+  `user_id`          VARCHAR(26)   NOT NULL DEFAULT '' COMMENT '用户ID(canonical_user_id ULID)',
+  `give_amount`      DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '优惠金额(旧 float(10,2))',
+  `imgs`             LONGTEXT      DEFAULT NULL COMMENT '图片备注(JSON 字符串)',
+  `memo`             LONGTEXT      DEFAULT NULL COMMENT '备注',
+  `request_user_id`  VARCHAR(26)   NOT NULL DEFAULT '' COMMENT '申请人(canonical_user_id ULID)',
+  `request_time`     DATETIME(3)   DEFAULT NULL COMMENT '申请时间',
+  `response_user_id` VARCHAR(26)   DEFAULT NULL COMMENT '审批人(canonical_user_id ULID;0→NULL)',
+  `response_time`    DATETIME(3)   DEFAULT NULL COMMENT '审批时间',
+  `response_msg`     LONGTEXT      DEFAULT NULL COMMENT '审批意见',
+  `approved`         TINYINT       NOT NULL DEFAULT 0 COMMENT '审批状态(-1不通过/0未处理/1通过)',
+  `created_at`       DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `updated_at`       DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_biz_no` (`biz_no`),
+  KEY `idx_activity_user` (`activity_id`, `user_id`),
+  KEY `idx_inst_campus_approved` (`institution_id`, `campus_id`, `approved`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='活动优惠申请表(旧 UUID→int64+biz_no)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 13. t_courses_activity_visit(活动回访记录)
+-- 迁移源:t_courses_activity_visit(id int AI,AUTO_INCREMENT=1 基本空表,latin1 → utf8mb4)
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_activity_visit`;
+CREATE TABLE `t_courses_activity_visit` (
+  `id`                 BIGINT       NOT NULL AUTO_INCREMENT COMMENT '回访ID(D3 自增)',
+  `activity_signed_id` BIGINT       DEFAULT NULL COMMENT '报名记录ID(原 activity_signedid → t_courses_activity_signed.id)',
+  `create_user_id`     VARCHAR(26)  DEFAULT NULL COMMENT '回访人(canonical_user_id ULID,原 create_userid)',
+  `create_time`        DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '回访时间',
+  `memo`               VARCHAR(500) DEFAULT NULL COMMENT '回访内容',
+  PRIMARY KEY (`id`),
+  KEY `idx_signed` (`activity_signed_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='活动回访记录表' ROW_FORMAT=Dynamic;
+
+-- ============================================================================
+-- 第三部分:分账(2 表,Nested Set 左右值树)
+-- 覆盖 SP:divide_into_add / divide_into_log_add / divide_into_log_delete / divide_into_update_quota
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 14. t_user_divide_into(分账树节点,Nested Set,自然键 user_id)
+-- 迁移源:t_user_divide_into(PK userid;quota/total_income/total_income_payed float(10,2))
+-- 语义:一人一节点(PK user_id);root_user_id 标识所属树;left_val/right_val 为嵌套集区间;
+--       layer 为层级(根=1)。下线 quota 不得超过上线(update_quota 级联约束)。
+-- ⚠️ 此表**无 institution_id/campus_id**(旧库为全局推广体系)→ 不继承 BaseRepo,
+--    访问控制由 Handler 角色白名单(super_admin/academic)+ 显式数据范围参数保证(设计 §1.7)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_user_divide_into`;
+CREATE TABLE `t_user_divide_into` (
+  `user_id`             VARCHAR(26)   NOT NULL COMMENT '节点用户(canonical_user_id ULID,原 userid,主键)',
+  `type`                TINYINT       NOT NULL DEFAULT 1 COMMENT '分成类型(1一次付清/2按课次)',
+  `quota`               DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '分成额度(每次分成金额)',
+  `total_income`        DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '分成总收入(累积)',
+  `total_income_payed`  DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '已支付的分成收入',
+  `root_user_id`        VARCHAR(26)   DEFAULT NULL COMMENT '树根用户(原 root_userid;自身为根时=user_id)',
+  `parent_user_id`      VARCHAR(26)   DEFAULT NULL COMMENT '上级用户(原 parent_userid;根为 NULL)',
+  `left_val`            INT           NOT NULL DEFAULT 1 COMMENT '左值(Nested Set)',
+  `right_val`           INT           NOT NULL DEFAULT 2 COMMENT '右值(Nested Set)',
+  `layer`               INT           NOT NULL DEFAULT 1 COMMENT '层级(根=1)',
+  `created_at`          DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `updated_at`          DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+  PRIMARY KEY (`user_id`),
+  KEY `idx_root` (`root_user_id`),
+  KEY `idx_parent` (`parent_user_id`),
+  KEY `idx_root_left_right` (`root_user_id`, `left_val`, `right_val`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户分账表(Nested Set 左右值树·一人一节点)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 15. t_user_divide_into_log(分账明细,只增)
+-- 迁移源:t_user_divide_into_log(id int AI;type tinyint ZEROFILL;money float(10,2))
+-- 去重幂等(divide_into_log_add 口径):
+--   type=1(一次付清):同 user+student 只生成一条(不区分 order);
+--   type=2(按课次)  :同 user+student+order_no 只生成一条。
+-- log_delete 语义:学生已有首次扣费上课记录 → 改 order_no 为首次 record(保留分成);
+--                   否则回滚 total_income 并物理删除该行(设计 §3-4.3)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_user_divide_into_log`;
+CREATE TABLE `t_user_divide_into_log` (
+  `id`          BIGINT        NOT NULL AUTO_INCREMENT COMMENT '分成记录ID(D3 自增)',
+  `type`        TINYINT       NOT NULL DEFAULT 1 COMMENT '分成类型(1一次付清/2按课次)',
+  `user_id`     VARCHAR(26)   NOT NULL COMMENT '获得分成的用户(canonical_user_id ULID,原 userid)',
+  `student_id`  VARCHAR(26)   NOT NULL COMMENT '报课学生(canonical_user_id ULID,原 studentid)',
+  `order_no`    VARCHAR(50)   DEFAULT NULL COMMENT '订单号(原 orderid:type=2 时=上课记录 id)',
+  `money`       DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '分成金额(旧 float(10,2))',
+  `create_time` DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `remark`      LONGTEXT      DEFAULT NULL COMMENT '备注',
+  PRIMARY KEY (`id`),
+  KEY `idx_user_student_type` (`user_id`, `student_id`, `type`),
+  KEY `idx_student_order` (`student_id`, `order_no`),
+  KEY `idx_create_time` (`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='分账明细表(type1 per student / type2 per order 幂等)' ROW_FORMAT=Dynamic;
+
+-- ============================================================================
+-- 第四部分:课程 / 班级 / 排班 / 打卡(6 表)
+-- 覆盖 SP:courses_student_counteract_arrears_course / courses_update / courses_update_campusid
+-- 说明:排班与打卡**无 SP 覆盖**(旧 PHP modules/courses 业务逻辑),本部分表为本期
+--       "排班生成 + 上课打卡 + 结束扣课时" 最小闭环的运行态载体(设计 §11-3 盘点结论)。
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 16. t_courses(课程模板:价格/次数/周规则/时长)
+-- 迁移源:t_courses(id int AI;original_cost/real_price/discount_cash float(10,3);week varchar(50))
+-- week 语义:'-1' 全部周 / '0' 周日 / '1..6' / 多值逗号分隔(字符串保留,不做位图化,保迁移稳定)
+-- status:0 未发布 / 1 发布未开始 / 2 进行中 / 3 已结束
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses`;
+CREATE TABLE `t_courses` (
+  `id`             BIGINT        NOT NULL AUTO_INCREMENT COMMENT '课程ID(D3 自增)',
+  `subject_id`     INT           NOT NULL DEFAULT 0 COMMENT '科目ID(0混合科)',
+  `grade_id`       INT           NOT NULL DEFAULT 0 COMMENT '年级ID(0混合年级)',
+  `type`           INT           NOT NULL DEFAULT 1 COMMENT '类型(1000集体班/2000VIP…)',
+  `institution_id` BIGINT        NOT NULL DEFAULT 0 COMMENT '机构ID(原 schoolid;作用域列)',
+  `campus_id`      BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;作用域列)',
+  `classify_id`    BIGINT        NOT NULL DEFAULT 0 COMMENT '课程分类ID(原 classifyid → t_courses_classify.id)',
+  `status`         INT           NOT NULL DEFAULT 1 COMMENT '状态(0未发布/1发布未开始/2进行中/3已结束)',
+  `name`           VARCHAR(500)  NOT NULL COMMENT '课程名称',
+  `number`         INT           NOT NULL DEFAULT 0 COMMENT '上课次数(课次)',
+  `duration`       DECIMAL(5,1)  NOT NULL DEFAULT 0.0 COMMENT '单节课时长(课时,1 位小数,保留旧精度)',
+  `week`           VARCHAR(50)   NOT NULL DEFAULT '-1' COMMENT '周规则(-1全部/0周日/1-6/多值逗号分隔)',
+  `start_time`     DATETIME(3)   DEFAULT NULL COMMENT '课程开始时间',
+  `end_time`       DATETIME(3)   DEFAULT NULL COMMENT '课程结束时间',
+  `address`        LONGTEXT      DEFAULT NULL COMMENT '上课地址',
+  `original_cost`  DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '原价(元;旧 float(10,3)→2 位收敛)',
+  `real_price`     DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '实价(元)',
+  `discount_cash`  DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '现金折扣(元)',
+  `discount_ratio` INT           NOT NULL DEFAULT 0 COMMENT '折扣率(百分比数字部分)',
+  `remark`         LONGTEXT      DEFAULT NULL COMMENT '备注',
+  `max_people`     INT           NOT NULL DEFAULT 0 COMMENT '最多人数(0 时由 courses_update 从 classify 继承)',
+  `update_user_id` VARCHAR(26)   DEFAULT NULL COMMENT '最后修改人(canonical_user_id ULID,原 update_userid)',
+  `update_time`    DATETIME(3)   DEFAULT NULL COMMENT '最后修改时间',
+  `created_at`     DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_inst_campus_status` (`institution_id`, `campus_id`, `status`),
+  KEY `idx_classify` (`classify_id`),
+  KEY `idx_subject_grade` (`subject_id`, `grade_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='课程信息表(课程模板·周规则+次数+价格)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 17. t_courses_classify(课程分类:班级类型/单价/人数区间 + 课酬方案绑定)
+-- 迁移源:t_courses_classify(id int AI;unit_price float(10,3))
+-- class_type:1000 集体班 / 1001 小班 …(与 t_course_payment_detail.class_type 对应)
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_classify`;
+CREATE TABLE `t_courses_classify` (
+  `id`               BIGINT        NOT NULL AUTO_INCREMENT COMMENT '分类ID(D3 自增)',
+  `institution_id`   BIGINT        NOT NULL DEFAULT 0 COMMENT '机构ID(原 schoolid;作用域列)',
+  `campus_id`        BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;作用域列)',
+  `class_type`       INT           NOT NULL DEFAULT 1000 COMMENT '班级类型(1000集体班/1001小班…)',
+  `course_payment_id` BIGINT       NOT NULL DEFAULT 0 COMMENT '课酬方案ID(原 course_paymentid → t_course_payment.id)',
+  `grade_id`         INT           NOT NULL DEFAULT 0 COMMENT '年级ID(0混合)',
+  `subject_id`       INT           NOT NULL DEFAULT 0 COMMENT '科目ID(0混合)',
+  `name`             VARCHAR(500)  NOT NULL COMMENT '分类名称',
+  `unit_price`       DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '单价(一次课价格)',
+  `min_people`       INT           NOT NULL DEFAULT 1 COMMENT '最少人数',
+  `max_people`       INT           NOT NULL DEFAULT 1000 COMMENT '最多人数',
+  PRIMARY KEY (`id`),
+  KEY `idx_payment` (`course_payment_id`),
+  KEY `idx_inst_campus` (`institution_id`, `campus_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='课程分类表(班级类型/单价/人数区间)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 18. t_courses_class(课程-班级-老师-课表规则关系 = **排班规则**)
+-- 迁移源:t_courses_class(id int AI;standard_lesson_cost/lesson_cost/discount_cash float(10,3))
+-- 语义:一门课在某班由某老师按 week + 上课起止时分 排课;finished_number 已上课量。
+-- ⚠️ start_time/end_time 旧库为"时分"(date 部分无意义);目标态保留 datetime(3) 以兼容旧数据,
+--    新写入统一以 1970-01-01 日期 + 时分(迁移与写入规则见 §11-3)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_class`;
+CREATE TABLE `t_courses_class` (
+  `id`                   BIGINT        NOT NULL AUTO_INCREMENT COMMENT '关系ID(D3 自增)',
+  `status`               INT           NOT NULL DEFAULT 1 COMMENT '状态(1进行中/2结束)',
+  `courses_id`           BIGINT        NOT NULL COMMENT '课程ID(原 coursesid → t_courses.id)',
+  `class_id`             BIGINT        NOT NULL COMMENT '班级ID(原 classid;旧库为外部班级体系 id,本期仅存值)',
+  `teacher_id`           VARCHAR(26)   DEFAULT NULL COMMENT '老师(canonical_user_id ULID,原 teacherid;0→NULL)',
+  `institution_id`       BIGINT        NOT NULL DEFAULT 0 COMMENT '机构ID(原 schoolid;作用域列)',
+  `campus_id`            BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;作用域列)',
+  `standard_lesson_cost` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '课程标准单价',
+  `number`               INT           NOT NULL DEFAULT 0 COMMENT '开课总量(课次)',
+  `lesson_cost`          DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '一次课的课费',
+  `discount_cash`        DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '课程优惠(元)',
+  `finished_number`      INT           NOT NULL DEFAULT 0 COMMENT '已上课量',
+  `confirm`              TINYINT       NOT NULL DEFAULT 0 COMMENT '是否确认(0未确认/1确认)',
+  `week`                 VARCHAR(50)   NOT NULL DEFAULT '-1' COMMENT '周规则(-1全部/0周日/1-6/多值逗号分隔)',
+  `start_time`           DATETIME(3)   DEFAULT NULL COMMENT '上课时间(时分;旧 NOT NULL,目标态放宽)',
+  `end_time`             DATETIME(3)   DEFAULT NULL COMMENT '下课时间(时分)',
+  `address`              LONGTEXT      DEFAULT NULL COMMENT '上课地址',
+  `remark`               LONGTEXT      DEFAULT NULL COMMENT '备注',
+  `created_at`           DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_courses` (`courses_id`),
+  KEY `idx_teacher` (`teacher_id`),
+  KEY `idx_class` (`class_id`),
+  KEY `idx_inst_campus` (`institution_id`, `campus_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='课程班级关系表(排班规则:周+时分+老师)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 19. t_courses_student(学生报课信息 = 学费账户/课次账户;旧 UUID 主键 → int64 + biz_no)
+-- 迁移源:t_courses_student(PK id varchar(32) UUID;多列 decimal(10,3);activityid varchar(200))
+-- 语义:应收 receivable / 实收 net_receipts / 欠费 unpaid / 单课费 lesson_cost / 总次数 number /
+--       已上 finished_number / 剩余费用 remainder_cost / 剩余应收 remainder_receivable / 坏账 bad_debt。
+--       confirm_discount_userid=0 表示"不可充值"(业务语义,见字段规范 §4.2)。
+-- ⚠️ activityid 旧为"多活动英文逗号串":目标态保留 activity_ids varchar(200) 原样,
+--    不做规范化拆表(保结构稳定、迁移映射简单,设计 §2.3)。
+-- ⚠️ 该表同时是"报课订单"载体:user_discount_log.courses_student_id / activity_used_log.order_no
+--    均指向本表;旧 UUID 必须保留在 biz_no。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_student`;
+CREATE TABLE `t_courses_student` (
+  `id`                       BIGINT        NOT NULL AUTO_INCREMENT COMMENT '报课记录ID(D3 自增)',
+  `biz_no`                   VARCHAR(32)   NOT NULL COMMENT '业务单号(旧 UUID 主键原样保留,UNIQUE)',
+  `courses_id`               BIGINT        NOT NULL COMMENT '课程ID(原 coursesid)',
+  `class_id`                 BIGINT        NOT NULL DEFAULT 0 COMMENT '班级ID(原 classid)',
+  `institution_id`           BIGINT        NOT NULL DEFAULT 0 COMMENT '机构ID(原 schoolid;作用域列)',
+  `campus_id`                BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;作用域列)',
+  `student_id`               VARCHAR(26)   NOT NULL COMMENT '学生(canonical_user_id ULID,原 studentid)',
+  `type`                     TINYINT       NOT NULL DEFAULT 1 COMMENT '上课类型(1正常/2插班)',
+  `status`                   TINYINT       NOT NULL DEFAULT 1 COMMENT '状态(1正常/2结束)',
+  `receivable`               DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '应收',
+  `net_receipts`             DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '实收(学生实际缴纳)',
+  `unpaid`                   DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '欠费(未支付费用)',
+  `lesson_cost`              DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '一节课的费用',
+  `number`                   INT           NOT NULL DEFAULT 0 COMMENT '总课次',
+  `finished_number`          INT           NOT NULL DEFAULT 0 COMMENT '已上课次',
+  `discount_cash`            DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '现金折扣(元)',
+  `activity_ids`             VARCHAR(200)  DEFAULT NULL COMMENT '报课使用的活动ID(英文逗号分隔,原 activityid)',
+  `give_number`              INT           NOT NULL DEFAULT 0 COMMENT '赠送课次',
+  `give_cost`                DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '使用的赠送金额(元)',
+  `changing_course_number`   INT           NOT NULL DEFAULT 0 COMMENT '调课次数',
+  `remainder_number`         INT           NOT NULL DEFAULT 0 COMMENT '剩余课次',
+  `remainder_cost`           DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '剩余费用(缴费扣除每次课费后余额)',
+  `remainder_receivable`     DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '剩余应收',
+  `bad_debt`                 DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '坏账',
+  `confirm_discount_user_id` VARCHAR(26)   DEFAULT NULL COMMENT '确认学生折扣的操作人(原 confirm_discount_userid;0/NULL=不可充值)',
+  `confirm_time`             DATETIME(3)   DEFAULT NULL COMMENT '确认时间',
+  `recommender_user_id`      VARCHAR(26)   DEFAULT NULL COMMENT '推荐人(原 recommender_userid)',
+  `recommender_time`         DATETIME(3)   DEFAULT NULL COMMENT '推荐时间',
+  `create_time`              DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `remark`                   LONGTEXT      DEFAULT NULL COMMENT '备注',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_biz_no` (`biz_no`),
+  KEY `idx_student_status` (`student_id`, `status`),
+  KEY `idx_courses` (`courses_id`),
+  KEY `idx_inst_campus` (`institution_id`, `campus_id`),
+  KEY `idx_unpaid` (`student_id`, `unpaid`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='学生报课信息表(学费/课次账户·旧 UUID→biz_no)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 20. t_courses_record(上课记录/课表实例 = **排班实例**;旧 UUID 主键 → int64 + biz_no)
+-- 迁移源:t_courses_record(PK id varchar(32) UUID;income/teacher_reward float)
+-- status:0 未开始 / 1 正在进行 / 2 已结束
+-- 语义:开始上课写 actual_start_time;结束上课写 actual_end_time + income + teacher_reward,
+--       并联动 t_courses_record_student / 余额 spend / 分账 / 课酬(设计 §7.3 流程外延)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_record`;
+CREATE TABLE `t_courses_record` (
+  `id`                  BIGINT        NOT NULL AUTO_INCREMENT COMMENT '上课记录ID(D3 自增)',
+  `biz_no`              VARCHAR(32)   NOT NULL COMMENT '业务单号(旧 UUID 主键原样保留,UNIQUE)',
+  `courses_id`          BIGINT        NOT NULL COMMENT '课程ID(原 coursesid)',
+  `class_id`            BIGINT        NOT NULL COMMENT '班级ID(原 classid)',
+  `teacher_id`          VARCHAR(26)   DEFAULT NULL COMMENT '老师(canonical_user_id ULID,原 teacherid)',
+  `institution_id`      BIGINT        NOT NULL DEFAULT 0 COMMENT '机构ID(原 schoolid;作用域列)',
+  `campus_id`           BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;作用域列)',
+  `status`              INT           NOT NULL DEFAULT 0 COMMENT '状态(0未开始/1正在进行/2已结束)',
+  `changing_course`     TINYINT       NOT NULL DEFAULT 0 COMMENT '是否调课(1调课)',
+  `student_changing_course` INT       NOT NULL DEFAULT 0 COMMENT '单学生调课数',
+  `transfer_student`    TINYINT       NOT NULL DEFAULT 0 COMMENT '是否有插班生(1有)',
+  `original_record_no`  VARCHAR(32)   DEFAULT NULL COMMENT '原上课记录单号(原 original_recordid UUID)',
+  `start_time`          DATETIME(3)   DEFAULT NULL COMMENT '上课时间',
+  `end_time`            DATETIME(3)   DEFAULT NULL COMMENT '下课时间',
+  `address`             LONGTEXT      DEFAULT NULL COMMENT '上课地点',
+  `actual_start_time`   DATETIME(3)   DEFAULT NULL COMMENT '实际上课时间',
+  `actual_end_time`     DATETIME(3)   DEFAULT NULL COMMENT '实际下课时间',
+  `student_number`      INT           NOT NULL DEFAULT 0 COMMENT '上课人数',
+  `income`              DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '课程收入(元)',
+  `teacher_reward`      DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '老师课酬',
+  `teacher_reward_type` TINYINT       NOT NULL DEFAULT 1 COMMENT '课酬结算方式(1正常/2按原课程老师课酬无最低课酬)',
+  `expect_student_ids`  VARCHAR(500)  DEFAULT NULL COMMENT '要求上课的学生 id 列表(逗号分隔,原 expect_studentids)',
+  `remark`              LONGTEXT      DEFAULT NULL COMMENT '备注',
+  `created_at`          DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_biz_no` (`biz_no`),
+  KEY `idx_courses_start` (`courses_id`, `start_time`),
+  KEY `idx_teacher_start` (`teacher_id`, `start_time`),
+  KEY `idx_inst_campus` (`institution_id`, `campus_id`),
+  KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='课程记录(课表/排班实例·旧 UUID→biz_no)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 21. t_courses_record_student(学生上课记录 = **打卡/考勤/扣课时**;旧 UUID 主键 → int64 + biz_no)
+-- 迁移源:t_courses_record_student(PK id varchar(32) UUID;cost/give_cost/teacher_reward float(10,3))
+-- type:1 正常 / 2 插班 / 3 赠送;pay:1 已支付
+-- 语义:一次打卡 = 一行;cost 为本次扣除课费;give_cost 为本次使用的赠送金额;
+--       divide_into_log_delete 用本表"首次 cost>0 且 end_time 非空"的记录判定分成归属。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_courses_record_student`;
+CREATE TABLE `t_courses_record_student` (
+  `id`             BIGINT        NOT NULL AUTO_INCREMENT COMMENT '记录ID(D3 自增)',
+  `biz_no`         VARCHAR(32)   NOT NULL COMMENT '业务单号(旧 UUID 主键原样保留,UNIQUE)',
+  `courses_id`     BIGINT        NOT NULL COMMENT '课程ID(原 coursesid)',
+  `class_id`       BIGINT        NOT NULL DEFAULT 0 COMMENT '班级ID(原 classid)',
+  `institution_id` BIGINT        NOT NULL DEFAULT 0 COMMENT '机构ID(原 schoolid;作用域列)',
+  `campus_id`      BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;作用域列)',
+  `record_id`      BIGINT        NOT NULL COMMENT '上课记录ID(原 recordid UUID → t_courses_record.id)',
+  `student_id`     VARCHAR(26)   NOT NULL COMMENT '学生(canonical_user_id ULID,原 studentid)',
+  `type`           INT           NOT NULL DEFAULT 1 COMMENT '上课类型(1正常/2插班/3赠送)',
+  `start_time`     DATETIME(3)   NOT NULL COMMENT '上课时间',
+  `end_time`       DATETIME(3)   DEFAULT NULL COMMENT '下课时间',
+  `cost`           DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '本次课程费用(扣课时金额)',
+  `give_cost`      DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '本次使用的赠送金额',
+  `teacher_reward` DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '本次课老师课酬',
+  `pay`            TINYINT       NOT NULL DEFAULT 0 COMMENT '是否已支付(0未/1已支付)',
+  `remark`         LONGTEXT      DEFAULT NULL COMMENT '备注',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_biz_no` (`biz_no`),
+  KEY `idx_record` (`record_id`),
+  KEY `idx_student_time` (`student_id`, `start_time`),
+  KEY `idx_inst_campus` (`institution_id`, `campus_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='课程上课记录(打卡/考勤/扣课时·旧 UUID→biz_no)' ROW_FORMAT=Dynamic;
+
+-- ============================================================================
+-- 第五部分:课酬与支出(6 表)
+-- 覆盖 SP:course_payment_detail_get_teacher_detail(多级回退匹配)
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 22. t_course_payment(课酬方案,机构/校区级,按生效日期取最新)
+-- 迁移源:t_course_payment(id int AI;actived bit(1))
+-- 匹配规则(SP 两级回退):campus_id 命中机构+校区 → 回退 campus_id=0(机构级)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_course_payment`;
+CREATE TABLE `t_course_payment` (
+  `id`             BIGINT       NOT NULL AUTO_INCREMENT COMMENT '方案ID(D3 自增)',
+  `institution_id` BIGINT       NOT NULL DEFAULT 0 COMMENT '机构ID(原 schoolid;0=所有机构;作用域列)',
+  `campus_id`      BIGINT       NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;0=机构所有校区)',
+  `name`           VARCHAR(200) DEFAULT NULL COMMENT '方案名称',
+  `effect_date`    DATETIME(3)  DEFAULT NULL COMMENT '生效日期(取 effect_date<=当前 最新一条)',
+  `actived`        TINYINT(1)   NOT NULL DEFAULT 0 COMMENT '是否生效(bit(1)→tinyint(1)/bool)',
+  `create_user_id` VARCHAR(26)  DEFAULT NULL COMMENT '创建人(canonical_user_id ULID,原 create_userid)',
+  `create_time`    DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `memo`           LONGTEXT     DEFAULT NULL COMMENT '备注',
+  PRIMARY KEY (`id`),
+  KEY `idx_inst_campus_actived_effect` (`institution_id`, `campus_id`, `actived`, `effect_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='课酬方案表(机构/校区级·按生效日期取最新)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 23. t_course_payment_detail(课酬明细规则;三级回退匹配)
+-- 迁移源:t_course_payment_detail(id int AI;percentage/min_payment/max_payment/reward float(10,1);
+--         teacher_exp_min/max float(10,3);teacher_cert bit(1))
+-- grade_id / subject_id 支持 -1 通配(匹配逻辑保留旧口径,见设计 §3-5.2)
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_course_payment_detail`;
+CREATE TABLE `t_course_payment_detail` (
+  `id`                BIGINT        NOT NULL AUTO_INCREMENT COMMENT '明细ID(D3 自增)',
+  `course_payment_id` BIGINT        NOT NULL COMMENT '课酬方案ID(原 course_paymentid)',
+  `grade_id`          INT           NOT NULL DEFAULT -1 COMMENT '年级ID(-1所有/0混合)',
+  `subject_id`        INT           NOT NULL DEFAULT -1 COMMENT '科目ID(-1所有/0混合)',
+  `class_type`        INT           NOT NULL DEFAULT 0 COMMENT '班级类型(对应 t_courses_classify.class_type)',
+  `classify_id`       BIGINT        NOT NULL DEFAULT 0 COMMENT '课程分类ID(原 classifyid;个人配置维度)',
+  `teacher_id`        VARCHAR(26)   DEFAULT NULL COMMENT '老师(canonical_user_id ULID,原 teacherid;NULL/空=通用规则)',
+  `name`              VARCHAR(300)  DEFAULT NULL COMMENT '计算方式名称',
+  `percentage`        DECIMAL(5,1)  NOT NULL DEFAULT 0.0 COMMENT '课酬比例(百分比数字部分)',
+  `min_payment`       DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '最低课酬标准(元)',
+  `max_payment`       DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '最高课酬标准(元)',
+  `reward`            DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '额外课酬(元)',
+  `teacher_cert`      TINYINT(1)    NOT NULL DEFAULT 0 COMMENT '是否持证(bit(1)→tinyint(1)/bool)',
+  `teacher_exp_min`   DECIMAL(6,2)  NOT NULL DEFAULT 0.00 COMMENT '教龄下限(年)',
+  `teacher_exp_max`   DECIMAL(6,2)  NOT NULL DEFAULT 0.00 COMMENT '教龄上限(年)',
+  `actived`           TINYINT(1)    NOT NULL DEFAULT 0 COMMENT '是否生效(bit(1)→tinyint(1)/bool)',
+  `memo`              LONGTEXT      DEFAULT NULL COMMENT '备注',
+  PRIMARY KEY (`id`),
+  KEY `idx_payment` (`course_payment_id`),
+  KEY `idx_teacher_classify` (`teacher_id`, `class_type`, `classify_id`),
+  KEY `idx_cert_exp` (`teacher_cert`, `teacher_exp_min`, `teacher_exp_max`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='课酬明细规则表(个人→持证通用→无证通用 三级回退)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 24. t_teacher_information(教师扩展:资格证 + 教龄)
+-- 迁移源:t_teacher_information(PK teacherid int;teacher_cert bit(1);teacher_exp date)
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_teacher_information`;
+CREATE TABLE `t_teacher_information` (
+  `teacher_id`   VARCHAR(26) NOT NULL COMMENT '老师(canonical_user_id ULID,原 teacherid,主键)',
+  `teacher_cert` TINYINT(1)  NOT NULL DEFAULT 0 COMMENT '教师资格证(bit(1)→tinyint(1)/bool;0无/1有)',
+  `teacher_exp`  DATE        DEFAULT NULL COMMENT '教龄起算日(date)',
+  `created_at`   DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `updated_at`   DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+  PRIMARY KEY (`teacher_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='教师扩展信息表(资格证/教龄)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 25. t_teacher_courses_percentage(老师课酬提成/固定课酬,UK(type, teacher_id))
+-- 迁移源:t_teacher_courses_percentage(id int AI;多列 float + *_extend 字符串/文本扩展)
+-- 说明:*_extend 为 JSON/逗号扩展串(按年级/科目分档),目标态**原样保留字符串**不结构化
+--       (保结构稳定;解析在 Service 层,见 §11-3 待补盘点)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_teacher_courses_percentage`;
+CREATE TABLE `t_teacher_courses_percentage` (
+  `id`                  BIGINT        NOT NULL AUTO_INCREMENT COMMENT '记录ID(D3 自增)',
+  `type`                INT           NOT NULL DEFAULT 1000 COMMENT '课程类型(class_type)',
+  `payment_method`      TINYINT       NOT NULL DEFAULT 1 COMMENT '结算方式(1提成结算/2现金结算)',
+  `teacher_id`          VARCHAR(26)   NOT NULL COMMENT '老师(canonical_user_id ULID,原 teacherid)',
+  `percentage`          INT           NOT NULL DEFAULT 0 COMMENT '提成比例(%)',
+  `cost`                DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '一次课课酬',
+  `added_cost`          DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '额外费用(比例基础上追加)',
+  `percentage_extend`   VARCHAR(500)  DEFAULT NULL COMMENT '提成比例扩展(原样保留)',
+  `cost_extend`         VARCHAR(500)  DEFAULT NULL COMMENT '固定课酬扩展(原样保留)',
+  `max_reward`          DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '最大课酬(一次课)',
+  `max_reward_extend`   LONGTEXT      DEFAULT NULL COMMENT '最大课酬扩展(按年级分档)',
+  `min_reward`          DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '最小课酬(一次课)',
+  `min_reward_extend`   VARCHAR(500)  DEFAULT NULL COMMENT '最小课酬扩展(原样保留)',
+  `effective_date`      DATE          DEFAULT NULL COMMENT '课酬生效日期',
+  `created_at`          DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `updated_at`          DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_type_teacher` (`type`, `teacher_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='老师课酬提成表(提成/固定两制·UK type+teacher)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 26. t_expenditure(收支类型字典,自引用树)
+-- 迁移源:t_expenditure(id int AI;type 1支出/2收入;parentid)
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_expenditure`;
+CREATE TABLE `t_expenditure` (
+  `id`             BIGINT       NOT NULL AUTO_INCREMENT COMMENT '类型ID(D3 自增)',
+  `type`           INT          NOT NULL DEFAULT 1 COMMENT '类型(1支出/2收入)',
+  `parent_id`      BIGINT       NOT NULL DEFAULT 0 COMMENT '父类型ID(原 parentid;0=顶级)',
+  `name`           VARCHAR(500) NOT NULL COMMENT '名称',
+  `remark`         LONGTEXT     DEFAULT NULL COMMENT '备注',
+  `institution_id` BIGINT       NOT NULL DEFAULT 0 COMMENT '机构ID(新增作用域列;0=平台通用字典)',
+  PRIMARY KEY (`id`),
+  KEY `idx_parent` (`parent_id`),
+  KEY `idx_inst_type` (`institution_id`, `type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='收支类型字典表(自引用树·支出/收入)' ROW_FORMAT=Dynamic;
+
+-- ----------------------------------------------------------------------------
+-- 27. t_expenditure_log(收支记录,旧 UUID 主键 → int64 + biz_no)
+-- 迁移源:t_expenditure_log(PK id varchar(32) UUID;cost float(10,3);year year/month tinyint)
+-- 说明:year/month 为报表分桶冗余列,目标态保留(避免月度报表改 SQL)。
+-- ----------------------------------------------------------------------------
+DROP TABLE IF EXISTS `t_expenditure_log`;
+CREATE TABLE `t_expenditure_log` (
+  `id`             BIGINT        NOT NULL AUTO_INCREMENT COMMENT '记录ID(D3 自增)',
+  `biz_no`         VARCHAR(32)   NOT NULL COMMENT '业务单号(旧 UUID 主键原样保留,UNIQUE)',
+  `institution_id` BIGINT        NOT NULL DEFAULT 0 COMMENT '机构ID(原 schoolid;作用域列)',
+  `campus_id`      BIGINT        NOT NULL DEFAULT 0 COMMENT '校区ID(原 campusid;作用域列)',
+  `expenditure_id` BIGINT        NOT NULL DEFAULT 0 COMMENT '收支类型ID(原 expenditureid → t_expenditure.id)',
+  `cost`           DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '费用(元;旧 float(10,3) 收敛)',
+  `create_user_id` VARCHAR(26)   DEFAULT NULL COMMENT '创建人(canonical_user_id ULID,原 create_userid)',
+  `create_time`    DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+  `year`           SMALLINT      DEFAULT NULL COMMENT '收支年份(报表分桶冗余列,原 year)',
+  `month`          TINYINT       DEFAULT NULL COMMENT '收支月份(报表分桶冗余列,原 month)',
+  `remark`         LONGTEXT      DEFAULT NULL COMMENT '备注',
+  `imgs`           LONGTEXT      DEFAULT NULL COMMENT '图片地址(JSON 字符串)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_biz_no` (`biz_no`),
+  KEY `idx_inst_campus_time` (`institution_id`, `campus_id`, `create_time`),
+  KEY `idx_expenditure` (`expenditure_id`),
+  KEY `idx_year_month` (`year`, `month`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='收支记录表(旧 UUID→int64+biz_no)' ROW_FORMAT=Dynamic;
+
+SET FOREIGN_KEY_CHECKS = 1;
+
+-- ============================================================================
+-- 未纳入目标态的表(旧库 44 表中本域 30 表的处置说明)
+-- ============================================================================
+-- 【P2 暂不建(本期不承载业务,迁移期只归档,见设计 §11)】
+--   · t_user_money_summary / t_user_money_summary_school
+--       —— PHP 全仓 0 引用(grep 实测 count=0),实际未使用的汇总表;
+--          目标态用视图/实时聚合替代,不建冗余汇总表(§11-8)。
+--   · t_user_balance_log
+--       —— 仅 UserBalanceLogHelper 存在、业务无调用点;已被 t_user_expense_log 取代(§11-9)。
+--   · t_courses_classify_edit_log / t_courses_record_student_copy / t_course_payment_detail_copy
+--       —— 备份/编辑留痕副本表,迁移后由 t_audit_log 覆盖。
+--   · tmp_replace_userid
+--       —— 一次性临时表。
+-- 【P2 待补盘点后建(课程域深水区,本期只做最小闭环)】
+--   · t_courses_change_student(调课)/ t_courses_closure_request(结课申请)/
+--     t_courses_other_request(课程其他申请)/ t_courses_record_send_msg_log(上课通知)/
+--     t_student_archives(学员档案)/ t_student_test_paper_achievement(测评成绩)
+--   · t_wechat_send_log(跨域,归 teaching/notification)
+--   · t_help / t_menu(平台级配置,归 org 域)
+-- ============================================================================
+
+-- ============================================================================
+-- ERD(ASCII,27 表关系主干)
+-- ============================================================================
+--
+--  【资金】
+--   t_user_expense(user_id PK)  1──────────────────*  t_user_expense_school(id PK, UK user+inst+campus)
+--          │                                                    │
+--          │ user_id                                            │ user_id
+--          └──────────────┬─────────────────────────────────────┘
+--                         ▼
+--                 t_user_expense_log(id PK)
+--                   · type 编码 20+ 种(100/106/107/108/206/207/208/209…)
+--                   · request_id + type 唯一 = 幂等键
+--                         ▲
+--   t_user_balance_request(id PK, biz_no UK) ─── approved=1 触发资金方法 ──┘
+--                         │ biz_no
+--                         ▼
+--                   t_order_pay(id PK, biz_no UK)    t_payment_log(biz_no UK, 只读历史)
+--
+--  【活动】   t_courses_activity(id PK)
+--                │ 1
+--                ├────*  t_courses_activity_signed(id PK, UK user+activity)  ── status 状态机 0/1/2/3/4
+--                ├────*  t_courses_activity_user_discount(id PK)  ── 赠送金额池
+--                │            │ 1
+--                │            └──*  t_courses_activity_user_discount_log(id PK)  ── 负=消费/正=退回
+--                ├────*  t_courses_activity_used_log(id PK)  ── 老带新结算(负值)
+--                ├────*  t_courses_activity_request(id PK, biz_no UK)
+--                └────*  t_courses_activity_visit(id PK, activity_signed_id)
+--
+--  【分账】   t_user_divide_into(user_id PK, Nested Set: root_user_id/left_val/right_val/layer)
+--                │ 1
+--                └────*  t_user_divide_into_log(id PK)   · type=1 per student / type=2 per order
+--
+--  【课程】   t_courses_classify(id PK)  1────*  t_courses(id PK)  1────*  t_courses_class(id PK)  ← 排班规则
+--                                                     │1                      │1
+--                                                     ├────*  t_courses_student(id PK, biz_no UK)    ← 报课/学费账户
+--                                                     └────*  t_courses_record(id PK, biz_no UK)     ← 排班实例
+--                                                                  │1
+--                                                                  └──*  t_courses_record_student(id PK, biz_no UK) ← 打卡
+--
+--  【课酬】   t_course_payment(id PK)  1────*  t_course_payment_detail(id PK)
+--            t_teacher_information(teacher_id PK)      t_teacher_courses_percentage(id PK, UK type+teacher)
+--            提供 get_teacher_detail 的输入(教龄/持证/个人配置/通用档)
+--
+--  【支出】   t_expenditure(id PK, 自引用 parent_id)  1────*  t_expenditure_log(id PK, biz_no UK)
+--
+-- 关系基线(无物理 FK,全部应用层保证):
+--   · 所有 *_id 引用 canonical_user_id 的列 = VARCHAR(26) ULID;机构/校区 = BIGINT
+--   · 旧 UUID 主键表(7 张)保留 biz_no UNIQUE,迁移/回滚/割接双跑的对账锚点
+--   · 多租户表(带 institution_id/campus_id)= _school / activity* / courses* / payroll / expenditure*;
+--     Repository 强制 BaseRepo.Scoping(跨作用域 → 1004)
+--   · 无作用域表 = t_user_expense(全局钱包)/ t_user_divide_into*(全局推广体系)
+--     → 不继承 BaseRepo,作用域断言在 Service 层(见设计 §1.7)
+-- ============================================================================
+
+-- ============================================================================
+-- 迁移落点调整(对 03-migration/stage0/migration-mapping.sql 的影响,T5 执行)
+-- ============================================================================
+--  1. 本域 27 表 INSERT...SELECT 全部经 t_legacy_mapping 重映射用户列:
+--     `JOIN t_legacy_mapping lm ON lm.legacy_academic_user_id = 旧.userid`
+--     取 lm.canonical_user_id 落 user_id/student_id/teacher_id/…(ADR-013:旧 int 只进映射表)。
+--  2. schoolid → institution_id、campusid → campus_id 直改列名(值不变,1:1)。
+--  3. 旧 varchar(32) UUID 主键 → biz_no 原样搬;新 id 由自增生成。
+--  4. 表间 UUID 引用列重映射:
+--     · t_courses_activity_signed.requestid → request_no(保留字符串,指向 t_user_balance_request.biz_no)
+--     · t_courses_activity_user_discount.balance_requestid → balance_request_id(VARCHAR→BIGINT,
+--       JOIN t_user_balance_request ON biz_no)
+--     · t_courses_activity_user_discount_log.courses_studentid → courses_student_id(JOIN t_courses_student ON biz_no)
+--     · t_courses_record_student.recordid → record_id(JOIN t_courses_record ON biz_no)
+--     · t_courses_activity_used_log.orderid → order_no(保留字符串:老带新=报课 biz_no,copy 重放=courses_studentid)
+--  5. 金额列 float(10,2/3)/double(10,3)/decimal(10,3) → decimal(12,2):`ROUND(x,2)`。
+--     **3 位小数的舍入差异须逐表出 diff 台账**(行数 / 前 20 行明细 / 差额绝对值合计),
+--     超阈值(> 0.01 × 行数)升级人工核对(§11-7)。
+--  6. bit(1)(actived/teacher_cert)→ tinyint(1):`IF(x=1,1,0)`。
+--  7. 时间 datetime(0) → datetime(3):`DATE_FORMAT` 补 `.000`(或直迁,MySQL 自动补 000)。
+--  8. 冗余列回填(迁移后一次性):
+--     · t_courses_activity_signed.institution_id/campus_id ← JOIN t_courses_activity
+--     · t_user_expense_log.balance_after 保持 NULL(历史无快照,双跑仅比新写入行)
+--  9. 索引重建:本文件索引为运行态口径;迁移期可先 `ALTER TABLE ... DISABLE KEYS` 批量导入后重建。
+-- ============================================================================
+
+-- 执行顺序:1..27(无物理外键,顺序不强制;按 资金→活动→分账→课程→课酬/支出 排列)
+-- 验证(建表后 information_schema 逐列核对,对齐门禁 §4):
+--   · 所有 user_id/student_id/teacher_id/create_user_id/request_user_id/response_user_id/
+--     recommend_user_id/confirm_discount_user_id/recommender_user_id = varchar(26)
+--   · 所有 institution_id/campus_id = bigint
+--   · 所有金额列(balance/spend/total_*/special_expenses/money/cost/receivable/unpaid/…)
+--     = decimal(12,2) —— 例外(精度保留):t_courses.duration decimal(5,1)、
+--     t_course_payment_detail.percentage decimal(5,1)、teacher_exp_min/max decimal(6,2)
+--   · 所有时间列 = datetime(3)(例外:t_teacher_information.teacher_exp / effective_date = date)
+--   · 字符集全部 utf8mb4 / utf8mb4_general_ci;ROW_FORMAT=Dynamic
+--   · 7 张旧 UUID 表 biz_no 均为 VARCHAR(32) NOT NULL + UNIQUE
+-- ============================================================================

+ 326 - 0
internal/academic/money/amount.go

@@ -0,0 +1,326 @@
+// Package money 金额值对象(int64「分」定点数,全程零 float64 路径)。
+//
+// 设计依据(unified/03-migration/academic-domain-design.md §1.5 / §9):
+//   - DDL 金额列统一 decimal(12,2)(旧 float(10,2/3)/double(10,3)/decimal(10,3) 全收敛);
+//   - Go 侧自研 Amount(单位:分),**零新增第三方依赖**(不引入 shopspring/decimal);
+//   - 禁止 float64 参与任何资金累加:解析/落库/序列化全程走字符串,
+//     decimal(12,2) ↔ Amount 的互转只经十进制定点运算。
+//
+// 为什么是「分」而不是「元」:
+//   - int64 分在 decimal(12,2) 全域(±9,999,999,999.99 元 = ±999,999,999,999 分)内精确,
+//     加/减/乘整数无误差累积;旧库 3 位小数已在迁移期 ROUND(x,2) 收敛(设计 §11-7 台账)。
+//
+// 溢出策略(显式,不静默):
+//   - 解析(NewFromYuanString / UnmarshalJSON / Scan)越界 → ErrOverflow;
+//   - 运算(Add/Sub/MulInt/Neg)为性能敏感路径,**不做每步溢出检查**,由调用方保证
+//     业务金额量级(资金域天然受限,单笔 ≤ 亿元级);写库前若需断言,用 Validate()。
+//
+// 典型用法:
+//
+//	a, err := money.NewFromYuanString("100.00") // 入参字符串(前端契约,设计 §10.6)
+//	if err != nil { return nil, apperr.ErrBadAmount }
+//	b := a.Add(fee).Sub(refund)
+//	if b.IsZero() { ... }
+//	s := b.String()                             // "99.99"(两位小数)
+package money
+
+import (
+	"database/sql"
+	"database/sql/driver"
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+// Scale 元 → 分的换算基数(1 元 = 100 分,对应 decimal 的 2 位小数)。
+const Scale int64 = 100
+
+// Amount 金额(单位:分,int64 定点数)。
+//
+// 取值域:decimal(12,2) 全域 = [-999_999_999_999, 999_999_999_999] 分
+// (即 ±9,999,999,999.99 元)。
+//
+// 约定:
+//   - 零值 = 0 元(IsZero 为 true),可直接用于 struct 字段默认值;
+//   - JSON 传输恒为**字符串**("100.00",避免前端 JS float 精度问题,设计 §10.6);
+//   - 落库经 driver.Valuer 输出字符串,MySQL decimal 无损接收。
+type Amount int64
+
+// MaxAmount 允许的最大金额(999,999,999,999 分 = 9,999,999,999.99 元,decimal(12,2) 上限)。
+const MaxAmount Amount = 999_999_999_999
+
+// MinAmount 允许的最小金额(-999,999,999,999 分,与 MaxAmount 对称;负数=欠费/冲正)。
+const MinAmount Amount = -999_999_999_999
+
+// 解析/扫描阶段的哨兵错误(Handler 统一映射为 2002 参数格式错误,设计 §10.1)。
+var (
+	// ErrInvalidFormat 金额字符串格式非法(空串/含非数字/多个小数点/"12."/".5"/JSON 非字符串)。
+	ErrInvalidFormat = errString("money: 金额格式非法(期望十进制字符串,如 \"12.34\"/\"12\"/\"-0.01\")")
+	// ErrOverflow 金额越界(超出 ±9,999,999,999.99 元或 int64 中间计算溢出)。
+	ErrOverflow = errString("money: 金额越界(decimal(12,2) 上限 ±9,999,999,999.99 元)")
+	// ErrScanNull 扫描到 NULL(金额列在 DDL 中均为 NOT NULL;NULL 多为 SQL 聚合/LEFT JOIN 未命中)。
+	ErrScanNull = errString("money: 不能扫描 NULL 金额")
+	// ErrUnsupportedScanType 扫描到不支持的类型(**明确拒绝 float64**,避免浮点进入资金链)。
+	ErrUnsupportedScanType = errString("money: 不支持的扫描类型(只接受 string/[]byte,拒绝 float64)")
+	// ErrNilReceiver JSON 反序列化接收者为 nil。
+	ErrNilReceiver = errString("money: 反序列化接收者为 nil")
+)
+
+// errString 实现 error 的最小字符串类型(避免为哨兵错误引入额外依赖)。
+type errString string
+
+// Error 实现 error 接口(返回固定文案)。
+func (e errString) Error() string { return string(e) }
+
+// 编译期接口合规断言(sql.Scanner / driver.Valuer 是落库互转的硬契约)。
+var (
+	_ driver.Valuer = Amount(0)
+	_ sql.Scanner   = (*Amount)(nil)
+)
+
+// NewFromYuanString 把「元」十进制字符串严格解析为 Amount(分)。
+//
+// 入参:
+//   - s string:十进制金额字符串;允许前后空格、可选正负号;
+//     合法形态:"12.34" / "12" / "-0.01" / "+100.5"(小数位 1~N 位,>2 位按四舍五入)。
+//
+// 出参:
+//   - Amount:解析结果(分);"-0.004" 归一为 0(不产生负零)。
+//   - error:ErrInvalidFormat(空串/非数字/多小数点/"12."/".5")或 ErrOverflow(越界)。
+//
+// 精确性:全程字符串 + int64 整数运算,**不经 float64**。
+// 舍入规则:第 3 位小数 ≥5 则进位(远离零方向),即 0.005 → 0.01、-0.005 → -0.01。
+func NewFromYuanString(s string) (Amount, error) {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return 0, ErrInvalidFormat
+	}
+	neg := false
+	switch s[0] {
+	case '-':
+		neg = true
+		s = s[1:]
+	case '+':
+		s = s[1:]
+	}
+	if s == "" {
+		return 0, ErrInvalidFormat // 仅剩符号:"-" / "+"
+	}
+
+	intPart, fracPart, hasDot := strings.Cut(s, ".")
+	if hasDot && fracPart == "" {
+		return 0, ErrInvalidFormat // "12." 拒绝(严格,避免前端半成品输入)
+	}
+	if intPart == "" {
+		return 0, ErrInvalidFormat // ".5" 拒绝(强制整数位,避免歧义)
+	}
+	if !allDigits(intPart) {
+		return 0, ErrInvalidFormat
+	}
+	if hasDot && !allDigits(fracPart) {
+		return 0, ErrInvalidFormat // 小数位非数字("12.a")
+	}
+
+	yuan, err := strconv.ParseInt(intPart, 10, 64)
+	if err != nil {
+		return 0, ErrOverflow // 整数位已超 int64(远超 decimal(12,2))
+	}
+	// 先做量级预检,避免 yuan*Scale 溢出(超过上限 1 元即必然越界)
+	if yuan > int64(MaxAmount)/Scale+1 {
+		return 0, ErrOverflow
+	}
+
+	// 取小数前 2 位 + 第 3 位决定是否进位
+	two := fracPart
+	roundUp := false
+	if len(fracPart) > 2 {
+		roundUp = fracPart[2] >= '5'
+		two = fracPart[:2]
+	}
+	var frac int64
+	for i := 0; i < len(two); i++ {
+		frac = frac*10 + int64(two[i]-'0')
+	}
+	if len(two) == 1 {
+		frac *= 10 // "0.5" → 50 分
+	}
+	if roundUp {
+		frac++ // 99 → 100 由 int64 相加自然进位
+	}
+
+	cents := yuan*Scale + frac
+	if neg {
+		cents = -cents
+	}
+	if cents > int64(MaxAmount) || cents < int64(MinAmount) {
+		return 0, ErrOverflow
+	}
+	return Amount(cents), nil
+}
+
+// Parse 语义等同 NewFromYuanString(短名别名,供 service/handler 里高频调用点使用)。
+//
+// 入参:s 十进制"元"字符串。出参:Amount + error(同 NewFromYuanString)。
+func Parse(s string) (Amount, error) { return NewFromYuanString(s) }
+
+// FromCents 由「分」构造 Amount(供 SQL SUM(decimal) 结果已换算为分的场景)。
+//
+// 入参:cents int64——分的整数表示。出参:Amount(不校验越界,调用方按量级保证)。
+func FromCents(cents int64) Amount { return Amount(cents) }
+
+// Cents 返回「分」的 int64 表示(写 SQL 参数/聚合计算时用;不产生浮点)。
+func (a Amount) Cents() int64 { return int64(a) }
+
+// String 输出两位小数的"元"字符串("12.34" / "-0.01" / "0.00")。
+//
+// 实现说明:用 uint64 取绝对值,规避 int64 最小值取负溢出(防御性,业务域不会出现)。
+func (a Amount) String() string {
+	neg := a < 0
+	var u uint64
+	if neg {
+		u = -uint64(a) // 二进制补码取绝对值(对 MinInt64 亦安全)
+	} else {
+		u = uint64(a)
+	}
+	s := fmt.Sprintf("%d.%02d", u/uint64(Scale), u%uint64(Scale))
+	if neg && u != 0 {
+		return "-" + s
+	}
+	return s
+}
+
+// Add 返回 a+b(不检查溢出,见包注释「溢出策略」)。
+func (a Amount) Add(b Amount) Amount { return a + b }
+
+// Sub 返回 a-b(不检查溢出)。
+func (a Amount) Sub(b Amount) Amount { return a - b }
+
+// MulInt 返回 a*n(整数倍:课次 × 单课费、数量 × 单价等场景;不检查溢出)。
+func (a Amount) MulInt(n int64) Amount { return a * Amount(n) }
+
+// Neg 返回 -a(反向流水/冲正场景;不检查溢出)。
+func (a Amount) Neg() Amount { return -a }
+
+// Cmp 比较大小:返回 -1(a<b)/ 0(a==b)/ 1(a>b)。
+func (a Amount) Cmp(b Amount) int {
+	switch {
+	case a < b:
+		return -1
+	case a > b:
+		return 1
+	default:
+		return 0
+	}
+}
+
+// IsZero 是否为零值(0 元)。
+func (a Amount) IsZero() bool { return a == 0 }
+
+// LessThan 是否小于 b(读性优先于 Cmp 的比较场景)。
+func (a Amount) LessThan(b Amount) bool { return a < b }
+
+// GreaterThan 是否大于 b。
+func (a Amount) GreaterThan(b Amount) bool { return a > b }
+
+// IsNegative 是否为负数(余额欠费/冲正方向判定)。
+func (a Amount) IsNegative() bool { return a < 0 }
+
+// Validate 校验是否落在 decimal(12,2) 可表示区间内(写库前的显式断言)。
+//
+// 出参:nil 合法;ErrOverflow 越界。
+func (a Amount) Validate() error {
+	if a > MaxAmount || a < MinAmount {
+		return ErrOverflow
+	}
+	return nil
+}
+
+// MarshalJSON 序列化为 JSON 字符串("12.34"),**不输出数字**,规避前端 float 精度损失。
+func (a Amount) MarshalJSON() ([]byte, error) {
+	return []byte(`"` + a.String() + `"`), nil
+}
+
+// UnmarshalJSON 严格反序列化(入参必须是 JSON 字符串,拒绝 number/null/bool)。
+//
+// 入参:b []byte——JSON 原始字节(如 `"100.00"`)。
+// 出参:ErrInvalidFormat(非字符串或格式非法)/ ErrOverflow(越界)/ ErrNilReceiver(接收者 nil)。
+func (a *Amount) UnmarshalJSON(b []byte) error {
+	if a == nil {
+		return ErrNilReceiver
+	}
+	s := strings.TrimSpace(string(b))
+	if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
+		return ErrInvalidFormat // 只接受字符串;数字/布尔/null 一律拒绝(前端契约)
+	}
+	v, err := NewFromYuanString(s[1 : len(s)-1])
+	if err != nil {
+		return err
+	}
+	*a = v
+	return nil
+}
+
+// Value 实现 driver.Valuer:输出字符串("12.34"),MySQL decimal(12,2) 无损接收。
+//
+// 说明:**不输出 float64**——MySQL 驱动会把 float64 转成浮点字面量,产生精度隐患。
+func (a Amount) Value() (driver.Value, error) {
+	if err := a.Validate(); err != nil {
+		return nil, err
+	}
+	return a.String(), nil
+}
+
+// Scan 实现 sql.Scanner:把数据库 decimal(12,2) 值解析为 Amount(分)。
+//
+// 入参:
+//   - src any:驱动返回的原始值;decimal 列在 MySQL 驱动下为 []byte("123.45"),部分驱动/单测为 string。
+//
+// 出参:
+//   - ErrScanNull:src 为 nil(金额列 NOT NULL;NULL 多来自聚合未命中或 LEFT JOIN);
+//   - ErrUnsupportedScanType:src 为 float64/float32 等(**拒绝浮点入资金链**)或其他未知类型;
+//   - ErrInvalidFormat / ErrOverflow:解析失败。
+func (a *Amount) Scan(src any) error {
+	if a == nil {
+		return ErrNilReceiver
+	}
+	switch v := src.(type) {
+	case nil:
+		return ErrScanNull
+	case []byte:
+		amt, err := NewFromYuanString(string(v))
+		if err != nil {
+			return err
+		}
+		*a = amt
+		return nil
+	case string:
+		amt, err := NewFromYuanString(v)
+		if err != nil {
+			return err
+		}
+		*a = amt
+		return nil
+	default:
+		// float64/float32/int64 等:类型语义不明(分还是元?)且浮点有精度风险 → 显式拒绝
+		return ErrUnsupportedScanType
+	}
+}
+
+// GormDataType 供 GORM 推断列类型(对齐 DDL decimal(12,2),设计 §1.3)。
+//
+// 本批 model 的金额列暂用 string 落库(AC-01 约定),本方法是后续切换
+// Amount 直接落 model 时的兼容钩子(不引入 gorm 依赖,仅返回类型字符串)。
+func (Amount) GormDataType() string { return "decimal(12,2)" }
+
+// allDigits 判断字符串是否全为 ASCII 数字(空串返回 false)。
+func allDigits(s string) bool {
+	if s == "" {
+		return false
+	}
+	for i := 0; i < len(s); i++ {
+		if s[i] < '0' || s[i] > '9' {
+			return false
+		}
+	}
+	return true
+}

+ 246 - 0
internal/academic/money/amount_test.go

@@ -0,0 +1,246 @@
+// Package money 金额值对象单测(纯逻辑,不连库)。
+//
+// 覆盖重点(AC-02 验收①②):
+//   - NewFromYuanString 严格解析:合法形态、"12."/".5"/"1.2.3"/非数字/空串拒绝;
+//   - 四位化舍入:0.005 → 0.01、-0.005 → -0.01、0.004 → 0.00、12.345 → 12.35(远离零方向);
+//   - String 恒两位小数、负数带号、零为 "0.00";
+//   - 运算 Add/Sub/MulInt/Neg/Cmp/IsZero/LessThan/GreaterThan;
+//   - JSON 序列化为**字符串**,反序列化只接受字符串(拒绝 number/null);
+//   - Value/Scan 互转(decimal 列 ↔ 分),Scan 拒绝 nil 与 float64(**无 float64 路径**)。
+package money
+
+import (
+	stderrors "errors"
+	"testing"
+)
+
+// TestNewFromYuanString 解析合法/非法输入(表驱动,边界逐条覆盖)。
+func TestNewFromYuanString(t *testing.T) {
+	okCases := []struct {
+		name string
+		in   string
+		want Amount
+	}{
+		{"两位小数", "12.34", 1234},
+		{"整数无小数", "12", 1200},
+		{"一位小数", "100.5", 10050},
+		{"负一分", "-0.01", -1},
+		{"正号", "+100.50", 10050},
+		{"零", "0", 0},
+		{"零两位小数", "0.00", 0},
+		{"前后空格", "  100.10  ", 10010},
+		{"第三位四舍五入进位", "0.005", 1},
+		{"第三位不足舍去", "0.004", 0},
+		{"负值四舍五入远离零", "-0.005", -1},
+		{"多位小数进位", "12.345", 1235},
+		{"多位小数舍去", "12.3449", 1234},
+		{"长小数尾部忽略", "1.00000000000001", 100},
+		{"负零归一", "-0.004", 0},
+		{"decimal 上限", "9999999999.99", MaxAmount},
+		{"decimal 下限", "-9999999999.99", MinAmount},
+	}
+	for _, tc := range okCases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := NewFromYuanString(tc.in)
+			if err != nil {
+				t.Fatalf("NewFromYuanString(%q) 意外失败: %v", tc.in, err)
+			}
+			if got != tc.want {
+				t.Fatalf("NewFromYuanString(%q) = %d,期望 %d", tc.in, got, tc.want)
+			}
+		})
+	}
+
+	badCases := []struct {
+		name string
+		in   string
+		want error
+	}{
+		{"空串", "", ErrInvalidFormat},
+		{"仅空格", "   ", ErrInvalidFormat},
+		{"仅负号", "-", ErrInvalidFormat},
+		{"非数字", "abc", ErrInvalidFormat},
+		{"数字混字母", "12a", ErrInvalidFormat},
+		{"两个小数点", "1.2.3", ErrInvalidFormat},
+		{"尾随小数点", "12.", ErrInvalidFormat},
+		{"缺整数位", ".5", ErrInvalidFormat},
+		{"中文数字", "一百", ErrInvalidFormat},
+		{"科学计数法", "1e5", ErrInvalidFormat},
+		{"千分位", "1,000.00", ErrInvalidFormat},
+		{"超上限", "10000000000.00", ErrOverflow},
+		{"超下限", "-10000000000.00", ErrOverflow},
+		{"整数位溢出 int64", "99999999999999999999999", ErrOverflow},
+	}
+	for _, tc := range badCases {
+		tc := tc
+		t.Run("非法_"+tc.name, func(t *testing.T) {
+			_, err := NewFromYuanString(tc.in)
+			if !stderrors.Is(err, tc.want) {
+				t.Fatalf("NewFromYuanString(%q) 错误 = %v,期望 %v", tc.in, err, tc.want)
+			}
+		})
+	}
+}
+
+// TestAmountString 输出格式恒定两位小数(前端契约)。
+func TestAmountString(t *testing.T) {
+	cases := []struct {
+		in   Amount
+		want string
+	}{
+		{0, "0.00"},
+		{1, "0.01"},
+		{5, "0.05"},
+		{1234, "12.34"},
+		{-1, "-0.01"},
+		{-1234, "-12.34"},
+		{100, "1.00"},
+		{MaxAmount, "9999999999.99"},
+		{MinAmount, "-9999999999.99"},
+	}
+	for _, tc := range cases {
+		if got := tc.in.String(); got != tc.want {
+			t.Fatalf("Amount(%d).String() = %q,期望 %q", int64(tc.in), got, tc.want)
+		}
+	}
+}
+
+// TestAmountArithmetic 加减乘负与比较(资金链核心运算,无浮点)。
+func TestAmountArithmetic(t *testing.T) {
+	a, _ := NewFromYuanString("100.00")
+	b, _ := NewFromYuanString("0.01")
+
+	if got := a.Add(b).String(); got != "100.01" {
+		t.Fatalf("Add = %s,期望 100.01", got)
+	}
+	if got := a.Sub(b).String(); got != "99.99" {
+		t.Fatalf("Sub = %s,期望 99.99", got)
+	}
+	if got := b.MulInt(300).String(); got != "3.00" {
+		t.Fatalf("MulInt = %s,期望 3.00", got)
+	}
+	if got := a.Neg().String(); got != "-100.00" {
+		t.Fatalf("Neg = %s,期望 -100.00", got)
+	}
+	if a.Cmp(b) != 1 || b.Cmp(a) != -1 || a.Cmp(a) != 0 {
+		t.Fatalf("Cmp 三元语义错误: a.Cmp(b)=%d b.Cmp(a)=%d a.Cmp(a)=%d", a.Cmp(b), b.Cmp(a), a.Cmp(a))
+	}
+	if !Amount(0).IsZero() || a.IsZero() {
+		t.Fatalf("IsZero 语义错误")
+	}
+	if !b.LessThan(a) || !a.GreaterThan(b) {
+		t.Fatalf("LessThan/GreaterThan 语义错误")
+	}
+	if !a.Neg().IsNegative() {
+		t.Fatalf("IsNegative 语义错误")
+	}
+	if a.Cents() != 10000 || FromCents(10000) != a {
+		t.Fatalf("Cents/FromCents 互转错误: %d", a.Cents())
+	}
+}
+
+// TestAmountValidate 写库前越界断言。
+func TestAmountValidate(t *testing.T) {
+	if err := MaxAmount.Validate(); err != nil {
+		t.Fatalf("MaxAmount 应合法: %v", err)
+	}
+	if err := MinAmount.Validate(); err != nil {
+		t.Fatalf("MinAmount 应合法: %v", err)
+	}
+	if err := (MaxAmount + 1).Validate(); !stderrors.Is(err, ErrOverflow) {
+		t.Fatalf("越界应返回 ErrOverflow,实得 %v", err)
+	}
+}
+
+// TestAmountJSON JSON 恒输出字符串,反序列化拒绝非字符串(前后端金额契约)。
+func TestAmountJSON(t *testing.T) {
+	a, _ := NewFromYuanString("-12.30")
+	buf, err := a.MarshalJSON()
+	if err != nil {
+		t.Fatalf("MarshalJSON 失败: %v", err)
+	}
+	if string(buf) != `"-12.30"` {
+		t.Fatalf("MarshalJSON = %s,期望 \"-12.30\"(字符串)", buf)
+	}
+
+	var back Amount
+	if err := back.UnmarshalJSON(buf); err != nil {
+		t.Fatalf("UnmarshalJSON 失败: %v", err)
+	}
+	if back != a {
+		t.Fatalf("JSON 往返不一致: %d vs %d", back, a)
+	}
+
+	if err := back.UnmarshalJSON([]byte(`100.00`)); !stderrors.Is(err, ErrInvalidFormat) {
+		t.Fatalf("数字 JSON 应被拒绝,实得 %v", err)
+	}
+	if err := back.UnmarshalJSON([]byte(`null`)); !stderrors.Is(err, ErrInvalidFormat) {
+		t.Fatalf("null JSON 应被拒绝,实得 %v", err)
+	}
+	if err := back.UnmarshalJSON([]byte(`"abc"`)); !stderrors.Is(err, ErrInvalidFormat) {
+		t.Fatalf("非法金额字符串应被拒绝,实得 %v", err)
+	}
+	var nilRecv *Amount
+	if err := nilRecv.UnmarshalJSON([]byte(`"1.00"`)); !stderrors.Is(err, ErrNilReceiver) {
+		t.Fatalf("nil 接收者应返回 ErrNilReceiver,实得 %v", err)
+	}
+}
+
+// TestAmountValueScan decimal(12,2) ↔ Amount 互转(驱动返回 []byte,落库输出字符串)。
+func TestAmountValueScan(t *testing.T) {
+	a, _ := NewFromYuanString("1234.56")
+	v, err := a.Value()
+	if err != nil {
+		t.Fatalf("Value 失败: %v", err)
+	}
+	str, ok := v.(string)
+	if !ok {
+		t.Fatalf("Value 必须返回 string(避免 float 精度),实得 %T", v)
+	}
+	if str != "1234.56" {
+		t.Fatalf("Value = %q,期望 1234.56", str)
+	}
+
+	// MySQL 驱动 decimal 列返回 []byte
+	var fromBytes Amount
+	if err := fromBytes.Scan([]byte("1234.56")); err != nil {
+		t.Fatalf("Scan([]byte) 失败: %v", err)
+	}
+	if fromBytes != a {
+		t.Fatalf("Scan([]byte) = %d,期望 %d", fromBytes, a)
+	}
+
+	// 部分驱动/单测桩返回 string
+	var fromString Amount
+	if err := fromString.Scan("1234.56"); err != nil {
+		t.Fatalf("Scan(string) 失败: %v", err)
+	}
+	if fromString != a {
+		t.Fatalf("Scan(string) = %d,期望 %d", fromString, a)
+	}
+
+	// NULL 拒绝(金额列 DDL 全 NOT NULL)
+	var nullAmt Amount
+	if err := nullAmt.Scan(nil); !stderrors.Is(err, ErrScanNull) {
+		t.Fatalf("Scan(nil) 应返回 ErrScanNull,实得 %v", err)
+	}
+
+	// float64 拒绝(资金链禁止浮点)
+	var floatAmt Amount
+	if err := floatAmt.Scan(float64(1.23)); !stderrors.Is(err, ErrUnsupportedScanType) {
+		t.Fatalf("Scan(float64) 应返回 ErrUnsupportedScanType,实得 %v", err)
+	}
+
+	var nilRecv *Amount
+	if err := nilRecv.Scan("1.00"); !stderrors.Is(err, ErrNilReceiver) {
+		t.Fatalf("nil 接收者应返回 ErrNilReceiver,实得 %v", err)
+	}
+}
+
+// TestAmountGormDataType 落库类型恒为 decimal(12,2)(与 DDL 收敛口径一致)。
+func TestAmountGormDataType(t *testing.T) {
+	if got := Amount(0).GormDataType(); got != "decimal(12,2)" {
+		t.Fatalf("GormDataType = %q,期望 decimal(12,2)", got)
+	}
+}

+ 78 - 0
internal/academic/money/decimal_test.go

@@ -0,0 +1,78 @@
+// Package money 十进制边界用例(极端值/超长小数/符号边界)。
+//
+// 与 amount_test.go 的分工:amount_test.go 覆盖"功能正确",
+// 本文件专门覆盖"精度与量级的边界",即旧库 float(10,3) → decimal(12,2) 收敛(设计 §11-7)
+// 最容易出问题的位置:第 3 位小数的进位、9999999999.99 边界、负值方向舍入。
+package money
+
+import (
+	stderrors "errors"
+	"testing"
+)
+
+// TestDecimalBoundary 极端金额的解析与回写一致性。
+func TestDecimalBoundary(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want Amount
+	}{
+		// decimal(12,2) 边界的 1 分钱精度
+		{"上限减一分", "9999999999.98", MaxAmount - 1},
+		{"下限加一分", "-9999999999.98", MinAmount + 1},
+		// 第 3 位小数:恰好 5 进位(half-up,远离零)
+		{"边界进位", "0.005", 1},
+		{"边界不进位", "0.0049", 0},
+		{"负边界进位", "-0.005", -1},
+		// 超长小数(20 位):只看第 3 位,尾部不影响
+		{"超长小数舍", "0.00499999999999999999", 0},
+		{"超长小数进", "0.00500000000000000001", 1},
+		// 大额 + 小数进位(整数位与小数位同时参与)
+		{"大额进位", "9999999999.994", 999999999999},
+		{"大额溢出", "9999999999.995", 0}, // 进位后越界 → ErrOverflow(want 占位,见下)
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := NewFromYuanString(tc.in)
+			if tc.name == "大额溢出" {
+				if !stderrors.Is(err, ErrOverflow) {
+					t.Fatalf("进位越界应返回 ErrOverflow,实得 %v(值 %d)", err, got)
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("NewFromYuanString(%q) 意外失败: %v", tc.in, err)
+			}
+			if got != tc.want {
+				t.Fatalf("NewFromYuanString(%q) = %d,期望 %d", tc.in, got, tc.want)
+			}
+			// 幂等性:格式化后再解析必须回到同一值(String ↔ NewFromYuanString 互逆)
+			again, err := NewFromYuanString(got.String())
+			if err != nil {
+				t.Fatalf("回写解析失败: %v", err)
+			}
+			if again != got {
+				t.Fatalf("格式化回写漂移: %d → %q → %d", got, got.String(), again)
+			}
+		})
+	}
+}
+
+// TestDecimalNegativeDirection 负数方向:减/加/比较不产生方向错误(欠费与冲正依赖此语义)。
+func TestDecimalNegativeDirection(t *testing.T) {
+	neg, _ := NewFromYuanString("-0.01")
+	zero := Amount(0)
+	if !(neg.Cmp(zero) == -1) {
+		t.Fatalf("负数应小于零")
+	}
+	if zero.Sub(neg) != Amount(1) {
+		t.Fatalf("0 - (-0.01) 应为 0.01,实得 %s", zero.Sub(neg).String())
+	}
+	if got := neg.Add(neg).String(); got != "-0.02" {
+		t.Fatalf("负数相加 = %s,期望 -0.02", got)
+	}
+	if got := neg.MulInt(0).String(); got != "0.00" {
+		t.Fatalf("乘零 = %s,期望 0.00", got)
+	}
+}

+ 88 - 0
internal/academic/port/campus.go

@@ -0,0 +1,88 @@
+// Package port academic 域跨域只读接口(**academic 定义,org 侧实现/注入**)。
+//
+// 依据:unified/03-migration/academic-domain-design.md §1.3(包结构与依赖方向)/ §1.6(跨库跨域收敛)。
+//
+// 依赖方向铁律:
+//   - academic → org(单向):本包只定义接口,实现放 port/orgadapter.go(只读转发)+ 装配层注入;
+//   - org **不得**反向依赖 academic;
+//   - 禁止跨库 SQL(`yuansiwei.xxx` 前缀)与跨域 JOIN——取数一律经本包接口 + 同事务查询。
+//
+// 为什么需要 port:
+//   - 旧 SP `custom_refund` / `cleared_balance` 跨库读 `yuansiwei.t_school_campus.self_support`,
+//     `cancel_recharge_activity` 读 `school_id`;目标态校区数据归 org 域 `t_campus`(§11.1 拍板 A
+//     补 `self_support`),academic 只能通过接口读,不得直接 import org 的仓储做业务判断。
+package port
+
+import (
+	"context"
+
+	"gorm.io/gorm"
+)
+
+// CampusInfo 校区只读视图(跨域取数的唯一载体,字段裁剪到 academic 所需最小集)。
+//
+// 字段:
+//   - CampusID int64:校区 ID(→ t_campus.campus_id)。
+//   - SchoolID int64:所属机构 ID(旧口径名 schoolid,即 t_campus.institution_id;作用域列取值源)。
+//   - SelfSupport bool:是否自营校区(t_campus.self_support:1 自营 / 0 加盟);
+//     决定 custom_refund / cleared_balance 的活动失效范围(`campus_id IN (x,0)` vs `= x`,设计 §11-2)。
+//   - Status string:校区状态(active/disabled;disabled 校区禁止发起资金操作)。
+type CampusInfo struct {
+	CampusID    int64
+	SchoolID    int64
+	SelfSupport bool
+	Status      string
+}
+
+// InstitutionID 机构 ID 的规范命名访问器(SchoolID 为旧口径字段名,语义等同 institution_id)。
+//
+// 出参:int64——机构 ID(与 t_campus.institution_id 同值)。
+func (c *CampusInfo) InstitutionID() int64 {
+	if c == nil {
+		return 0
+	}
+	return c.SchoolID
+}
+
+// IsActive 校区是否可用(status=active)。
+func (c *CampusInfo) IsActive() bool {
+	return c != nil && c.Status == "active"
+}
+
+// CampusReader 校区只读读取器(academic 定义;实现见 OrgCampusReader,可 mock 单测)。
+//
+// 契约:
+//   - 校区不存在 → `*infraerrors.AppError{Code: 3104}`(apperr.ErrCampusNotFound),不返回 gorm 原错;
+//   - 只读,不写 org 表;
+//   - 同一事务内只读的强一致场景(如退费事务里判自营),用可选的 TxCampusReader 扩展接口。
+type CampusReader interface {
+	// GetByID 按校区 ID 取校区只读视图。
+	//
+	// 入参:ctx 请求上下文;campusID 校区 ID(<=0 视为业务参数错 3140)。
+	// 出参:*CampusInfo(命中);error——apperr.ErrCampusNotFound(3104) / infraerrors.ErrDBError(5002)。
+	GetByID(ctx context.Context, campusID int64) (*CampusInfo, error)
+
+	// GetSchoolID 按校区 ID 取所属机构 ID(旧 SP 的 `SchoolCampusHelper::getInfo($campusid)->school_id`)。
+	//
+	// 入参:ctx 请求上下文;campusID 校区 ID。
+	// 出参:int64 机构 ID;error——apperr.ErrCampusNotFound(3104) / infraerrors.ErrDBError(5002)。
+	GetSchoolID(ctx context.Context, campusID int64) (int64, error)
+
+	// IsSelfSupport 校区是否自营(决定活动失效范围的判据,设计 §1.6 / §11-2)。
+	//
+	// 入参:ctx 请求上下文;campusID 校区 ID。
+	// 出参:bool 自营=true;error——apperr.ErrCampusNotFound(3104) / infraerrors.ErrDBError(5002)。
+	IsSelfSupport(ctx context.Context, campusID int64) (bool, error)
+}
+
+// TxCampusReader 同事务只读扩展接口(可选实现)。
+//
+// 使用场景:资金事务内需要"与写操作同一快照"的校区判定(旧 SP 是同库 JOIN 语义),
+// 传 tx 保证读到的校区行与事务可见性一致;不传 tx 的 GetByID 走连接池默认隔离级别。
+type TxCampusReader interface {
+	// GetByIDTx 在给定事务句柄上读取校区(只读,不写)。
+	//
+	// 入参:ctx 请求上下文;tx 事务句柄(可为 nil → 退化到连接池);campusID 校区 ID。
+	// 出参:*CampusInfo;error 语义同 CampusReader.GetByID。
+	GetByIDTx(ctx context.Context, tx *gorm.DB, campusID int64) (*CampusInfo, error)
+}

+ 39 - 0
internal/academic/port/membership.go

@@ -0,0 +1,39 @@
+// 成员关系校验接口(academic 定义,org 侧实现/注入):越权预检的第二道防线。
+//
+// 依据:unified/03-migration/academic-domain-design.md §1.7「全局无作用域表」——
+// `t_user_expense`(全局钱包)/ `t_user_divide_into*`(全局推广体系)不带 institution_id,
+// 无法靠 BaseRepo.Scoping 过滤,必须由 Service 层显式断言"该用户确实属于本机构/校区"。
+//
+// 三层防线分工(架构规格 §3.5):
+//  1. 路由角色白名单(handler/route.go,AC-14)——粗粒度;
+//  2. **本接口(MembershipChecker)**——Service 层的对象级越权预检(本次);
+//  3. Repository 的 BaseRepo.Scoped——数据层强制过滤(academic/repository/base.go)。
+package port
+
+import "context"
+
+// MembershipChecker 成员关系校验器(读 t_membership 只读视图)。
+//
+// 契约:
+//   - 只读,不写 org 表;
+//   - 角色码取 org/model.Role*(super_admin/academic/finance/teacher/student/parent/market);
+//   - `campusID=0` 语义 = 只看机构级(不限定校区)。
+type MembershipChecker interface {
+	// HasMembership 判断用户在指定机构/校区是否具备任一给定角色。
+	//
+	// 入参:
+	//   - ctx 请求上下文;
+	//   - userID 用户 canonical_user_id(ULID 26 位;空串 → false);
+	//   - institutionID 机构 ID(0 = 不限机构,仅平台级判断);
+	//   - campusID 校区 ID(0 = 机构级任一校区);
+	//   - roles 期望角色码集合(空集合 = 只要有任一角色即算通过)。
+	//
+	// 出参:bool 是否命中;error 仅数据库异常(infraerrors.ErrDBError)。
+	HasMembership(ctx context.Context, userID string, institutionID, campusID int64, roles ...string) (bool, error)
+
+	// AssertInScope 断言用户在作用域内(越权即返回业务错误,供 Service 直接上抛)。
+	//
+	// 入参:ctx / userID / institutionID / campusID(语义同 HasMembership,但不限定角色)。
+	// 出参:nil 在范围内;infraerrors.ErrUnscoped(1004) 越权;infraerrors.ErrDBError(5002) DB 异常。
+	AssertInScope(ctx context.Context, userID string, institutionID, campusID int64) error
+}

+ 252 - 0
internal/academic/port/orgadapter.go

@@ -0,0 +1,252 @@
+// org 域适配器(academic → org 单向只读转发,设计 §1.3 依赖方向铁律 / §1.6 跨库收敛)。
+//
+// 职责边界:
+//   - 本文件是 academic 域内**唯一**允许"知道 org 表结构"的代码,且**只读**(SELECT),不写 org 表;
+//   - 不 import `internal/org/repository`(避免跨域业务判断),只按 `t_campus` / `t_membership`
+//     的列名做最小字段映射;org 侧列变更只需同步本文件的 row struct;
+//   - 不跨库:旧 `yuansiwei.t_school_campus` 已收敛为同库 `t_campus`(设计 §1.6),
+//     割接期禁止出现 `yuansiwei.` 前缀 SQL。
+//
+// 装配方式(AC-14 接线时):
+//
+//	campusReader := port.NewOrgCampusReader(gdb)                 // 或事务内 tx 版本
+//	memberChecker := port.NewOrgMembershipChecker(gdb)
+//	fundSvc := service.NewFundService(repo, campusReader, memberChecker)
+package port
+
+import (
+	"context"
+	stderrors "errors"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// campusRow t_campus 的最小只读映射(仅 academic 需要的 4 列)。
+//
+// 列语义(对齐 core-model-ddl.sql 第二部分 t_campus + AC-01 增量列 self_support):
+//   - campus_id:校区主键(D2 int64)。
+//   - institution_id:所属机构 ID(旧口径 schoolid;作用域列取值源)。
+//   - self_support:是否自营(1 自营 / 0 加盟;2026-11-09 拍板 A 新增,来源 t_school_campus.self_support)。
+//   - status:active/disabled。
+//
+// 说明:本 struct **不加入** model.AllTables()(不参与 AutoMigrate,仅查询映射)。
+type campusRow struct {
+	CampusID      int64  `gorm:"column:campus_id;primaryKey"`
+	InstitutionID int64  `gorm:"column:institution_id"`
+	SelfSupport   int8   `gorm:"column:self_support"`
+	Status        string `gorm:"column:status"`
+}
+
+// TableName 锁定表名(与 org 域 model.Campus 同表;本 struct 只读)。
+func (campusRow) TableName() string { return "t_campus" }
+
+// membershipRow t_membership 的最小只读映射(判断"人是否在作用域内 + 角色")。
+//
+// 列语义(对齐 core-model-ddl.sql t_membership):
+//   - canonical_user_id:用户 ULID;institution_id/campus_id:作用域;role:角色码;status:active/inactive。
+type membershipRow struct {
+	MembershipID    int64  `gorm:"column:membership_id;primaryKey"`
+	CanonicalUserID string `gorm:"column:canonical_user_id"`
+	InstitutionID   int64  `gorm:"column:institution_id"`
+	CampusID        int64  `gorm:"column:campus_id"`
+	Role            string `gorm:"column:role"`
+	Status          string `gorm:"column:status"`
+}
+
+// TableName 锁定表名(与 org 域 model.Membership 同表;本 struct 只读)。
+func (membershipRow) TableName() string { return "t_membership" }
+
+// membershipStatusActive t_membership.status 的"在籍"取值(其余值视为已离开,不参与授权)。
+const membershipStatusActive = "active"
+
+// OrgCampusReader org 域校区只读读取器(实现 CampusReader + TxCampusReader)。
+//
+// 字段:
+//   - DB *gorm.DB:连接池句柄;事务内读取请用 GetByIDTx 传 tx(同一快照,避免读到旧值)。
+type OrgCampusReader struct {
+	DB *gorm.DB
+}
+
+// NewOrgCampusReader 构造校区读取器。
+//
+// 入参:db *gorm.DB——连接池句柄(必填)。出参:*OrgCampusReader(同时满足 CampusReader/TxCampusReader)。
+func NewOrgCampusReader(db *gorm.DB) *OrgCampusReader { return &OrgCampusReader{DB: db} }
+
+// 编译期接口合规断言(装配层可把实现直接赋给接口变量)。
+var (
+	_ CampusReader   = (*OrgCampusReader)(nil)
+	_ TxCampusReader = (*OrgCampusReader)(nil)
+)
+
+// GetByID 走连接池读取校区(非事务场景,如列表页展示自营标识)。
+//
+// 入参:ctx 请求上下文;campusID>0。出参:*CampusInfo;error——3140 参数错 / 3104 不存在 / 5002 DB 异常。
+func (r *OrgCampusReader) GetByID(ctx context.Context, campusID int64) (*CampusInfo, error) {
+	return r.GetByIDTx(ctx, nil, campusID)
+}
+
+// GetByIDTx 在给定事务句柄上读取校区(tx 为 nil 时退化到连接池)。
+//
+// 入参:ctx / tx(可为 nil)/ campusID。出参:*CampusInfo;error 语义同 GetByID。
+//
+// 实现:`SELECT campus_id, institution_id, self_support, status FROM t_campus WHERE campus_id = ? LIMIT 1`。
+func (r *OrgCampusReader) GetByIDTx(ctx context.Context, tx *gorm.DB, campusID int64) (*CampusInfo, error) {
+	if campusID <= 0 {
+		return nil, apperr.ErrBusinessParam // 3140:campusId<=0 属业务参数错(设计 §1.6 末尾)
+	}
+	db, err := r.pickDB(ctx, tx)
+	if err != nil {
+		return nil, err
+	}
+	var row campusRow
+	q := db.Model(&campusRow{}).Where("campus_id = ?", campusID).Limit(1)
+	if err := q.Take(&row).Error; err != nil {
+		if stderrors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, apperr.ErrCampusNotFound // 3104
+		}
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return &CampusInfo{
+		CampusID:    row.CampusID,
+		SchoolID:    row.InstitutionID,
+		SelfSupport: row.SelfSupport == 1, // 1 自营 / 0 加盟(DDL 默认 1)
+		Status:      row.Status,
+	}, nil
+}
+
+// GetSchoolID 读取校区所属机构 ID(旧 SP 的隐式取数点统一收口)。
+//
+// 入参:ctx / campusID。出参:int64 机构 ID;error——3140 / 3104 / 5002。
+func (r *OrgCampusReader) GetSchoolID(ctx context.Context, campusID int64) (int64, error) {
+	info, err := r.GetByID(ctx, campusID)
+	if err != nil {
+		return 0, err
+	}
+	if info.SchoolID <= 0 {
+		// 校区存在但未挂机构(旧数据异常):与"校区不存在"同码,避免资金操作落到错误机构
+		return 0, apperr.ErrCampusNotFound
+	}
+	return info.SchoolID, nil
+}
+
+// IsSelfSupport 判断校区是否自营(决定活动失效范围:`campus_id IN (x,0)` vs `= x`)。
+//
+// 入参:ctx / campusID。出参:bool 自营=true;error——3140 / 3104 / 5002。
+func (r *OrgCampusReader) IsSelfSupport(ctx context.Context, campusID int64) (bool, error) {
+	info, err := r.GetByID(ctx, campusID)
+	if err != nil {
+		return false, err
+	}
+	return info.SelfSupport, nil
+}
+
+// pickDB 选择查询句柄(tx 优先;两者皆空视为装配错误)。
+func (r *OrgCampusReader) pickDB(ctx context.Context, tx *gorm.DB) (*gorm.DB, error) {
+	if tx != nil {
+		if ctx != nil {
+			return tx.WithContext(ctx), nil
+		}
+		return tx, nil
+	}
+	if r == nil || r.DB == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errNoDB)
+	}
+	if ctx != nil {
+		return r.DB.WithContext(ctx), nil
+	}
+	return r.DB, nil
+}
+
+// OrgMembershipChecker org 域成员关系校验器(实现 MembershipChecker,只读 t_membership)。
+//
+// 用途:全局无作用域表(t_user_expense / t_user_divide_into*)的 Service 层越权预检(设计 §1.7)。
+type OrgMembershipChecker struct {
+	DB *gorm.DB
+}
+
+// NewOrgMembershipChecker 构造成员校验器。
+//
+// 入参:db *gorm.DB——连接池句柄(必填)。出参:*OrgMembershipChecker。
+func NewOrgMembershipChecker(db *gorm.DB) *OrgMembershipChecker {
+	return &OrgMembershipChecker{DB: db}
+}
+
+// 编译期接口合规断言。
+var _ MembershipChecker = (*OrgMembershipChecker)(nil)
+
+// HasMembership 判断用户在指定机构/校区是否具备任一给定角色。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - userID 用户 ULID(空串直接 false,不打库);
+//   - institutionID 机构 ID(>0 时强制过滤;0=不限);
+//   - campusID 校区 ID(>0 时强制过滤;0=机构级任一校区);
+//   - roles 期望角色(空=任一角色)。
+//
+// 出参:bool 是否命中;error——5002 DB 异常。
+//
+// 说明:仅统计 status='active' 的成员关系(离职/退学记录不授权)。
+func (c *OrgMembershipChecker) HasMembership(
+	ctx context.Context, userID string, institutionID, campusID int64, roles ...string,
+) (bool, error) {
+	if userID == "" {
+		return false, nil
+	}
+	if c == nil || c.DB == nil {
+		return false, infraerrors.WithCause(infraerrors.ErrInternal, errNoDB)
+	}
+	db := c.DB
+	if ctx != nil {
+		db = db.WithContext(ctx)
+	}
+	q := db.Model(&membershipRow{}).
+		Where("canonical_user_id = ?", userID).
+		Where("status = ?", membershipStatusActive)
+	if institutionID > 0 {
+		q = q.Where("institution_id = ?", institutionID)
+	}
+	if campusID > 0 {
+		q = q.Where("campus_id = ?", campusID)
+	}
+	if len(roles) > 0 {
+		q = q.Where("role IN ?", roles)
+	}
+	var count int64
+	if err := q.Count(&count).Error; err != nil { // GORM 纪律:Count 收 *int64
+		return false, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return count > 0, nil
+}
+
+// AssertInScope 断言用户在作用域内(不限定角色;越权 → 1004)。
+//
+// 入参:ctx / userID / institutionID / campusID。
+// 出参:nil 在范围内;infraerrors.ErrUnscoped(1004) 越权;5002 DB 异常。
+//
+// 语义细节:institutionID<=0 时视为"未提供作用域",直接 1004(资金操作必须带机构上下文);
+// 该判定与设计 §1.6「campusid<=0 → 3140」互补:3140 用于参数合法性,1004 用于越权。
+func (c *OrgMembershipChecker) AssertInScope(ctx context.Context, userID string, institutionID, campusID int64) error {
+	if institutionID <= 0 {
+		return infraerrors.ErrUnscoped
+	}
+	ok, err := c.HasMembership(ctx, userID, institutionID, campusID)
+	if err != nil {
+		return err
+	}
+	if !ok {
+		return infraerrors.ErrUnscoped // 1004
+	}
+	return nil
+}
+
+// errNoDB 装配期错误的哨兵文案(DB 句柄缺失 = 装配层 bug)。
+var errNoDB = errString("port: 数据库句柄未注入(装配层错误)")
+
+// errString 实现 error 的最小字符串类型(避免为哨兵引入额外依赖)。
+type errString string
+
+// Error 实现 error 接口。
+func (e errString) Error() string { return string(e) }

+ 235 - 0
internal/academic/port/port_test.go

@@ -0,0 +1,235 @@
+// academic port 接口实现单测(org 只读适配器 + mock 可替换性)。
+//
+// 测试栈:glebarez/sqlite 临时文件库,手工建 t_campus / t_membership 最小表结构
+// (不依赖 org/model 的 AutoMigrate,验证适配器只看列名、不耦合 org 的 struct)。
+package port
+
+import (
+	"context"
+	stderrors "errors"
+	"path/filepath"
+	"testing"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// newCampusDB 建临时 sqlite 库并写入校区/成员样例数据。
+func newCampusDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_port_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open 失败: %v", err)
+	}
+	// Windows 下连接未关闭会导致 t.TempDir 清理失败(unlinkat 占用),故注册清理钩子
+	t.Cleanup(func() {
+		if sqlDB, e := db.DB(); e == nil {
+			_ = sqlDB.Close()
+		}
+	})
+	stmts := []string{
+		`CREATE TABLE t_campus (
+			campus_id INTEGER PRIMARY KEY,
+			institution_id INTEGER NOT NULL,
+			self_support TINYINT NOT NULL DEFAULT 1,
+			status TEXT NOT NULL
+		)`,
+		`CREATE TABLE t_membership (
+			membership_id INTEGER PRIMARY KEY,
+			canonical_user_id VARCHAR(26) NOT NULL,
+			institution_id INTEGER NOT NULL,
+			campus_id INTEGER NOT NULL,
+			role VARCHAR(32) NOT NULL,
+			status VARCHAR(16) NOT NULL
+		)`,
+		`INSERT INTO t_campus (campus_id, institution_id, self_support, status) VALUES
+			(7, 42, 1, 'active'),
+			(8, 42, 0, 'active'),
+			(9, 43, 1, 'disabled'),
+			(10, 0, 1, 'active')`,
+		`INSERT INTO t_membership (membership_id, canonical_user_id, institution_id, campus_id, role, status) VALUES
+			(1, '01HZZZZZZZZZZZZZZZZZZZZZZ1', 42, 7, 'finance', 'active'),
+			(2, '01HZZZZZZZZZZZZZZZZZZZZZZ2', 42, 7, 'teacher', 'inactive'),
+			(3, '01HZZZZZZZZZZZZZZZZZZZZZZ3', 43, 9, 'academic', 'active')`,
+	}
+	for _, s := range stmts {
+		if err := db.Exec(s).Error; err != nil {
+			t.Fatalf("初始化样例数据失败: %v\nSQL: %s", err, s)
+		}
+	}
+	return db
+}
+
+// TestCampusReaderGetByID 校区读取(自营/加盟/停用/不存在/参数错)。
+func TestCampusReaderGetByID(t *testing.T) {
+	db := newCampusDB(t)
+	r := NewOrgCampusReader(db)
+	ctx := context.Background()
+
+	self, err := r.GetByID(ctx, 7)
+	if err != nil {
+		t.Fatalf("GetByID(7) 失败: %v", err)
+	}
+	if self.CampusID != 7 || self.SchoolID != 42 || !self.SelfSupport || !self.IsActive() {
+		t.Fatalf("自营校区字段不符: %+v", self)
+	}
+	if self.InstitutionID() != 42 {
+		t.Fatalf("InstitutionID() 应等于 SchoolID,实得 %d", self.InstitutionID())
+	}
+
+	franchise, err := r.GetByID(ctx, 8)
+	if err != nil {
+		t.Fatalf("GetByID(8) 失败: %v", err)
+	}
+	if franchise.SelfSupport {
+		t.Fatal("campus 8 应判定为加盟(self_support=0)")
+	}
+
+	disabled, err := r.GetByID(ctx, 9)
+	if err != nil {
+		t.Fatalf("GetByID(9) 失败: %v", err)
+	}
+	if disabled.IsActive() {
+		t.Fatal("campus 9 status=disabled 应判定为不可用")
+	}
+
+	if _, err := r.GetByID(ctx, 999); !stderrors.Is(err, apperr.ErrCampusNotFound) {
+		t.Fatalf("不存在校区应返回 3104,实得 %v", err)
+	}
+	if _, err := r.GetByID(ctx, 0); !stderrors.Is(err, apperr.ErrBusinessParam) {
+		t.Fatalf("campusID<=0 应返回 3140,实得 %v", err)
+	}
+}
+
+// TestCampusReaderSchoolID 机构 ID 解析(含"校区存在但未挂机构"的异常分支)。
+func TestCampusReaderSchoolID(t *testing.T) {
+	db := newCampusDB(t)
+	r := NewOrgCampusReader(db)
+	ctx := context.Background()
+
+	id, err := r.GetSchoolID(ctx, 7)
+	if err != nil || id != 42 {
+		t.Fatalf("GetSchoolID(7) = (%d,%v),期望 (42,nil)", id, err)
+	}
+	if _, err := r.GetSchoolID(ctx, 10); !stderrors.Is(err, apperr.ErrCampusNotFound) {
+		t.Fatalf("校区 institution_id=0 应返回 3104,实得 %v", err)
+	}
+}
+
+// TestCampusReaderIsSelfSupport 自营判定(活动失效范围的关键输入)。
+func TestCampusReaderIsSelfSupport(t *testing.T) {
+	db := newCampusDB(t)
+	r := NewOrgCampusReader(db)
+	ctx := context.Background()
+
+	if ok, err := r.IsSelfSupport(ctx, 7); err != nil || !ok {
+		t.Fatalf("IsSelfSupport(7) = (%v,%v),期望 (true,nil)", ok, err)
+	}
+	if ok, err := r.IsSelfSupport(ctx, 8); err != nil || ok {
+		t.Fatalf("IsSelfSupport(8) = (%v,%v),期望 (false,nil)", ok, err)
+	}
+}
+
+// TestCampusReaderGetByIDTx 同事务只读(TxCampusReader 扩展接口)。
+func TestCampusReaderGetByIDTx(t *testing.T) {
+	db := newCampusDB(t)
+	r := NewOrgCampusReader(db)
+	ctx := context.Background()
+
+	err := db.Transaction(func(tx *gorm.DB) error {
+		info, err := r.GetByIDTx(ctx, tx, 8)
+		if err != nil {
+			return err
+		}
+		if info.CampusID != 8 {
+			t.Fatalf("事务内读取 campus_id = %d,期望 8", info.CampusID)
+		}
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("事务内读取失败: %v", err)
+	}
+
+	// 无 DB / tx 均为空 → 装配错误(5001 兜底)
+	empty := &OrgCampusReader{}
+	if _, err := empty.GetByIDTx(ctx, nil, 7); !stderrors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("DB 未注入应返回 ErrInternal,实得 %v", err)
+	}
+}
+
+// TestMembershipChecker 成员关系校验(角色过滤 / inactive 不计 / 作用域断言 1004)。
+func TestMembershipChecker(t *testing.T) {
+	db := newCampusDB(t)
+	c := NewOrgMembershipChecker(db)
+	ctx := context.Background()
+
+	ok, err := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 42, 7, "finance")
+	if err != nil || !ok {
+		t.Fatalf("finance 成员应命中,实得 (%v,%v)", ok, err)
+	}
+	if ok, _ := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 42, 7, "academic"); ok {
+		t.Fatal("角色不匹配不应命中")
+	}
+	if ok, _ := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 43, 0, "finance"); ok {
+		t.Fatal("跨机构不应命中")
+	}
+	// status=inactive 不授权
+	if ok, _ := c.HasMembership(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ2", 42, 7); ok {
+		t.Fatal("inactive 成员关系不应授权")
+	}
+	// 空 userID 直接 false(不打库)
+	if ok, _ := c.HasMembership(ctx, "", 42, 7); ok {
+		t.Fatal("空 userID 应返回 false")
+	}
+
+	if err := c.AssertInScope(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 42, 7); err != nil {
+		t.Fatalf("作用域内应通过,实得 %v", err)
+	}
+	if err := c.AssertInScope(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 43, 0); !stderrors.Is(err, infraerrors.ErrUnscoped) {
+		t.Fatalf("越权应返回 1004,实得 %v", err)
+	}
+	if err := c.AssertInScope(ctx, "01HZZZZZZZZZZZZZZZZZZZZZZ1", 0, 0); !stderrors.Is(err, infraerrors.ErrUnscoped) {
+		t.Fatalf("缺少机构上下文应返回 1004,实得 %v", err)
+	}
+}
+
+// fakeCampusReader 接口可替换性验证(Service 单测用 mock,不连库)。
+type fakeCampusReader struct {
+	info *CampusInfo
+	err  error
+}
+
+// GetByID 返回预置结果。
+func (f *fakeCampusReader) GetByID(context.Context, int64) (*CampusInfo, error) { return f.info, f.err }
+
+// GetSchoolID 返回预置结果的机构 ID。
+func (f *fakeCampusReader) GetSchoolID(context.Context, int64) (int64, error) {
+	if f.info == nil {
+		return 0, f.err
+	}
+	return f.info.SchoolID, f.err
+}
+
+// IsSelfSupport 返回预置结果的自营标记。
+func (f *fakeCampusReader) IsSelfSupport(context.Context, int64) (bool, error) {
+	if f.info == nil {
+		return false, f.err
+	}
+	return f.info.SelfSupport, f.err
+}
+
+// TestCampusReaderMockable 接口在 mock 下可测(AC-02 验收④)。
+func TestCampusReaderMockable(t *testing.T) {
+	var reader CampusReader = &fakeCampusReader{info: &CampusInfo{CampusID: 1, SchoolID: 2, SelfSupport: true, Status: "active"}}
+	got, err := reader.GetByID(context.Background(), 1)
+	if err != nil || got.SchoolID != 2 {
+		t.Fatalf("mock 读取失败: (%+v,%v)", got, err)
+	}
+	if ok, _ := reader.IsSelfSupport(context.Background(), 1); !ok {
+		t.Fatal("mock 自营标记应为 true")
+	}
+}

+ 333 - 0
internal/academic/repository/base.go

@@ -0,0 +1,333 @@
+// Package repository academic 域唯一数据访问层(多租户表继承 BaseRepo 强制 scoping)。
+//
+// 依据:unified/03-migration/academic-domain-design.md §1.4(事务模型)/ §1.7(scoping 与两条例外)
+// / §10.5(BaseRepo 继承方式)。
+//
+// 分层纪律(依赖方向单向:repository → model + infra/scope + infra/errors):
+//   - **repository 不开事务**:一律接收 `tx *gorm.DB` 参数(事务边界只在 service 层,设计 §10.3);
+//   - **repository 不返回 gorm 原错**:统一转 infra/errors 的 sentinel(ErrNotFound/ErrDBError);
+//   - 多租户表(带 institution_id/campus_id)必须走 `Scoped(db)`(禁止裸 r.DB.Where);
+//   - 无作用域表(t_user_expense 全局钱包 / t_user_divide_into* 全局推广体系)**不继承 BaseRepo**,
+//     作用域断言在 Service 层经 port.MembershipChecker 完成(设计 §1.7 两条例外)。
+//
+// 本文件只提供"共享内核"能力:BaseRepo(scoping)、TxRunner(事务透传)、分页钳制、唯一键撞车识别。
+package repository
+
+import (
+	"context"
+	stderrors "errors"
+	"strings"
+
+	"gorm.io/gorm"
+
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// BaseRepo 多租户仓储基类:持有 DB 句柄 + 本次请求作用域,提供强制 scoping 入口。
+//
+// 字段:
+//   - DB *gorm.DB:数据库句柄(连接池由 infra/db 统一建立)。
+//   - Sc *appscope.Scope:请求作用域(Auth 中间件解析 token 后注入;nil → Scoped panic fail-fast)。
+//   - CampusScoped bool:该仓储查询的表是否含 campus_id 列(机构级资源置 false,避免 Unknown column)。
+//
+// 使用方式(设计 §10.5):
+//
+//	type ExpenseSchoolRepo struct{ *repository.BaseRepo }
+//	func NewExpenseSchoolRepo(db *gorm.DB, sc *appscope.Scope) *ExpenseSchoolRepo {
+//	    r := &ExpenseSchoolRepo{BaseRepo: NewBaseRepo(db, sc)}
+//	    r.CampusScoped = true // 表带 campus_id
+//	    return r
+//	}
+//	// 查询:r.Scoped(r.WithContext(ctx)).Where("user_id = ?", uid)
+//	// 事务内:r.Scoped(tx).Where(...)
+type BaseRepo struct {
+	DB           *gorm.DB
+	Sc           *appscope.Scope
+	CampusScoped bool
+}
+
+// NewBaseRepo 构造 academic 仓储基类(默认按"表带 campus_id"处理,机构级资源由子类置 false)。
+//
+// 入参:
+//   - db *gorm.DB:数据库句柄(必填;nil 时任何查询都会 panic,属配置错误)。
+//   - sc *appscope.Scope:请求作用域;系统任务/割接脚本可传 nil,但一旦查询即 panic(fail-fast)。
+//
+// 出参:*BaseRepo——指针类型(便于子类内嵌后修改 CampusScoped)。
+func NewBaseRepo(db *gorm.DB, sc *appscope.Scope) *BaseRepo {
+	return &BaseRepo{DB: db, Sc: sc, CampusScoped: true}
+}
+
+// WithContext 绑定请求上下文(仅处理 ctx 传递,**不**注入作用域——作用域永远显式走 Scoped)。
+//
+// 入参:ctx context.Context——标准请求上下文(取消/超时传播)。
+// 出参:*gorm.DB——已 WithContext 的句柄(调用方必须再经 Scoped 注入租户过滤)。
+func (r *BaseRepo) WithContext(ctx context.Context) *gorm.DB {
+	if r == nil || r.DB == nil {
+		return nil
+	}
+	if ctx == nil {
+		return r.DB
+	}
+	return r.DB.WithContext(ctx)
+}
+
+// Scoped 把作用域过滤条件注入查询(academic 域多租户防线唯一收口点)。
+//
+// 入参:
+//   - db *gorm.DB:基础查询(通常 `r.WithContext(ctx)` 或事务内 `tx`);nil 时返回 nil。
+//
+// 出参:
+//   - *gorm.DB:追加 `institution_id` / `campus_id` 过滤后的查询(链式安全,不改原 db)。
+//
+// 过滤规则(对齐 infra/scope.BaseRepo.Scoping 语义):
+//  1. Sc 为 nil → **panic**(无作用域上下文进入数据访问层 = 配置错误;HTTP 层 Recover 兜底 5001);
+//  2. Sc.SuperAdmin=true → 不加机构/校区过滤(平台超管跨机构视角);
+//  3. 普通用户 → 强制 `institution_id = Sc.InstitutionID`;
+//     仅当 `CampusScoped=true 且 Sc.CampusID>0` 时再强制 `campus_id = Sc.CampusID`。
+//
+// 安全红线:本方法**不允许**被绕过;资金/活动/课程表的任何读写都必须经它(代码评审逐处检查)。
+func (r *BaseRepo) Scoped(db *gorm.DB) *gorm.DB {
+	if db == nil {
+		return nil
+	}
+	if r == nil || r.Sc == nil {
+		// 纵深防御:无作用域上下文禁止进入数据访问层(与 infra/scope 同口径 fail-fast)
+		panic(errNoScope.Error())
+	}
+	if r.Sc.SuperAdmin {
+		return db // 超管跨机构,不加作用域
+	}
+	db = db.Where("institution_id = ?", r.Sc.InstitutionID)
+	if r.CampusScoped && r.Sc.CampusID > 0 {
+		db = db.Where("campus_id = ?", r.Sc.CampusID)
+	}
+	return db
+}
+
+// GetScopedID 便捷读取当前作用域机构/校区(供 Service 组装写入数据的冗余作用域列)。
+//
+// 出参:(institutionID, campusID int64);Sc 为 nil 时返回 (0, 0)(调用方须自行拒绝该情况)。
+func (r *BaseRepo) GetScopedID() (institutionID, campusID int64) {
+	if r == nil || r.Sc == nil {
+		return 0, 0
+	}
+	return r.Sc.InstitutionID, r.Sc.CampusID
+}
+
+// ErrNoScope 无作用域上下文的哨兵错误(Scoped panic 文案;导出供单测断言)。
+var ErrNoScope = errString("repository: missing tenant scope, refusing unsoped query")
+
+// errNoScope 包内别名(与 ErrNoScope 同值,避免调用点改名扩散)。
+var errNoScope = ErrNoScope
+
+// errString 实现 error 的最小字符串类型(不为哨兵错误引入额外依赖)。
+type errString string
+
+// Error 实现 error 接口。
+func (e errString) Error() string { return string(e) }
+
+// TxRunner 事务运行器(academic 域唯一事务入口形状,设计 §10.3)。
+//
+// 语义(关键):
+//   - 顶层调用:`DB.Transaction(fn)` 开启事务;
+//   - **已在事务中**(三种透传入口任命中其一):**直接透传,不重复 BEGIN**
+//     ① runner 本身绑定的 DB 已是 tx(`WithTx(tx)` 构造);
+//     ② ctx 中已携带 tx(`WithTxContext(ctx, tx)` 注入,外层事务内继续调用下游 service 时用);
+//     ③ fn 内部再次调用 WithinTx 且显式传了 tx 绑定的 runner;
+//     ——避免 savepoint 语义混乱与"内层提交早于外层"的隐性 bug;
+//   - fn 返回 *infraerrors.AppError 原样上抛(业务错误不包装),其余错误统一转 ErrDBError(5002)。
+//
+// 禁止(设计 §10.3):Service 内嵌套 `db.Transaction`、Repository 自己开事务、事务内调外部 HTTP/Redis。
+//
+// 典型用法:
+//
+//	// 顶层
+//	runner.WithinTx(ctx, func(tx *gorm.DB) error { ... })
+//	// 事务内继续调用会"再开事务"的下游 service:把 tx 注入 ctx,下游自动透传
+//	runner.WithinTx(ctx, func(tx *gorm.DB) error {
+//	    return recommendSvc.Settle(WithTxContext(ctx, tx), in) // 内部 WithinTx 命中透传
+//	})
+type TxRunner struct {
+	DB *gorm.DB
+}
+
+// NewTxRunner 构造事务运行器。
+//
+// 入参:db *gorm.DB——连接池句柄(必填)。出参:*TxRunner(DB 为 nil 时 WithinTx 返回 ErrInternal)。
+func NewTxRunner(db *gorm.DB) *TxRunner { return &TxRunner{DB: db} }
+
+// WithTx 返回绑定到指定事务句柄的 runner(事务内调用下游方法时用,保证透传同一 tx)。
+//
+// 入参:tx *gorm.DB——事务句柄。出参:*TxRunner(DB = tx;后续 WithinTx 直接透传,不再 BEGIN)。
+func (t *TxRunner) WithTx(tx *gorm.DB) *TxRunner { return &TxRunner{DB: tx} }
+
+// txCtxKey ctx 中承载事务句柄的私有键类型(避免与其他包的字符串 key 冲突)。
+type txCtxKey struct{}
+
+// WithTxContext 把事务句柄注入 context(事务内跨函数传递 tx 的标准通道)。
+//
+// 入参:ctx context.Context(nil 时退化为 Background);tx 事务句柄(nil 时原样返回 ctx)。
+// 出参:携带 tx 的 context.Context。
+func WithTxContext(ctx context.Context, tx *gorm.DB) context.Context {
+	if tx == nil {
+		if ctx == nil {
+			return context.Background()
+		}
+		return ctx
+	}
+	if ctx == nil {
+		ctx = context.Background()
+	}
+	return context.WithValue(ctx, txCtxKey{}, tx)
+}
+
+// TxFromContext 从 context 取出事务句柄(未注入时返回 nil)。
+//
+// 入参:ctx context.Context(可 nil)。出参:*gorm.DB 或 nil。
+func TxFromContext(ctx context.Context) *gorm.DB {
+	if ctx == nil {
+		return nil
+	}
+	if tx, ok := ctx.Value(txCtxKey{}).(*gorm.DB); ok {
+		return tx
+	}
+	return nil
+}
+
+// WithinTx 在事务内执行 fn(已在事务中则透传)。
+//
+// 入参:
+//   - ctx context.Context:请求上下文;若已含 tx(WithTxContext 注入)则优先透传该 tx。
+//   - fn func(tx *gorm.DB) error:事务内逻辑;**必须使用传入的 tx**(用 r.DB 会跑到事务外)。
+//
+// 出参:
+//   - error:fn 的业务错误原样返回(*infraerrors.AppError);
+//     非业务错误 → infraerrors.ErrDBError 包装(保留 Cause 供日志);事务已回滚。
+func (t *TxRunner) WithinTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
+	if t == nil || t.DB == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: TxRunner.DB 为 nil"))
+	}
+	if fn == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: WithinTx fn 为 nil"))
+	}
+
+	// ① runner 已绑定事务句柄 → 透传
+	if InTx(t.DB) {
+		return fn(bindCtx(t.DB, ctx))
+	}
+	// ② ctx 携带事务句柄 → 透传(外层事务内调用下游 service 的主通道)
+	if ctxTx := TxFromContext(ctx); ctxTx != nil && InTx(ctxTx) {
+		return fn(bindCtx(ctxTx, ctx))
+	}
+	// ③ 顶层:开启事务
+	db := t.DB
+	if ctx != nil {
+		db = db.WithContext(ctx)
+	}
+	err := db.Transaction(func(tx *gorm.DB) error {
+		// 把 tx 注入 ctx,使 fn 内部(含下游 service)的 WithinTx 调用自动透传
+		return fn(tx.WithContext(WithTxContext(ctx, tx)))
+	})
+	return wrapTxErr(err)
+}
+
+// bindCtx 在透传路径上绑定请求上下文并回注 tx(不修改连接池/事务归属)。
+func bindCtx(tx *gorm.DB, ctx context.Context) *gorm.DB {
+	if ctx == nil {
+		return tx
+	}
+	return tx.WithContext(WithTxContext(ctx, tx))
+}
+
+// InTx 判断给定 GORM 句柄是否已处于事务中(tx 透传判定)。
+//
+// 判定依据:GORM 在事务内会把 `Statement.ConnPool` 替换为 `*sql.Tx`(实现 Commit/Rollback)。
+// 用接口探测而非具体类型断言,兼容不同驱动/包装(MySQL 驱动、glebarez sqlite 均为 *sql.Tx)。
+//
+// 入参:db *gorm.DB(可 nil)。出参:true=已在事务中。
+func InTx(db *gorm.DB) bool {
+	if db == nil || db.Statement == nil || db.Statement.ConnPool == nil {
+		return false
+	}
+	if _, ok := db.Statement.ConnPool.(txCommitter); ok {
+		return true
+	}
+	return false
+}
+
+// txCommitter 事务连接的公共方法集(*sql.Tx 满足;普通 *sql.DB 不满足)。
+type txCommitter interface {
+	Commit() error
+	Rollback() error
+}
+
+// wrapTxErr 把事务执行错误归一为 academic 错误契约。
+//
+// 规则:*infraerrors.AppError(业务错误)原样返回;其余(SQL/驱动/回滚失败)转 ErrDBError 并挂 Cause。
+func wrapTxErr(err error) error {
+	if err == nil {
+		return nil
+	}
+	var appErr *infraerrors.AppError
+	if stderrors.As(err, &appErr) {
+		return err
+	}
+	return infraerrors.WithCause(infraerrors.ErrDBError, err)
+}
+
+// DefaultPageSize 默认每页条数(与 response.ParsePage 一致,设计 §10.6)。
+const DefaultPageSize = 10
+
+// MaxPageSize 每页条数上限(防大查询打爆内存/慢查询,设计 §10.6)。
+const MaxPageSize = 200
+
+// ClampPage 分页参数钳制(page < 1 → 1;pageSize < 1 → 10;pageSize > 200 → 200)。
+//
+// 入参:page/pageSize——请求原始值。出参:钳制后的 (page, pageSize)。
+func ClampPage(page, pageSize int) (int, int) {
+	if page < 1 {
+		page = 1
+	}
+	if pageSize < 1 {
+		pageSize = DefaultPageSize
+	}
+	if pageSize > MaxPageSize {
+		pageSize = MaxPageSize
+	}
+	return page, pageSize
+}
+
+// PageOffset 由 page/pageSize 计算 SQL OFFSET(入参已钳制)。
+func PageOffset(page, pageSize int) int {
+	page, pageSize = ClampPage(page, pageSize)
+	return (page - 1) * pageSize
+}
+
+// dupKeyHints 唯一键撞车的文案特征(不引第三方驱动错误类型,兼容 MySQL 与 sqlite)。
+var dupKeyHints = []string{
+	"Duplicate entry",          // MySQL 1062
+	"Duplicate entry for key",  // MySQL 变体
+	"UNIQUE constraint failed", // sqlite
+	"Error 1062",               // MySQL 错误号形态
+}
+
+// IsDupKey 判断错误是否为唯一键冲突(幂等键 uk_request_type / uk_user_activity 撞车判定)。
+//
+// 入参:err error——repository 层返回的错误(可为 *infraerrors.AppError 或原生驱动错误)。
+// 出参:true 表示唯一键冲突(调用方应视为"重复请求"而非 5002)。
+//
+// 实现说明:依次检查 Cause 链上的字符串文案特征;不用 go-sql-driver/mysql 的具体错误类型,
+// 以保持"零新增直接依赖"(该驱动目前是 indirect 依赖)+ 兼容 sqlite 单测。
+func IsDupKey(err error) bool {
+	if err == nil {
+		return false
+	}
+	msg := err.Error()
+	for _, hint := range dupKeyHints {
+		if strings.Contains(msg, hint) {
+			return true
+		}
+	}
+	return false
+}

+ 333 - 0
internal/academic/repository/base_test.go

@@ -0,0 +1,333 @@
+// academic repository 共享内核单测(Scoped 作用域注入 / TxRunner 事务透传 / 分页钳制 / 唯一键识别)。
+//
+// 测试栈:glebarez/sqlite(纯 Go,无 CGO,CI 的 CGO_ENABLED=0 环境同样可跑,与 infra/scope 单测一致)。
+// 需要"真实事务"的用例用临时文件库(t.TempDir),`:memory:` 在连接池多连接下不可共享。
+package repository
+
+import (
+	"context"
+	stderrors "errors"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// txFixture 事务测试用 fixture 表(仅需 id/name 两列)。
+type txFixture struct {
+	ID   int64  `gorm:"column:id;primaryKey"`
+	Name string `gorm:"column:name;size:32"`
+}
+
+// TableName 指定表名。
+func (txFixture) TableName() string { return "t_academic_tx_fixture" }
+
+// newDryRunDB DryRun 句柄(只构造 SQL,不落库;用于断言 scoping 注入的 WHERE 片段)。
+func newDryRunDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	db, err := gorm.Open(gormsqlite.Open(":memory:"), &gorm.Config{DryRun: true})
+	if err != nil {
+		t.Fatalf("gorm.Open(DryRun) 失败: %v", err)
+	}
+	return db
+}
+
+// newFileDB 临时文件库(支持真实事务/回滚)。
+func newFileDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_repo_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(文件库) 失败: %v", err)
+	}
+	// Windows 下未关闭连接会导致 t.TempDir 清理失败(unlinkat 占用),故注册清理钩子
+	t.Cleanup(func() {
+		if sqlDB, e := db.DB(); e == nil {
+			_ = sqlDB.Close()
+		}
+	})
+	if err := db.AutoMigrate(&txFixture{}); err != nil {
+		t.Fatalf("AutoMigrate 失败: %v", err)
+	}
+	return db
+}
+
+// TestScopedInjectsTenantFilters 普通用户:强制机构 + 校区过滤。
+func TestScopedInjectsTenantFilters(t *testing.T) {
+	db := newDryRunDB(t)
+	r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 7})
+	q := r.Scoped(db.Session(&gorm.Session{NewDB: true}))
+	var out []txFixture
+	if err := q.Find(&out).Error; err != nil {
+		t.Fatalf("DryRun Find 失败: %v", err)
+	}
+	sql := q.Statement.SQL.String()
+	if !strings.Contains(sql, "institution_id = ?") {
+		t.Errorf("应注入 institution_id 过滤,SQL=%q", sql)
+	}
+	if !strings.Contains(sql, "campus_id = ?") {
+		t.Errorf("CampusScoped=true 且 campus>0 应注入 campus_id 过滤,SQL=%q", sql)
+	}
+}
+
+// TestScopedInstitutionOnly CampusScoped=false(机构级资源)→ 只注入机构过滤。
+func TestScopedInstitutionOnly(t *testing.T) {
+	db := newDryRunDB(t)
+	r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 7})
+	r.CampusScoped = false
+	q := r.Scoped(db.Session(&gorm.Session{NewDB: true}))
+	var out []txFixture
+	if err := q.Find(&out).Error; err != nil {
+		t.Fatalf("DryRun Find 失败: %v", err)
+	}
+	sql := q.Statement.SQL.String()
+	if !strings.Contains(sql, "institution_id = ?") {
+		t.Errorf("应注入 institution_id 过滤,SQL=%q", sql)
+	}
+	if strings.Contains(sql, "campus_id = ?") {
+		t.Errorf("CampusScoped=false 不应注入 campus_id(否则 Unknown column),SQL=%q", sql)
+	}
+}
+
+// TestScopedCampusZero Sc.CampusID=0(机构级视角)→ 不限定校区。
+func TestScopedCampusZero(t *testing.T) {
+	db := newDryRunDB(t)
+	r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 0})
+	q := r.Scoped(db.Session(&gorm.Session{NewDB: true}))
+	var out []txFixture
+	if err := q.Find(&out).Error; err != nil {
+		t.Fatalf("DryRun Find 失败: %v", err)
+	}
+	if sql := q.Statement.SQL.String(); strings.Contains(sql, "campus_id = ?") {
+		t.Errorf("CampusID=0 不应注入 campus_id,SQL=%q", sql)
+	}
+}
+
+// TestScopedSuperAdminBypass 超管跨机构:不注入任何作用域过滤。
+func TestScopedSuperAdminBypass(t *testing.T) {
+	db := newDryRunDB(t)
+	r := NewBaseRepo(db, &appscope.Scope{InstitutionID: 42, CampusID: 7, SuperAdmin: true})
+	q := r.Scoped(db.Session(&gorm.Session{NewDB: true}))
+	var out []txFixture
+	if err := q.Find(&out).Error; err != nil {
+		t.Fatalf("DryRun Find 失败: %v", err)
+	}
+	sql := q.Statement.SQL.String()
+	if strings.Contains(sql, "institution_id = ?") || strings.Contains(sql, "campus_id = ?") {
+		t.Errorf("超管不应注入作用域过滤,SQL=%q", sql)
+	}
+}
+
+// TestScopedNilScopePanics 无作用域进入数据访问层 → panic(fail-fast,纵深防御)。
+func TestScopedNilScopePanics(t *testing.T) {
+	db := newDryRunDB(t)
+	r := NewBaseRepo(db, nil)
+	defer func() {
+		rec := recover()
+		if rec == nil {
+			t.Fatal("Sc 为 nil 时应 panic")
+		}
+		if !strings.Contains(rec.(string), "missing tenant scope") {
+			t.Fatalf("panic 文案不符: %v", rec)
+		}
+	}()
+	_ = r.Scoped(db.Session(&gorm.Session{NewDB: true}))
+}
+
+// TestScopedNilDBReturnsNil db 为 nil 时返回 nil(不 panic,便于装配期防御)。
+func TestScopedNilDBReturnsNil(t *testing.T) {
+	r := NewBaseRepo(newDryRunDB(t), &appscope.Scope{InstitutionID: 1})
+	if got := r.Scoped(nil); got != nil {
+		t.Fatal("Scoped(nil) 应返回 nil")
+	}
+}
+
+// TestGetScopedID 读取当前作用域的机构/校区。
+func TestGetScopedID(t *testing.T) {
+	r := NewBaseRepo(nil, &appscope.Scope{InstitutionID: 42, CampusID: 7})
+	if inst, camp := r.GetScopedID(); inst != 42 || camp != 7 {
+		t.Fatalf("GetScopedID = (%d,%d),期望 (42,7)", inst, camp)
+	}
+	if inst, camp := NewBaseRepo(nil, nil).GetScopedID(); inst != 0 || camp != 0 {
+		t.Fatalf("无作用域应返回 (0,0),实得 (%d,%d)", inst, camp)
+	}
+}
+
+// TestWithinTxRollback 顶层事务:fn 返回错误 → 已写行回滚(资金场景的核心保证)。
+func TestWithinTxRollback(t *testing.T) {
+	db := newFileDB(t)
+	runner := NewTxRunner(db)
+	boom := stderrors.New("boom")
+
+	err := runner.WithinTx(context.Background(), func(tx *gorm.DB) error {
+		if InTx(tx) != true {
+			t.Fatal("事务内 InTx(tx) 应为 true")
+		}
+		if e := tx.Create(&txFixture{ID: 1, Name: "a"}).Error; e != nil {
+			return e
+		}
+		return boom
+	})
+	if !stderrors.Is(err, infraerrors.ErrDBError) {
+		t.Fatalf("非业务错误应包装为 ErrDBError,实得 %v", err)
+	}
+
+	var count int64
+	if e := db.Model(&txFixture{}).Count(&count).Error; e != nil {
+		t.Fatalf("Count 失败: %v", e)
+	}
+	if count != 0 {
+		t.Fatalf("回滚后应有 0 行,实得 %d", count)
+	}
+}
+
+// TestWithinTxCommit 顶层事务:fn 返回 nil → 提交。
+func TestWithinTxCommit(t *testing.T) {
+	db := newFileDB(t)
+	runner := NewTxRunner(db)
+	if err := runner.WithinTx(context.Background(), func(tx *gorm.DB) error {
+		return tx.Create(&txFixture{ID: 2, Name: "b"}).Error
+	}); err != nil {
+		t.Fatalf("WithinTx 失败: %v", err)
+	}
+	var count int64
+	if err := db.Model(&txFixture{}).Count(&count).Error; err != nil {
+		t.Fatalf("Count 失败: %v", err)
+	}
+	if count != 1 {
+		t.Fatalf("提交后应有 1 行,实得 %d", count)
+	}
+}
+
+// TestWithinTxNestedPassThrough 嵌套调用:内层**透传同一 tx,不重复 BEGIN**。
+//
+// 验证两条透传通道:
+//   - ctx 注入(WithTxContext,事务内跨 service 调用的主通道);
+//   - runner 绑定(WithTx,显式传 tx 的场景)。
+//
+// 并验证:内层写入后由外层回滚 → 内层数据一并丢失(说明没有独立事务提前提交)。
+func TestWithinTxNestedPassThrough(t *testing.T) {
+	db := newFileDB(t)
+	runner := NewTxRunner(db)
+	rollback := stderrors.New("outer-rollback")
+
+	err := runner.WithinTx(context.Background(), func(outerTx *gorm.DB) error {
+		if e := outerTx.Create(&txFixture{ID: 3, Name: "outer"}).Error; e != nil {
+			return e
+		}
+		// 通道 ①:ctx 内已携带 tx(fn 收到的 tx.WithContext 已注入)
+		if e := runner.WithinTx(outerTx.Statement.Context, func(innerTx *gorm.DB) error {
+			if innerTx.Statement.ConnPool != outerTx.Statement.ConnPool {
+				t.Fatal("经 ctx 透传的内层应复用外层 tx 连接(未重复 BEGIN)")
+			}
+			return innerTx.Create(&txFixture{ID: 4, Name: "inner"}).Error
+		}); e != nil {
+			return e
+		}
+		// 通道 ②:显式 WithTx 绑定 tx
+		if e := runner.WithTx(outerTx).WithinTx(context.Background(), func(innerTx *gorm.DB) error {
+			if innerTx.Statement.ConnPool != outerTx.Statement.ConnPool {
+				t.Fatal("经 WithTx 绑定的内层应复用外层 tx 连接")
+			}
+			return innerTx.Create(&txFixture{ID: 5, Name: "inner2"}).Error
+		}); e != nil {
+			return e
+		}
+		return rollback
+	})
+	if err == nil {
+		t.Fatal("外层应返回回滚错误")
+	}
+
+	var count int64
+	if e := db.Model(&txFixture{}).Count(&count).Error; e != nil {
+		t.Fatalf("Count 失败: %v", e)
+	}
+	if count != 0 {
+		t.Fatalf("外层回滚后内层数据也应消失(无独立事务),实得 %d 行", count)
+	}
+}
+
+// TestWithinTxBusinessErrorPassthrough 业务错误(*AppError)原样上抛,不被包装成 5002。
+func TestWithinTxBusinessErrorPassthrough(t *testing.T) {
+	db := newFileDB(t)
+	runner := NewTxRunner(db)
+	bizErr := infraerrors.New(3101, "余额不足")
+
+	err := runner.WithinTx(context.Background(), func(tx *gorm.DB) error { return bizErr })
+	if !stderrors.Is(err, bizErr) {
+		t.Fatalf("业务错误应原样返回,实得 %v", err)
+	}
+	var appErr *infraerrors.AppError
+	if !stderrors.As(err, &appErr) || appErr.Code != 3101 {
+		t.Fatalf("应保留 3101 业务码,实得 %v", err)
+	}
+}
+
+// TestWithinTxNilGuards 装配错误防御(DB/fn 为 nil)。
+func TestWithinTxNilGuards(t *testing.T) {
+	if err := (&TxRunner{}).WithinTx(context.Background(), func(*gorm.DB) error { return nil }); !stderrors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("DB 为 nil 应返回 ErrInternal,实得 %v", err)
+	}
+	if err := NewTxRunner(newFileDB(t)).WithinTx(context.Background(), nil); !stderrors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("fn 为 nil 应返回 ErrInternal,实得 %v", err)
+	}
+}
+
+// TestInTx 事务判定(nil/非事务句柄)。
+func TestInTx(t *testing.T) {
+	if InTx(nil) {
+		t.Fatal("InTx(nil) 应为 false")
+	}
+	if InTx(newDryRunDB(t)) {
+		t.Fatal("连接池句柄不应判为事务中")
+	}
+}
+
+// TestClampPage 分页钳制(默认 1/10,上限 200)。
+func TestClampPage(t *testing.T) {
+	cases := []struct {
+		page, size, wantPage, wantSize int
+	}{
+		{0, 0, 1, DefaultPageSize},
+		{-5, -1, 1, DefaultPageSize},
+		{2, 20, 2, 20},
+		{3, 999, 3, MaxPageSize},
+	}
+	for _, tc := range cases {
+		p, s := ClampPage(tc.page, tc.size)
+		if p != tc.wantPage || s != tc.wantSize {
+			t.Fatalf("ClampPage(%d,%d) = (%d,%d),期望 (%d,%d)", tc.page, tc.size, p, s, tc.wantPage, tc.wantSize)
+		}
+	}
+	if got := PageOffset(3, 20); got != 40 {
+		t.Fatalf("PageOffset(3,20) = %d,期望 40", got)
+	}
+	if got := PageOffset(0, 0); got != 0 {
+		t.Fatalf("PageOffset(0,0) = %d,期望 0", got)
+	}
+}
+
+// TestIsDupKey 唯一键冲突识别(MySQL 1062 / sqlite 文案)。
+func TestIsDupKey(t *testing.T) {
+	cases := []struct {
+		name string
+		err  error
+		want bool
+	}{
+		{"MySQL 1062", stderrors.New("Error 1062 (23000): Duplicate entry 'r1-100' for key 'uk_request_type'"), true},
+		{"sqlite unique", stderrors.New("UNIQUE constraint failed: t_user_expense_school.user_id"), true},
+		{"普通错误", stderrors.New("sql: connection refused"), false},
+		{"nil", nil, false},
+	}
+	for _, tc := range cases {
+		if got := IsDupKey(tc.err); got != tc.want {
+			t.Fatalf("%s: IsDupKey = %v,期望 %v", tc.name, got, tc.want)
+		}
+	}
+}

+ 4 - 2
internal/infra/db/db.go

@@ -4,8 +4,10 @@
 //   - 本包**只提供**"建连接池 + 执行 AutoMigrate"两个通用能力,**不**内置任何业务 model;
 //     具体要迁哪些表由**调用方**(cmd/api 装配层 / 集成测试)传入 model 列表——
 //     这样 infra 保持零业务域依赖(若在本包 import identity/model、org/model 会违反铁律)。
-//   - 业务域 model 的"全表清单"由各域 model 包提供(identity/model.AllTables / org/model.AllTables),
-//     装配层合并后传入 Migrate。
+//   - 业务域 model 的"全表清单"由各域 model 包提供(identity/model.AllTables / org/model.AllTables /
+//     academic/model.AllTables),装配层合并后传入 Migrate。
+//     注:academic 域 27 表由 AC-14 在 cmd/api/main.go 装配处接入(本包不感知业务域,保持零依赖);
+//     接入时的守卫语义不变:仅 `UNIFIED_AUTO_MIGRATE=1` 才执行 AutoMigrate。
 //
 // 与 DDL 的关系(unified/02-data-model/core-model-ddl.sql):
 //   - 生产环境割接以 **DDL 文件为准**(PolarDB 建表,information_schema 核对);

+ 4 - 0
internal/org/model/models.go

@@ -46,6 +46,9 @@ func (Institution) TableName() string { return "t_institution" }
 // Campus 校区表 t_campus(隶属机构)。
 //
 // D2:主键 CampusID int64 自增;InstitutionID int64(→ t_institution,作用域字段)。
+// SelfSupport 为 2026-11-09 academic 域设计 §11.1 拍板 A 的增量列(AC-01 并入):
+// 来源旧库 `yuansiwei.t_school_campus.self_support`,供 academic 域判定活动失效范围
+// (自营 → `campus_id IN (x,0)`,加盟 → `= x`;经 port.CampusReader 只读获取,不跨库)。
 type Campus struct {
 	CampusID      int64     `gorm:"column:campus_id;primaryKey" json:"campusId"`
 	InstitutionID int64     `gorm:"column:institution_id;not null" json:"institutionId"`
@@ -54,6 +57,7 @@ type Campus struct {
 	Address       *string   `gorm:"column:address;size:255" json:"address,omitempty"`
 	Phone         *string   `gorm:"column:phone;size:20" json:"phone,omitempty"`
 	Status        string    `gorm:"column:status;size:16;not null" json:"status"`
+	SelfSupport   int8      `gorm:"column:self_support;not null;default:1" json:"selfSupport"` // 是否自营校区(1 自营 / 0 加盟)
 	CreatedAt     time.Time `gorm:"column:created_at" json:"createdAt"`
 	UpdatedAt     time.Time `gorm:"column:updated_at" json:"updatedAt"`
 }