314 lines
11 KiB
Go
314 lines
11 KiB
Go
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")
|
||
}
|
||
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
|
||
}
|