ソースを参照

feat(identity): core-model-ddl v1.2 表结构对齐

拍板 18 项(2026-11-07)触发的 DDL v1.1→v1.2 GORM model 对齐:

- t_identity 唯一键 (type,identifier) → (type,identifier,appid)
  · Identity.AppID *string → string(NOT NULL DEFAULT '')
  · 三列加 uniqueIndex:uk_type_identifier_appid (priority 1/2/3)
- 新增 t_wechat_union(unionid 跨 appid 归一锚点,第 12 表)
  · WechatUnion struct + AllTables() 加表
- IdentityRepo.GetByTypeIdentifier → GetByTypeIdentifierAppID
  · 签名 +appid 参数(非微信 ,微信必填)
  · interfaces.go 接口同步更新
  · auth_service 传 ;wechat_service 传 campus.AppID
- 测试对齐:mock 三级 map (type,identifier,appid);
  identity_repo_test 改名 + 传 appid;
  db_test 加 t_wechat_union 列断言 + DropTable 加表
- e2e AutoMigrate 对账 12 表全绿;go build/vet/test 全绿
Q 2 日 前
親
コミット
ee4ab1dfb7

+ 17 - 13
internal/identity/handler/handler_test.go

@@ -64,19 +64,22 @@ func (m *mockUsers) UpdatePasswordLazyUpgrade(_ context.Context, uid, newHash st
 	return nil
 }
 
-type mockIdentities struct{ byTypeIdent map[string]map[string]*model.Identity }
+type mockIdentities struct{ byTypeIdentAppID map[string]map[string]map[string]*model.Identity }
 
 func newMockIdentities() *mockIdentities {
-	return &mockIdentities{byTypeIdent: map[string]map[string]*model.Identity{}}
+	return &mockIdentities{byTypeIdentAppID: map[string]map[string]map[string]*model.Identity{}}
 }
-func (m *mockIdentities) add(typ, identifier string, it *model.Identity) {
-	if m.byTypeIdent[typ] == nil {
-		m.byTypeIdent[typ] = map[string]*model.Identity{}
+func (m *mockIdentities) add(typ, identifier, appid string, it *model.Identity) {
+	if m.byTypeIdentAppID[typ] == nil {
+		m.byTypeIdentAppID[typ] = map[string]map[string]*model.Identity{}
 	}
-	m.byTypeIdent[typ][identifier] = it
+	if m.byTypeIdentAppID[typ][identifier] == nil {
+		m.byTypeIdentAppID[typ][identifier] = map[string]*model.Identity{}
+	}
+	m.byTypeIdentAppID[typ][identifier][appid] = it
 }
-func (m *mockIdentities) GetByTypeIdentifier(_ context.Context, typ, identifier string) (*model.Identity, error) {
-	it, ok := m.byTypeIdent[typ][identifier]
+func (m *mockIdentities) GetByTypeIdentifierAppID(_ context.Context, typ, identifier, appid string) (*model.Identity, error) {
+	it, ok := m.byTypeIdentAppID[typ][identifier][appid]
 	if !ok {
 		return nil, apperrors.ErrNotFound
 	}
@@ -230,14 +233,15 @@ func genRSAKey(t *testing.T) (string, []string) {
 	return privPEM, []string{pubPEM}
 }
 
-// addWechatUser 造一个用户 + wechat 身份(type=wechat, identifier=openid;扫码登录绑定用)。
+// addWechatUser 造一个用户 + wechat 身份(type=wechat, identifier=openid, appid=appid-test;扫码登录绑定用)。
+// v1.2:wechat 类型 appid 进唯一键,须与 WechatCampus 配置一致("appid-test")。
 func (fx *fixture) addWechatUser(uid, openid string) {
 	fx.t.Helper()
 	if fx.users.byUID[uid] == nil {
 		fx.users.byUID[uid] = &model.User{CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive}
 	}
-	fx.identities.add(model.IdentityTypeWechat, openid,
-		&model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeWechat, Identifier: openid, Verified: true})
+	fx.identities.add(model.IdentityTypeWechat, openid, "appid-test",
+		&model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeWechat, Identifier: openid, AppID: "appid-test", Verified: true})
 }
 
 // addUser 造一个 argon2 用户 + username 身份(测试夹具快捷构造)。
@@ -251,7 +255,7 @@ func (fx *fixture) addArgon2User(uid, username, password string) {
 		CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive,
 		PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2),
 	}
-	fx.identities.add(model.IdentityTypeUsername, username,
+	fx.identities.add(model.IdentityTypeUsername, username, "",
 		&model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: username})
 }
 
@@ -443,7 +447,7 @@ func TestLogin_Md5Salt_LazyUpgrade(t *testing.T) {
 		CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive,
 		PasswordHash: &d, PasswordSalt: ptr(salt), HashVersion: ptr(model.HashVersionMd5Salt),
 	}
-	fx.identities.add(model.IdentityTypeUsername, "testuser",
+	fx.identities.add(model.IdentityTypeUsername, "testuser", "",
 		&model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: "testuser"})
 	fx.memberships.add(uid, orgmodel.Membership{
 		MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,

+ 25 - 5
internal/identity/model/models.go

@@ -86,13 +86,15 @@ const (
 // Identity 登录身份表 t_identity(多对一挂 User,一人多种登录方式)。
 //
 // D3:主键 IdentityID int64 自增;CanonicalUserID string(ULID,size:26)。
-// (Type, Identifier) 联合唯一——phone/email 全局唯一,wechat open_id 天然 per-appid 唯一。
+// v1.2:(Type, Identifier, AppID) 联合唯一(uk_type_identifier_appid)——
+//   phone/email/username 类型 AppID 恒 "",wechat 类型 AppID 必填(per-appid 唯一)。
+//   AppID 从 *string 改 string(NOT NULL DEFAULT ''),对齐 DDL v1.2 拍板"不用可空列"。
 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"`
+	Type            string    `gorm:"column:type;size:32;not null;uniqueIndex:uk_type_identifier_appid,priority:1" json:"type"`
+	Identifier      string    `gorm:"column:identifier;size:128;not null;uniqueIndex:uk_type_identifier_appid,priority:2" json:"identifier"`
+	AppID           string    `gorm:"column:appid;size:64;not null;uniqueIndex:uk_type_identifier_appid,priority:3" json:"appId"`
 	Verified        bool      `gorm:"column:verified;not null" json:"verified"` // tinyint(1) → bool
 	CreatedAt       time.Time `gorm:"column:created_at" json:"createdAt"`
 }
@@ -100,6 +102,23 @@ type Identity struct {
 // TableName 指定表名(GORM 默认 Identity → identities,与 DDL 单数 t_identity 不符)。
 func (Identity) TableName() string { return "t_identity" }
 
+// WechatUnion 微信 unionid 锚点表 t_wechat_union(跨 appid 自然人归一锚点,v1.2 新增)。
+//
+// D3:主键 ID int64 自增;CanonicalUserID string(ULID,size:26,UNIQUE——一人一 unionid)。
+// UnionID string(size:100,UNIQUE——一个 unionid 只能锚定一个 canonical 用户)。
+// unionid 不是登录标识(不落 t_identity),是归一 pipeline / 登录绑定时把多 appid 的
+// t_identity(wechat) 收敛到同一 canonical_user_id 的锚点。
+type WechatUnion struct {
+	ID              int64     `gorm:"column:id;primaryKey" json:"id"`
+	CanonicalUserID string    `gorm:"column:canonical_user_id;size:26;not null;uniqueIndex:uk_canonical" json:"canonicalUserId"`
+	UnionID         string    `gorm:"column:unionid;size:100;not null;uniqueIndex:uk_unionid" json:"unionId"`
+	CreatedAt       time.Time `gorm:"column:created_at" json:"createdAt"`
+	UpdatedAt       time.Time `gorm:"column:updated_at" json:"updatedAt"`
+}
+
+// TableName 指定表名(GORM 默认 WechatUnion → wechat_unions,与 DDL 单数 t_wechat_union 不符)。
+func (WechatUnion) TableName() string { return "t_wechat_union" }
+
 // LegacyMapping 遗留映射表 t_legacy_mapping(三源旧 int user_id → ULID,永久保留,审计/回滚底线)。
 //
 // D3:主键 ID int64 自增;CanonicalUserID string(ULID,size:26,UNIQUE)。
@@ -161,12 +180,13 @@ func (AuditLog) TableName() string { return "t_audit_log" }
 // AllTables 返回本域全部 model 指针切片(供装配层/集成测试合并后传入 infra/db.Migrate)。
 //
 // 入参:无。
-// 出参:[]any——identity 域 4 表(User/Identity/LegacyMapping/AuditLog)。
+// 出参:[]any——identity 域 5 表(User/Identity/WechatUnion/LegacyMapping/AuditLog;v1.2 +WechatUnion)。
 // 说明:返回新切片(非共享底层数组),调用方可安全 append。
 func AllTables() []any {
 	return []any{
 		&User{},
 		&Identity{},
+		&WechatUnion{},
 		&LegacyMapping{},
 		&AuditLog{},
 	}

+ 11 - 7
internal/identity/repository/identity_repo.go

@@ -1,7 +1,9 @@
-// IdentityRepo 登录身份仓储(t_identity,一人多登录方式,(type,identifier) 唯一)。
+// IdentityRepo 登录身份仓储(t_identity,一人多登录方式,(type,identifier,appid) 唯一·v1.2)。
 //
 // 平台级访问(同 user_repo.go 包注释):不继承 scope.BaseRepo,裸 *gorm.DB。
-// 登录主链路的核心查询:按 (type, identifier) 唯一定位 → 取 canonical_user_id → 再查 user。
+// 登录主链路的核心查询:按 (type, identifier, appid) 唯一定位 → 取 canonical_user_id → 再查 user。
+// v1.2:唯一键从 (type,identifier) 扩为 (type,identifier,appid)——非微信类型 appid 恒 "",
+//   微信类型 appid 必填(per-appid 唯一,P0-07 M5 实测同 appid 同 openid 重复=0)。
 package repository
 
 import (
@@ -25,29 +27,31 @@ func NewIdentityRepo(db *gorm.DB) *IdentityRepo {
 	return &IdentityRepo{db: db}
 }
 
-// GetByTypeIdentifier 按 (type, identifier) 唯一键取登录身份记录。
+// GetByTypeIdentifierAppID 按 (type, identifier, appid) 唯一键取登录身份记录(v1.2)。
 //
 // 入参:
 //   - ctx context.Context:请求上下文。
 //   - typ string:身份类型(model.IdentityType* 之一:phone/email/wechat/username/legacy_academic/legacy_teaching)。
 //   - identifier string:归一后的标识值(phone 11 位 / email 小写 / wechat open_id / username 原值)。
+//   - appid string:来源 appid(v1.2 进唯一键;非微信类型恒 "",微信类型必填)。
 //
 // 出参:
 //   - *model.Identity:身份记录(含 CanonicalUserID,供 Service 再查 user)。
 //   - error:
-//     - errors.ErrNotFound:该 (type, identifier) 无身份记录(登录"账号不存在"路径之一,防枚举统一文案);
+//     - errors.ErrNotFound:该 (type, identifier, appid) 无身份记录(登录"账号不存在"路径之一,防枚举统一文案);
 //     - errors.ErrDBError:DB 异常。
 //
 // 归一纪律(调用方责任):
 //   - email 必须小写、phone 必须 11 位数字、wechat 必须 open_id 原值——Repository 不做归一,
 //     归一发生在 Service 入参处理(与旧系统数据归一规则一致,见 P0-02 盘点)。
+//   - 非微信类型 appid 必须传 ""(对齐 DDL NOT NULL DEFAULT '');微信类型 appid 必须传实际值。
 //
 // 实现说明:用 model(&model.Identity{}) 显式指定实体(GORM 自动映射表名 t_identity,TableName() 已锁定),
-// Where 用 (type, identifier) 联合条件 + First 取单条(UNIQUE 键保证最多 1 条,无需 Limit 但 First 自带取一)。
-func (r *IdentityRepo) GetByTypeIdentifier(ctx context.Context, typ, identifier string) (*model.Identity, error) {
+// Where 用 (type, identifier, appid) 三列联合条件 + First 取单条(UNIQUE 键保证最多 1 条,无需 Limit 但 First 自带取一)。
+func (r *IdentityRepo) GetByTypeIdentifierAppID(ctx context.Context, typ, identifier, appid string) (*model.Identity, error) {
 	var it model.Identity
 	if err := r.db.WithContext(ctx).
-		Where("type = ? AND identifier = ?", typ, identifier).
+		Where("type = ? AND identifier = ? AND appid = ?", typ, identifier, appid).
 		First(&it).Error; err != nil {
 		return nil, mapGormErr(err)
 	}

+ 9 - 8
internal/identity/repository/identity_repo_test.go

@@ -1,4 +1,4 @@
-// IdentityRepo 仓储集成测试(t_identity 登录身份,(type,identifier) 唯一键)。
+// IdentityRepo 仓储集成测试(t_identity 登录身份,(type,identifier,appid) 唯一键·v1.2)。
 //
 // 依赖 test_helpers_test.go 的 setup/insertFixture;未设置 UNIFIED_TEST_DSN 时跳过。
 package repository
@@ -12,17 +12,18 @@ import (
 	identitymodel "yuansiwei/unified-backend/internal/identity/model"
 )
 
-// TestIdentityRepo_GetByTypeIdentifier (type,identifier) 唯一键命中 / 未命中。
-func TestIdentityRepo_GetByTypeIdentifier(t *testing.T) {
+// TestIdentityRepo_GetByTypeIdentifierAppID (type,identifier,appid) 唯一键命中 / 未命中(v1.2)。
+// 非微信类型 appid 恒 "",微信类型 appid 必填。
+func TestIdentityRepo_GetByTypeIdentifierAppID(t *testing.T) {
 	gdb := setup(t)
 	ctx := context.Background()
 	fx := insertFixture(t, gdb, ctx)
 	repo := NewIdentityRepo(gdb)
 
 	t.Run("命中_phone", func(t *testing.T) {
-		it, err := repo.GetByTypeIdentifier(ctx, identitymodel.IdentityTypePhone, fx.phone)
+		it, err := repo.GetByTypeIdentifierAppID(ctx, identitymodel.IdentityTypePhone, fx.phone, "")
 		if err != nil {
-			t.Fatalf("GetByTypeIdentifier phone: %v", err)
+			t.Fatalf("GetByTypeIdentifierAppID phone: %v", err)
 		}
 		if it.CanonicalUserID != fx.canonical {
 			t.Errorf("返回 canonical_user_id = %q,期望 %q", it.CanonicalUserID, fx.canonical)
@@ -33,9 +34,9 @@ func TestIdentityRepo_GetByTypeIdentifier(t *testing.T) {
 	})
 
 	t.Run("命中_email", func(t *testing.T) {
-		it, err := repo.GetByTypeIdentifier(ctx, identitymodel.IdentityTypeEmail, fx.email)
+		it, err := repo.GetByTypeIdentifierAppID(ctx, identitymodel.IdentityTypeEmail, fx.email, "")
 		if err != nil {
-			t.Fatalf("GetByTypeIdentifier email: %v", err)
+			t.Fatalf("GetByTypeIdentifierAppID email: %v", err)
 		}
 		if it.CanonicalUserID != fx.canonical {
 			t.Errorf("返回 canonical_user_id = %q,期望 %q", it.CanonicalUserID, fx.canonical)
@@ -46,7 +47,7 @@ func TestIdentityRepo_GetByTypeIdentifier(t *testing.T) {
 	})
 
 	t.Run("未命中_返回ErrNotFound", func(t *testing.T) {
-		_, err := repo.GetByTypeIdentifier(ctx, identitymodel.IdentityTypeWechat, "unknown_openid")
+		_, err := repo.GetByTypeIdentifierAppID(ctx, identitymodel.IdentityTypeWechat, "unknown_openid", "appid-x")
 		if !stderrors.Is(err, apperrors.ErrNotFound) {
 			t.Fatalf("期望 ErrNotFound,实际 %v", err)
 		}

+ 3 - 2
internal/identity/repository/interfaces.go

@@ -35,8 +35,9 @@ type UserRepository interface {
 
 // IdentityRepository 登录身份数据访问接口(*IdentityRepo 隐式满足)。
 type IdentityRepository interface {
-	// GetByTypeIdentifier 按 (type, identifier) 唯一键取登录身份记录。
-	GetByTypeIdentifier(ctx context.Context, typ, identifier string) (*model.Identity, error)
+	// GetByTypeIdentifierAppID 按 (type, identifier, appid) 唯一键取登录身份记录(v1.2)。
+	// 非微信类型 appid 传 "",微信类型传实际 appid。
+	GetByTypeIdentifierAppID(ctx context.Context, typ, identifier, appid string) (*model.Identity, error)
 }
 
 // MembershipRepository 成员关系数据访问接口(*MembershipRepo 隐式满足)。

+ 1 - 1
internal/identity/repository/test_helpers_test.go

@@ -12,7 +12,7 @@
 // 覆盖(User 主表 + 登录身份 + 成员关系,登录两步协议的仓储支撑):
 //   - UserRepo.GetByCanonicalUserID:命中 / 未命中(ErrNotFound);
 //   - UserRepo.UpdatePasswordLazyUpgrade:argon2 重哈希 + salt 置 NULL + hash_version 升级 / 未命中用户;
-//   - IdentityRepo.GetByTypeIdentifier:(type,identifier) 唯一键命中 / 未命中;
+//   - IdentityRepo.GetByTypeIdentifierAppID:(type,identifier,appid) 唯一键命中 / 未命中(v1.2);
 //   - MembershipRepo.ListByCanonicalUserID:多 membership 按 created_at 升序 / 无 membership 返非 nil 空切片;
 //   - MembershipRepo.GetByUserInstitutionCampus:命中 / 同作用域多角色取最新 / 未命中。
 package repository

+ 1 - 1
internal/identity/service/auth_service.go

@@ -277,7 +277,7 @@ func (s *AuthService) Login(ctx context.Context, identityType, identifier, passw
 		return nil, apperrors.ErrBadParam
 	}
 
-	it, err := s.identities.GetByTypeIdentifier(ctx, identityType, ident)
+	it, err := s.identities.GetByTypeIdentifierAppID(ctx, identityType, ident, "")
 	if err != nil {
 		// 区分"不存在"(统一文案,防枚举)与"DB 故障"(5002)
 		if stderrors.Is(err, apperrors.ErrNotFound) {

+ 15 - 10
internal/identity/service/auth_service_test_helpers.go

@@ -71,22 +71,26 @@ func (m *mockUsers) UpdatePasswordLazyUpgrade(_ context.Context, uid, newHash st
 	return nil
 }
 
-// mockIdentities 身份仓储 mock(按 (type, identifier) 索引)。
+// mockIdentities 身份仓储 mock(按 (type, identifier, appid) 索引·v1.2;非微信类型 appid 恒 "")。
 type mockIdentities struct {
-	byTypeIdent map[string]map[string]*model.Identity
+	byTypeIdentAppID map[string]map[string]map[string]*model.Identity
 }
 
 func newMockIdentities() *mockIdentities {
-	return &mockIdentities{byTypeIdent: map[string]map[string]*model.Identity{}}
+	return &mockIdentities{byTypeIdentAppID: map[string]map[string]map[string]*model.Identity{}}
 }
-func (m *mockIdentities) add(typ, identifier string, it *model.Identity) {
-	if m.byTypeIdent[typ] == nil {
-		m.byTypeIdent[typ] = map[string]*model.Identity{}
+// add 按 (typ, identifier, appid) 索引;非微信类型 appid 恒 "",微信类型传实际 appid。
+func (m *mockIdentities) add(typ, identifier, appid string, it *model.Identity) {
+	if m.byTypeIdentAppID[typ] == nil {
+		m.byTypeIdentAppID[typ] = map[string]map[string]*model.Identity{}
 	}
-	m.byTypeIdent[typ][identifier] = it
+	if m.byTypeIdentAppID[typ][identifier] == nil {
+		m.byTypeIdentAppID[typ][identifier] = map[string]*model.Identity{}
+	}
+	m.byTypeIdentAppID[typ][identifier][appid] = it
 }
-func (m *mockIdentities) GetByTypeIdentifier(_ context.Context, typ, identifier string) (*model.Identity, error) {
-	it, ok := m.byTypeIdent[typ][identifier]
+func (m *mockIdentities) GetByTypeIdentifierAppID(_ context.Context, typ, identifier, appid string) (*model.Identity, error) {
+	it, ok := m.byTypeIdentAppID[typ][identifier][appid]
 	if !ok {
 		return nil, apperrors.ErrNotFound
 	}
@@ -203,7 +207,8 @@ func (fx *testFixture) addUser(uid, hashVersion, hash, salt, typ, identifier str
 	}
 	fx.users.byUID[uid] = u
 	if typ != "" && identifier != "" {
-		fx.identities.add(typ, identifier, &model.Identity{
+		// 非微信类型 appid 恒 ""(v1.2 唯一键 (type,identifier,appid);账密/手机/邮箱登录测试用)
+		fx.identities.add(typ, identifier, "", &model.Identity{
 			CanonicalUserID: uid, Type: typ, Identifier: identifier,
 		})
 	}

+ 3 - 2
internal/identity/service/wechat_service.go

@@ -302,8 +302,9 @@ func (s *WechatService) Callback(ctx context.Context, code, state string) (*Stat
 		return nil, apperrors.New(apperrors.CodeThirdTimeout, "微信扫码失败,请重试") // 4003 第三方
 	}
 
-	// 查绑定:(type=wechat, identifier=open_id) → canonical_user_id
-	it, err := s.identities.GetByTypeIdentifier(ctx, model.IdentityTypeWechat, openid)
+	// 查绑定:(type=wechat, identifier=open_id, appid) → canonical_user_id(v1.2:appid 进唯一键,
+	// 同 openid 在不同公众号(appid)下是不同登录身份,须带 appid 精确定位)
+	it, err := s.identities.GetByTypeIdentifierAppID(ctx, model.IdentityTypeWechat, openid, campus.AppID)
 	if err != nil {
 		if stderrors.Is(err, apperrors.ErrNotFound) {
 			// 未绑定:置 unbound(前端提示"该微信号未绑定账号",引导去账密登录后绑定)

+ 8 - 3
internal/infra/db/db_test.go

@@ -8,7 +8,7 @@
 //
 // 未设置 UNIFIED_TEST_DSN 时本测试自动跳过(CI / 无库环境 go test ./... 不受影响)。
 // 测试目标库用 e2e_test(dev 账号有建表/DROP 表权限),**不污染** dev 主库 yuansiwei_2026;
-// 测后 DropTable 清理 9 张表(保留 e2e_test 库本身,供 W3 e2e 复用)。
+// 测后 DropTable 清理 12 张表(保留 e2e_test 库本身,供 W3 e2e 复用)。
 //
 // 对账逻辑:AutoMigrate 按 model tag 建表 → 读 information_schema → 断言关键列的
 // 类型/可空性/主键与 T1 DDL 一致。任一不符说明 model tag 写错(列名/类型/指针化漂移),fail。
@@ -69,8 +69,9 @@ func TestAutoMigrateReconcile(t *testing.T) {
 	// 测后清理(保留库,只 drop 表;e2e_test 供 W3 复用)
 	t.Cleanup(func() {
 		m := gdb.Migrator()
-		_ = m.DropTable("t_user", "t_identity", "t_legacy_mapping", "t_audit_log",
-			"t_institution", "t_campus", "t_membership", "t_role", "t_guardian_link")
+	_ = m.DropTable("t_user", "t_identity", "t_wechat_union", "t_legacy_mapping", "t_audit_log",
+		"t_institution", "t_campus", "t_membership", "t_role", "t_guardian_link",
+		"t_permission", "t_role_permission")
 	})
 
 	// 逐列对账预期(与 T1 core-model-ddl.sql 一致)
@@ -90,7 +91,11 @@ func TestAutoMigrateReconcile(t *testing.T) {
 		{"t_identity", "identity_id", "bigint", "NO", true},
 		{"t_identity", "canonical_user_id", "varchar(26)", "NO", false},
 		{"t_identity", "type", "varchar(32)", "NO", false},
+		{"t_identity", "appid", "varchar(64)", "NO", false}, // v1.2:NOT NULL DEFAULT '',进唯一键
 		{"t_identity", "verified", "tinyint(1)", "NO", false},
+		{"t_wechat_union", "id", "bigint", "NO", true},
+		{"t_wechat_union", "canonical_user_id", "varchar(26)", "NO", false},
+		{"t_wechat_union", "unionid", "varchar(100)", "NO", false},
 		{"t_membership", "membership_id", "bigint", "NO", true},
 		{"t_membership", "canonical_user_id", "varchar(26)", "NO", false},
 		{"t_membership", "role", "varchar(32)", "NO", false},