2026-07-14 20:23:12 +08:00

350 lines
11 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 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 ""
}