瀏覽代碼

fix(academic): 审计时间零值落库缺陷根因修复(27 列补 default + 撤 ensureCreateTime)

## 根因(AC-01 暴露)
academic 域 27 表的时间列 model tag 只有 `not null`、**无 default**:
GORM 对 time.Time 零值会**显式**写入 0001-01-01,DB 侧 DEFAULT CURRENT_TIMESTAMP(3) 根本兜不住。
上一批在 3 条写路径加的 `ensureCreateTime`(Go 时钟兜底)只是局部补丁,且造成
「Go 时钟 / DB 时钟」双口径 —— 审计时间是对账依据(红线),必须单一权威来源。

## 修复
1. model 层补 default tag(共 27 列,全部对应 DDL 里带 `DEFAULT CURRENT_TIMESTAMP(3)` 的列):
   - `create_time` 13 列(activity 6 / course 1 / divide 1 / fund 3 / payroll 2);
   - `created_at` 8 列、`updated_at` 6 列(DDL 均带 DEFAULT,updated_at 另有 ON UPDATE)。
   语义:**零值 → 列不出现在 INSERT → DB 默认值生效;非零值 → 原样写入**
   (历史回放 / 人工录入不受影响,已在单测中双向断言)。
2. 删除 `repository.ensureCreateTime` 及 signed / discount_log / used_log 三处调用,
   根因已修,保留会造成双口径。
3. `internal/academic/model/migrate_dev_check_test.go` 之外的 DDL 无改动:
   `t_courses_student.start_time`(DDL `NOT NULL` 无 DEFAULT)保持非指针且**不**加 default。

## 口径偏差(已在 models_fund.go 文件头登记)
tag 写 `default:CURRENT_TIMESTAMP`(不带 `(3)`):纯 Go 的 glebarez/sqlite 单测栈
不支持 `DEFAULT CURRENT_TIMESTAMP(3)`(`near "(": syntax error`,实测),带 `(3)` 会让
academic 全域 AutoMigrate 失败。差异仅「DB 默认值精度 = 秒」,列类型仍 datetime(3)
(显式赋值可写毫秒),且默认值不在 AC-01 列对账范围内。MySQL 侧如需毫秒默认值,
应以文档 DDL(`CURRENT_TIMESTAMP(3)`)建表,AutoMigrate 只补缺列。

## 影响面 / 验证
- 写路径无需再手工填 create_time;`TestWriteAuditTimeFallback` 改为断言
  「插入零值后读回 > 2020 年」+「显式值不被覆盖」。
- `go build ./... && go vet ./... && go test ./... -short` 全绿
  (model 27 表逐列对账、DDL 指纹锁、活动 5 表、分账 2 表用例均 PASS)。
Q 19 小時之前
父節點
當前提交
a1971b7dcd

+ 9 - 9
internal/academic/model/models_activity.go

@@ -72,8 +72,8 @@ type CoursesActivity struct {
 	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:无|红线:勿手改
+	CreateTime    time.Time  `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                                              // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt     time.Time  `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`                                                                // 更新时间(datetime(3),ON UPDATE;新增列)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。
@@ -96,7 +96,7 @@ type CoursesActivitySigned struct {
 	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:无|红线:勿手改(对账依据)
+	CreateTime        time.Time  `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP" 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
@@ -122,7 +122,7 @@ type CoursesActivityUserDiscount struct {
 	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:无|红线:勿手改
+	CreateTime         time.Time `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                                                          // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。
@@ -138,7 +138,7 @@ type CoursesActivityUserDiscountLog struct {
 	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)
+	CreateTime             time.Time `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                                  // 使用时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:同秒多行用 id 排序(退回游标 DESC)
 	Memo                   *string   `gorm:"column:memo;size:500" json:"memo,omitempty"`                                                     // 备注(默认文案"使用充值赠送金额")|枚举:无|写入:使用/退回时|FK:无|红线:脱敏
 }
 
@@ -159,7 +159,7 @@ type CoursesActivityUsedLog struct {
 	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:无|红线:勿手改
+	CreateTime        time.Time `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                                   // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
 	Memo              *string   `gorm:"column:memo;size:255" json:"memo,omitempty"`                                                      // 备注|枚举:无|写入:结算/重放|FK:无|红线:脱敏
 }
 
@@ -186,8 +186,8 @@ type CoursesActivityRequest struct {
 	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:无|红线:勿手改
+	CreatedAt      time.Time  `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"`                                                             // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt      time.Time  `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`                                                             // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。
@@ -200,7 +200,7 @@ 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:无|红线:勿手改
+	CreateTime       time.Time `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                // 回访时间(datetime(3))|枚举:无|写入:DB 默认/录入|FK:无|红线:勿手改
 	Memo             *string   `gorm:"column:memo;size:500" json:"memo,omitempty"`                                   // 回访内容|枚举:无|写入:回访登记时|FK:无|红线:含学员沟通内容,按权限裁剪输出
 }
 

+ 4 - 4
internal/academic/model/models_course.go

@@ -102,7 +102,7 @@ type Courses struct {
 	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:无|红线:勿手改
+	CreatedAt     time.Time  `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"`                                                           // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。
@@ -149,7 +149,7 @@ type CoursesClass struct {
 	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:无|红线:勿手改
+	CreatedAt          time.Time  `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"`                                                    // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。
@@ -192,7 +192,7 @@ type CoursesStudent struct {
 	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)
+	CreateTime            time.Time  `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                                                       // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:欠费核销排序键(create_time ASC)
 	Remark                *string    `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                                                 // 备注|枚举:无|写入:报课/编辑|FK:无|红线:脱敏
 }
 
@@ -227,7 +227,7 @@ type CoursesRecord struct {
 	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:无|红线:勿手改
+	CreatedAt             time.Time  `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"`                                                                        // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。

+ 3 - 3
internal/academic/model/models_divide.go

@@ -34,8 +34,8 @@ type UserDivideInto struct {
 	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:无|红线:勿手改
+	CreatedAt        time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"`                                                                 // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt        time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`                                                                 // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。
@@ -56,7 +56,7 @@ type UserDivideIntoLog struct {
 	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:无|红线:勿手改(对账依据)
+	CreateTime time.Time `gorm:"index:idx_create_time;column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                                           // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改(对账依据)
 	Remark     *string   `gorm:"column:remark;type:longtext" json:"remark,omitempty"`                                                                           // 备注(撤销改绑时写"推广分成")|枚举:无|写入:上行/撤销|FK:无|红线:脱敏
 }
 

+ 13 - 5
internal/academic/model/models_fund.go

@@ -10,6 +10,14 @@
 //   - 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));
+//   - **审计时间列(create_time / created_at / updated_at,DDL 带 DEFAULT CURRENT_TIMESTAMP(3))
+//     的 `CreateTime`/`CreatedAt`/`UpdatedAt` 非指针字段必须带 `default:CURRENT_TIMESTAMP`**:
+//     GORM 对 time.Time 零值会**显式**写入 0001-01-01(DB 默认值兜不住),带 default tag 后
+//     零值走「列不出现在 INSERT 里 → DB 默认生效」,非零值仍原样写入(历史回放/人工录入不受影响)。
+//     口径说明:tag 写 `CURRENT_TIMESTAMP`(不带 `(3)`)是**跨方言可执行的唯一写法**——
+//     纯 Go 的 glebarez/sqlite(本仓单测栈)不支持 `DEFAULT CURRENT_TIMESTAMP(3)`(语法错),
+//     故 MySQL 侧由 DB 默认兜底时精度为秒(DATETIME(3) 列存 .000);需要毫秒的路径用显式赋值。
+//     DDL 文档仍写 `CURRENT_TIMESTAMP(3)`,两者差异只在「默认值精度」,不参与列对账(AC-01 口径);
 //   - 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 直存,规避自定义类型序列化坑)。
@@ -126,7 +134,7 @@ type UserExpenseLog struct {
 	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)
+	CreateTime   time.Time `gorm:"index:idx_user_time,priority:2;column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                     // 创建时间(datetime(3))|枚举:无|写入:DB 默认 CURRENT_TIMESTAMP(3)|FK:无|红线:只增不改(勿 UPDATE)
 	Remark       *string   `gorm:"column:remark;size:1000" json:"remark,omitempty"`                                                                  // 备注(原 text → varchar(1000),超长截断见迁移报告)|枚举:无|写入:资金方法内|FK:无|红线:不得写入敏感信息(手机号/证件号)
 }
 
@@ -159,8 +167,8 @@ type UserBalanceRequest struct {
 	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:无|红线:勿手改
+	CreatedAt            time.Time  `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"`                                                                                     // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt            time.Time  `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`                                                                                     // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。
@@ -184,7 +192,7 @@ type OrderPay struct {
 	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:无|红线:勿手改
+	CreateTime    time.Time  `gorm:"index:idx_user_create,priority:2;column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`  // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
 	Remark        *string    `gorm:"column:remark;size:500" json:"remark,omitempty"`                                  // 用户备注|枚举:无|写入:下单时|FK:无|红线:用户输入,展示需转义
 }
 
@@ -203,7 +211,7 @@ type PaymentLog struct {
 	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:无|红线:只读
+	CreateTime  time.Time  `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                      // 生成时间(datetime(3))|枚举:无|写入:DB 默认/迁移|FK:无|红线:只读
 	Result      int32      `gorm:"index:idx_result;column:result;not null;default:0" json:"result"`    // 处理结果(旧编码原样)|枚举:旧编码(不收敛)|写入:迁移搬入|FK:无|红线:只读
 }
 

+ 6 - 6
internal/academic/model/models_payroll.go

@@ -36,7 +36,7 @@ type CoursePayment struct {
 	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:无|红线:勿手改
+	CreateTime    time.Time  `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP" json:"createTime"`                                                                 // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
 	Memo          *string    `gorm:"column:memo;type:longtext" json:"memo,omitempty"`                                                               // 备注|枚举:无|写入:创建/编辑|FK:无|红线:脱敏
 }
 
@@ -78,8 +78,8 @@ 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:无|红线:勿手改
+	CreatedAt   time.Time  `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"`                    // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt   time.Time  `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`                    // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。
@@ -104,8 +104,8 @@ type TeacherCoursesPercentage struct {
 	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:无|红线:勿手改
+	CreatedAt        time.Time  `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"createdAt"`                                                // 创建时间(datetime(3))|枚举:无|写入:DB 默认|FK:无|红线:勿手改
+	UpdatedAt        time.Time  `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updatedAt"`                                                // 更新时间(datetime(3),ON UPDATE)|枚举:无|写入:DB 自动|FK:无|红线:勿手改
 }
 
 // TableName 指定表名。
@@ -135,7 +135,7 @@ type ExpenditureLog struct {
 	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:无|红线:勿手改
+	CreateTime    time.Time `gorm:"index:idx_inst_campus_time,priority:3;column:create_time;not null;default:CURRENT_TIMESTAMP" 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:无|红线:脱敏

+ 0 - 2
internal/academic/repository/activity_discount_repo.go

@@ -299,7 +299,6 @@ func (r *ActivityDiscountRepo) InsertDiscountLog(ctx context.Context, tx *gorm.D
 	if row.ActivityUserDiscountID <= 0 {
 		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: discount log 缺少 activity_user_discount_id"))
 	}
-	ensureCreateTime(&row.CreateTime) // 审计时间兜底(见 activity_repo.go ensureCreateTime 说明)
 	if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
 		return infraerrors.WithCause(infraerrors.ErrDBError, err)
 	}
@@ -327,7 +326,6 @@ func (r *ActivityDiscountRepo) InsertUsedLog(ctx context.Context, tx *gorm.DB, r
 	if row.ActivityID <= 0 || strings.TrimSpace(row.UserID) == "" {
 		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: used log 缺少 activity_id/user_id"))
 	}
-	ensureCreateTime(&row.CreateTime) // 审计时间兜底(见 activity_repo.go ensureCreateTime 说明)
 	if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
 		return infraerrors.WithCause(infraerrors.ErrDBError, err)
 	}

+ 1 - 17
internal/academic/repository/activity_repo.go

@@ -27,7 +27,6 @@ import (
 	"errors"
 	"fmt"
 	"strings"
-	"time"
 
 	"gorm.io/gorm"
 
@@ -259,8 +258,7 @@ func (r *ActivityRepo) UpsertSigned(ctx context.Context, tx *gorm.DB, signed *mo
 		Delete(&model.CoursesActivitySigned{}).Error; err != nil {
 		return infraerrors.WithCause(infraerrors.ErrDBError, err)
 	}
-	// ② 插入新行(状态机全字段重置;审计时间兜底,见 ensureCreateTime)
-	ensureCreateTime(&signed.CreateTime)
+	// ② 插入新行(状态机全字段重置)
 	if err := tx.WithContext(ctxOrBackground(ctx)).Create(signed).Error; err != nil {
 		return infraerrors.WithCause(infraerrors.ErrDBError, err)
 	}
@@ -442,17 +440,3 @@ func ctxOrBackground(ctx context.Context) context.Context {
 	}
 	return context.Background()
 }
-
-// ensureCreateTime 审计时间兜底(零值 → 当前时间)。
-//
-// 为什么需要(实测缺陷):AC-01 的活动域 model 用的是 `CreateTime` 字段名(非 GORM 约定的
-// `CreatedAt`),且 tag 只有 `not null` 没有 `default:CURRENT_TIMESTAMP(3)`;
-// 实测 GORM 会把 time.Time 零值**显式**写进 INSERT(得到 0001-01-01),DB 默认值根本兜不住。
-// 审计时间是对账依据(红线),故在仓储写入前统一兜底,避免调用方漏填造成时间列被写坏。
-//
-// 入参:dst *time.Time——待写入行的 create_time 字段地址(nil 时忽略)。
-func ensureCreateTime(dst *time.Time) {
-	if dst != nil && dst.IsZero() {
-		*dst = time.Now()
-	}
-}

+ 36 - 10
internal/academic/repository/activity_repo_test.go

@@ -530,11 +530,13 @@ func TestNormalizeOrder(t *testing.T) {
 	}
 }
 
-// TestWriteAuditTimeFallback 零值 create_time 必须被仓储兜底。
+// TestWriteAuditTimeFallback 零值 create_time 必须由 **DB 默认值**兜底(不再有 Go 侧兜底函数)。
 //
-// 背景(实测确认):活动域 model 的审计列名为 `CreateTime`(非 GORM 约定的 `CreatedAt`),
-// 且无 `default:CURRENT_TIMESTAMP(3)` tag → GORM 会把 0001-01-01 显式写库,DB 默认值兜不住。
-// 审计时间是对账依据,故三条写路径(signed / discount_log / used_log)都必须兜底。
+// 背景(实测缺陷 → 根因修复):活动域 model 的审计列名为 `CreateTime`(非 GORM 约定的 `CreatedAt`),
+// 原先 tag 只有 `not null`、没有 default → GORM 会把 time.Time 零值**显式**写成 0001-01-01,
+// DB 默认值兜不住。根因已在 model 层修复(`default:CURRENT_TIMESTAMP`):零值列不进 INSERT,
+// 由 DB 应用默认值;非零值仍原样写入。故三条写路径(signed / discount_log / used_log)现在
+// **不需要任何仓储兜底**,断言改为「读回时间晚于 2020 年」(证明确实落的是当前时钟而非零值)。
 func TestWriteAuditTimeFallback(t *testing.T) {
 	db := newActivityRepoDB(t)
 	seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "0.00")
@@ -564,25 +566,49 @@ func TestWriteAuditTimeFallback(t *testing.T) {
 		t.Fatalf("写入失败: %v", err)
 	}
 
+	// 阈值:任何真实写入都必须晚于 2020 年(零值 0001-01-01 会被判红)
+	floor := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
 	var signed model.CoursesActivitySigned
 	if err := db.Where("user_id = ?", user).First(&signed).Error; err != nil {
 		t.Fatalf("读 signed 失败: %v", err)
 	}
-	if signed.CreateTime.IsZero() {
-		t.Fatal("signed.create_time 必须被兜底(否则审计时间被写成 0001-01-01)")
+	if !signed.CreateTime.After(floor) {
+		t.Fatalf("signed.create_time = %s,期望由 DB 默认值落当前时间(> 2020)", signed.CreateTime)
 	}
 	var dlog model.CoursesActivityUserDiscountLog
 	if err := db.Where("activity_user_discount_id = ?", 1).First(&dlog).Error; err != nil {
 		t.Fatalf("读 discount log 失败: %v", err)
 	}
-	if dlog.CreateTime.IsZero() {
-		t.Fatal("discount_log.create_time 必须被兜底")
+	if !dlog.CreateTime.After(floor) {
+		t.Fatalf("discount_log.create_time = %s,期望由 DB 默认值落当前时间(> 2020)", dlog.CreateTime)
 	}
 	var ulog model.CoursesActivityUsedLog
 	if err := db.Where("user_id = ?", user).First(&ulog).Error; err != nil {
 		t.Fatalf("读 used log 失败: %v", err)
 	}
-	if ulog.CreateTime.IsZero() {
-		t.Fatal("used_log.create_time 必须被兜底")
+	if !ulog.CreateTime.After(floor) {
+		t.Fatalf("used_log.create_time = %s,期望由 DB 默认值落当前时间(> 2020)", ulog.CreateTime)
+	}
+
+	// 显式值必须**不被默认值覆盖**(历史回放/人工录入依赖此语义)
+	explicit := time.Date(2015, 3, 4, 5, 6, 7, 0, time.UTC)
+	if err := db.Create(&model.CoursesActivity{
+		ID:            2,
+		Type:          model.ActivityTypeRecharge,
+		InstitutionID: 1,
+		CampusID:      2,
+		Money:         "0.00",
+		Status:        model.ActivityStatusEnabled,
+		CreateTime:    explicit,
+		UpdatedAt:     explicit,
+	}).Error; err != nil {
+		t.Fatalf("显式 create_time INSERT 失败: %v", err)
+	}
+	var back model.CoursesActivity
+	if err := db.Where("id = ?", 2).First(&back).Error; err != nil {
+		t.Fatalf("回读 activity#2 失败: %v", err)
+	}
+	if !back.CreateTime.Equal(explicit) {
+		t.Fatalf("显式 create_time 被默认值覆盖:写 %s,读 %s", explicit, back.CreateTime)
 	}
 }