models.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  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. // (Type, Identifier) 联合唯一——phone/email 全局唯一,wechat open_id 天然 per-appid 唯一。
  82. type Identity struct {
  83. IdentityID int64 `gorm:"column:identity_id;primaryKey" json:"identityId"`
  84. CanonicalUserID string `gorm:"column:canonical_user_id;size:26;not null" json:"canonicalUserId"`
  85. Type string `gorm:"column:type;size:32;not null" json:"type"`
  86. Identifier string `gorm:"column:identifier;size:128;not null" json:"identifier"`
  87. AppID *string `gorm:"column:appid;size:64" json:"appId,omitempty"`
  88. Verified bool `gorm:"column:verified;not null" json:"verified"` // tinyint(1) → bool
  89. CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
  90. }
  91. // TableName 指定表名(GORM 默认 Identity → identities,与 DDL 单数 t_identity 不符)。
  92. func (Identity) TableName() string { return "t_identity" }
  93. // LegacyMapping 遗留映射表 t_legacy_mapping(三源旧 int user_id → ULID,永久保留,审计/回滚底线)。
  94. //
  95. // D3:主键 ID int64 自增;CanonicalUserID string(ULID,size:26,UNIQUE)。
  96. // 旧 int 只存这里(ADR-013);legacy_*_user_id 用 *int32 对齐 DDL 的 int(非 bigint)。
  97. type LegacyMapping struct {
  98. ID int64 `gorm:"column:id;primaryKey" json:"id"`
  99. CanonicalUserID string `gorm:"column:canonical_user_id;size:26;not null" json:"canonicalUserId"`
  100. LegacyAcademicUserID *int32 `gorm:"column:legacy_academic_user_id" json:"legacyAcademicUserId,omitempty"` // int → int32
  101. LegacyTeachingUserID *int32 `gorm:"column:legacy_teaching_user_id" json:"legacyTeachingUserId,omitempty"` // int → int32
  102. LegacyIUserID *int32 `gorm:"column:legacy_i_user_id" json:"legacyIUserId,omitempty"` // int → int32
  103. InstitutionID int64 `gorm:"column:institution_id;not null" json:"institutionId"`
  104. CampusID int64 `gorm:"column:campus_id;not null" json:"campusId"`
  105. Role *string `gorm:"column:role;size:32" json:"role,omitempty"`
  106. MatchSource *string `gorm:"column:match_source;size:32" json:"matchSource,omitempty"`
  107. MatchConfidence int8 `gorm:"column:match_confidence;not null" json:"matchConfidence"` // tinyint → int8
  108. AuditRef *string `gorm:"column:audit_ref;size:64" json:"auditRef,omitempty"`
  109. MergedAt *time.Time `gorm:"column:merged_at" json:"mergedAt,omitempty"`
  110. CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
  111. UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
  112. }
  113. // TableName 指定表名(GORM 默认 LegacyMapping → legacy_mappings,与 DDL 单数 t_legacy_mapping 不符)。
  114. func (LegacyMapping) TableName() string { return "t_legacy_mapping" }
  115. // AuditAction 审计动作枚举(t_audit_log.action,登录/合并/权限变更全量,ADR-016 §6.4 安全要点)。
  116. const (
  117. AuditActionLogin = "login" // 登录成功
  118. AuditActionLogout = "logout" // 登出
  119. AuditActionRefresh = "refresh" // refresh 轮换
  120. AuditActionMerge = "merge" // 用户归一合并
  121. AuditActionRoleGrant = "role_grant" // 授权
  122. AuditActionRoleRevoke = "role_revoke" // 撤权
  123. AuditActionScopeChange = "scope_change" // 作用域变更(换机构/校区)
  124. AuditActionPasswordChange = "password_change" // 改密(含惰性升级)
  125. )
  126. // AuditLog 审计日志表 t_audit_log(登录/合并/权限变更全量留痕)。
  127. //
  128. // D3:主键 ID int64 自增;ActorUserID string(ULID,size:26,可空——系统操作 NULL)。
  129. // Detail 用 *string 直存 JSON(GORM → longtext;DDL detail 列已同步为 longtext,对齐 task.Payload 纪律)。
  130. // 审计表只增不改(DDL 无 updated_at 列,故 model 不设 UpdatedAt,GORM 不为无字段建列)。
  131. type AuditLog struct {
  132. ID int64 `gorm:"column:id;primaryKey" json:"id"`
  133. ActorUserID *string `gorm:"column:actor_user_id;size:26" json:"actorUserId,omitempty"`
  134. Action string `gorm:"column:action;size:64;not null" json:"action"`
  135. EntityType *string `gorm:"column:entity_type;size:32" json:"entityType,omitempty"`
  136. EntityID *string `gorm:"column:entity_id;size:64" json:"entityId,omitempty"`
  137. InstitutionID *int64 `gorm:"column:institution_id" json:"institutionId,omitempty"`
  138. CampusID *int64 `gorm:"column:campus_id" json:"campusId,omitempty"`
  139. Detail *string `gorm:"column:detail" json:"detail,omitempty"` // JSON → longtext(string 直存)
  140. TraceID *string `gorm:"column:trace_id;size:64" json:"traceId,omitempty"`
  141. IP *string `gorm:"column:ip;size:64" json:"ip,omitempty"`
  142. CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
  143. }
  144. // TableName 指定表名(GORM 默认 AuditLog → audit_logs,与 DDL 单数 t_audit_log 不符)。
  145. func (AuditLog) TableName() string { return "t_audit_log" }
  146. // AllTables 返回本域全部 model 指针切片(供装配层/集成测试合并后传入 infra/db.Migrate)。
  147. //
  148. // 入参:无。
  149. // 出参:[]any——identity 域 4 表(User/Identity/LegacyMapping/AuditLog)。
  150. // 说明:返回新切片(非共享底层数组),调用方可安全 append。
  151. func AllTables() []any {
  152. return []any{
  153. &User{},
  154. &Identity{},
  155. &LegacyMapping{},
  156. &AuditLog{},
  157. }
  158. }