aslan数据

This commit is contained in:
zhx 2026-07-03 15:53:36 +08:00
parent 87081a6324
commit 3d3ebb19a0
12 changed files with 3114 additions and 2634 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1093,6 +1093,8 @@ message GetRechargeBillSummaryResponse {
RechargeBillSummaryBucket total = 1;
RechargeBillSummaryBucket google_play = 2;
RechargeBillSummaryBucket third_party = 3;
// coin_seller coin_seller_stock_records counts_as_seller_recharge
RechargeBillSummaryBucket coin_seller = 4;
}
message RefreshGooglePaymentPricesRequest {

View File

@ -23,12 +23,12 @@ money_region_sources:
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
- enabled: false
- enabled: true
app_code: "aslan"
app_name: "Aslan"
sys_origin: "ATYOU"
mongo_uri: "mongodb://mongouser:REPLACE_ME@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin"
mongo_database: "tarab_all"
mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred"
mongo_database: "atyou"
mongo_collection: "sys_region_config"
request_timeout: "5s"
# Yumi 的充值账单在利雅得内网 likei Mongo与 hyapp-server 同 VPC启用后 APP 充值详情直接读该库。
@ -41,6 +41,14 @@ finance_bill_sources:
mongo_database: "tarab_all"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
- enabled: true
app_code: "aslan"
app_name: "Aslan"
sys_origin: "ATYOU"
mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred"
mongo_database: "atyou"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
mysql_auto_migrate: false
migrations:
enabled: true
@ -100,8 +108,8 @@ dashboard_external_sources:
app_code: "aslan"
app_name: "Aslan"
sys_origin: "ATYOU"
mongo_uri: "mongodb://mongouser:REPLACE_ME@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin"
mongo_database: "test"
mongo_uri: "mongodb://aslan_data:aslan_data@lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27017,lb-le274w4o-wjgmcdkhye3v0ors.clb.sg-tencentclb.com:27018/test?replicaSet=cmgo-6cztxjgr_0&authSource=admin&readPreference=secondaryPreferred"
mongo_database: "atyou"
stat_timezone: "Asia/Shanghai"
request_timeout: "5s"
finance_notifications:

View File

@ -28,7 +28,7 @@ money_region_sources:
app_name: "Aslan"
sys_origin: "ATYOU"
mongo_uri: ""
mongo_database: "tarab_all"
mongo_database: "atyou"
mongo_collection: "sys_region_config"
request_timeout: "5s"
# legacy App 的财务充值账单外部源:命中 app_code 后APP 充值详情直接读 likei Mongo 的内购明细。
@ -41,6 +41,14 @@ finance_bill_sources:
mongo_database: "tarab_all"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
- enabled: false
app_code: "aslan"
app_name: "Aslan"
sys_origin: "ATYOU"
mongo_uri: ""
mongo_database: "atyou"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
mysql_auto_migrate: true
migrations:
enabled: true

View File

@ -224,6 +224,16 @@ func Default() Config {
MongoCollection: "in_app_purchase_details",
RequestTimeout: 5 * time.Second,
},
{
Enabled: false,
AppCode: "aslan",
AppName: "Aslan",
// Aslanlikei-services 项目)与 Yumi 同一套平台代码,但独立 Mongo 集群;业务库是 atyou。
SysOrigin: "ATYOU",
MongoDatabase: "atyou",
MongoCollection: "in_app_purchase_details",
RequestTimeout: 5 * time.Second,
},
},
MySQLAutoMigrate: true,
Migrations: MigrationConfig{

View File

@ -325,7 +325,8 @@ func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRe
const legacyUserProfileCollection = "user_run_profile"
// legacyRegionLookupStages 按付款用户的 user_run_profile.countryCode 过滤区域codes 为空时不追加任何阶段。
// legacyRegionLookupStages 解析账单的区域归属国家:优先账单自身 countryCodeAslan 的 MifaPay 单会带),
// 为空时回退付款用户 user_run_profile.countryCodeYumi/Aslan 的谷歌单都不带国家码codes 为空时不追加任何阶段。
func legacyRegionLookupStages(codes []string) mongo.Pipeline {
if len(codes) == 0 {
return mongo.Pipeline{}
@ -338,9 +339,10 @@ func legacyRegionLookupStages(codes []string) mongo.Pipeline {
"as": "legacyBillUser",
}}},
bson.D{{Key: "$addFields", Value: bson.M{
"legacyBillCountry": bson.M{"$toUpper": bson.M{"$ifNull": bson.A{
bson.M{"$first": "$legacyBillUser.countryCode"},
"",
"legacyBillCountry": bson.M{"$toUpper": bson.M{"$cond": bson.A{
bson.M{"$ne": bson.A{bson.M{"$ifNull": bson.A{"$countryCode", ""}}, ""}},
"$countryCode",
bson.M{"$ifNull": bson.A{bson.M{"$first": "$legacyBillUser.countryCode"}, ""}},
}}},
}}},
bson.D{{Key: "$match", Value: bson.M{"legacyBillCountry": bson.M{"$in": codes}}}},
@ -425,6 +427,9 @@ func legacyRechargeType(factoryCode string) string {
return "huawei_recharge"
case "TELEGRAM":
return "telegram_recharge"
case "MIFA_PAY":
// Aslan 的 H5 三方走 MifaPay复用 hyapp 的 mifapay 展示口径。
return "mifapay"
default:
return "web_recharge"
}

View File

@ -0,0 +1,266 @@
package payment
import (
"bytes"
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
walletv1 "hyapp.local/api/proto/wallet/v1"
"github.com/gin-gonic/gin"
)
const (
rechargeBillExportPageSize = 500
// rechargeBillExportMaxRows 限制单次导出行数,避免财务误选超大时间范围拖垮 admin 与 wallet-service。
rechargeBillExportMaxRows = 50_000
)
// rechargeBillExportTimezone 与页面展示一致,导出统一使用中国时区时间。
var rechargeBillExportTimezone = time.FixedZone("GMT+8", 8*3600)
// ExportRechargeBills 按当前筛选口径导出充值明细 CSVhyapp 与 legacy 账单源共用同一套列。
func (h *Handler) ExportRechargeBills(c *gin.Context) {
appCode := appctx.FromContext(c.Request.Context())
bills, ok := h.collectRechargeBillsForExport(c, appCode)
if !ok {
return
}
truncated := len(bills) >= rechargeBillExportMaxRows
regionNames := map[int64]string{}
if regions, err := h.listRechargeBillRegions(c.Request.Context(), appCode); err == nil {
for _, region := range regions {
name := region.Name
if name == "" {
name = region.RegionCode
}
regionNames[region.RegionID] = name
}
}
buffer := &bytes.Buffer{}
// UTF-8 BOM 让 Excel 直接双击打开时正确识别中文。
buffer.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(buffer)
_ = writer.Write([]string{
"充值时间", "APP", "充值来源", "交易号", "业务单号", "外部订单号",
"金币数量", "美金金额(USD)", "账单币种", "账单金额",
"实付币种", "实付金额", "手续费", "税费", "净收入", "扣款占比",
"充值用户ID", "充值用户昵称", "币商用户ID", "区域", "状态",
})
for _, item := range bills {
_ = writer.Write(rechargeBillExportRow(item, regionNames))
}
if truncated {
_ = writer.Write([]string{fmt.Sprintf("-- 导出达到上限 %d 行,请缩小时间范围后重新导出 --", rechargeBillExportMaxRows)})
}
writer.Flush()
if err := writer.Error(); err != nil {
response.ServerError(c, "生成导出文件失败")
return
}
fileName := fmt.Sprintf("recharge-bills-%s-%s.csv", appCode, time.Now().In(rechargeBillExportTimezone).Format("20060102-150405"))
shared.OperationLog(c, h.audit, "export-recharge-bills", "wallet_recharge_records", "success", fmt.Sprintf("%s:%d rows", appCode, len(bills)))
c.Header("Content-Disposition", "attachment; filename="+fileName)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Writer.WriteHeader(200)
_, _ = c.Writer.Write(buffer.Bytes())
}
// collectRechargeBillsForExport 复用列表筛选逐页拉取账单;返回 ok=false 表示已写出错误响应。
func (h *Handler) collectRechargeBillsForExport(c *gin.Context, appCode string) ([]rechargeBillDTO, bool) {
options := shared.ListOptions(c)
if source, ok := h.billSources[appCode]; ok {
bills := make([]rechargeBillDTO, 0, rechargeBillExportPageSize)
for page := 1; len(bills) < rechargeBillExportMaxRows; page++ {
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
Page: page,
PageSize: rechargeBillExportPageSize,
})
if err != nil {
response.ServerError(c, "导出账单失败")
return nil, false
}
bills = append(bills, items...)
if len(items) < rechargeBillExportPageSize || int64(len(bills)) >= total {
break
}
}
return capRechargeBillExportRows(bills), true
}
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
if !ok {
return nil, false
}
sellerFilter := shared.UserIdentityFilterFromQuery(c, "seller")
sellerUserID, sellerMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, sellerFilter, "seller_user_id 参数不正确", "seller_user_id", "sellerUserId")
if !ok {
return nil, false
}
if (!userFilter.IsEmpty() && !userMatched) || (!sellerFilter.IsEmpty() && !sellerMatched) {
return []rechargeBillDTO{}, true
}
bills := make([]rechargeBillDTO, 0, rechargeBillExportPageSize)
for page := 1; len(bills) < rechargeBillExportMaxRows; page++ {
resp, err := h.wallet.ListRechargeBills(c.Request.Context(), &walletv1.ListRechargeBillsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
UserId: userID,
SellerUserId: sellerUserID,
RegionId: queryInt64(c, "region_id", "regionId"),
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
Status: options.Status,
Keyword: options.Keyword,
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
Page: int32(page),
PageSize: rechargeBillExportPageSize,
})
if err != nil {
writeWalletError(c, err, "导出账单失败")
return nil, false
}
items := make([]rechargeBillDTO, 0, len(resp.GetBills()))
for _, item := range resp.GetBills() {
items = append(items, rechargeBillFromProto(item))
}
if err := h.fillRechargeBillUsers(c.Request.Context(), appCode, items); err != nil {
response.ServerError(c, "导出账单用户资料失败")
return nil, false
}
bills = append(bills, items...)
if len(items) < rechargeBillExportPageSize || int64(len(bills)) >= resp.GetTotal() {
break
}
}
return capRechargeBillExportRows(bills), true
}
func capRechargeBillExportRows(bills []rechargeBillDTO) []rechargeBillDTO {
if len(bills) > rechargeBillExportMaxRows {
return bills[:rechargeBillExportMaxRows]
}
return bills
}
func rechargeBillExportRow(item rechargeBillDTO, regionNames map[int64]string) []string {
deductionMicro := item.ProviderFeeMicro + item.ProviderTaxMicro
deductionRatio := ""
if deductionMicro > 0 && item.UserPaidAmountMicro > 0 {
deductionRatio = fmt.Sprintf("%.2f%%", float64(deductionMicro)/float64(item.UserPaidAmountMicro)*100)
}
billAmountMinor := item.USDMinorAmount
if item.ProviderAmountMinor > 0 {
billAmountMinor = item.ProviderAmountMinor
}
chargeUserID := ""
if item.User.UserID != "" {
chargeUserID = item.User.UserID
} else if item.UserID > 0 {
chargeUserID = strconv.FormatInt(item.UserID, 10)
}
sellerUserID := ""
if item.SellerUserID > 0 {
sellerUserID = strconv.FormatInt(item.SellerUserID, 10)
}
regionID := item.TargetRegionID
if regionID == 0 {
regionID = item.SellerRegionID
}
regionText := ""
if regionID > 0 {
if name, ok := regionNames[regionID]; ok && name != "" {
regionText = name
} else {
regionText = strconv.FormatInt(regionID, 10)
}
}
return []string{
time.UnixMilli(item.CreatedAtMS).In(rechargeBillExportTimezone).Format("2006-01-02 15:04:05"),
item.AppCode,
rechargeSourceLabel(item.RechargeType),
item.TransactionID,
item.CommandID,
item.ExternalRef,
strconv.FormatInt(item.CoinAmount, 10),
csvMinorAmount(item.USDMinorAmount),
item.CurrencyCode,
csvMinorAmount(billAmountMinor),
item.UserPaidCurrencyCode,
csvMicroAmount(item.UserPaidAmountMicro),
csvMicroAmount(item.ProviderFeeMicro),
csvMicroAmount(item.ProviderTaxMicro),
csvMicroAmount(item.ProviderNetMicro),
deductionRatio,
chargeUserID,
item.User.Username,
sellerUserID,
regionText,
item.Status,
}
}
// rechargeSourceLabel 与前端 rechargeTypeLabel 保持同一套中文口径,导出文件可直接给财务阅读。
func rechargeSourceLabel(rechargeType string) string {
switch rechargeType {
case "google_play_recharge":
return "谷歌充值"
case "coin_seller_stock_purchase":
return "币商进货"
case "coin_seller_stock_deduction":
return "币商冲回"
case "coin_seller_transfer":
return "币商充值"
case "mifapay":
return "三方充值-MiFaPay"
case "v5pay":
return "三方充值-V5Pay"
case "usdt_trc20":
return "三方充值-USDT"
case "apple_recharge":
return "苹果充值"
case "huawei_recharge":
return "华为充值"
case "telegram_recharge":
return "Telegram充值"
case "web_recharge":
return "三方充值-Web"
default:
return rechargeType
}
}
func csvMinorAmount(minor int64) string {
return strconv.FormatFloat(float64(minor)/100, 'f', 2, 64)
}
func csvMicroAmount(micro int64) string {
if micro == 0 {
return "0"
}
negative := micro < 0
if negative {
micro = -micro
}
text := formatUSDTMicro(micro)
if negative {
return "-" + text
}
return text
}

View File

@ -26,6 +26,7 @@ type rechargeBillSummaryDTO struct {
Total rechargeBillSummaryBucketDTO `json:"total"`
GooglePlay rechargeBillSummaryBucketDTO `json:"googlePlay"`
ThirdParty rechargeBillSummaryBucketDTO `json:"thirdParty"`
CoinSeller rechargeBillSummaryBucketDTO `json:"coinSeller"`
}
type rechargeBillRegionDTO struct {
@ -118,6 +119,7 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
Total: rechargeBillSummaryBucketFromProto(resp.GetTotal()),
GooglePlay: rechargeBillSummaryBucketFromProto(resp.GetGooglePlay()),
ThirdParty: rechargeBillSummaryBucketFromProto(resp.GetThirdParty()),
CoinSeller: rechargeBillSummaryBucketFromProto(resp.GetCoinSeller()),
})
}

View File

@ -13,6 +13,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/payment/recharge-bills", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBills)
protected.GET("/admin/payment/recharge-bills/summary", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillSummary)
protected.GET("/admin/payment/recharge-bills/export", middleware.RequirePermission("payment-bill:view"), h.ExportRechargeBills)
protected.POST("/admin/payment/recharge-bills/google-paid/refresh", middleware.RequirePermission("payment-bill:view"), h.RefreshGoogleRechargePaidDetails)
protected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions)
protected.GET("/admin/payment/recharge-apps", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillApps)

View File

@ -45,11 +45,12 @@ type RechargeBillSummaryBucket struct {
USDMinorAmount int64
}
// RechargeBillSummary 拆分财务系统关心的三个口径总和、Google 充值与三方充值
// RechargeBillSummary 拆分财务系统关心的四个口径总和、Google 充值、三方充值与币商进货
type RechargeBillSummary struct {
Total RechargeBillSummaryBucket
GooglePlay RechargeBillSummaryBucket
ThirdParty RechargeBillSummaryBucket
CoinSeller RechargeBillSummaryBucket
}
// ListRechargeBillsQuery 是后台查询所有充值渠道账单的筛选条件。

View File

@ -13,6 +13,7 @@ import (
)
// ListRechargeBills 查询 wallet-service 的充值账单投影,后台不能直接读写业务库内部语义。
// 结果集是两类事实的并集wallet_recharge_records用户充值与 coin_seller_stock_records币商进货/冲回)。
func (r *Repository) ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) {
if r == nil || r.db == nil {
return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -21,28 +22,40 @@ func (r *Repository) ListRechargeBills(ctx context.Context, query ledger.ListRec
query = normalizeRechargeBillsQuery(query)
query.AppCode = appcode.FromContext(ctx)
where, args := rechargeBillsWhereSQL(query)
total, err := r.countRechargeBillRows(ctx, where, args...)
if err != nil {
return nil, 0, err
includeRecords, includeStock := rechargeBillBranches(query)
if !includeRecords && !includeStock {
return []ledger.RechargeBill{}, 0, nil
}
// 充值事实仍以 wallet_recharge_records 为主表external_recharge_orders 补 H5 三方订单的实付币种和金额,
// payment_orders 补 Google 订单经 Orders API 同步的实付明细,两个 LEFT JOIN 都走 wallet_transaction_id 索引。
selects := make([]string, 0, 2)
args := make([]any, 0, 16)
var total int64
if includeRecords {
where, whereArgs := rechargeBillsWhereSQL(query)
branchTotal, err := r.countRechargeBillRows(ctx, where, whereArgs...)
if err != nil {
return nil, 0, err
}
total += branchTotal
selects = append(selects, rechargeBillRecordsSelectSQL+where)
args = append(args, whereArgs...)
}
if includeStock {
where, whereArgs := rechargeBillStockWhereSQL(query)
branchTotal, err := r.countRechargeBillStockRows(ctx, where, whereArgs...)
if err != nil {
return nil, 0, err
}
total += branchTotal
selects = append(selects, rechargeBillStockSelectSQL+where)
args = append(args, whereArgs...)
}
rows, err := r.db.QueryContext(ctx, `
SELECT rr.app_code, rr.transaction_id, wt.command_id, wt.biz_type, wt.status, wt.external_ref,
rr.user_id, rr.seller_user_id, rr.seller_region_id, rr.target_region_id,
rr.policy_id, rr.policy_version, rr.currency_code,
rr.coin_amount, rr.usd_minor_amount, rr.exchange_coin_amount, rr.exchange_usd_minor_amount,
COALESCE(eo.provider_amount_minor, 0), rr.created_at_ms,
COALESCE(eo.provider_code, ''), COALESCE(eo.currency_code, ''), COALESCE(CAST(eo.provider_payload AS CHAR), ''),
COALESCE(po.paid_currency_code, ''), COALESCE(po.paid_amount_micro, 0), COALESCE(po.paid_tax_micro, 0),
COALESCE(po.paid_net_micro, 0), COALESCE(po.paid_synced_at_ms, 0)
FROM wallet_recharge_records rr
JOIN wallet_transactions wt ON wt.app_code = rr.app_code AND wt.transaction_id = rr.transaction_id
LEFT JOIN external_recharge_orders eo ON eo.app_code = rr.app_code AND eo.wallet_transaction_id = rr.transaction_id
LEFT JOIN payment_orders po ON po.app_code = rr.app_code AND po.wallet_transaction_id = rr.transaction_id
`+where+`
ORDER BY rr.created_at_ms DESC, rr.transaction_id DESC
SELECT * FROM (`+strings.Join(selects, `
UNION ALL
`)+`) bills
ORDER BY created_at_ms DESC, transaction_id DESC
LIMIT ? OFFSET ?`,
append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
)
@ -98,9 +111,78 @@ func (r *Repository) ListRechargeBills(ctx context.Context, query ledger.ListRec
return items, total, nil
}
// fillRechargeBillPaidFacts 把三方订单快照和 Google 实付明细收敛成账单统一的实付字段。
// rechargeBillRecordsSelectSQL 是用户充值分支external_recharge_orders 补 H5 三方实付payment_orders 补 Google 实付明细。
const rechargeBillRecordsSelectSQL = `
SELECT rr.app_code, rr.transaction_id, wt.command_id, wt.biz_type, wt.status, wt.external_ref,
rr.user_id, rr.seller_user_id, rr.seller_region_id, rr.target_region_id,
rr.policy_id, rr.policy_version, rr.currency_code,
rr.coin_amount, rr.usd_minor_amount, rr.exchange_coin_amount, rr.exchange_usd_minor_amount,
COALESCE(eo.provider_amount_minor, 0) AS provider_amount_minor, rr.created_at_ms,
COALESCE(eo.provider_code, '') AS external_provider_code, COALESCE(eo.currency_code, '') AS external_currency_code,
COALESCE(CAST(eo.provider_payload AS CHAR), '') AS external_payload_json,
COALESCE(po.paid_currency_code, '') AS paid_currency_code, COALESCE(po.paid_amount_micro, 0) AS paid_amount_micro,
COALESCE(po.paid_tax_micro, 0) AS paid_tax_micro, COALESCE(po.paid_net_micro, 0) AS paid_net_micro,
COALESCE(po.paid_synced_at_ms, 0) AS paid_synced_at_ms
FROM wallet_recharge_records rr
JOIN wallet_transactions wt ON wt.app_code = rr.app_code AND wt.transaction_id = rr.transaction_id
LEFT JOIN external_recharge_orders eo ON eo.app_code = rr.app_code AND eo.wallet_transaction_id = rr.transaction_id
LEFT JOIN payment_orders po ON po.app_code = rr.app_code AND po.wallet_transaction_id = rr.transaction_id
`
// rechargeBillStockSelectSQL 是币商进货分支:列结构与用户充值分支对齐;实付事实来自库存记录本身,
// 美元口径只认 USDT/USD 付款micro DIV 10000 = 美分),冲回单的金额与美元都为负数。
const rechargeBillStockSelectSQL = `
SELECT sr.app_code, sr.transaction_id, wt.command_id, wt.biz_type, wt.status, wt.external_ref,
0 AS user_id, sr.seller_user_id,
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(sr.metadata_json, '$.seller_region_id')) AS SIGNED), 0) AS seller_region_id,
0 AS target_region_id,
0 AS policy_id, '' AS policy_version, sr.paid_currency_code AS currency_code,
sr.coin_amount, CASE WHEN sr.paid_currency_code IN ('USDT', 'USD') THEN sr.paid_amount_micro DIV 10000 ELSE 0 END AS usd_minor_amount,
0 AS exchange_coin_amount, 0 AS exchange_usd_minor_amount,
0 AS provider_amount_minor, sr.created_at_ms,
'' AS external_provider_code, '' AS external_currency_code, '' AS external_payload_json,
sr.paid_currency_code AS paid_currency_code, sr.paid_amount_micro AS paid_amount_micro,
0 AS paid_tax_micro, 0 AS paid_net_micro, sr.created_at_ms AS paid_synced_at_ms
FROM coin_seller_stock_records sr
JOIN wallet_transactions wt ON wt.app_code = sr.app_code AND wt.transaction_id = sr.transaction_id
`
// rechargeBillBranches 判定筛选条件命中的账单分支:用户筛选和普通渠道只在充值记录分支,币商进货走库存分支。
func rechargeBillBranches(query ledger.ListRechargeBillsQuery) (includeRecords bool, includeStock bool) {
switch query.RechargeType {
case rechargeTypeFilterCoinSeller:
includeStock = true
case "":
includeRecords = true
includeStock = true
default:
includeRecords = true
}
// 库存记录没有充值用户维度;按充值用户筛选时币商分支注定无结果。
if query.UserID > 0 {
includeStock = false
}
return includeRecords, includeStock
}
const (
// rechargeTypeFilterCoinSeller/ThirdParty 是后台筛选哨兵值,不对应单一 biz_type
// coin_seller 命中币商进货/冲回分支third_party 命中除 Google 外的所有用户充值渠道。
rechargeTypeFilterCoinSeller = "coin_seller"
rechargeTypeFilterThirdParty = "third_party"
providerCodeCoinSeller = "coin_seller"
)
// fillRechargeBillPaidFacts 把三方订单快照、Google 实付明细和币商进货付款收敛成账单统一的实付字段。
func fillRechargeBillPaidFacts(item *ledger.RechargeBill, externalProviderCode string, externalCurrencyCode string, externalPayloadJSON string,
paidCurrencyCode string, paidAmountMicro int64, paidTaxMicro int64, paidNetMicro int64, paidSyncedAtMS int64) {
if item.RechargeType == bizTypeCoinSellerStockPurchase || item.RechargeType == bizTypeCoinSellerStockDeduction {
item.ProviderCode = providerCodeCoinSeller
item.UserPaidCurrencyCode = paidCurrencyCode
item.UserPaidAmountMicro = paidAmountMicro
item.PaidSyncedAtMS = paidSyncedAtMS
return
}
if item.RechargeType == bizTypeGooglePlayRecharge {
item.ProviderCode = "google_play"
item.UserPaidCurrencyCode = paidCurrencyCode
@ -158,7 +240,52 @@ func decimalAmountToMicro(value string) int64 {
return int64(math.Round(parsed * 1_000_000))
}
// SummarizeRechargeBills 按与列表一致的筛选口径聚合充值账单,拆分 Google 与三方两个渠道桶。
// rechargeBillStockWhereSQL 构造币商进货分支的筛选;与列表分支同一套查询参数语义。
func rechargeBillStockWhereSQL(query ledger.ListRechargeBillsQuery) (string, []any) {
// 只有 counts_as_seller_recharge 的进货/冲回属于财务充值口径;金币补偿等运营动作不进账单。
where := `WHERE sr.app_code = ? AND sr.counts_as_seller_recharge = TRUE`
args := []any{query.AppCode}
if query.SellerUserID > 0 {
where += ` AND sr.seller_user_id = ?`
args = append(args, query.SellerUserID)
}
if query.RegionID > 0 {
where += ` AND COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(sr.metadata_json, '$.seller_region_id')) AS SIGNED), 0) = ?`
args = append(args, query.RegionID)
}
if query.Status != "" {
where += ` AND wt.status = ?`
args = append(args, query.Status)
}
if query.StartAtMS > 0 {
where += ` AND sr.created_at_ms >= ?`
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
where += ` AND sr.created_at_ms < ?`
args = append(args, query.EndAtMS)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
where += ` AND (sr.transaction_id LIKE ? OR wt.command_id LIKE ? OR COALESCE(sr.payment_ref, '') LIKE ? OR sr.evidence_ref LIKE ?)`
args = append(args, like, like, like, like)
}
return where, args
}
func (r *Repository) countRechargeBillStockRows(ctx context.Context, where string, args ...any) (int64, error) {
var total int64
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM coin_seller_stock_records sr
JOIN wallet_transactions wt ON wt.app_code = sr.app_code AND wt.transaction_id = sr.transaction_id
`+where,
args...,
).Scan(&total)
return total, err
}
// SummarizeRechargeBills 按与列表一致的筛选口径聚合充值账单Google、三方与币商进货三个渠道桶。
func (r *Repository) SummarizeRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillSummary, error) {
if r == nil || r.db == nil {
return ledger.RechargeBillSummary{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
@ -167,6 +294,27 @@ func (r *Repository) SummarizeRechargeBills(ctx context.Context, query ledger.Li
query = normalizeRechargeBillsQuery(query)
query.AppCode = appcode.FromContext(ctx)
includeRecords, includeStock := rechargeBillBranches(query)
var summary ledger.RechargeBillSummary
if includeRecords {
if err := r.summarizeRechargeRecordBills(ctx, query, &summary); err != nil {
return ledger.RechargeBillSummary{}, err
}
}
if includeStock {
if err := r.summarizeCoinSellerStockBills(ctx, query, &summary); err != nil {
return ledger.RechargeBillSummary{}, err
}
}
summary.Total = ledger.RechargeBillSummaryBucket{
BillCount: summary.GooglePlay.BillCount + summary.ThirdParty.BillCount + summary.CoinSeller.BillCount,
CoinAmount: summary.GooglePlay.CoinAmount + summary.ThirdParty.CoinAmount + summary.CoinSeller.CoinAmount,
USDMinorAmount: summary.GooglePlay.USDMinorAmount + summary.ThirdParty.USDMinorAmount + summary.CoinSeller.USDMinorAmount,
}
return summary, nil
}
func (r *Repository) summarizeRechargeRecordBills(ctx context.Context, query ledger.ListRechargeBillsQuery, summary *ledger.RechargeBillSummary) error {
where, args := rechargeBillsWhereSQL(query)
rows, err := r.db.QueryContext(ctx, `
SELECT wt.biz_type, COUNT(*), COALESCE(SUM(rr.coin_amount), 0), COALESCE(SUM(rr.usd_minor_amount), 0)
@ -177,20 +325,16 @@ func (r *Repository) SummarizeRechargeBills(ctx context.Context, query ledger.Li
args...,
)
if err != nil {
return ledger.RechargeBillSummary{}, err
return err
}
defer rows.Close()
var summary ledger.RechargeBillSummary
for rows.Next() {
var bizType string
var bucket ledger.RechargeBillSummaryBucket
if err := rows.Scan(&bizType, &bucket.BillCount, &bucket.CoinAmount, &bucket.USDMinorAmount); err != nil {
return ledger.RechargeBillSummary{}, err
return err
}
summary.Total.BillCount += bucket.BillCount
summary.Total.CoinAmount += bucket.CoinAmount
summary.Total.USDMinorAmount += bucket.USDMinorAmount
if bizType == bizTypeGooglePlayRecharge {
summary.GooglePlay.BillCount += bucket.BillCount
summary.GooglePlay.CoinAmount += bucket.CoinAmount
@ -201,10 +345,21 @@ func (r *Repository) SummarizeRechargeBills(ctx context.Context, query ledger.Li
summary.ThirdParty.USDMinorAmount += bucket.USDMinorAmount
}
}
if err := rows.Err(); err != nil {
return ledger.RechargeBillSummary{}, err
}
return summary, nil
return rows.Err()
}
func (r *Repository) summarizeCoinSellerStockBills(ctx context.Context, query ledger.ListRechargeBillsQuery, summary *ledger.RechargeBillSummary) error {
where, args := rechargeBillStockWhereSQL(query)
// 币商口径是进货减冲回的净额:冲回记录的金币与付款额本身就是负数,直接求和。
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*), COALESCE(SUM(sr.coin_amount), 0),
COALESCE(SUM(CASE WHEN sr.paid_currency_code IN ('USDT', 'USD') THEN sr.paid_amount_micro DIV 10000 ELSE 0 END), 0)
FROM coin_seller_stock_records sr
JOIN wallet_transactions wt ON wt.app_code = sr.app_code AND wt.transaction_id = sr.transaction_id
`+where,
args...,
).Scan(&summary.CoinSeller.BillCount, &summary.CoinSeller.CoinAmount, &summary.CoinSeller.USDMinorAmount)
return err
}
func (r *Repository) countRechargeBillRows(ctx context.Context, where string, args ...any) (int64, error) {
@ -248,7 +403,13 @@ func rechargeBillsWhereSQL(query ledger.ListRechargeBillsQuery) (string, []any)
where += ` AND rr.target_region_id = ?`
args = append(args, query.RegionID)
}
if query.RechargeType != "" {
switch query.RechargeType {
case "", rechargeTypeFilterCoinSeller:
// 空值或币商哨兵不追加渠道条件:分支取舍由 rechargeBillBranches 决定。
case rechargeTypeFilterThirdParty:
where += ` AND wt.biz_type <> ?`
args = append(args, bizTypeGooglePlayRecharge)
default:
where += ` AND wt.biz_type = ?`
args = append(args, query.RechargeType)
}

View File

@ -57,6 +57,7 @@ func (s *Server) GetRechargeBillSummary(ctx context.Context, req *walletv1.GetRe
Total: rechargeBillSummaryBucketToProto(summary.Total),
GooglePlay: rechargeBillSummaryBucketToProto(summary.GooglePlay),
ThirdParty: rechargeBillSummaryBucketToProto(summary.ThirdParty),
CoinSeller: rechargeBillSummaryBucketToProto(summary.CoinSeller),
}, nil
}