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, 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 countriesJSON []byte if err := rows.Scan(&item.AppCode, &sellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { return nil, err } item.SellerUserID = strconv.FormatInt(sellerUserID, 10) if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil { return nil, fmt.Errorf("parse service countries for seller %d: %w", sellerUserID, err) } 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") } if _, _, _, _, err := s.lookupSeller(ctx, appCode, req.SellerUserID, true); err != nil { return itemDTO{}, err } if err := s.ensureSchema(ctx); err != nil { return itemDTO{}, err } countries := normalizeCountries(req.ServiceCountryCodes) 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, 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), 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, 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) 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, 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 提现币商白名单与兑换比例'`) 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 normalizeAppCode(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { return "lalu" } return value }