2026-07-09 18:15:57 +08:00

1184 lines
39 KiB
Go
Raw Permalink 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 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_typetotal 只依赖 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
}