2026-07-03 21:03:23 +08:00

385 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 !repository.RegionIDLooseEqual(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 !repository.RegionIDLooseEqual(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
}
// RegionCatalogEntry 是给其他模块(社交 BI 等)消费的 legacy 区域目录导出形态;
// RegionID 与财务范围/大屏过滤使用同一套合成 int64 口径。
type RegionCatalogEntry struct {
AppCode string
AppName string
RegionID int64
RegionCode string
RegionName string
Countries []string
}
func (s *MongoMoneyRegionSource) RegionCatalog(ctx context.Context) ([]RegionCatalogEntry, error) {
if s == nil || s.collection == nil {
return nil, nil
}
_, regions, _, err := s.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true})
if err != nil {
return nil, err
}
appName := strings.TrimSpace(s.config.AppName)
entries := make([]RegionCatalogEntry, 0, len(regions))
for _, region := range regions {
entries = append(entries, RegionCatalogEntry{
AppCode: region.AppCode,
AppName: appName,
RegionID: region.RegionID,
RegionCode: region.RegionCode,
RegionName: region.Name,
Countries: region.Countries,
})
}
return entries, 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 ObjectIdadmin 授权表只能存 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
}