2026-05-13 16:07:25 +08:00

320 lines
8.2 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 coinledger
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
)
const (
coinAssetType = "COIN"
directionIn = "income"
directionOut = "expense"
)
type Service struct {
userDB *sql.DB
walletDB *sql.DB
}
type userProfile struct {
UserID int64
DisplayUserID string
Username string
Avatar string
}
func NewService(userDB *sql.DB, walletDB *sql.DB) *Service {
return &Service{userDB: userDB, walletDB: walletDB}
}
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)
if err != nil {
return nil, 0, err
}
if userFiltered && len(userIDs) == 0 {
return []coinLedgerEntryDTO{}, 0, nil
}
// 后台只做只读查询:金币流水事实仍以 wallet_entries 追加分录为准,不在 admin 库冗余账务状态。
whereSQL, args := coinLedgerWhere(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, 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) resolveUserFilter(ctx context.Context, appCode string, keyword string) ([]int64, bool, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return nil, false, nil
}
directUserIDs := make([]int64, 0, 1)
if numeric, err := strconv.ParseInt(keyword, 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")
}
// 用户列筛选同时支持长 ID、当前短 ID 和昵称;数字关键字直接加入 user_id避免用户资料缺失时查不到账务事实。
args := []any{appCode}
where := "WHERE app_code = ? AND (current_display_user_id = ?"
args = append(args, keyword)
if numeric, err := strconv.ParseInt(keyword, 10, 64); err == nil && numeric > 0 {
where += " OR user_id = ?"
args = append(args, numeric)
}
where += " OR username LIKE ? ESCAPE '\\\\')"
args = append(args, "%"+escapeLike(keyword)+"%")
rows, err := s.userDB.QueryContext(ctx, `
SELECT user_id
FROM users
`+where+`
ORDER BY updated_at_ms DESC, 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) 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 coinLedgerWhere(appCode string, query listQuery, userIDs []int64) (string, []any) {
where := "WHERE e.app_code = ? AND e.asset_type = ?"
args := []any{appCode, coinAssetType}
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)
return query
}
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 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
}