package payment import ( "context" "fmt" "hash/fnv" "strconv" "strings" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/config" "hyapp-admin-server/internal/repository" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" ) const ( legacySyntheticRegionIDBase int64 = 9_000_000_000_000_000_000 legacySyntheticRegionIDRange uint64 = 223_372_036_854_775_807 ) type MoneyRegionSource interface { ListMoneyMasterData(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) CountryBelongsToRegion(ctx context.Context, appCode string, regionID int64, countryCode string) (bool, bool, error) } type MongoMoneyRegionSource struct { collection *mongo.Collection config config.MoneyRegionSourceConfig } type legacyRegionDocument struct { ID any `bson:"_id"` RegionCode string `bson:"regionCode"` SysOrigin string `bson:"sysOrigin"` RegionName string `bson:"regionName"` CountryCodes string `bson:"countryCodes"` Del bool `bson:"del"` } func NewMongoMoneyRegionSource(database *mongo.Database, sourceConfig config.MoneyRegionSourceConfig) MoneyRegionSource { if database == nil || !sourceConfig.Enabled { return nil } collectionName := strings.TrimSpace(sourceConfig.MongoCollection) if collectionName == "" { collectionName = "sys_region_config" } sourceConfig.AppCode = appctx.Normalize(sourceConfig.AppCode) sourceConfig.AppName = strings.TrimSpace(sourceConfig.AppName) sourceConfig.SysOrigin = strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin)) sourceConfig.MongoCollection = collectionName return &MongoMoneyRegionSource{collection: database.Collection(collectionName), config: sourceConfig} } func (s *MongoMoneyRegionSource) ListMoneyMasterData(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) { if s == nil || s.collection == nil { return nil, nil, nil, nil } appCode := appctx.Normalize(s.config.AppCode) if appCode == "" || !moneyAccessAllowsApp(access, appCode) { return nil, nil, nil, nil } filter := bson.M{ "sysOrigin": s.config.SysOrigin, "del": false, } // Yumi/Aslan 的区域事实仍由 legacy likei Mongo 持有;admin 只读与 Java 当前查询等价的字段,不复制对方写模型。 cursor, err := s.collection.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "createTime", Value: -1}})) if err != nil { return nil, nil, nil, fmt.Errorf("query legacy money regions for %s: %w", appCode, err) } defer cursor.Close(ctx) documents := []legacyRegionDocument{} for cursor.Next(ctx) { var document legacyRegionDocument if err := cursor.Decode(&document); err != nil { return nil, nil, nil, fmt.Errorf("decode legacy money region for %s: %w", appCode, err) } documents = append(documents, document) } if err := cursor.Err(); err != nil { return nil, nil, nil, fmt.Errorf("iterate legacy money regions for %s: %w", appCode, err) } return legacyRegionDocumentsToMoneyData(s.config, documents, access) } func (s *MongoMoneyRegionSource) CountryBelongsToRegion(ctx context.Context, appCode string, regionID int64, countryCode string) (bool, bool, error) { if s == nil || s.collection == nil || appctx.Normalize(s.config.AppCode) != appctx.Normalize(appCode) { return false, false, nil } filter := bson.M{ "sysOrigin": s.config.SysOrigin, "del": false, } cursor, err := s.collection.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "createTime", Value: -1}})) if err != nil { return false, false, fmt.Errorf("query legacy region countries for %s: %w", appCode, err) } defer cursor.Close(ctx) for cursor.Next(ctx) { var document legacyRegionDocument if err := cursor.Decode(&document); err != nil { return false, false, fmt.Errorf("decode legacy region countries for %s: %w", appCode, err) } documentRegionID, _, err := legacyMoneyRegionID(s.config, document) if err != nil { return false, false, err } if documentRegionID != regionID { continue } for _, item := range splitLegacyCountryCodes(document.CountryCodes) { if item == strings.ToUpper(strings.TrimSpace(countryCode)) { return true, true, nil } } return false, true, nil } if err := cursor.Err(); err != nil { return false, false, fmt.Errorf("iterate legacy region countries for %s: %w", appCode, err) } return false, false, nil } func (s *MongoMoneyRegionSource) CountryCodesForRegion(ctx context.Context, appCode string, regionID int64) ([]string, bool, error) { if s == nil || s.collection == nil || appctx.Normalize(s.config.AppCode) != appctx.Normalize(appCode) { return nil, false, nil } filter := bson.M{ "sysOrigin": s.config.SysOrigin, "del": false, } cursor, err := s.collection.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "createTime", Value: -1}})) if err != nil { return nil, false, fmt.Errorf("query legacy region countries for %s: %w", appCode, err) } defer cursor.Close(ctx) for cursor.Next(ctx) { var document legacyRegionDocument if err := cursor.Decode(&document); err != nil { return nil, false, fmt.Errorf("decode legacy region countries for %s: %w", appCode, err) } documentRegionID, _, err := legacyMoneyRegionID(s.config, document) if err != nil { return nil, false, err } if documentRegionID != regionID { continue } // 大屏外接源只认 legacy country_code;这里集中复用财务范围的 Mongo 区域口径,避免每个外接 App 自己维护一套区域映射。 return splitLegacyCountryCodes(document.CountryCodes), true, nil } if err := cursor.Err(); err != nil { return nil, false, fmt.Errorf("iterate legacy region countries for %s: %w", appCode, err) } return nil, false, nil } func legacyRegionDocumentsToMoneyData(sourceConfig config.MoneyRegionSourceConfig, documents []legacyRegionDocument, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) { appCode := appctx.Normalize(sourceConfig.AppCode) if appCode == "" || !moneyAccessAllowsApp(access, appCode) { return nil, nil, nil, nil } appName := strings.TrimSpace(sourceConfig.AppName) if appName == "" { appName = appCode } sysOrigin := strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin)) regions := []moneyRegionDTO{} countries := []moneyCountryDTO{} seenRegionIDs := map[int64]string{} for index, document := range documents { if document.Del || sysOrigin != "" && !strings.EqualFold(strings.TrimSpace(document.SysOrigin), sysOrigin) { continue } regionID, rawRegionID, err := legacyMoneyRegionID(sourceConfig, document) if err != nil { return nil, nil, nil, err } if previousRawID, ok := seenRegionIDs[regionID]; ok && previousRawID != rawRegionID { return nil, nil, nil, fmt.Errorf("legacy money region id collision for %s: %s and %s", appCode, previousRawID, rawRegionID) } seenRegionIDs[regionID] = rawRegionID if !access.All && !access.Allows(appCode, regionID) { continue } regionCode := strings.TrimSpace(document.RegionCode) if regionCode == "" { regionCode = strconv.FormatInt(regionID, 10) } regionName := strings.TrimSpace(document.RegionName) if regionName == "" { regionName = regionCode } countryCodes := splitLegacyCountryCodes(document.CountryCodes) regions = append(regions, moneyRegionDTO{ AppCode: appCode, RegionID: regionID, RegionCode: regionCode, Name: regionName, Status: "active", SortOrder: index + 1, Countries: countryCodes, }) for countryIndex, countryCode := range countryCodes { countries = append(countries, moneyCountryDTO{ AppCode: appCode, CountryCode: countryCode, CountryName: countryCode, CountryDisplayName: countryCode, Enabled: true, RegionID: regionID, SortOrder: countryIndex + 1, }) } } return []moneyAppDTO{{ AppCode: appCode, AppName: appName, Platform: "legacy", Status: "active", }}, regions, countries, nil } func legacyMoneyRegionID(sourceConfig config.MoneyRegionSourceConfig, document legacyRegionDocument) (int64, string, error) { appCode := appctx.Normalize(sourceConfig.AppCode) rawID := legacyRawRegionID(document.ID) if rawID == "" { return 0, "", fmt.Errorf("legacy money region %s has empty id", appCode) } if numericID, err := strconv.ParseInt(rawID, 10, 64); err == nil && numericID > 0 { return numericID, rawID, nil } // Java legacy 数据里历史区域多数是雪花数字字符串,但线上已存在 Mongo ObjectId;admin 授权表只能存 int64, // 因此把 appCode + 原始 _id 哈希到 9e18 以上的保留正整数段,保证同一 legacy 区域跨进程、跨部署稳定。 hash := fnv.New64a() _, _ = hash.Write([]byte(appCode)) _, _ = hash.Write([]byte{0}) _, _ = hash.Write([]byte(rawID)) return legacySyntheticRegionIDBase + int64(hash.Sum64()%legacySyntheticRegionIDRange), rawID, nil } func legacyRawRegionID(value any) string { switch typed := value.(type) { case nil: return "" case string: return strings.TrimSpace(typed) case bson.ObjectID: return typed.Hex() case fmt.Stringer: return strings.TrimSpace(typed.String()) default: return strings.TrimSpace(fmt.Sprint(typed)) } } func (h *Handler) listMoneyRegionSources(ctx context.Context, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) { if h == nil || len(h.moneyRegionSources) == 0 { return nil, nil, nil, nil } apps := []moneyAppDTO{} regions := []moneyRegionDTO{} countries := []moneyCountryDTO{} for _, source := range h.moneyRegionSources { sourceApps, sourceRegions, sourceCountries, err := source.ListMoneyMasterData(ctx, access) if err != nil { return nil, nil, nil, err } apps = appendUniqueMoneyApps(apps, sourceApps) regions = appendUniqueMoneyRegions(regions, sourceRegions) countries = appendUniqueMoneyCountries(countries, sourceCountries) } return apps, regions, countries, nil } func splitLegacyCountryCodes(value string) []string { parts := strings.FieldsFunc(value, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' || r == '\r' }) out := make([]string, 0, len(parts)) seen := map[string]struct{}{} for _, part := range parts { code := strings.ToUpper(strings.TrimSpace(part)) if code == "" { continue } if _, ok := seen[code]; ok { continue } seen[code] = struct{}{} out = append(out, code) } return out } func appendUniqueMoneyApps(base []moneyAppDTO, extra []moneyAppDTO) []moneyAppDTO { seen := map[string]struct{}{} for _, item := range base { seen[appctx.Normalize(item.AppCode)] = struct{}{} } for _, item := range extra { key := appctx.Normalize(item.AppCode) if key == "" { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} base = append(base, item) } return base } func appendUniqueMoneyRegions(base []moneyRegionDTO, extra []moneyRegionDTO) []moneyRegionDTO { seen := map[string]struct{}{} for _, item := range base { seen[fmt.Sprintf("%s:%d", appctx.Normalize(item.AppCode), item.RegionID)] = struct{}{} } for _, item := range extra { key := fmt.Sprintf("%s:%d", appctx.Normalize(item.AppCode), item.RegionID) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} base = append(base, item) } return base } func appendUniqueMoneyCountries(base []moneyCountryDTO, extra []moneyCountryDTO) []moneyCountryDTO { seen := map[string]struct{}{} for _, item := range base { seen[fmt.Sprintf("%s:%d:%s", appctx.Normalize(item.AppCode), item.RegionID, strings.ToUpper(item.CountryCode))] = struct{}{} } for _, item := range extra { key := fmt.Sprintf("%s:%d:%s", appctx.Normalize(item.AppCode), item.RegionID, strings.ToUpper(item.CountryCode)) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} base = append(base, item) } return base }