package pointwithdrawalconfig import ( "context" "database/sql" "encoding/json" "errors" "fmt" "sort" "strconv" "strings" "time" ) type Service struct { walletDB *sql.DB userDB *sql.DB } func NewService(walletDB *sql.DB, userDB *sql.DB) *Service { return &Service{walletDB: walletDB, userDB: userDB} } // List 按 app_code 读取同一套公共配置表,并补齐用户资料;孤儿配置仍返回,便于运营删除而不是静默隐藏。 func (s *Service) List(ctx context.Context, appCode string) ([]itemDTO, error) { if err := s.ensureSchema(ctx); err != nil { return nil, err } rows, err := s.walletDB.QueryContext(ctx, ` SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, COALESCE(service_region_ids, JSON_ARRAY()), service_country_codes, status, created_at_ms, updated_at_ms FROM point_withdrawal_coin_seller_configs WHERE app_code = ? ORDER BY sort_order ASC, seller_user_id ASC`, normalizeAppCode(appCode)) if err != nil { return nil, err } defer rows.Close() items := make([]itemDTO, 0) for rows.Next() { var item itemDTO var sellerUserID int64 var regionsJSON []byte var countriesJSON []byte if err := rows.Scan(&item.AppCode, &sellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, ®ionsJSON, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { return nil, err } item.SellerUserID = strconv.FormatInt(sellerUserID, 10) if err := json.Unmarshal(regionsJSON, &item.ServiceRegionIDs); err != nil { return nil, fmt.Errorf("parse service regions for seller %d: %w", sellerUserID, err) } if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil { return nil, fmt.Errorf("parse service countries for seller %d: %w", sellerUserID, err) } item.ServiceRegionIDs = normalizeRegionIDs(item.ServiceRegionIDs) item.ServiceCountryCodes = normalizeCountries(item.ServiceCountryCodes) displayUserID, nickname, avatar, countryName, lookupErr := s.lookupSeller(ctx, item.AppCode, sellerUserID, false) if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) { return nil, lookupErr } item.DisplayUserID, item.Nickname, item.Avatar, item.CountryName = displayUserID, nickname, avatar, countryName items = append(items, item) } return items, rows.Err() } // Upsert 在写白名单前验证同 App 的 active 币商身份,防止普通用户被配置成账务收款方。 func (s *Service) Upsert(ctx context.Context, appCode string, req upsertRequest, adminID int64) (itemDTO, error) { appCode = normalizeAppCode(appCode) if req.SellerUserID <= 0 || req.PointAmount <= 0 || req.SellerCoinAmount <= 0 || req.SortOrder < 0 { return itemDTO{}, errors.New("币商、排序和兑换比例必须是正数") } status := strings.ToLower(strings.TrimSpace(req.Status)) if status == "" { status = "active" } if status != "active" && status != "disabled" { return itemDTO{}, errors.New("状态只能是 active 或 disabled") } resolvedSellerUserID, err := s.resolveActiveSellerUserID(ctx, appCode, req.SellerUserID) if err != nil { return itemDTO{}, err } // 后台运营看到并输入的是短展示号,而提现配置必须持久化不可变的内部 user_id;解析后统一覆盖请求值, // 避免展示号变更时配置失联,也避免 18 位内部 ID 经过浏览器 Number 后发生精度损失。 req.SellerUserID = resolvedSellerUserID if err := s.ensureSchema(ctx); err != nil { return itemDTO{}, err } // 非法值不能在规范化时被静默丢弃,否则“输入错误”会意外扩大成全部区域、全部国家。 for _, regionID := range req.ServiceRegionIDs { if regionID <= 0 { return itemDTO{}, errors.New("服务区域 ID 不正确") } } for _, countryCode := range req.ServiceCountryCodes { countryCode = strings.TrimSpace(countryCode) if countryCode == "" || len(countryCode) > 3 { return itemDTO{}, errors.New("服务国家不正确") } } regions := normalizeRegionIDs(req.ServiceRegionIDs) countries := normalizeCountries(req.ServiceCountryCodes) if len(regions) > 0 && len(countries) > 0 { return itemDTO{}, errors.New("服务区域和服务国家不能同时选择") } // 运营只能选择当前 App 的有效主数据;拒绝已停用或不存在的值,避免资金资格配置悬空。 if err := s.validateServiceScope(ctx, appCode, regions, countries); err != nil { return itemDTO{}, err } regionsJSON, err := json.Marshal(regions) if err != nil { return itemDTO{}, err } countriesJSON, err := json.Marshal(countries) if err != nil { return itemDTO{}, err } nowMS := time.Now().UTC().UnixMilli() _, err = s.walletDB.ExecContext(ctx, ` INSERT INTO point_withdrawal_coin_seller_configs ( app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, service_country_codes, service_region_ids, status, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE sort_order = VALUES(sort_order), point_amount = VALUES(point_amount), seller_coin_amount = VALUES(seller_coin_amount), service_country_codes = VALUES(service_country_codes), service_region_ids = VALUES(service_region_ids), status = VALUES(status), updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`, appCode, req.SellerUserID, req.SortOrder, req.PointAmount, req.SellerCoinAmount, countriesJSON, regionsJSON, status, adminID, adminID, nowMS, nowMS) if err != nil { return itemDTO{}, err } return s.Get(ctx, appCode, req.SellerUserID) } func (s *Service) Get(ctx context.Context, appCode string, sellerUserID int64) (itemDTO, error) { items, err := s.List(ctx, appCode) if err != nil { return itemDTO{}, err } needle := strconv.FormatInt(sellerUserID, 10) for _, item := range items { if item.SellerUserID == needle { return item, nil } } return itemDTO{}, sql.ErrNoRows } func (s *Service) Delete(ctx context.Context, appCode string, sellerUserID int64) error { if sellerUserID <= 0 { return errors.New("币商 ID 不正确") } if err := s.ensureSchema(ctx); err != nil { return err } result, err := s.walletDB.ExecContext(ctx, `DELETE FROM point_withdrawal_coin_seller_configs WHERE app_code = ? AND seller_user_id = ?`, normalizeAppCode(appCode), sellerUserID) if err != nil { return err } affected, err := result.RowsAffected() if err != nil { return err } if affected == 0 { return sql.ErrNoRows } return nil } func (s *Service) lookupSeller(ctx context.Context, appCode string, sellerUserID int64, requireActive bool) (displayUserID string, nickname string, avatar string, countryName string, err error) { if s.userDB == nil { return "", "", "", "", errors.New("user db is not configured") } query := ` SELECT COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''), COALESCE(u.avatar, ''), COALESCE(c.country_display_name, c.country_name, u.country, '') FROM coin_seller_profiles csp JOIN users u ON u.user_id = csp.user_id AND u.app_code = csp.app_code LEFT JOIN countries c ON c.app_code = u.app_code AND c.country_code = u.country WHERE csp.app_code = ? AND csp.user_id = ?` if requireActive { query += " AND csp.status = 'active' AND u.status = 'active'" } query += " LIMIT 1" err = s.userDB.QueryRowContext(ctx, query, appCode, sellerUserID).Scan(&displayUserID, &nickname, &avatar, &countryName) if errors.Is(err, sql.ErrNoRows) && requireActive { return "", "", "", "", errors.New("目标用户不是当前 App 的启用币商") } return } func (s *Service) resolveActiveSellerUserID(ctx context.Context, appCode string, inputID int64) (int64, error) { if s.userDB == nil { return 0, errors.New("user db is not configured") } // 与后台其他用户身份入口保持一致:数字展示号优先于同值内部 ID。两次查询分别命中 // users(app_code,current_display_user_id) 唯一索引和 users 主键,避免 OR 条件破坏索引选择。 var sellerUserID int64 err := s.userDB.QueryRowContext(ctx, ` SELECT u.user_id FROM users u JOIN coin_seller_profiles csp ON csp.user_id = u.user_id AND csp.app_code = u.app_code WHERE u.app_code = ? AND u.current_display_user_id = ? AND u.status = 'active' AND csp.status = 'active' LIMIT 1`, appCode, strconv.FormatInt(inputID, 10)).Scan(&sellerUserID) if err == nil { return sellerUserID, nil } if !errors.Is(err, sql.ErrNoRows) { return 0, err } err = s.userDB.QueryRowContext(ctx, ` SELECT u.user_id FROM users u JOIN coin_seller_profiles csp ON csp.user_id = u.user_id AND csp.app_code = u.app_code WHERE u.app_code = ? AND u.user_id = ? AND u.status = 'active' AND csp.status = 'active' LIMIT 1`, appCode, inputID).Scan(&sellerUserID) if errors.Is(err, sql.ErrNoRows) { return 0, errors.New("目标用户不是当前 App 的启用币商") } return sellerUserID, err } func (s *Service) validateServiceScope(ctx context.Context, appCode string, regionIDs []int64, countryCodes []string) error { if s.userDB == nil { return errors.New("user db is not configured") } if len(regionIDs) > 0 { args := make([]any, 0, len(regionIDs)+1) args = append(args, appCode) for _, regionID := range regionIDs { args = append(args, regionID) } var count int query := `SELECT COUNT(*) FROM regions WHERE app_code = ? AND status = 'active' AND region_code <> 'GLOBAL' AND region_id IN (` + placeholders(len(regionIDs)) + `)` if err := s.userDB.QueryRowContext(ctx, query, args...).Scan(&count); err != nil { return err } if count != len(regionIDs) { return errors.New("服务区域包含不存在或已停用的区域") } } if len(countryCodes) > 0 { args := make([]any, 0, len(countryCodes)+1) args = append(args, appCode) for _, countryCode := range countryCodes { args = append(args, countryCode) } var count int query := `SELECT COUNT(*) FROM countries WHERE app_code = ? AND enabled = TRUE AND country_code IN (` + placeholders(len(countryCodes)) + `)` if err := s.userDB.QueryRowContext(ctx, query, args...).Scan(&count); err != nil { return err } if count != len(countryCodes) { return errors.New("服务国家包含不存在或已停用的国家") } } return nil } func placeholders(count int) string { if count <= 0 { return "" } return strings.TrimSuffix(strings.Repeat("?,", count), ",") } func (s *Service) ensureSchema(ctx context.Context) error { if s == nil || s.walletDB == nil { return errors.New("wallet db is not configured") } _, err := s.walletDB.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS point_withdrawal_coin_seller_configs ( app_code VARCHAR(32) NOT NULL, seller_user_id BIGINT NOT NULL, sort_order INT NOT NULL DEFAULT 0, point_amount BIGINT NOT NULL, seller_coin_amount BIGINT NOT NULL, service_country_codes JSON NOT NULL, service_region_ids JSON NULL, status VARCHAR(24) NOT NULL DEFAULT 'active', created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, seller_user_id), KEY idx_point_withdraw_seller_list (app_code, status, sort_order, seller_user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT 提现币商白名单与兑换比例'`) if err != nil { return err } var columnCount int if err := s.walletDB.QueryRowContext(ctx, ` SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'point_withdrawal_coin_seller_configs' AND column_name = 'service_region_ids'`).Scan(&columnCount); err != nil { return err } if columnCount > 0 { return nil } // 这是按币商唯一的小型配置表;INSTANT 加 nullable JSON 列不复制表,也不回填历史数据。 _, err = s.walletDB.ExecContext(ctx, ` ALTER TABLE point_withdrawal_coin_seller_configs ADD COLUMN service_region_ids JSON NULL AFTER service_country_codes, ALGORITHM=INSTANT`) return err } func normalizeCountries(values []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(values)) for _, value := range values { value = strings.ToUpper(strings.TrimSpace(value)) if value == "" || len(value) > 16 { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} out = append(out, value) } sort.Strings(out) return out } func normalizeRegionIDs(values []int64) []int64 { seen := map[int64]struct{}{} out := make([]int64, 0, len(values)) for _, value := range values { if value <= 0 { continue } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} out = append(out, value) } sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) return out } func normalizeAppCode(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { return "lalu" } return value }