2026-07-22 18:36:49 +08:00

314 lines
11 KiB
Go
Raw 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 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, &regionsJSON, &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")
}
if _, _, _, _, err := s.lookupSeller(ctx, appCode, req.SellerUserID, true); err != nil {
return itemDTO{}, err
}
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) 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
}