320 lines
8.2 KiB
Go
320 lines
8.2 KiB
Go
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
|
||
}
|