1184 lines
39 KiB
Go
1184 lines
39 KiB
Go
package coinledger
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"database/sql"
|
||
"encoding/csv"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"hyapp-admin-server/internal/integration/walletclient"
|
||
"hyapp-admin-server/internal/modules/shared"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
)
|
||
|
||
const (
|
||
coinAssetType = "COIN"
|
||
coinSellerAssetType = "COIN_SELLER_COIN"
|
||
coinManualCreditBizType = "manual_credit"
|
||
coinSellerTransferBizType = "coin_seller_transfer"
|
||
coinSellerSubTransferBizType = "coin_seller_sub_transfer"
|
||
coinSellerRechargeBizType = "coin_seller_recharge"
|
||
coinSellerStockPurchaseBizType = "coin_seller_stock_purchase"
|
||
coinSellerStockDeductionBizType = "coin_seller_stock_deduction"
|
||
coinSellerCoinCompensationBizType = "coin_seller_coin_compensation"
|
||
salaryTransferToCoinSellerBizType = "salary_transfer_to_coin_seller"
|
||
coinSellerLedgerTypeAdminStockCredit = "admin_stock_credit"
|
||
coinSellerLedgerTypeSellerTransfer = "seller_transfer"
|
||
coinSellerLedgerTypeSubSellerTransfer = "sub_seller_transfer"
|
||
coinSellerLedgerTypeSalaryTransferIncome = "salary_transfer_received"
|
||
directionIn = "income"
|
||
directionOut = "expense"
|
||
)
|
||
|
||
var (
|
||
errCoinAdjustmentTargetNotFound = errors.New("coin adjustment target user not found")
|
||
errInvalidCoinSellerLedgerType = errors.New("coin seller ledger type is invalid")
|
||
)
|
||
|
||
type Service struct {
|
||
userDB *sql.DB
|
||
walletDB *sql.DB
|
||
adminDB *sql.DB
|
||
walletClient walletclient.Client
|
||
}
|
||
|
||
type userProfile struct {
|
||
UserID int64
|
||
DisplayUserID string
|
||
Username string
|
||
Avatar string
|
||
}
|
||
|
||
func NewService(userDB *sql.DB, walletDB *sql.DB, adminDB *sql.DB, walletClient walletclient.Client) *Service {
|
||
return &Service{userDB: userDB, walletDB: walletDB, adminDB: adminDB, walletClient: walletClient}
|
||
}
|
||
|
||
func (s *Service) ListCoinLedger(ctx context.Context, appCode string, query listQuery) ([]coinLedgerEntryDTO, int64, error) {
|
||
query = normalizeListQuery(query)
|
||
if s == nil || s.walletDB == nil {
|
||
return nil, 0, fmt.Errorf("wallet mysql is not configured")
|
||
}
|
||
|
||
userIDs, userFiltered, err := s.resolveUserFilter(ctx, appCode, query.UserKeyword, query.UserFilter)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if userFiltered && len(userIDs) == 0 {
|
||
return []coinLedgerEntryDTO{}, 0, nil
|
||
}
|
||
|
||
// 后台只做只读查询:金币流水事实仍以 wallet_entries 追加分录为准,不在 admin 库冗余账务状态。
|
||
// 普通打开列表时不带 biz_type,total 只依赖 entries 自身;避免为了一个总数对百万级 COIN 分录逐行回表 join 交易主表。
|
||
whereSQL, args := coinLedgerWhere(appCode, query, userIDs)
|
||
var total int64
|
||
if query.BizType == "" {
|
||
if err := s.walletDB.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM wallet_entries e
|
||
`+whereSQL,
|
||
args...,
|
||
).Scan(&total); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
} else {
|
||
if err := s.walletDB.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM wallet_entries e
|
||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||
`+whereSQL,
|
||
args...,
|
||
).Scan(&total); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
}
|
||
|
||
rows, err := s.walletDB.QueryContext(ctx, `
|
||
SELECT e.entry_id, e.transaction_id, wt.command_id, wt.external_ref, e.user_id, wt.biz_type,
|
||
e.available_delta, e.available_after, e.counterparty_user_id,
|
||
e.room_id, COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
|
||
FROM wallet_entries e
|
||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||
`+whereSQL+`
|
||
ORDER BY e.created_at_ms DESC, e.entry_id DESC
|
||
LIMIT ? OFFSET ?`,
|
||
append(args, query.PageSize, offset(query.Page, query.PageSize))...,
|
||
)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]coinLedgerEntryDTO, 0, query.PageSize)
|
||
itemUserIDs := make([]int64, 0, query.PageSize)
|
||
for rows.Next() {
|
||
var item coinLedgerEntryDTO
|
||
var userID int64
|
||
var counterpartyUserID int64
|
||
var metadataJSON string
|
||
if err := rows.Scan(
|
||
&item.EntryID,
|
||
&item.TransactionID,
|
||
&item.CommandID,
|
||
&item.ExternalRef,
|
||
&userID,
|
||
&item.BizType,
|
||
&item.AvailableDelta,
|
||
&item.AvailableAfter,
|
||
&counterpartyUserID,
|
||
&item.RoomID,
|
||
&metadataJSON,
|
||
&item.CreatedAtMS,
|
||
); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
metadata, err := parseMetadataJSON(metadataJSON)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
item.UserID = strconv.FormatInt(userID, 10)
|
||
item.User = coinLedgerUserDTO{UserID: item.UserID}
|
||
item.CounterpartyUserID = formatOptionalID(counterpartyUserID)
|
||
item.Metadata = metadata
|
||
item.Direction = directionForDelta(item.AvailableDelta)
|
||
item.Amount = absInt64(item.AvailableDelta)
|
||
items = append(items, item)
|
||
itemUserIDs = append(itemUserIDs, userID)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
profiles, err := s.userProfiles(ctx, appCode, itemUserIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
for i := range items {
|
||
userID, _ := strconv.ParseInt(items[i].UserID, 10, 64)
|
||
if profile, ok := profiles[userID]; ok {
|
||
items[i].User = coinLedgerUserDTO{
|
||
UserID: strconv.FormatInt(profile.UserID, 10),
|
||
DisplayUserID: profile.DisplayUserID,
|
||
Username: profile.Username,
|
||
Avatar: profile.Avatar,
|
||
}
|
||
}
|
||
}
|
||
return items, total, nil
|
||
}
|
||
|
||
func (s *Service) ListCoinSellerLedger(ctx context.Context, appCode string, query coinSellerLedgerQuery) ([]coinSellerLedgerDTO, int64, error) {
|
||
return s.listCoinSellerLedger(ctx, appCode, query, true)
|
||
}
|
||
|
||
func (s *Service) ExportCoinSellerLedger(ctx context.Context, appCode string, query coinSellerLedgerQuery) (CSVExport, error) {
|
||
items, total, err := s.listCoinSellerLedger(ctx, appCode, query, false)
|
||
if err != nil {
|
||
return CSVExport{}, err
|
||
}
|
||
var buf bytes.Buffer
|
||
writer := csv.NewWriter(&buf)
|
||
_ = writer.Write([]string{"币商名称", "币商短ID", "币商长ID", "流水类型", "金币变动", "转账USD金额", "USDT数量", "收款人名称", "收款人短ID", "收款人长ID", "操作人", "操作人ID", "币商余额", "转账时间", "交易ID", "命令ID"})
|
||
for _, item := range items {
|
||
operatorName, operatorID := coinSellerLedgerOperatorExportFields(item)
|
||
_ = writer.Write([]string{
|
||
item.Seller.Username,
|
||
item.Seller.DisplayUserID,
|
||
item.Seller.UserID,
|
||
coinSellerLedgerLabel(item),
|
||
strconv.FormatInt(signedCoinSellerLedgerAmount(item), 10),
|
||
formatUSDMinorForCSV(item.TransferUSDMinor),
|
||
formatUSDTMicroForCSV(item.PaidAmountMicro),
|
||
item.Receiver.Username,
|
||
item.Receiver.DisplayUserID,
|
||
item.Receiver.UserID,
|
||
operatorName,
|
||
operatorID,
|
||
strconv.FormatInt(item.SellerBalanceAfter, 10),
|
||
time.UnixMilli(item.CreatedAtMS).UTC().Format(time.RFC3339),
|
||
item.TransactionID,
|
||
item.CommandID,
|
||
})
|
||
}
|
||
writer.Flush()
|
||
return CSVExport{FileName: "hyapp-coin-seller-ledger.csv", Content: buf.Bytes(), Count: int(total)}, writer.Error()
|
||
}
|
||
|
||
func (s *Service) listCoinSellerLedger(ctx context.Context, appCode string, query coinSellerLedgerQuery, paginated bool) ([]coinSellerLedgerDTO, int64, error) {
|
||
query = normalizeCoinSellerLedgerQuery(query)
|
||
if s == nil || s.walletDB == nil {
|
||
return nil, 0, fmt.Errorf("wallet mysql is not configured")
|
||
}
|
||
|
||
// 先把前端筛选解析成币商 user_id 集合,再进入 wallet_entries 查询;
|
||
// 这样 seller_keyword 只影响币商本人范围,不会因为收款用户昵称或短 ID 命中而把其他币商流水带出来。
|
||
sellerIDs, sellerFiltered, err := s.resolveCoinSellerFilter(ctx, appCode, query)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if sellerFiltered && len(sellerIDs) == 0 {
|
||
// 用户库已经确认没有匹配币商时直接返回空分页,避免对 wallet 库做没有结果意义的全量账本扫描。
|
||
return []coinSellerLedgerDTO{}, 0, nil
|
||
}
|
||
|
||
// 币商流水只读币商专用金币分录;wallet-service 仍是账务事实 owner,后台不回写余额、不补账、不改变交易状态,
|
||
// 这里只按运营筛选把 wallet_entries 与 wallet_transactions 的事实组装成后台展示投影。
|
||
whereSQL, args, err := coinSellerLedgerWhere(appCode, query, sellerIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
var total int64
|
||
if paginated {
|
||
if err := s.walletDB.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM wallet_entries e
|
||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||
`+whereSQL,
|
||
args...,
|
||
).Scan(&total); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
}
|
||
|
||
// 明细查询只取币商侧 COIN_SELLER_COIN 分录:available_delta 表示币商库存可用变化,
|
||
// available_after 表示该币商分录落账后的库存余额,receiver 再按 biz_type 从 metadata/counterparty 补出。
|
||
querySQL := `
|
||
SELECT e.entry_id, e.transaction_id, wt.command_id, e.user_id, wt.biz_type,
|
||
e.available_delta, e.available_after, e.counterparty_user_id,
|
||
COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
|
||
FROM wallet_entries e
|
||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||
` + whereSQL + `
|
||
ORDER BY e.created_at_ms DESC, e.entry_id DESC`
|
||
queryArgs := args
|
||
if paginated {
|
||
querySQL += `
|
||
LIMIT ? OFFSET ?`
|
||
queryArgs = append(queryArgs, query.PageSize, offset(query.Page, query.PageSize))
|
||
}
|
||
rows, err := s.walletDB.QueryContext(ctx, querySQL, queryArgs...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]coinSellerLedgerDTO, 0, query.PageSize)
|
||
profileIDs := make([]int64, 0, query.PageSize*2)
|
||
operatorIDs := make([]int64, 0, query.PageSize)
|
||
for rows.Next() {
|
||
var item coinSellerLedgerDTO
|
||
var sellerUserID int64
|
||
var counterpartyUserID int64
|
||
var metadataJSON string
|
||
if err := rows.Scan(
|
||
&item.EntryID,
|
||
&item.TransactionID,
|
||
&item.CommandID,
|
||
&sellerUserID,
|
||
&item.BizType,
|
||
&item.AvailableDelta,
|
||
&item.SellerBalanceAfter,
|
||
&counterpartyUserID,
|
||
&metadataJSON,
|
||
&item.CreatedAtMS,
|
||
); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
metadata, err := parseMetadataJSON(metadataJSON)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
operatorUserID := metadataInt64(metadata, "operator_user_id", "operatorUserId")
|
||
item.StockType = firstNonEmpty(metadataString(metadata, "stock_type"), metadataString(metadata, "stockType"))
|
||
item.PaidCurrencyCode = firstNonEmpty(metadataString(metadata, "paid_currency_code"), metadataString(metadata, "paidCurrencyCode"))
|
||
item.PaidAmountMicro = metadataInt64(metadata, "paid_amount_micro", "paidAmountMicro")
|
||
item.TransferUSDMinor = metadataInt64(metadata, "salary_usd_minor", "salaryUsdMinor", "transfer_usd_minor", "transferUsdMinor")
|
||
item.Reason = metadataString(metadata, "reason")
|
||
// 收款人按产品口径取实际收款人:币商转用户优先取 metadata.target_user_id,老数据没有 metadata 时再用 counterparty_user_id;
|
||
// 后台入账和工资转币商的实际收款人都是币商本人,所以不能被 counterparty 字段误导成付款方或操作方。
|
||
receiverUserID := coinSellerLedgerReceiverUserID(item.BizType, sellerUserID, counterpartyUserID, metadata)
|
||
item.LedgerType = coinSellerLedgerTypeForBizType(item.BizType)
|
||
item.Direction = directionForDelta(item.AvailableDelta)
|
||
item.Amount = absInt64(item.AvailableDelta)
|
||
item.CounterpartyUserID = formatOptionalID(counterpartyUserID)
|
||
item.OperatorUserID = formatOptionalID(operatorUserID)
|
||
item.Metadata = metadata
|
||
item.Seller = coinLedgerUserDTO{UserID: strconv.FormatInt(sellerUserID, 10)}
|
||
item.Receiver = coinLedgerUserDTO{UserID: strconv.FormatInt(receiverUserID, 10)}
|
||
items = append(items, item)
|
||
profileIDs = append(profileIDs, sellerUserID, receiverUserID)
|
||
if operatorUserID > 0 && item.LedgerType == coinSellerLedgerTypeAdminStockCredit {
|
||
operatorIDs = append(operatorIDs, operatorUserID)
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if !paginated {
|
||
total = int64(len(items))
|
||
}
|
||
|
||
profiles, err := s.userProfiles(ctx, appCode, profileIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
operators, err := s.adminProfiles(ctx, operatorIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
for i := range items {
|
||
sellerUserID, _ := strconv.ParseInt(items[i].Seller.UserID, 10, 64)
|
||
receiverUserID, _ := strconv.ParseInt(items[i].Receiver.UserID, 10, 64)
|
||
// 用户资料只做头像、短 ID 和昵称展示补全;资料缺失时仍返回账本里的 user_id,
|
||
// 避免用户资料迁移、删除或延迟同步把真实账务事实从后台列表里吞掉。
|
||
if profile, ok := profiles[sellerUserID]; ok {
|
||
items[i].Seller = userDTOFromProfile(profile)
|
||
}
|
||
if profile, ok := profiles[receiverUserID]; ok {
|
||
items[i].Receiver = userDTOFromProfile(profile)
|
||
}
|
||
operatorUserID, _ := strconv.ParseInt(items[i].OperatorUserID, 10, 64)
|
||
if operator, ok := operators[operatorUserID]; ok {
|
||
items[i].Operator = operator
|
||
}
|
||
}
|
||
return items, total, nil
|
||
}
|
||
|
||
func (s *Service) ListCoinAdjustments(ctx context.Context, appCode string, query listQuery) ([]coinAdjustmentDTO, int64, error) {
|
||
query = normalizeListQuery(query)
|
||
if s == nil || s.walletDB == nil {
|
||
return nil, 0, fmt.Errorf("wallet mysql is not configured")
|
||
}
|
||
|
||
userIDs, userFiltered, err := s.resolveUserFilter(ctx, appCode, query.UserKeyword, query.UserFilter)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if userFiltered && len(userIDs) == 0 {
|
||
return []coinAdjustmentDTO{}, 0, nil
|
||
}
|
||
|
||
// 金币增减列表只读 wallet-service 账本事实;后台不维护第二份余额或调账流水。
|
||
whereSQL, args := coinAdjustmentWhere(appCode, query, userIDs)
|
||
var total int64
|
||
if err := s.walletDB.QueryRowContext(ctx, `
|
||
SELECT COUNT(*)
|
||
FROM wallet_entries e
|
||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||
`+whereSQL,
|
||
args...,
|
||
).Scan(&total); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
rows, err := s.walletDB.QueryContext(ctx, `
|
||
SELECT e.entry_id, e.transaction_id, wt.command_id, e.user_id,
|
||
e.available_delta, e.available_after, COALESCE(CAST(wt.metadata_json AS CHAR), '{}'), e.created_at_ms
|
||
FROM wallet_entries e
|
||
JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
|
||
`+whereSQL+`
|
||
ORDER BY e.created_at_ms DESC, e.entry_id DESC
|
||
LIMIT ? OFFSET ?`,
|
||
append(args, query.PageSize, offset(query.Page, query.PageSize))...,
|
||
)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]coinAdjustmentDTO, 0, query.PageSize)
|
||
itemUserIDs := make([]int64, 0, query.PageSize)
|
||
operatorIDs := make([]int64, 0, query.PageSize)
|
||
for rows.Next() {
|
||
var item coinAdjustmentDTO
|
||
var userID int64
|
||
var metadataJSON string
|
||
if err := rows.Scan(
|
||
&item.EntryID,
|
||
&item.TransactionID,
|
||
&item.CommandID,
|
||
&userID,
|
||
&item.AvailableDelta,
|
||
&item.AvailableAfter,
|
||
&metadataJSON,
|
||
&item.CreatedAtMS,
|
||
); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
metadata, err := parseMetadataJSON(metadataJSON)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
operatorID := metadataInt64(metadata, "operator_user_id", "operatorUserId")
|
||
item.UserID = strconv.FormatInt(userID, 10)
|
||
item.User = coinLedgerUserDTO{UserID: item.UserID}
|
||
item.Direction = directionForDelta(item.AvailableDelta)
|
||
item.Amount = item.AvailableDelta
|
||
item.Reason = metadataString(metadata, "reason")
|
||
item.Remark = metadataString(metadata, "remark")
|
||
item.OperatorUserID = formatOptionalID(operatorID)
|
||
items = append(items, item)
|
||
itemUserIDs = append(itemUserIDs, userID)
|
||
if operatorID > 0 {
|
||
operatorIDs = append(operatorIDs, operatorID)
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
profiles, err := s.userProfiles(ctx, appCode, itemUserIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
operators, err := s.adminProfiles(ctx, operatorIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
for i := range items {
|
||
userID, _ := strconv.ParseInt(items[i].UserID, 10, 64)
|
||
if profile, ok := profiles[userID]; ok {
|
||
items[i].User = coinLedgerUserDTO{
|
||
UserID: strconv.FormatInt(profile.UserID, 10),
|
||
DisplayUserID: profile.DisplayUserID,
|
||
Username: profile.Username,
|
||
Avatar: profile.Avatar,
|
||
}
|
||
}
|
||
operatorID, _ := strconv.ParseInt(items[i].OperatorUserID, 10, 64)
|
||
if operator, ok := operators[operatorID]; ok {
|
||
items[i].Operator = operator
|
||
}
|
||
}
|
||
return items, total, nil
|
||
}
|
||
|
||
func (s *Service) LookupCoinAdjustmentTarget(ctx context.Context, appCode string, keyword string) (coinLedgerUserDTO, error) {
|
||
keyword = strings.TrimSpace(keyword)
|
||
if keyword == "" {
|
||
return coinLedgerUserDTO{}, fmt.Errorf("user_id is required")
|
||
}
|
||
if s == nil || s.userDB == nil {
|
||
return coinLedgerUserDTO{}, fmt.Errorf("user mysql is not configured")
|
||
}
|
||
nowMs := time.Now().UnixMilli()
|
||
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "u.user_id", keyword, nowMs)
|
||
orderSQL, orderArgs := shared.UserIdentityExactOrderSQL("u", "u.user_id", keyword, nowMs)
|
||
args := append([]any{appCode}, matchArgs...)
|
||
args = append(args, orderArgs...)
|
||
row := s.userDB.QueryRowContext(ctx, `
|
||
SELECT u.user_id, u.current_display_user_id, COALESCE(u.username, ''), COALESCE(u.avatar, '')
|
||
FROM users u
|
||
WHERE u.app_code = ? AND `+matchSQL+`
|
||
ORDER BY
|
||
`+orderSQL+`,
|
||
u.user_id DESC
|
||
LIMIT 1`,
|
||
args...,
|
||
)
|
||
var profile userProfile
|
||
if err := row.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return coinLedgerUserDTO{}, errCoinAdjustmentTargetNotFound
|
||
}
|
||
return coinLedgerUserDTO{}, err
|
||
}
|
||
return coinLedgerUserDTO{
|
||
UserID: strconv.FormatInt(profile.UserID, 10),
|
||
DisplayUserID: profile.DisplayUserID,
|
||
Username: profile.Username,
|
||
Avatar: profile.Avatar,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) CreateCoinAdjustment(ctx context.Context, appCode string, actorID int64, requestID string, req coinAdjustmentRequest) (coinAdjustmentCreateDTO, error) {
|
||
if s == nil || s.walletClient == nil {
|
||
return coinAdjustmentCreateDTO{}, fmt.Errorf("wallet client is not configured")
|
||
}
|
||
targetUserID, err := parseFlexibleUserID(req.TargetUserID)
|
||
if err != nil {
|
||
return coinAdjustmentCreateDTO{}, err
|
||
}
|
||
if actorID <= 0 || targetUserID <= 0 || req.Amount == 0 {
|
||
return coinAdjustmentCreateDTO{}, fmt.Errorf("target_user_id, operator_user_id and amount are required")
|
||
}
|
||
reason := strings.TrimSpace(req.Reason)
|
||
if reason == "" {
|
||
return coinAdjustmentCreateDTO{}, fmt.Errorf("reason is required")
|
||
}
|
||
if utf8.RuneCountInString(reason) > 512 {
|
||
return coinAdjustmentCreateDTO{}, fmt.Errorf("reason is too long")
|
||
}
|
||
remark := strings.TrimSpace(req.Remark)
|
||
if remark == "" {
|
||
return coinAdjustmentCreateDTO{}, fmt.Errorf("remark is required")
|
||
}
|
||
if utf8.RuneCountInString(remark) > 512 {
|
||
return coinAdjustmentCreateDTO{}, fmt.Errorf("remark is too long")
|
||
}
|
||
user, err := s.LookupCoinAdjustmentTarget(ctx, appCode, strconv.FormatInt(targetUserID, 10))
|
||
if err != nil {
|
||
return coinAdjustmentCreateDTO{}, err
|
||
}
|
||
commandID := strings.TrimSpace(req.CommandID)
|
||
if commandID == "" {
|
||
commandID = defaultCoinAdjustmentCommandID(requestID)
|
||
}
|
||
if len(commandID) > 128 {
|
||
return coinAdjustmentCreateDTO{}, fmt.Errorf("command_id is too long")
|
||
}
|
||
evidenceRef := defaultCoinAdjustmentEvidenceRef(requestID)
|
||
resp, err := s.walletClient.AdminCreditAsset(ctx, &walletv1.AdminCreditAssetRequest{
|
||
CommandId: commandID,
|
||
TargetUserId: targetUserID,
|
||
AssetType: coinAssetType,
|
||
Amount: req.Amount,
|
||
OperatorUserId: actorID,
|
||
Reason: reason,
|
||
EvidenceRef: evidenceRef,
|
||
AppCode: appCode,
|
||
Remark: remark,
|
||
})
|
||
if err != nil {
|
||
return coinAdjustmentCreateDTO{}, err
|
||
}
|
||
balanceAfter := int64(0)
|
||
if resp.GetBalance() != nil {
|
||
balanceAfter = resp.GetBalance().GetAvailableAmount()
|
||
}
|
||
return coinAdjustmentCreateDTO{
|
||
TransactionID: resp.GetTransactionId(),
|
||
BalanceAfter: balanceAfter,
|
||
User: user,
|
||
Amount: req.Amount,
|
||
AvailableDelta: req.Amount,
|
||
Reason: reason,
|
||
Remark: remark,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) resolveUserFilter(ctx context.Context, appCode string, keyword string, filter shared.UserIdentityFilter) ([]int64, bool, error) {
|
||
keyword = strings.TrimSpace(keyword)
|
||
if filter.IsEmpty() && keyword == "" {
|
||
return nil, false, nil
|
||
}
|
||
directUserIDs := make([]int64, 0, 1)
|
||
numericKeyword := keyword
|
||
if strings.TrimSpace(filter.UserID) != "" {
|
||
numericKeyword = filter.UserID
|
||
}
|
||
if numeric, err := strconv.ParseInt(numericKeyword, 10, 64); err == nil && numeric > 0 {
|
||
directUserIDs = append(directUserIDs, numeric)
|
||
}
|
||
if s == nil || s.userDB == nil {
|
||
if len(directUserIDs) > 0 {
|
||
return directUserIDs, true, nil
|
||
}
|
||
return nil, true, fmt.Errorf("user mysql is not configured")
|
||
}
|
||
|
||
var matchSQL string
|
||
var matchArgs []any
|
||
if !filter.IsEmpty() {
|
||
// 拆分字段来自表头明确输入:短 ID、长 ID 和昵称分别构造精确约束,避免数字短号被昵称或长 ID LIKE 扩大。
|
||
matchSQL, matchArgs = shared.UserIdentityFieldsSQL("u", "u.user_id", filter, time.Now().UnixMilli())
|
||
} else {
|
||
// 旧 keyword 入口保留兼容;数字关键字直接加入 user_id,避免用户资料缺失时查不到账务事实。
|
||
matchSQL, matchArgs = shared.UserIdentityExactSQL("u", "u.user_id", keyword, time.Now().UnixMilli())
|
||
}
|
||
args := append([]any{appCode}, matchArgs...)
|
||
|
||
rows, err := s.userDB.QueryContext(ctx, `
|
||
SELECT u.user_id
|
||
FROM users u
|
||
WHERE u.app_code = ? AND `+matchSQL+`
|
||
ORDER BY u.updated_at_ms DESC, u.user_id DESC
|
||
LIMIT 1000`,
|
||
args...,
|
||
)
|
||
if err != nil {
|
||
return nil, true, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
userIDs := append([]int64(nil), directUserIDs...)
|
||
for rows.Next() {
|
||
var userID int64
|
||
if err := rows.Scan(&userID); err != nil {
|
||
return nil, true, err
|
||
}
|
||
userIDs = append(userIDs, userID)
|
||
}
|
||
return uniqueInt64s(userIDs), true, rows.Err()
|
||
}
|
||
|
||
func (s *Service) resolveCoinSellerFilter(ctx context.Context, appCode string, query coinSellerLedgerQuery) ([]int64, bool, error) {
|
||
if query.SellerUserID > 0 {
|
||
// seller_user_id 是 Coin Saller 行按钮传入的精确条件,直接使用账本 user_id 过滤;
|
||
// 即使用户资料暂时查不到,也允许账本事实被查出来,避免抽屉入口被资料缺失阻断。
|
||
return []int64{query.SellerUserID}, true, nil
|
||
}
|
||
if !query.SellerFilter.IsEmpty() {
|
||
if s == nil || s.userDB == nil {
|
||
return nil, true, fmt.Errorf("user mysql is not configured")
|
||
}
|
||
matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "csp.user_id", query.SellerFilter, time.Now().UnixMilli())
|
||
return s.resolveCoinSellerIDs(ctx, appCode, matchSQL, matchArgs)
|
||
}
|
||
keyword := strings.TrimSpace(query.SellerKeyword)
|
||
if keyword == "" {
|
||
// 未传币商筛选时返回 userFiltered=false,调用方会查询所有币商流水,但仍被 biz_type、asset_type 和分页限制住。
|
||
return nil, false, nil
|
||
}
|
||
if s == nil || s.userDB == nil {
|
||
return nil, true, fmt.Errorf("user mysql is not configured")
|
||
}
|
||
|
||
// 币商关键字只命中 coin_seller_profiles 里的币商本人,LEFT JOIN users 只用于短 ID、active 靓号和昵称补充;
|
||
// 这里故意不搜索 receiver/counterparty,避免“收款用户命中”把其他币商的流水带进当前币商筛选结果。
|
||
matchSQL, matchArgs := shared.UserIdentityExactSQL("u", "csp.user_id", keyword, time.Now().UnixMilli())
|
||
|
||
return s.resolveCoinSellerIDs(ctx, appCode, matchSQL, matchArgs)
|
||
}
|
||
|
||
func (s *Service) resolveCoinSellerIDs(ctx context.Context, appCode string, matchSQL string, matchArgs []any) ([]int64, bool, error) {
|
||
args := append([]any{appCode}, matchArgs...)
|
||
rows, err := s.userDB.QueryContext(ctx, `
|
||
SELECT csp.user_id
|
||
FROM coin_seller_profiles csp
|
||
LEFT JOIN users u ON u.app_code = csp.app_code AND u.user_id = csp.user_id
|
||
WHERE csp.app_code = ? AND `+matchSQL+`
|
||
ORDER BY csp.updated_at_ms DESC, csp.user_id DESC
|
||
LIMIT 1000`,
|
||
args...,
|
||
)
|
||
if err != nil {
|
||
return nil, true, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
userIDs := make([]int64, 0)
|
||
for rows.Next() {
|
||
var userID int64
|
||
if err := rows.Scan(&userID); err != nil {
|
||
return nil, true, err
|
||
}
|
||
userIDs = append(userIDs, userID)
|
||
}
|
||
return uniqueInt64s(userIDs), true, rows.Err()
|
||
}
|
||
|
||
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
|
||
result := make(map[int64]userProfile, len(userIDs))
|
||
if s == nil || s.userDB == nil || len(userIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
userIDs = uniqueInt64s(userIDs)
|
||
args := make([]any, 0, len(userIDs)+1)
|
||
args = append(args, appCode)
|
||
for _, id := range userIDs {
|
||
args = append(args, id)
|
||
}
|
||
rows, err := s.userDB.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT user_id, current_display_user_id, COALESCE(username, ''), COALESCE(avatar, '')
|
||
FROM users
|
||
WHERE app_code = ? AND user_id IN (%s)
|
||
`, placeholders(len(userIDs))), args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var profile userProfile
|
||
if err := rows.Scan(&profile.UserID, &profile.DisplayUserID, &profile.Username, &profile.Avatar); err != nil {
|
||
return nil, err
|
||
}
|
||
result[profile.UserID] = profile
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
func userDTOFromProfile(profile userProfile) coinLedgerUserDTO {
|
||
return coinLedgerUserDTO{
|
||
UserID: strconv.FormatInt(profile.UserID, 10),
|
||
DisplayUserID: profile.DisplayUserID,
|
||
Username: profile.Username,
|
||
Avatar: profile.Avatar,
|
||
}
|
||
}
|
||
|
||
func coinLedgerWhere(appCode string, query listQuery, userIDs []int64) (string, []any) {
|
||
where := "WHERE e.app_code = ? AND e.asset_type = ?"
|
||
args := []any{appCode, coinAssetType}
|
||
if query.BizType != "" {
|
||
// 普通金币流水的类型就是 wallet_transactions.biz_type;这里做精确参数匹配,
|
||
// 不把用户输入拼进 SQL,保证新增业务类型也能通过同一个查询入口按真实账务类型定位。
|
||
where += " AND wt.biz_type = ?"
|
||
args = append(args, query.BizType)
|
||
}
|
||
if query.StartAtMS > 0 {
|
||
where += " AND e.created_at_ms >= ?"
|
||
args = append(args, query.StartAtMS)
|
||
}
|
||
if query.EndAtMS > 0 {
|
||
where += " AND e.created_at_ms < ?"
|
||
args = append(args, query.EndAtMS)
|
||
}
|
||
if len(userIDs) > 0 {
|
||
where += fmt.Sprintf(" AND e.user_id IN (%s)", placeholders(len(userIDs)))
|
||
for _, id := range userIDs {
|
||
args = append(args, id)
|
||
}
|
||
}
|
||
return where, args
|
||
}
|
||
|
||
func coinSellerLedgerWhere(appCode string, query coinSellerLedgerQuery, sellerIDs []int64) (string, []any, error) {
|
||
// 所有币商流水都必须落在当前 app_code 和 COIN_SELLER_COIN 资产上;
|
||
// 普通 COIN 分录属于用户金币流水,不能混入币商库存流水展示。
|
||
where := "WHERE e.app_code = ? AND e.asset_type = ?"
|
||
args := []any{appCode, coinSellerAssetType}
|
||
bizTypes, err := coinSellerLedgerBizTypes(query.LedgerType)
|
||
if err != nil {
|
||
return "", nil, err
|
||
}
|
||
if len(bizTypes) == 1 {
|
||
// 单一类型用等值条件,保持 SQL 简洁,也方便后续索引按 biz_type 命中。
|
||
where += " AND wt.biz_type = ?"
|
||
args = append(args, bizTypes[0])
|
||
} else {
|
||
// 后台入账和“全部”都可能映射多个底层 biz_type,只展开白名单占位符,不拼接用户输入。
|
||
where += fmt.Sprintf(" AND wt.biz_type IN (%s)", placeholders(len(bizTypes)))
|
||
for _, bizType := range bizTypes {
|
||
args = append(args, bizType)
|
||
}
|
||
}
|
||
if query.StartAtMS > 0 {
|
||
// 开始边界包含,和所有后台列表的 [start_at_ms, end_at_ms) 约定保持一致。
|
||
where += " AND e.created_at_ms >= ?"
|
||
args = append(args, query.StartAtMS)
|
||
}
|
||
if query.EndAtMS > 0 {
|
||
// 结束边界不包含,避免按小时/天连续筛选时重复命中边界毫秒的同一条账本分录。
|
||
where += " AND e.created_at_ms < ?"
|
||
args = append(args, query.EndAtMS)
|
||
}
|
||
if len(sellerIDs) > 0 {
|
||
// sellerIDs 来自精确 seller_user_id 或 coin_seller_profiles 搜索结果,只用于限制币商分录的 e.user_id。
|
||
where += fmt.Sprintf(" AND e.user_id IN (%s)", placeholders(len(sellerIDs)))
|
||
for _, id := range sellerIDs {
|
||
args = append(args, id)
|
||
}
|
||
}
|
||
return where, args, nil
|
||
}
|
||
|
||
func coinAdjustmentWhere(appCode string, query listQuery, userIDs []int64) (string, []any) {
|
||
where := "WHERE e.app_code = ? AND e.asset_type = ? AND wt.biz_type = ?"
|
||
args := []any{appCode, coinAssetType, coinManualCreditBizType}
|
||
if query.StartAtMS > 0 {
|
||
where += " AND e.created_at_ms >= ?"
|
||
args = append(args, query.StartAtMS)
|
||
}
|
||
if query.EndAtMS > 0 {
|
||
where += " AND e.created_at_ms < ?"
|
||
args = append(args, query.EndAtMS)
|
||
}
|
||
if len(userIDs) > 0 {
|
||
where += fmt.Sprintf(" AND e.user_id IN (%s)", placeholders(len(userIDs)))
|
||
for _, id := range userIDs {
|
||
args = append(args, id)
|
||
}
|
||
}
|
||
return where, args
|
||
}
|
||
|
||
func normalizeListQuery(query listQuery) listQuery {
|
||
if query.Page < 1 {
|
||
query.Page = 1
|
||
}
|
||
if query.PageSize < 1 {
|
||
query.PageSize = 20
|
||
}
|
||
if query.PageSize > 100 {
|
||
query.PageSize = 100
|
||
}
|
||
query.UserKeyword = strings.TrimSpace(query.UserKeyword)
|
||
query.UserFilter.UserID = strings.TrimSpace(query.UserFilter.UserID)
|
||
query.UserFilter.DisplayUserID = strings.TrimSpace(query.UserFilter.DisplayUserID)
|
||
query.UserFilter.Username = strings.TrimSpace(query.UserFilter.Username)
|
||
query.BizType = strings.TrimSpace(query.BizType)
|
||
return query
|
||
}
|
||
|
||
func normalizeCoinSellerLedgerQuery(query coinSellerLedgerQuery) coinSellerLedgerQuery {
|
||
if query.Page < 1 {
|
||
query.Page = 1
|
||
}
|
||
if query.PageSize < 1 {
|
||
query.PageSize = 20
|
||
}
|
||
if query.PageSize > 100 {
|
||
query.PageSize = 100
|
||
}
|
||
query.SellerKeyword = strings.TrimSpace(query.SellerKeyword)
|
||
query.SellerFilter.UserID = strings.TrimSpace(query.SellerFilter.UserID)
|
||
query.SellerFilter.DisplayUserID = strings.TrimSpace(query.SellerFilter.DisplayUserID)
|
||
query.SellerFilter.Username = strings.TrimSpace(query.SellerFilter.Username)
|
||
query.LedgerType = strings.TrimSpace(query.LedgerType)
|
||
return query
|
||
}
|
||
|
||
func coinSellerLedgerBizTypes(ledgerType string) ([]string, error) {
|
||
switch strings.TrimSpace(ledgerType) {
|
||
case "":
|
||
// 空类型表示“全部币商流水”,但仍只允许当前产品定义的公开币商库存口径,不能把其他内部账带出来。
|
||
return []string{
|
||
coinSellerStockPurchaseBizType,
|
||
coinSellerStockDeductionBizType,
|
||
coinSellerRechargeBizType,
|
||
coinSellerCoinCompensationBizType,
|
||
coinSellerTransferBizType,
|
||
coinSellerSubTransferBizType,
|
||
salaryTransferToCoinSellerBizType,
|
||
coinManualCreditBizType,
|
||
}, nil
|
||
case coinSellerLedgerTypeAdminStockCredit:
|
||
// 进货操作是运营库存口径,底层包含后台 USDT 进货、H5 三方币商充值、金币补偿和后台扣除;资产条件仍限定在 COIN_SELLER_COIN。
|
||
return []string{coinSellerStockPurchaseBizType, coinSellerStockDeductionBizType, coinSellerRechargeBizType, coinSellerCoinCompensationBizType, coinManualCreditBizType}, nil
|
||
case coinSellerLedgerTypeSellerTransfer:
|
||
// 币商转用户只展示币商侧出账分录,收款用户资料在展示投影里补齐。
|
||
return []string{coinSellerTransferBizType}, nil
|
||
case coinSellerLedgerTypeSubSellerTransfer:
|
||
// 父子币商库存划拨按 wallet_entries 展示双边余额变动,不能混入普通玩家充值流水。
|
||
return []string{coinSellerSubTransferBizType}, nil
|
||
case coinSellerLedgerTypeSalaryTransferIncome:
|
||
// 工资转币商是用户工资资产换入币商库存,币商侧表现为 COIN_SELLER_COIN 入账。
|
||
return []string{salaryTransferToCoinSellerBizType}, nil
|
||
default:
|
||
return nil, errInvalidCoinSellerLedgerType
|
||
}
|
||
}
|
||
|
||
func coinSellerLedgerTypeForBizType(bizType string) string {
|
||
switch bizType {
|
||
case coinSellerStockPurchaseBizType, coinSellerStockDeductionBizType, coinSellerRechargeBizType, coinSellerCoinCompensationBizType:
|
||
return coinSellerLedgerTypeAdminStockCredit
|
||
case coinManualCreditBizType:
|
||
return coinSellerLedgerTypeAdminStockCredit
|
||
case coinSellerTransferBizType:
|
||
return coinSellerLedgerTypeSellerTransfer
|
||
case coinSellerSubTransferBizType:
|
||
return coinSellerLedgerTypeSubSellerTransfer
|
||
case salaryTransferToCoinSellerBizType:
|
||
return coinSellerLedgerTypeSalaryTransferIncome
|
||
default:
|
||
return bizType
|
||
}
|
||
}
|
||
|
||
func coinSellerLedgerLabel(item coinSellerLedgerDTO) string {
|
||
switch item.BizType {
|
||
case coinSellerStockPurchaseBizType:
|
||
return "USDT进货"
|
||
case coinSellerStockDeductionBizType:
|
||
return "USDT扣除"
|
||
case coinSellerRechargeBizType:
|
||
return "三方充值"
|
||
case coinSellerCoinCompensationBizType:
|
||
return "金币补偿"
|
||
case coinManualCreditBizType:
|
||
if item.AvailableDelta < 0 {
|
||
return "金币扣除"
|
||
}
|
||
return "金币增加"
|
||
case coinSellerTransferBizType:
|
||
return "币商转用户"
|
||
case coinSellerSubTransferBizType:
|
||
return "向子币商转账"
|
||
case salaryTransferToCoinSellerBizType:
|
||
return "工资转币商"
|
||
}
|
||
switch item.LedgerType {
|
||
case coinSellerLedgerTypeAdminStockCredit:
|
||
return "后台操作"
|
||
case coinSellerLedgerTypeSellerTransfer:
|
||
return "币商转用户"
|
||
case coinSellerLedgerTypeSubSellerTransfer:
|
||
return "向子币商转账"
|
||
case coinSellerLedgerTypeSalaryTransferIncome:
|
||
return "工资转币商"
|
||
default:
|
||
return firstNonEmpty(item.LedgerType, item.BizType, "-")
|
||
}
|
||
}
|
||
|
||
func signedCoinSellerLedgerAmount(item coinSellerLedgerDTO) int64 {
|
||
if item.Direction == directionOut || item.AvailableDelta < 0 {
|
||
return -absInt64(item.Amount)
|
||
}
|
||
return absInt64(item.Amount)
|
||
}
|
||
|
||
func formatUSDMinorForCSV(value int64) string {
|
||
if value <= 0 {
|
||
return ""
|
||
}
|
||
return strconv.FormatFloat(float64(value)/100, 'f', 2, 64)
|
||
}
|
||
|
||
func formatUSDTMicroForCSV(value int64) string {
|
||
if value <= 0 {
|
||
return ""
|
||
}
|
||
formatted := strconv.FormatFloat(float64(value)/1_000_000, 'f', 6, 64)
|
||
return strings.TrimRight(strings.TrimRight(formatted, "0"), ".")
|
||
}
|
||
|
||
func coinSellerLedgerOperatorExportFields(item coinSellerLedgerDTO) (string, string) {
|
||
if item.LedgerType != coinSellerLedgerTypeAdminStockCredit {
|
||
return "", ""
|
||
}
|
||
operatorID := firstNonEmpty(item.Operator.AdminID, item.OperatorUserID)
|
||
return firstNonEmpty(item.Operator.Username, item.Operator.Name, operatorID), operatorID
|
||
}
|
||
|
||
func coinSellerLedgerReceiverUserID(bizType string, sellerUserID int64, counterpartyUserID int64, metadata map[string]any) int64 {
|
||
if bizType == coinSellerTransferBizType {
|
||
// 新数据把真实收款用户写在 metadata.target_user_id,优先使用它,避免 counterparty 语义随老交易实现变化。
|
||
if targetUserID := metadataInt64(metadata, "target_user_id", "targetUserId"); targetUserID > 0 {
|
||
return targetUserID
|
||
}
|
||
// 老数据没有 target_user_id 时使用 counterparty_user_id 兜底;只有币商转用户才允许这样兜底。
|
||
if counterpartyUserID > 0 {
|
||
return counterpartyUserID
|
||
}
|
||
}
|
||
if bizType == coinSellerSubTransferBizType {
|
||
// 父子币商划拨会落父出账和子入账两条库存分录;两条记录的实际收款人都是 child_user_id。
|
||
// 子币商入账侧的 counterparty_user_id 是父币商,不能拿来展示为收款人。
|
||
if childUserID := metadataInt64(metadata, "child_user_id", "childUserId"); childUserID > 0 {
|
||
return childUserID
|
||
}
|
||
return sellerUserID
|
||
}
|
||
// 后台入账和工资转币商都展示币商本人为收款人,这是页面“实际收款人”的产品口径。
|
||
return sellerUserID
|
||
}
|
||
|
||
func directionForDelta(delta int64) string {
|
||
if delta < 0 {
|
||
return directionOut
|
||
}
|
||
return directionIn
|
||
}
|
||
|
||
func absInt64(value int64) int64 {
|
||
if value < 0 {
|
||
return -value
|
||
}
|
||
return value
|
||
}
|
||
|
||
func formatOptionalID(value int64) string {
|
||
if value <= 0 {
|
||
return ""
|
||
}
|
||
return strconv.FormatInt(value, 10)
|
||
}
|
||
|
||
func parseMetadataJSON(value string) (map[string]any, error) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" || value == "null" {
|
||
return map[string]any{}, nil
|
||
}
|
||
var metadata map[string]any
|
||
if err := json.Unmarshal([]byte(value), &metadata); err != nil {
|
||
return nil, err
|
||
}
|
||
if metadata == nil {
|
||
return map[string]any{}, nil
|
||
}
|
||
return metadata, nil
|
||
}
|
||
|
||
func metadataString(metadata map[string]any, key string) string {
|
||
value, ok := metadata[key]
|
||
if !ok || value == nil {
|
||
return ""
|
||
}
|
||
switch typed := value.(type) {
|
||
case string:
|
||
return strings.TrimSpace(typed)
|
||
default:
|
||
return strings.TrimSpace(fmt.Sprint(typed))
|
||
}
|
||
}
|
||
|
||
func metadataInt64(metadata map[string]any, keys ...string) int64 {
|
||
for _, key := range keys {
|
||
value, ok := metadata[key]
|
||
if !ok || value == nil {
|
||
continue
|
||
}
|
||
switch typed := value.(type) {
|
||
case float64:
|
||
return int64(typed)
|
||
case int64:
|
||
return typed
|
||
case int:
|
||
return int64(typed)
|
||
case string:
|
||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||
if err == nil {
|
||
return parsed
|
||
}
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func parseFlexibleUserID(value any) (int64, error) {
|
||
switch typed := value.(type) {
|
||
case float64:
|
||
userID := int64(typed)
|
||
if float64(userID) != typed || userID <= 0 {
|
||
return 0, fmt.Errorf("target_user_id is invalid")
|
||
}
|
||
return userID, nil
|
||
case string:
|
||
userID, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||
if err != nil || userID <= 0 {
|
||
return 0, fmt.Errorf("target_user_id is invalid")
|
||
}
|
||
return userID, nil
|
||
case json.Number:
|
||
userID, err := typed.Int64()
|
||
if err != nil || userID <= 0 {
|
||
return 0, fmt.Errorf("target_user_id is invalid")
|
||
}
|
||
return userID, nil
|
||
default:
|
||
return 0, fmt.Errorf("target_user_id is required")
|
||
}
|
||
}
|
||
|
||
func (s *Service) adminProfiles(ctx context.Context, adminIDs []int64) (map[int64]coinAdjustmentOperatorDTO, error) {
|
||
result := make(map[int64]coinAdjustmentOperatorDTO, len(adminIDs))
|
||
if s == nil || s.adminDB == nil || len(adminIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
adminIDs = positiveUniqueInt64s(adminIDs)
|
||
if len(adminIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
args := make([]any, 0, len(adminIDs))
|
||
for _, id := range adminIDs {
|
||
args = append(args, id)
|
||
}
|
||
rows, err := s.adminDB.QueryContext(ctx, fmt.Sprintf(`
|
||
SELECT id, COALESCE(username, ''), COALESCE(name, '')
|
||
FROM admin_users
|
||
WHERE id IN (%s)
|
||
`, placeholders(len(adminIDs))), args...)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var id int64
|
||
var operator coinAdjustmentOperatorDTO
|
||
if err := rows.Scan(&id, &operator.Username, &operator.Name); err != nil {
|
||
return nil, err
|
||
}
|
||
operator.AdminID = strconv.FormatInt(id, 10)
|
||
result[id] = operator
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
func positiveUniqueInt64s(values []int64) []int64 {
|
||
seen := make(map[int64]struct{}, len(values))
|
||
result := make([]int64, 0, len(values))
|
||
for _, value := range values {
|
||
if value <= 0 {
|
||
continue
|
||
}
|
||
if _, ok := seen[value]; ok {
|
||
continue
|
||
}
|
||
seen[value] = struct{}{}
|
||
result = append(result, value)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func defaultCoinAdjustmentCommandID(requestID string) string {
|
||
requestID = strings.TrimSpace(requestID)
|
||
if requestID == "" {
|
||
return "admin-coin-adjustment-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
|
||
}
|
||
return "admin-coin-adjustment-" + requestID
|
||
}
|
||
|
||
func defaultCoinAdjustmentEvidenceRef(requestID string) string {
|
||
requestID = strings.TrimSpace(requestID)
|
||
if requestID == "" {
|
||
return "admin-coin-adjustment"
|
||
}
|
||
return "admin-request:" + requestID
|
||
}
|
||
|
||
func offset(page int, pageSize int) int {
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
return (page - 1) * pageSize
|
||
}
|
||
|
||
func placeholders(count int) string {
|
||
if count <= 0 {
|
||
return ""
|
||
}
|
||
return strings.TrimRight(strings.Repeat("?,", count), ",")
|
||
}
|
||
|
||
func uniqueInt64s(values []int64) []int64 {
|
||
seen := make(map[int64]struct{}, len(values))
|
||
result := make([]int64, 0, len(values))
|
||
for _, value := range values {
|
||
if _, ok := seen[value]; ok {
|
||
continue
|
||
}
|
||
seen[value] = struct{}{}
|
||
result = append(result, value)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func escapeLike(value string) string {
|
||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||
value = strings.ReplaceAll(value, `%`, `\%`)
|
||
value = strings.ReplaceAll(value, `_`, `\_`)
|
||
return value
|
||
}
|