350 lines
11 KiB
Go
350 lines
11 KiB
Go
package giftrecord
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp-admin-server/internal/modules/shared"
|
||
)
|
||
|
||
const (
|
||
bizTypeGiftDebit = "gift_debit"
|
||
bizTypeDirectGiftDebit = "direct_gift_debit"
|
||
walletStatusSucceeded = "succeeded"
|
||
)
|
||
|
||
type Service struct {
|
||
userDB *sql.DB
|
||
walletDB *sql.DB
|
||
}
|
||
|
||
type giftMetadata struct {
|
||
GiftID string `json:"gift_id"`
|
||
GiftName string `json:"gift_name"`
|
||
GiftIconURL string `json:"gift_icon_url"`
|
||
GiftCount int32 `json:"gift_count"`
|
||
CoinPrice int64 `json:"coin_price"`
|
||
ChargeAmount int64 `json:"charge_amount"`
|
||
CoinSpent int64 `json:"coin_spent"`
|
||
SenderUserID int64 `json:"sender_user_id"`
|
||
TargetUserID int64 `json:"target_user_id"`
|
||
RoomID string `json:"room_id"`
|
||
DirectGift bool `json:"direct_gift"`
|
||
}
|
||
|
||
type userProfile struct {
|
||
UserID int64
|
||
DisplayUserID string
|
||
DefaultDisplayUserID string
|
||
PrettyDisplayUserID string
|
||
PrettyID string
|
||
Username string
|
||
Avatar string
|
||
}
|
||
|
||
func NewService(userDB *sql.DB, walletDB *sql.DB) *Service {
|
||
return &Service{userDB: userDB, walletDB: walletDB}
|
||
}
|
||
|
||
// List 只读取 wallet-service 已成功落账的交易快照:名称、封面、数量和价格都来自送礼发生时的 metadata,
|
||
// 不回查当前礼物配置覆盖历史事实,也不扫描 room_outbox 这类会按保留策略清理的投递表。
|
||
func (s *Service) List(ctx context.Context, appCode string, query listQuery) ([]recordDTO, int64, error) {
|
||
query = normalizeListQuery(query)
|
||
if s == nil || s.walletDB == nil {
|
||
return nil, 0, fmt.Errorf("wallet mysql is not configured")
|
||
}
|
||
senderIDs, senderFiltered, err := s.resolveSenderFilter(ctx, appCode, query.SenderFilter)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if senderFiltered && len(senderIDs) == 0 {
|
||
// 用户身份筛选没有命中时直接返回空页,避免在钱包大表上构造永远为假的扫描条件。
|
||
return []recordDTO{}, 0, nil
|
||
}
|
||
|
||
whereSQL, args := giftRecordWhere(appCode, query, senderIDs)
|
||
var total int64
|
||
if err := s.walletDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM wallet_transactions "+whereSQL, args...).Scan(&total); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
// created_at_ms + transaction_id 组成稳定倒序;对应 owner 库组合索引,避免同一毫秒多笔交易跨页重复或遗漏。
|
||
rows, err := s.walletDB.QueryContext(ctx, `
|
||
SELECT transaction_id, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}'), created_at_ms
|
||
FROM wallet_transactions
|
||
`+whereSQL+`
|
||
ORDER BY created_at_ms DESC, transaction_id DESC
|
||
LIMIT ? OFFSET ?`, append(args, query.PageSize, listOffset(query))...)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
items := make([]recordDTO, 0, query.PageSize)
|
||
userIDs := make([]int64, 0, query.PageSize*2)
|
||
for rows.Next() {
|
||
var item recordDTO
|
||
var bizType string
|
||
var metadataJSON string
|
||
if err := rows.Scan(&item.TransactionID, &bizType, &metadataJSON, &item.CreatedAtMS); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
var metadata giftMetadata
|
||
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
||
return nil, 0, fmt.Errorf("decode gift transaction %s metadata: %w", item.TransactionID, err)
|
||
}
|
||
item = recordFromMetadata(item, bizType, metadata)
|
||
items = append(items, item)
|
||
userIDs = append(userIDs, metadata.SenderUserID, metadata.TargetUserID)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
|
||
profiles, err := s.userProfiles(ctx, appCode, userIDs)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
for index := range items {
|
||
senderID, _ := strconv.ParseInt(items[index].Sender.UserID, 10, 64)
|
||
receiverID, _ := strconv.ParseInt(items[index].Receiver.UserID, 10, 64)
|
||
// 用户资料只补充公共用户组件所需展示字段;即使用户资料缺失,也保留账务快照中的长 ID,不吞掉送礼事实。
|
||
if profile, ok := profiles[senderID]; ok {
|
||
items[index].Sender = userDTOFromProfile(profile)
|
||
}
|
||
if profile, ok := profiles[receiverID]; ok {
|
||
items[index].Receiver = userDTOFromProfile(profile)
|
||
}
|
||
}
|
||
return items, total, nil
|
||
}
|
||
|
||
func giftRecordWhere(appCode string, query listQuery, senderIDs []int64) (string, []any) {
|
||
where := "WHERE app_code = ? AND status = ?"
|
||
args := []any{strings.TrimSpace(appCode), walletStatusSucceeded}
|
||
switch query.Scene {
|
||
case sceneRoom:
|
||
where += " AND biz_type = ?"
|
||
args = append(args, bizTypeGiftDebit)
|
||
case sceneDirect:
|
||
where += " AND biz_type = ?"
|
||
args = append(args, bizTypeDirectGiftDebit)
|
||
default:
|
||
where += " AND biz_type IN (?, ?)"
|
||
args = append(args, bizTypeGiftDebit, bizTypeDirectGiftDebit)
|
||
}
|
||
if query.StartAtMS > 0 {
|
||
where += " AND created_at_ms >= ?"
|
||
args = append(args, query.StartAtMS)
|
||
}
|
||
if query.EndAtMS > 0 {
|
||
where += " AND created_at_ms < ?"
|
||
args = append(args, query.EndAtMS)
|
||
}
|
||
if len(senderIDs) > 0 {
|
||
// gift_sender_user_id 是 wallet owner 从不可变 metadata 快照生成的索引列;后台不能为筛选跨库 JOIN 用户表,
|
||
// 因此先在 user DB 解析公共身份字段,再用内部 user_id 命中钱包组合索引完成分页。
|
||
where += " AND gift_sender_user_id IN (" + placeholders(len(senderIDs)) + ")"
|
||
for _, senderID := range senderIDs {
|
||
args = append(args, senderID)
|
||
}
|
||
}
|
||
return where, args
|
||
}
|
||
|
||
func (s *Service) resolveSenderFilter(ctx context.Context, appCode string, filter shared.UserIdentityFilter) ([]int64, bool, error) {
|
||
if filter.IsEmpty() {
|
||
return nil, false, nil
|
||
}
|
||
directUserIDs := make([]int64, 0, 1)
|
||
if userID, err := strconv.ParseInt(strings.TrimSpace(filter.UserID), 10, 64); err == nil && userID > 0 {
|
||
// 钱包事实必须在用户资料已删除或未同步时仍可按内部长 ID 检索。
|
||
directUserIDs = append(directUserIDs, userID)
|
||
}
|
||
if s == nil || s.userDB == nil {
|
||
if len(directUserIDs) > 0 {
|
||
return directUserIDs, true, nil
|
||
}
|
||
return nil, true, fmt.Errorf("user mysql is not configured")
|
||
}
|
||
|
||
matchSQL, matchArgs := shared.UserIdentityFieldsSQL("u", "u.user_id", filter, time.Now().UTC().UnixMilli())
|
||
args := append([]any{strings.TrimSpace(appCode)}, matchArgs...)
|
||
// 公共用户筛选允许昵称模糊匹配;最多解析 1000 个内部 ID,既覆盖正常运营检索,又限制后续钱包 IN 条件规模。
|
||
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)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, true, err
|
||
}
|
||
return uniquePositiveInt64s(userIDs), true, nil
|
||
}
|
||
|
||
func recordFromMetadata(item recordDTO, bizType string, metadata giftMetadata) recordDTO {
|
||
item.Scene = sceneRoom
|
||
if bizType == bizTypeDirectGiftDebit || metadata.DirectGift {
|
||
item.Scene = sceneDirect
|
||
}
|
||
item.RoomID = strings.TrimSpace(metadata.RoomID)
|
||
item.Sender = userDTO{UserID: formatUserID(metadata.SenderUserID)}
|
||
item.Receiver = userDTO{UserID: formatUserID(metadata.TargetUserID)}
|
||
item.Gift = giftDTO{
|
||
GiftID: strings.TrimSpace(metadata.GiftID),
|
||
Name: firstNonEmpty(metadata.GiftName, metadata.GiftID, "-"),
|
||
CoverURL: strings.TrimSpace(metadata.GiftIconURL),
|
||
}
|
||
item.GiftCount = metadata.GiftCount
|
||
item.UnitValue = metadata.CoinPrice
|
||
item.GiftValue = metadata.CoinSpent
|
||
if item.GiftValue <= 0 {
|
||
item.GiftValue = metadata.ChargeAmount
|
||
}
|
||
if item.UnitValue <= 0 && item.GiftCount > 0 {
|
||
item.UnitValue = item.GiftValue / int64(item.GiftCount)
|
||
}
|
||
return item
|
||
}
|
||
|
||
func (s *Service) userProfiles(ctx context.Context, appCode string, userIDs []int64) (map[int64]userProfile, error) {
|
||
result := make(map[int64]userProfile)
|
||
if s == nil || s.userDB == nil {
|
||
return result, nil
|
||
}
|
||
userIDs = uniquePositiveInt64s(userIDs)
|
||
if len(userIDs) == 0 {
|
||
return result, nil
|
||
}
|
||
|
||
nowMS := time.Now().UTC().UnixMilli()
|
||
args := []any{nowMS, nowMS, strings.TrimSpace(appCode)}
|
||
for _, userID := range userIDs {
|
||
args = append(args, userID)
|
||
}
|
||
// 每页最多查询 2 * page_size 个用户;靓号子查询均命中 (app_code,user_id,status) 索引,
|
||
// 这样公共用户组件能展示短 ID/靓号,又不会把用户表 join 进大体量钱包分页查询。
|
||
rows, err := s.userDB.QueryContext(ctx, `
|
||
SELECT users.user_id,
|
||
users.current_display_user_id,
|
||
COALESCE(users.default_display_user_id, ''),
|
||
COALESCE((
|
||
SELECT lease.display_user_id
|
||
FROM pretty_display_user_id_leases lease
|
||
WHERE lease.app_code = users.app_code
|
||
AND lease.user_id = users.user_id
|
||
AND lease.status = 'active'
|
||
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
|
||
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
|
||
LIMIT 1
|
||
), ''),
|
||
COALESCE((
|
||
SELECT pretty.pretty_id
|
||
FROM pretty_display_user_id_leases lease
|
||
JOIN pretty_display_ids pretty
|
||
ON pretty.app_code = lease.app_code
|
||
AND pretty.assigned_lease_id = lease.lease_id
|
||
AND pretty.status = 'assigned'
|
||
WHERE lease.app_code = users.app_code
|
||
AND lease.user_id = users.user_id
|
||
AND lease.status = 'active'
|
||
AND (COALESCE(lease.expires_at_ms, 0) = 0 OR lease.expires_at_ms > ?)
|
||
ORDER BY lease.created_at_ms DESC, lease.lease_id DESC
|
||
LIMIT 1
|
||
), ''),
|
||
COALESCE(users.username, ''), COALESCE(users.avatar, '')
|
||
FROM users
|
||
WHERE users.app_code = ? AND users.user_id IN (`+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.DefaultDisplayUserID,
|
||
&profile.PrettyDisplayUserID,
|
||
&profile.PrettyID,
|
||
&profile.Username,
|
||
&profile.Avatar,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
result[profile.UserID] = profile
|
||
}
|
||
return result, rows.Err()
|
||
}
|
||
|
||
func userDTOFromProfile(profile userProfile) userDTO {
|
||
return userDTO{
|
||
UserID: formatUserID(profile.UserID),
|
||
DisplayUserID: profile.DisplayUserID,
|
||
DefaultDisplayUserID: profile.DefaultDisplayUserID,
|
||
PrettyDisplayUserID: profile.PrettyDisplayUserID,
|
||
PrettyID: profile.PrettyID,
|
||
Username: profile.Username,
|
||
Avatar: profile.Avatar,
|
||
}
|
||
}
|
||
|
||
func uniquePositiveInt64s(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 placeholders(count int) string {
|
||
if count <= 0 {
|
||
return ""
|
||
}
|
||
return strings.TrimRight(strings.Repeat("?,", count), ",")
|
||
}
|
||
|
||
func formatUserID(userID int64) string {
|
||
if userID <= 0 {
|
||
return ""
|
||
}
|
||
return strconv.FormatInt(userID, 10)
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if normalized := strings.TrimSpace(value); normalized != "" {
|
||
return normalized
|
||
}
|
||
}
|
||
return ""
|
||
}
|