// Package model 身份域 GORM 领域模型(与 unified/02-data-model/core-model-ddl.sql 逐列对齐)。 // // 设计决策(T1 D1/D2/D3,2026-11-06 用户拍板): // - D1 用户类主键 = canonical_user_id string(ULID 26 位,VARCHAR(26),ADR-013)—— // t_user 主键;t_identity/t_membership/t_guardian_link 中"引用人"的外键列一律 string。 // - D2 机构/校区主键 = int64 自增(org/model,非 ULID,对齐 scope.Scope 的 int64 作用域字段)。 // - D3 关系/日志表(t_identity/t_legacy_mapping/t_audit_log)主键 = int64 自增, // 持有 canonical_user_id string 外键列。 // // GORM 类型映射纪律(架构规格 §3.6,T2 验收"DDL 逐表与 GORM Model 对齐"的硬要求): // - **string 必须带 size:N**:GORM MySQL 驱动对未指定 size 的 string 字段默认建 **longtext**, // 与 DDL 的 varchar(N) 严重漂移(T2 对账测试实证:漏 size 导致 26 列全变 longtext)。 // 每个 string/*string 字段的 size 严格等于 DDL 的 varchar(N) 的 N。 // - **tinyint(1) 用 bool**:GORM bool → tinyint(1);int64 会建 bigint(与 DDL 不符)。 // - **int(11) 用 int32**:GORM int32 → int;int64 会建 bigint(legacy_*_user_id 列对齐 DDL int)。 // - **tinyint(非 1)用 int8**:GORM int8 → tinyint(match_confidence 置信度列)。 // - **JSON 列用 string 直存 → longtext**:对齐 task.Payload 纪律,避免 GORM 自定义类型序列化坑 // (DDL t_audit_log.detail 已同步为 longtext)。 // - 可空列(IS_NULLABLE=YES)一律指针化(*string/*int64/*time.Time);写边界 nil 归一由 Service 负责, // model 只如实映射列,不在 model 里做业务归一(防"converting NULL" 500 + 防字段语义漂移)。 // - 列名显式:所有字段都写 `gorm:"column:xxx"`,不依赖 GORM 默认命名。 // - 时间列 CreatedAt/UpdatedAt:GORM 按字段名自动管理(GORM v1.31 MySQL 默认 datetime(3), // 与 DDL 的 DEFAULT/ON UPDATE CURRENT_TIMESTAMP(3) 语义一致)。 // // 依赖方向铁律:本包属 identity 域 model,只被 identity 域 service/repo 依赖; // 不依赖 handler/service,不碰 DB 句柄(纯实体 + 列映射)。 package model import "time" // PersonType User 的身份类型(t_user.person_type,规划 §5.1:User 级区分"人"的业务身份)。 // 与 Membership.Role(运营角色 7 类)是**两个维度**:person_type 是"这什么人",role 是"在哪个机构/校区担什么职"。 const ( PersonTypeStudent = "student" // 学生 PersonTypeTeacher = "teacher" // 老师 PersonTypeParent = "parent" // 家长 PersonTypeStaff = "staff" // 员工(教务/财务/管理等运营角色,默认值) ) // UserStatus 用户全局状态(t_user.status,平台级,跨机构)。 const ( UserStatusActive = "active" // 正常 UserStatusDisabled = "disabled" // 禁用(账号封禁,全机构不可登录) UserStatusMerged = "merged" // 已合并(归一 pipeline 软合并,不物理删,数据保留供审计) ) // HashVersion 密码哈希版本(t_user.hash_version,ADR-016 惰性升级路由依据)。 // 登录时先按版本路由比对,避免双算法误判;命中旧版本即升级 argon2。 const ( HashVersionMd5Salt = "md5_salt" // 旧教务:md5(password + salt) HashVersionShaSalt = "sha_salt" // 旧教学:sha1(password + salt) HashVersionArgon2 = "argon2" // 目标态:argon2id(惰性升级终态) ) // User 用户主表 t_user(人,平台级全局唯一,跨机构共享)。 // // D1:主键 CanonicalUserID 为 string(ULID),size:26 对齐 varchar(26); // GORM string 主键用 primaryKey tag 标注(非自增)。 type User struct { CanonicalUserID string `gorm:"column:canonical_user_id;primaryKey;size:26" json:"canonicalUserId"` Username *string `gorm:"column:username;size:64" json:"username,omitempty"` DisplayName *string `gorm:"column:display_name;size:64" json:"displayName,omitempty"` AvatarURL *string `gorm:"column:avatar_url;size:512" json:"avatarUrl,omitempty"` PersonType string `gorm:"column:person_type;size:16;not null" json:"personType"` PasswordHash *string `gorm:"column:password_hash;size:255" json:"-"` // 密码哈希永不出 JSON(敏感,脱敏纪律 §3.3) PasswordSalt *string `gorm:"column:password_salt;size:128" json:"-"` // 盐永不出 JSON HashVersion *string `gorm:"column:hash_version;size:16" json:"-"` // 版本永不出 JSON Status string `gorm:"column:status;size:16;not null" json:"status"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` } // TableName 指定表名(GORM 默认会把 User → users 复数,与 DDL 单数 t_user 不符,必须显式)。 func (User) TableName() string { return "t_user" } // IdentityType 登录身份类型(t_identity.type,身份归一后的"登录方式"枚举)。 const ( IdentityTypePhone = "phone" // 手机号(11 位归一) IdentityTypeEmail = "email" // 邮箱(小写归一) IdentityTypeWechat = "wechat" // 微信 open_id(per-appid 天然唯一) IdentityTypeUsername = "username" // 用户名(账密登录账号) IdentityTypeLegacyAcademic = "legacy_academic" // 教务旧 user 记录(迁移种子) IdentityTypeLegacyTeaching = "legacy_teaching" // 教学旧 user 记录(迁移种子) ) // Identity 登录身份表 t_identity(多对一挂 User,一人多种登录方式)。 // // D3:主键 IdentityID int64 自增;CanonicalUserID string(ULID,size:26)。 // (Type, Identifier) 联合唯一——phone/email 全局唯一,wechat open_id 天然 per-appid 唯一。 type Identity struct { IdentityID int64 `gorm:"column:identity_id;primaryKey" json:"identityId"` CanonicalUserID string `gorm:"column:canonical_user_id;size:26;not null" json:"canonicalUserId"` Type string `gorm:"column:type;size:32;not null" json:"type"` Identifier string `gorm:"column:identifier;size:128;not null" json:"identifier"` AppID *string `gorm:"column:appid;size:64" json:"appId,omitempty"` Verified bool `gorm:"column:verified;not null" json:"verified"` // tinyint(1) → bool CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` } // TableName 指定表名(GORM 默认 Identity → identities,与 DDL 单数 t_identity 不符)。 func (Identity) TableName() string { return "t_identity" } // LegacyMapping 遗留映射表 t_legacy_mapping(三源旧 int user_id → ULID,永久保留,审计/回滚底线)。 // // D3:主键 ID int64 自增;CanonicalUserID string(ULID,size:26,UNIQUE)。 // 旧 int 只存这里(ADR-013);legacy_*_user_id 用 *int32 对齐 DDL 的 int(非 bigint)。 type LegacyMapping struct { ID int64 `gorm:"column:id;primaryKey" json:"id"` CanonicalUserID string `gorm:"column:canonical_user_id;size:26;not null" json:"canonicalUserId"` LegacyAcademicUserID *int32 `gorm:"column:legacy_academic_user_id" json:"legacyAcademicUserId,omitempty"` // int → int32 LegacyTeachingUserID *int32 `gorm:"column:legacy_teaching_user_id" json:"legacyTeachingUserId,omitempty"` // int → int32 LegacyIUserID *int32 `gorm:"column:legacy_i_user_id" json:"legacyIUserId,omitempty"` // int → int32 InstitutionID int64 `gorm:"column:institution_id;not null" json:"institutionId"` CampusID int64 `gorm:"column:campus_id;not null" json:"campusId"` Role *string `gorm:"column:role;size:32" json:"role,omitempty"` MatchSource *string `gorm:"column:match_source;size:32" json:"matchSource,omitempty"` MatchConfidence int8 `gorm:"column:match_confidence;not null" json:"matchConfidence"` // tinyint → int8 AuditRef *string `gorm:"column:audit_ref;size:64" json:"auditRef,omitempty"` MergedAt *time.Time `gorm:"column:merged_at" json:"mergedAt,omitempty"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` } // TableName 指定表名(GORM 默认 LegacyMapping → legacy_mappings,与 DDL 单数 t_legacy_mapping 不符)。 func (LegacyMapping) TableName() string { return "t_legacy_mapping" } // AuditAction 审计动作枚举(t_audit_log.action,登录/合并/权限变更全量,ADR-016 §6.4 安全要点)。 const ( AuditActionLogin = "login" // 登录成功 AuditActionLogout = "logout" // 登出 AuditActionRefresh = "refresh" // refresh 轮换 AuditActionMerge = "merge" // 用户归一合并 AuditActionRoleGrant = "role_grant" // 授权 AuditActionRoleRevoke = "role_revoke" // 撤权 AuditActionScopeChange = "scope_change" // 作用域变更(换机构/校区) AuditActionPasswordChange = "password_change" // 改密(含惰性升级) ) // AuditLog 审计日志表 t_audit_log(登录/合并/权限变更全量留痕)。 // // D3:主键 ID int64 自增;ActorUserID string(ULID,size:26,可空——系统操作 NULL)。 // Detail 用 *string 直存 JSON(GORM → longtext;DDL detail 列已同步为 longtext,对齐 task.Payload 纪律)。 // 审计表只增不改(DDL 无 updated_at 列,故 model 不设 UpdatedAt,GORM 不为无字段建列)。 type AuditLog struct { ID int64 `gorm:"column:id;primaryKey" json:"id"` ActorUserID *string `gorm:"column:actor_user_id;size:26" json:"actorUserId,omitempty"` Action string `gorm:"column:action;size:64;not null" json:"action"` EntityType *string `gorm:"column:entity_type;size:32" json:"entityType,omitempty"` EntityID *string `gorm:"column:entity_id;size:64" json:"entityId,omitempty"` InstitutionID *int64 `gorm:"column:institution_id" json:"institutionId,omitempty"` CampusID *int64 `gorm:"column:campus_id" json:"campusId,omitempty"` Detail *string `gorm:"column:detail" json:"detail,omitempty"` // JSON → longtext(string 直存) TraceID *string `gorm:"column:trace_id;size:64" json:"traceId,omitempty"` IP *string `gorm:"column:ip;size:64" json:"ip,omitempty"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"` } // TableName 指定表名(GORM 默认 AuditLog → audit_logs,与 DDL 单数 t_audit_log 不符)。 func (AuditLog) TableName() string { return "t_audit_log" } // AllTables 返回本域全部 model 指针切片(供装配层/集成测试合并后传入 infra/db.Migrate)。 // // 入参:无。 // 出参:[]any——identity 域 4 表(User/Identity/LegacyMapping/AuditLog)。 // 说明:返回新切片(非共享底层数组),调用方可安全 append。 func AllTables() []any { return []any{ &User{}, &Identity{}, &LegacyMapping{}, &AuditLog{}, } }