aslan数据
This commit is contained in:
parent
87081a6324
commit
3d3ebb19a0
File diff suppressed because it is too large
Load Diff
@ -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 {
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -224,6 +224,16 @@ func Default() Config {
|
||||
MongoCollection: "in_app_purchase_details",
|
||||
RequestTimeout: 5 * time.Second,
|
||||
},
|
||||
{
|
||||
Enabled: false,
|
||||
AppCode: "aslan",
|
||||
AppName: "Aslan",
|
||||
// Aslan(likei-services 项目)与 Yumi 同一套平台代码,但独立 Mongo 集群;业务库是 atyou。
|
||||
SysOrigin: "ATYOU",
|
||||
MongoDatabase: "atyou",
|
||||
MongoCollection: "in_app_purchase_details",
|
||||
RequestTimeout: 5 * time.Second,
|
||||
},
|
||||
},
|
||||
MySQLAutoMigrate: true,
|
||||
Migrations: MigrationConfig{
|
||||
|
||||
@ -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 解析账单的区域归属国家:优先账单自身 countryCode(Aslan 的 MifaPay 单会带),
|
||||
// 为空时回退付款用户 user_run_profile.countryCode(Yumi/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"
|
||||
}
|
||||
|
||||
266
server/admin/internal/modules/payment/recharge_bill_export.go
Normal file
266
server/admin/internal/modules/payment/recharge_bill_export.go
Normal 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 按当前筛选口径导出充值明细 CSV;hyapp 与 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
|
||||
}
|
||||
@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 是后台查询所有充值渠道账单的筛选条件。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user