models.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. // Package model 身份域 GORM 领域模型(与 unified/02-data-model/core-model-ddl.sql 逐列对齐)。
  2. //
  3. // 设计决策(T1 D1/D2/D3,2026-11-06 用户拍板):
  4. // - D1 用户类主键 = canonical_user_id string(ULID 26 位,VARCHAR(26),ADR-013)——
  5. // t_user 主键;t_identity/t_membership/t_guardian_link 中"引用人"的外键列一律 string。
  6. // - D2 机构/校区主键 = int64 自增(org/model,非 ULID,对齐 scope.Scope 的 int64 作用域字段)。
  7. // - D3 关系/日志表(t_identity/t_legacy_mapping/t_audit_log)主键 = int64 自增,
  8. // 持有 canonical_user_id string 外键列。
  9. //
  10. // GORM 类型映射纪律(架构规格 §3.6,T2 验收"DDL 逐表与 GORM Model 对齐"的硬要求):
  11. // - **string 必须带 size:N**:GORM MySQL 驱动对未指定 size 的 string 字段默认建 **longtext**,
  12. // 与 DDL 的 varchar(N) 严重漂移(T2 对账测试实证:漏 size 导致 26 列全变 longtext)。
  13. // 每个 string/*string 字段的 size 严格等于 DDL 的 varchar(N) 的 N。
  14. // - **tinyint(1) 用 bool**:GORM bool → tinyint(1);int64 会建 bigint(与 DDL 不符)。
  15. // - **int(11) 用 int32**:GORM int32 → int;int64 会建 bigint(legacy_*_user_id 列对齐 DDL int)。
  16. // - **tinyint(非 1)用 int8**:GORM int8 → tinyint(match_confidence 置信度列)。
  17. // - **JSON 列用 string 直存 → longtext**:对齐 task.Payload 纪律,避免 GORM 自定义类型序列化坑
  18. // (DDL t_audit_log.detail 已同步为 longtext)。
  19. // - 可空列(IS_NULLABLE=YES)一律指针化(*string/*int64/*time.Time);写边界 nil 归一由 Service 负责,
  20. // model 只如实映射列,不在 model 里做业务归一(防"converting NULL" 500 + 防字段语义漂移)。
  21. // - 列名显式:所有字段都写 `gorm:"column:xxx"`,不依赖 GORM 默认命名。
  22. // - 时间列 CreatedAt/UpdatedAt:GORM 按字段名自动管理(GORM v1.31 MySQL 默认 datetime(3),
  23. // 与 DDL 的 DEFAULT/ON UPDATE CURRENT_TIMESTAMP(3) 语义一致)。
  24. //
  25. // 依赖方向铁律:本包属 identity 域 model,只被 identity 域 service/repo 依赖;
  26. // 不依赖 handler/service,不碰 DB 句柄(纯实体 + 列映射)。
  27. package model
  28. import "time"
  29. // PersonType User 的身份类型(t_user.person_type,规划 §5.1:User 级区分"人"的业务身份)。
  30. // 与 Membership.Role(运营角色 7 类)是**两个维度**:person_type 是"这什么人",role 是"在哪个机构/校区担什么职"。
  31. const (
  32. PersonTypeStudent = "student" // 学生
  33. PersonTypeTeacher = "teacher" // 老师
  34. PersonTypeParent = "parent" // 家长
  35. PersonTypeStaff = "staff" // 员工(教务/财务/管理等运营角色,默认值)
  36. )
  37. // UserStatus 用户全局状态(t_user.status,平台级,跨机构)。
  38. const (
  39. UserStatusActive = "active" // 正常
  40. UserStatusDisabled = "disabled" // 禁用(账号封禁,全机构不可登录)
  41. UserStatusMerged = "merged" // 已合并(归一 pipeline 软合并,不物理删,数据保留供审计)
  42. )
  43. // HashVersion 密码哈希版本(t_user.hash_version,ADR-016 惰性升级路由依据)。
  44. // 登录时先按版本路由比对,避免双算法误判;命中旧版本即升级 argon2。
  45. const (
  46. HashVersionMd5Salt = "md5_salt" // 旧教务:md5(password + salt)
  47. HashVersionShaSalt = "sha_salt" // 旧教学:sha1(password + salt)
  48. HashVersionArgon2 = "argon2" // 目标态:argon2id(惰性升级终态)
  49. )
  50. // User 用户主表 t_user(人,平台级全局唯一,跨机构共享)。
  51. //
  52. // D1:主键 CanonicalUserID 为 string(ULID),size:26 对齐 varchar(26);
  53. // GORM string 主键用 primaryKey tag 标注(非自增)。
  54. type User struct {
  55. CanonicalUserID string `gorm:"column:canonical_user_id;primaryKey;size:26" json:"canonicalUserId"`
  56. Username *string `gorm:"column:username;size:64" json:"username,omitempty"`
  57. DisplayName *string `gorm:"column:display_name;size:64" json:"displayName,omitempty"`
  58. AvatarURL *string `gorm:"column:avatar_url;size:512" json:"avatarUrl,omitempty"`
  59. PersonType string `gorm:"column:person_type;size:16;not null" json:"personType"`
  60. PasswordHash *string `gorm:"column:password_hash;size:255" json:"-"` // 密码哈希永不出 JSON(敏感,脱敏纪律 §3.3)
  61. PasswordSalt *string `gorm:"column:password_salt;size:128" json:"-"` // 盐永不出 JSON
  62. HashVersion *string `gorm:"column:hash_version;size:16" json:"-"` // 版本永不出 JSON
  63. Status string `gorm:"column:status;size:16;not null" json:"status"`
  64. CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
  65. UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
  66. }
  67. // TableName 指定表名(GORM 默认会把 User → users 复数,与 DDL 单数 t_user 不符,必须显式)。
  68. func (User) TableName() string { return "t_user" }
  69. // IdentityType 登录身份类型(t_identity.type,身份归一后的"登录方式"枚举)。
  70. const (
  71. IdentityTypePhone = "phone" // 手机号(11 位归一)
  72. IdentityTypeEmail = "email" // 邮箱(小写归一)
  73. IdentityTypeWechat = "wechat" // 微信 open_id(per-appid 天然唯一)
  74. IdentityTypeUsername = "username" // 用户名(账密登录账号)
  75. IdentityTypeLegacyAcademic = "legacy_academic" // 教务旧 user 记录(迁移种子)
  76. IdentityTypeLegacyTeaching = "legacy_teaching" // 教学旧 user 记录(迁移种子)
  77. )
  78. // Identity 登录身份表 t_identity(多对一挂 User,一人多种登录方式)。
  79. //
  80. // D3:主键 IdentityID int64 自增;CanonicalUserID string(ULID,size:26)。
  81. // v1.2:(Type, Identifier, AppID) 联合唯一(uk_type_identifier_appid)——
  82. // phone/email/username 类型 AppID 恒 "",wechat 类型 AppID 必填(per-appid 唯一)。
  83. // AppID 从 *string 改 string(NOT NULL DEFAULT ''),对齐 DDL v1.2 拍板"不用可空列"。
  84. type Identity struct {
  85. IdentityID int64 `gorm:"column:identity_id;primaryKey" json:"identityId"`
  86. CanonicalUserID string `gorm:"column:canonical_user_id;size:26;not null" json:"canonicalUserId"`
  87. Type string `gorm:"column:type;size:32;not null;uniqueIndex:uk_type_identifier_appid,priority:1" json:"type"`
  88. Identifier string `gorm:"column:identifier;size:128;not null;uniqueIndex:uk_type_identifier_appid,priority:2" json:"identifier"`
  89. AppID string `gorm:"column:appid;size:64;not null;uniqueIndex:uk_type_identifier_appid,priority:3" json:"appId"`
  90. Verified bool `gorm:"column:verified;not null" json:"verified"` // tinyint(1) → bool
  91. CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
  92. }
  93. // TableName 指定表名(GORM 默认 Identity → identities,与 DDL 单数 t_identity 不符)。
  94. func (Identity) TableName() string { return "t_identity" }
  95. // WechatUnion 微信 unionid 锚点表 t_wechat_union(跨 appid 自然人归一锚点,v1.2 新增)。
  96. //
  97. // D3:主键 ID int64 自增;CanonicalUserID string(ULID,size:26,UNIQUE——一人一 unionid)。
  98. // UnionID string(size:100,UNIQUE——一个 unionid 只能锚定一个 canonical 用户)。
  99. // unionid 不是登录标识(不落 t_identity),是归一 pipeline / 登录绑定时把多 appid 的
  100. // t_identity(wechat) 收敛到同一 canonical_user_id 的锚点。
  101. type WechatUnion struct {
  102. ID int64 `gorm:"column:id;primaryKey" json:"id"`
  103. CanonicalUserID string `gorm:"column:canonical_user_id;size:26;not null;uniqueIndex:uk_canonical" json:"canonicalUserId"`
  104. UnionID string `gorm:"column:unionid;size:100;not null;uniqueIndex:uk_unionid" json:"unionId"`
  105. CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
  106. UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
  107. }
  108. // TableName 指定表名(GORM 默认 WechatUnion → wechat_unions,与 DDL 单数 t_wechat_union 不符)。
  109. func (WechatUnion) TableName() string { return "t_wechat_union" }
  110. // LegacyMapping 遗留映射表 t_legacy_mapping(三源旧 int user_id → ULID,永久保留,审计/回滚底线)。
  111. //
  112. // D3:主键 ID int64 自增;CanonicalUserID string(ULID,size:26,UNIQUE)。
  113. // 旧 int 只存这里(ADR-013);legacy_*_user_id 用 *int32 对齐 DDL 的 int(非 bigint)。
  114. type LegacyMapping struct {
  115. ID int64 `gorm:"column:id;primaryKey" json:"id"`
  116. CanonicalUserID string `gorm:"column:canonical_user_id;size:26;not null" json:"canonicalUserId"`
  117. LegacyAcademicUserID *int32 `gorm:"column:legacy_academic_user_id" json:"legacyAcademicUserId,omitempty"` // int → int32
  118. LegacyTeachingUserID *int32 `gorm:"column:legacy_teaching_user_id" json:"legacyTeachingUserId,omitempty"` // int → int32
  119. LegacyIUserID *int32 `gorm:"column:legacy_i_user_id" json:"legacyIUserId,omitempty"` // int → int32
  120. InstitutionID int64 `gorm:"column:institution_id;not null" json:"institutionId"`
  121. CampusID int64 `gorm:"column:campus_id;not null" json:"campusId"`
  122. Role *string `gorm:"column:role;size:32" json:"role,omitempty"`
  123. MatchSource *string `gorm:"column:match_source;size:32" json:"matchSource,omitempty"`
  124. MatchConfidence int8 `gorm:"column:match_confidence;not null" json:"matchConfidence"` // tinyint → int8
  125. AuditRef *string `gorm:"column:audit_ref;size:64" json:"auditRef,omitempty"`
  126. MergedAt *time.Time `gorm:"column:merged_at" json:"mergedAt,omitempty"`
  127. CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
  128. UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
  129. }
  130. // TableName 指定表名(GORM 默认 LegacyMapping → legacy_mappings,与 DDL 单数 t_legacy_mapping 不符)。
  131. func (LegacyMapping) TableName() string { return "t_legacy_mapping" }
  132. // AuditAction 审计动作枚举(t_audit_log.action,登录/合并/权限变更全量,ADR-016 §6.4 安全要点)。
  133. const (
  134. AuditActionLogin = "login" // 登录成功
  135. AuditActionLogout = "logout" // 登出
  136. AuditActionRefresh = "refresh" // refresh 轮换
  137. AuditActionMerge = "merge" // 用户归一合并
  138. AuditActionRoleGrant = "role_grant" // 授权
  139. AuditActionRoleRevoke = "role_revoke" // 撤权
  140. AuditActionScopeChange = "scope_change" // 作用域变更(换机构/校区)
  141. AuditActionPasswordChange = "password_change" // 改密(含惰性升级)
  142. )
  143. // AuditLog 审计日志表 t_audit_log(登录/合并/权限变更全量留痕)。
  144. //
  145. // D3:主键 ID int64 自增;ActorUserID string(ULID,size:26,可空——系统操作 NULL)。
  146. // Detail 用 *string 直存 JSON(GORM → longtext;DDL detail 列已同步为 longtext,对齐 task.Payload 纪律)。
  147. // 审计表只增不改(DDL 无 updated_at 列,故 model 不设 UpdatedAt,GORM 不为无字段建列)。
  148. type AuditLog struct {
  149. ID int64 `gorm:"column:id;primaryKey" json:"id"`
  150. ActorUserID *string `gorm:"column:actor_user_id;size:26" json:"actorUserId,omitempty"`
  151. Action string `gorm:"column:action;size:64;not null" json:"action"`
  152. EntityType *string `gorm:"column:entity_type;size:32" json:"entityType,omitempty"`
  153. EntityID *string `gorm:"column:entity_id;size:64" json:"entityId,omitempty"`
  154. InstitutionID *int64 `gorm:"column:institution_id" json:"institutionId,omitempty"`
  155. CampusID *int64 `gorm:"column:campus_id" json:"campusId,omitempty"`
  156. Detail *string `gorm:"column:detail" json:"detail,omitempty"` // JSON → longtext(string 直存)
  157. TraceID *string `gorm:"column:trace_id;size:64" json:"traceId,omitempty"`
  158. IP *string `gorm:"column:ip;size:64" json:"ip,omitempty"`
  159. CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
  160. }
  161. // TableName 指定表名(GORM 默认 AuditLog → audit_logs,与 DDL 单数 t_audit_log 不符)。
  162. func (AuditLog) TableName() string { return "t_audit_log" }
  163. // AllTables 返回本域全部 model 指针切片(供装配层/集成测试合并后传入 infra/db.Migrate)。
  164. //
  165. // 入参:无。
  166. // 出参:[]any——identity 域 5 表(User/Identity/WechatUnion/LegacyMapping/AuditLog;v1.2 +WechatUnion)。
  167. // 说明:返回新切片(非共享底层数组),调用方可安全 append。
  168. func AllTables() []any {
  169. return []any{
  170. &User{},
  171. &Identity{},
  172. &WechatUnion{},
  173. &LegacyMapping{},
  174. &AuditLog{},
  175. }
  176. }