package fixture import ( "database/sql" "fmt" "time" ) // 本文件是第三批的**纯映射规则**(不连库、不取当前时间)。 // // 三条需要写清楚、否则会被当成"随手填"的判断: // // 1. `status`:legacy 是 `tinyint(1)`(1=正常 / 0=停用),target 是 `VARCHAR(16)`(active/disabled)。 // 这是**值域翻译**,不是占位:1→active、0→disabled;出现 1/0 之外的值直接报错, // 不猜(更不默认 active —— 那会把已停用的机构/校区悄悄"复活")。 // 2. `institution_code` / `address` / `phone`:legacy **根本没有这三列** // (t_school 只有 province_id/city_id/district_id)→ 一律 NULL,不编造。 // `uk_institution_code` 是 UNIQUE 但允许 NULL(MySQL 唯一键对 NULL 放行多行),不会撞键。 // 3. `created_at` / `updated_at`:legacy 无时间列 → **epoch 哨兵**(与 t_user_divide_into 同口径)。 // 这两列虽然已有 `DEFAULT CURRENT_TIMESTAMP(3)`,但取默认值会让同一份数据每次迁移结果不同, // 双跑/回滚就失去可比性。审计列也不需要"迁移时刻"以外的语义。 // legacyStatusToTarget 把 legacy 的 tinyint 状态翻译成 target 的 active/disabled。 // // 只接受 1 和 0:其它值说明这一列在旧系统里有我们没搞懂的含义,报错比猜安全。 func legacyStatusToTarget(v int8, what string) (string, error) { switch v { case 1: return "active", nil case 0: return "disabled", nil default: return "", fmt.Errorf("%s.status = %d,超出可解释范围(1=active/0=disabled),拒绝猜测", what, v) } } // MapInstitution 映射一个机构(D1:institution_id 原值)。 func MapInstitution(l LegacySchool) (TargetInstitution, []string, error) { var warns []string status, err := legacyStatusToTarget(l.Status, fmt.Sprintf("t_school(id=%d)", l.ID)) if err != nil { return TargetInstitution{}, warns, err } if l.Name == "" { return TargetInstitution{}, warns, fmt.Errorf("t_school(id=%d).name 为空,目标态 institution_name NOT NULL,拒绝填占位值", l.ID) } // 省市区有值但 target 无对应列(address 是自由文本)—— 记录一条告警,不编造地址 if l.ProvinceID > 0 || l.CityID > 0 || l.DistrictID > 0 { warns = append(warns, fmt.Sprintf( "t_school(id=%d) 有省市区 id(%d/%d/%d)但 target t_institution 无对应列,address 落 NULL", l.ID, l.ProvinceID, l.CityID, l.DistrictID)) } return TargetInstitution{ InstitutionID: l.ID, // D1 InstitutionName: l.Name, InstitutionCode: sql.NullString{}, // legacy 无此列 Address: sql.NullString{}, // 同上 Phone: sql.NullString{}, // 同上 Status: status, CreatedAt: epochSentinel(), UpdatedAt: epochSentinel(), }, warns, nil } // MapCampus 映射一个校区(D1:campus_id 原值)。 // // `institution_id` **如实保留** legacy `school_id`(R17):campus 1/2 的 school_id=1, // 而数据里它们常与 schoolid=63317 一起出现 —— 这是旧系统的跨机构校区, // 这里不修正、不反推、不特判,只在报告里标 cross-org-campus。 func MapCampus(l LegacyCampus) (TargetCampus, []string, error) { var warns []string if l.ID == 0 { return TargetCampus{}, warns, fmt.Errorf("campus_id=0 不建行(0 是机构级约定值),调用方应过滤") } if l.SchoolID == 0 { return TargetCampus{}, warns, fmt.Errorf( "t_school_campus(id=%d).school_id=0,无法确定所属机构,拒绝建孤儿校区", l.ID) } if l.Name == "" { return TargetCampus{}, warns, fmt.Errorf( "t_school_campus(id=%d).name 为空,目标态 campus_name NOT NULL,拒绝填占位值", l.ID) } status, err := legacyStatusToTarget(l.Status, fmt.Sprintf("t_school_campus(id=%d)", l.ID)) if err != nil { return TargetCampus{}, warns, err } if l.SelfSupport != 0 && l.SelfSupport != 1 { return TargetCampus{}, warns, fmt.Errorf( "t_school_campus(id=%d).self_support = %d,bit(1) 只应取 0/1,拒绝猜测", l.ID, l.SelfSupport) } if l.ProvinceID > 0 || l.CityID > 0 || l.DistrictID > 0 { warns = append(warns, fmt.Sprintf( "t_school_campus(id=%d) 有省市区 id(%d/%d/%d)但 target t_campus 无对应列,address 落 NULL", l.ID, l.ProvinceID, l.CityID, l.DistrictID)) } return TargetCampus{ CampusID: l.ID, // D1 InstitutionID: l.SchoolID, CampusName: l.Name, CampusCode: truncateTo(l.Code, 32, fmt.Sprintf("t_school_campus(id=%d).code", l.ID), &warns), Address: sql.NullString{}, Phone: sql.NullString{}, Status: status, SelfSupport: l.SelfSupport, CreatedAt: epochSentinel(), UpdatedAt: epochSentinel(), }, warns, nil } // IsCrossOrgCampus 判定「数据里的 (schoolid, campusid) 与校区的真实所属机构不一致」。 // // 只用于报告标注(R17),不参与任何修正。campusOwner 是 campus_id → 真实 school_id 的索引。 func IsCrossOrgCampus(schoolID, campusID int64, campusOwner map[int64]int64) bool { if campusID == 0 { return false } owner, ok := campusOwner[campusID] if !ok { return false } return owner != schoolID } // orgEpoch 便于单测断言哨兵值。 func orgEpoch() time.Time { return epochSentinel() }