feat: 迁移注册充值明细和筛选导出

This commit is contained in:
Yvan
2026-11-04 00:32:49 +08:00
parent f5b7ce21b9
commit 6703a66f0d
34 changed files with 1964 additions and 9 deletions
+180
View File
@@ -0,0 +1,180 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
businessbiz "kra/internal/biz/business"
"kra/internal/biz/system"
businessdata "kra/internal/data/business"
systemdata "kra/internal/data/system"
"kra/internal/server/handler"
"kra/internal/server/router"
businessservice "kra/internal/service/business"
)
func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ledger.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
pool, _ := db.DB()
t.Cleanup(func() { _ = pool.Close() })
for _, step := range append(systemdata.Migrations(), businessdata.Migrations()...) {
if err := step.Migrate(db); err != nil {
t.Fatal(err)
}
}
for _, sql := range []string{
"INSERT INTO sys_users(id,uuid,username,level,parent_id,id_path,enable) VALUES(1,'root','root',0,0,'0',1),(7,'owner','owner',1,0,'0',1),(9,'leaf','leaf',3,7,'0,7',1),(99,'other','other',1,0,'0',1)",
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(9,'lx',14,'sdk','0'),(99,'hw',20,'other','0')",
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER)",
"INSERT INTO tab_promote VALUES(100,'lx','root-channel',1,0,14),(101,'lx','child-channel',2,100,14),(100,'hw','outside',1,0,20)",
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
"INSERT INTO tab_game VALUES(1,'lx','当前游戏'),(1,'hw','同号其他游戏')",
"CREATE TABLE tab_user(id INTEGER,source_db TEXT,register_time TEXT,fgame_id INTEGER,fgame_name TEXT,register_ip TEXT,device_name TEXT,promote_id INTEGER,puid INTEGER)",
"CREATE TABLE tab_spend(id INTEGER,source_db TEXT,user_id INTEGER,pay_order_number TEXT,game_id INTEGER,game_name TEXT,server_id TEXT,server_name TEXT,game_player_name TEXT,pay_amount DECIMAL(20,2),pay_time INTEGER,pay_way INTEGER,pay_status INTEGER,spend_ip TEXT,promote_id INTEGER,promote_account TEXT)",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
now := time.Now().In(time.FixedZone("CST", 8*3600))
day := time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, now.Location())
stamp := day.Unix() + 43200
if err := db.Exec("INSERT INTO tab_user VALUES(1,'lx',?,1,'历史游戏','192.0.2.1','设备甲',100,0),(2,'lx',?,1,'历史游戏','','设备乙',101,NULL),(3,'lx',?,1,'历史游戏','','设备丙',101,0),(1,'hw',?,1,'外服游戏','','外服',100,0)", strconv.FormatInt(stamp, 10), strconv.FormatInt(stamp, 10), strconv.FormatInt(stamp, 10), strconv.FormatInt(stamp, 10)).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("INSERT INTO tab_spend VALUES(101,'lx',1,'000000000001',1,'当前游戏','s1','区服','角色',12.34,?,3,1,'192.0.2.2',100,''),(102,'lx',3,'00002',1,'当前游戏','s1','区服','角色',3.00,?,4,1,'',101,''),(103,'lx',1,'fail',1,'当前游戏','s1','区服','角色',20.00,?,3,0,'',100,''),(104,'lx',1,'history',1,'当前游戏','s1','区服','角色',1000,0,3,1,'',999,'历史渠道'),(105,'lx',5,'official',1,'当前游戏','s1','区服','角色',5,?,3,1,'',0,'')", stamp, stamp, stamp, stamp).Error; err != nil {
t.Fatal(err)
}
provider := businessHTTPData{db}
uc := businessbiz.NewLedgerUsecase(businessdata.NewLedgerRepo(provider), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)))
engine := gin.New()
engine.Use(func(c *gin.Context) {
id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64)
aid := uint(901)
if id == 1 {
aid = 888
}
c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), system.Actor{UserID: uint(id), AuthorityID: aid}))
c.Next()
})
router.RegisterLedger(engine.Group(""), handler.NewLedger(businessservice.NewLedgerService(uc)))
server := httptest.NewServer(engine)
defer server.Close()
request := func(user int, method, path, body string) ([]byte, http.Header) {
t.Helper()
req, _ := http.NewRequest(method, server.URL+path, strings.NewReader(body))
req.Header.Set("X-Test-User", strconv.Itoa(user))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := server.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
return data, res.Header
}
call := func(user int, method, path, body string, code int) any {
t.Helper()
raw, _ := request(user, method, path, body)
var result map[string]any
if err := json.Unmarshal(raw, &result); err != nil {
t.Fatal(err)
}
if result["code"] != float64(code) {
t.Fatalf("%s %s: %s", method, path, raw)
}
return result["data"]
}
data := call(7, "GET", "/sw/register/list?sourceDb=lx&sortField=totalPayAmount", "", 0).(map[string]any)
rows := data["rows"].([]any)
first := rows[0].(map[string]any)
if data["total"] != float64(2) || first["userId"] != float64(1) || first["totalPayAmount"] != "1012.34" || first["fgameName"] != "历史游戏" || first["busierName"] != "sdk(leaf)" {
t.Fatalf("注册明细口径或字段错误: %+v", data)
}
games := call(7, "GET", "/sw/serverAnalysis/games?sourceDb=lx&limit=50", "", 0).([]any)
if len(games) != 1 || games[0].(map[string]any)["gameName"] != "当前游戏" {
t.Fatal("游戏下拉平台范围错误")
}
filtered := call(7, "GET", "/sw/register/list?sourceDb=lx&gameName="+url.QueryEscape("同号其他游戏"), "", 0).(map[string]any)
if filtered["total"] != float64(0) {
t.Fatal("游戏筛选跨平台串号")
}
data = call(7, "GET", "/sw/spend/list?sourceDb=lx&payStatus=0", "", 0).(map[string]any)
if data["total"] != float64(1) || data["totalPayAmount"] != "15.34" {
t.Fatal("失败列表的合计仍须取对应成功订单")
}
data = call(1, "GET", "/sw/spend/list?sourceDb=lx", "", 0).(map[string]any)
if data["total"] != float64(4) || data["totalPayAmount"] != "20.34" {
t.Fatal("超管全平台遗漏官方订单")
}
data = call(99, "GET", "/sw/register/list?sourceDb=lx", "", 0).(map[string]any)
if data["total"] != float64(0) {
t.Fatal("其他商务看到了 lx 明细")
}
if err := db.Exec("INSERT INTO sys_user_preference(user_id,include_sub_promote,idcard_dedup) VALUES(7,1,0)").Error; err != nil {
t.Fatal(err)
}
data = call(7, "GET", "/sw/register/list?sourceDb=lx", "", 0).(map[string]any)
if data["total"] != float64(2) {
t.Fatal("身份证开关不应改变明细行")
}
form := url.Values{"sourceDb": {"lx"}, "beginRegisterTime": {day.Format("2006-01-02")}, "endRegisterTime": {day.Format("2006-01-02")}}.Encode()
raw, header := request(7, "POST", "/sw/register/export", form)
if header.Get("success") != "true" {
t.Fatalf("导出失败: %s", raw)
}
f, err := excelize.OpenReader(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
xlsRows, err := f.GetRows("注册明细")
f.Close()
if err != nil || len(xlsRows) != 3 || xlsRows[0][7] != "累计充值" {
t.Fatal("注册 XLSX 字段或行数错误")
}
raw, header = request(7, "POST", "/sw/spend/export", "sourceDb=lx&payStatus=1&sortField=payAmount")
if header.Get("success") != "true" {
t.Fatalf("充值导出失败: %s", raw)
}
f, err = excelize.OpenReader(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
order, _ := f.GetCellValue("充值明细", "E2")
f.Close()
if order != "000000000001" {
t.Fatal("订单号导出丢失前导零")
}
call(7, "GET", "/sw/register/list?beginRegisterTime=bad&endRegisterTime=2026-11-04", "", 7)
if err := db.Exec("WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n<10001) INSERT INTO tab_user(id,source_db,register_time,fgame_id,fgame_name,promote_id,puid) SELECT 1000+n,'lx',?,1,'游戏',100,0 FROM seq", strconv.FormatInt(stamp, 10)).Error; err != nil {
t.Fatal(err)
}
call(7, "POST", "/sw/register/export", form, 7)
if err := db.Exec("DROP TABLE tab_promote").Error; err != nil {
t.Fatal(err)
}
data = call(7, "GET", "/sw/spend/list", "", 0).(map[string]any)
if data["available"] != false || data["totalPayAmount"] != nil {
t.Fatal("缺表不能返回伪造零合计")
}
call(7, "POST", "/sw/spend/export", "sourceDb=lx", 7)
}
+8 -3
View File
@@ -82,13 +82,17 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
registry := taskRegistry()
channelPortraitRepo := business.NewChannelPortraitRepo(dataData)
channelRepo := business.NewChannelRepo(dataData)
businessPortraitRepo := business.NewBusinessPortraitRepo(dataData, dataData)
ledgerRepo := business.NewLedgerRepo(dataData)
accountRepo := business.NewAccountRepo(dataData)
accountUsecase := business2.NewAccountUsecase(accountRepo)
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
ledgerUsecase := business2.NewLedgerUsecase(ledgerRepo, accountUsecase, userPreferenceUsecase)
ledgerService := business3.NewLedgerService(ledgerUsecase)
ledger := handler.NewLedger(ledgerService)
channelPortraitRepo := business.NewChannelPortraitRepo(dataData)
channelRepo := business.NewChannelRepo(dataData)
businessPortraitRepo := business.NewBusinessPortraitRepo(dataData, dataData)
channelPortraitUsecase := business2.NewChannelPortraitUsecase(channelPortraitRepo, channelRepo, businessPortraitRepo, accountUsecase, userPreferenceUsecase)
channelPortraitService := business3.NewChannelPortraitService(channelPortraitUsecase)
channelPortrait := handler.NewChannelPortrait(channelPortraitService)
@@ -227,6 +231,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
set := &handler.Set{
Ledger: ledger,
ChannelPortrait: channelPortrait,
BusinessPortrait: businessPortrait,
Channel: channel,
+4 -1
View File
@@ -25,7 +25,7 @@
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 |
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 |
| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 功能已接入;包含渠道画像,完整业务数据和源平台联调仍待最终验收 |
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 待实现 |
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出已接入,其余统计与任务待迁移 |
| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 |
| 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 |
@@ -87,3 +87,6 @@
- 当前没有执行最终旧数据库复制与迁移验收。
- 渠道画像 `/sw/promote/portrait` 与列表入口已接入,包含基本资料、累计/当期指标、趋势、四档付费分层、关联预警及推广游戏抽屉。复用画像统计并增加经过校验的单渠道树范围、无对照期分支;渠道第四项保持活跃玩家。分层成员取累计注册身份,金额按历史成功支付及原始付款账号/身份证口径,保持无日期过滤,不能误复用指标周期过滤。
- 修复旧渠道页面两处参数错配:近7/30天改发 last7/last30,关联预警改发 targetPromoteId 而不是 businessId。领域、存储和隔离 TCP HTTP 覆盖分层边界、子账号与跨平台、模式切换、累计无环比、原始小时趋势和缺数据;浏览器验证入口、正确参数、四档分层及游戏抽屉。全量 Go、ESLint/构建通过;当前真实会话请求记录于 `channel-portrait-http-2026-11-03.json`,仍未复制实际旧业务库。
- 注册/充值明细、两个 XLSX 导出及游戏下拉共五个接口已接入。注册保留 puid=0、同平台玩家全部历史成功充值;充值合计只算成功单,不随列表状态改变。渠道精确匹配、商务文字筛选、官方/未知渠道和跨平台同号均按旧口径处理。
- 修复注册游戏关联跨平台、超过200个渠道后放宽筛选的问题,批量补充当前页资料。导出超过10000条提前拒绝,保留数字/文本类型及原字段顺序。页面使用 Kra 表格、筛选、日期控件与下载,保留近30天默认显示和大额行高亮;注册导出权限与接口一致。
- 明细批次通过全量 Go、前端 ESLint/构建、隔离 TCP HTTP、工作簿内容校验和浏览器筛选/下载。真实 sw7 请求及空查询工作簿验证记录见 `ledger-http-2026-11-04.json`、`ledger-page-2026-11-04.json`,没有导出或修改真实业务行;详细边界见 `ledger-source-notes.md`。
@@ -0,0 +1,86 @@
{
"scope": "真实 sw7 会话,只读查询;导出使用确定为空的商务筛选,仅校验表头工作簿,不导出真实业务行。",
"checks": [
{
"method": "GET",
"path": "/sw/register/list?sourceDb=lx,hw,yq",
"http": 200,
"code": 0,
"msg": "成功",
"passed": true,
"total": 0,
"available": false
},
{
"method": "GET",
"path": "/sw/register/list?busierAccount=__kra_empty_export_probe_20261104__",
"http": 200,
"code": 0,
"msg": "成功",
"passed": true,
"total": 0,
"available": true
},
{
"method": "POST",
"path": "/sw/register/export",
"http": 200,
"xlsx": true,
"worksheet_rows": 1,
"passed": true
},
{
"method": "POST",
"path": "/sw/register/export",
"http": 200,
"code": 7,
"msg": "明细数据尚未接入,无法导出",
"passed": true
},
{
"method": "GET",
"path": "/sw/spend/list?sourceDb=lx,hw,yq",
"http": 200,
"code": 0,
"msg": "成功",
"passed": true,
"total": 0,
"available": false
},
{
"method": "GET",
"path": "/sw/spend/list?busierAccount=__kra_empty_export_probe_20261104__",
"http": 200,
"code": 0,
"msg": "成功",
"passed": true,
"total": 0,
"available": true
},
{
"method": "POST",
"path": "/sw/spend/export",
"http": 200,
"xlsx": true,
"worksheet_rows": 1,
"passed": true
},
{
"method": "POST",
"path": "/sw/spend/export",
"http": 200,
"code": 7,
"msg": "明细数据尚未接入,无法导出",
"passed": true
},
{
"method": "GET",
"path": "/sw/register/list?beginRegisterTime=bad&endRegisterTime=2026-11-04",
"http": 200,
"code": 7,
"msg": "明细查询参数不正确",
"passed": true
}
],
"passed": true
}
@@ -0,0 +1,27 @@
{
"method": "真实 Chrome,列表数据受控;导出使用真实服务的空筛选生成 XLSX,不读取业务数据。",
"reports": [
{
"kind": "register",
"passed": true,
"defaultThirtyDays": true,
"gameNameSelection": true,
"exactAmountOrSuccessSummary": true,
"xlsxDownload": true,
"exportKeepsFilters": true,
"failedQueryClearsOldData": true,
"pageErrors": []
},
{
"kind": "spend",
"passed": true,
"defaultThirtyDays": true,
"gameNameSelection": true,
"exactAmountOrSuccessSummary": true,
"xlsxDownload": true,
"exportKeepsFilters": true,
"failedQueryClearsOldData": true,
"pageErrors": []
}
]
}
+36
View File
@@ -0,0 +1,36 @@
# 注册与充值明细迁移核对
已接入注册/充值列表、对应 XLSX 导出,以及页面依赖的游戏下拉接口。来源为旧 `RegisterServiceImpl`、`SpendServiceImpl`、`BusinessBindScopeServiceImpl`、`ReportQueryScopes` 和实际明细 Vue 页面。
## 查询口径
- 注册列表严格 `puid=0`,不包含 NULL,也不受身份证去重开关影响。注册时间使用 Unix 秒;未传完整日期时默认截至昨日的七天,页面主动传入的默认范围则是含今日的近30天。
- 注册累计充值按同平台+玩家 ID 汇总全部历史成功支付,不受本次注册日期和渠道过滤影响,也不归并子账号。
- 充值列表按选定状态过滤;成功支付合计强制 pay_status=1,忽略选定状态,保留其余过滤。因此筛选失败订单时合计仍可非零。
- 充值保留历史游戏名和渠道账号,注册保留首登游戏名快照。渠道资料缺失时依次保留历史账号、显示官方渠道(ID=0)或未知渠道及 ID。
- 游戏选项来自平台游戏表,列表按游戏名称传参;注册游戏筛选通过同平台游戏 ID 关联,充值直接筛订单的游戏名快照。
## 可见范围
- 普通商务只用本人和下级正常绑定;三级只用本人。指定商务账号按平台账号或 SW 登录名的文字子串收窄。
- 渠道账号为区分大小写的精确匹配。包含下级时,先解析命中渠道所属一级树,再与原可见渠道范围取交集;不包含下级时只保留一级。
- 超管未按商务/渠道收窄且包含下级时,可以看到官方和已无当前渠道资料的历史数据;其他情况必须命中真实可见渠道。
- 所有平台实体关联均带 source_db,不将同号玩家、渠道或游戏混为同一条。
## 修复与优化
- 修复旧注册游戏 EXISTS 内未限定外层 source_db 导致跨平台串号。
- 保留旧累计充值排序已修复的外层用户关联,并按数值注册时间排序。
- 移除“命中超过200个渠道后退回全平台”的筛选失效行为;用相关 EXISTS 表达渠道树,不需要把大量 ID 拼进请求。
- 只读取当前页/导出范围所需的渠道与绑定资料,累计充值按玩家组合键分批查询。
- 注册导出按钮使用正确的注册导出权限,不再误用充值导出权限;充值 ID 列不显示后台不支持的排序箭头。
## 导出与页面
- 超过10000条在读取完整结果和生成工作簿前拒绝;缺业务表不导出假空数据,确定无匹配绑定可导出只有表头的工作簿。
- 保留原 VO 导出字段顺序,包括没有中文 ExcelProperty 的辅助字段;时间保留 Unix 秒,状态/方式保留原代码。
- 数值直接写入数值 XML,不先转 float64;账号、订单号等强制文本,保留前导零并避免当作公式。
- 页面按旧模板提供筛选、游戏远程下拉、日期快捷项、排序、分页、导出;充值金额≥5000的行高亮。查询失败会清除旧行和旧合计。
- 游戏下拉的默认/批量 limit 行为沿用旧接口;区服分析的 servers/list 尚待后续实现,不能因 games 可用而标记整个区服页面完成。
验证:领域/存储测试,实际 TCP HTTP 与 XLSX 内容校验,真实 sw7 的只读查询及确定空查询导出,Chrome 筛选/下载回归。见 `ledger-http-2026-11-04.json`、`ledger-page-2026-11-04.json`。未修改真实业务数据,最终旧库复制和数据比对尚未开始。
+18 -1
View File
@@ -52,7 +52,24 @@
## 其他页面
渠道统计、充值明细、注册明细、游戏数据、区服分析和排行榜,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。
渠道统计、游戏数据、区服分析和排行榜,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。
## 注册与充值明细
来源:旧 `RegistrationDetails.vue`、`RechargeDetails.vue`,查询及导出边界见 `ledger-source-notes.md`。
| 旧页面行为 | 新页面状态 |
| --- | --- |
| 平台、商务账号、玩家ID、精确渠道账号、游戏名称与日期筛选 | 已实现;游戏选项按平台查询并按名称选择 |
| 页面默认含今日近30天,后台不传日期默认截至昨日7天 | 分别保留;日期快捷为今日/昨日/本周/本月 |
| 注册列没有独立 ID 与角色名称,包含累计充值 | 已对齐;累计充值按同平台玩家全部历史成功支付 |
| 充值包含订单、区服、角色、方式、状态及成功支付合计 | 已实现;合计不跟随失败/处理中状态筛选改变口径 |
| 时间、金额服务端排序 | 已实现;不提供后台不支持的充值 ID 排序 |
| 充值≥5000的行高亮 | 已保留 |
| 导出当前筛选数据,最多10000条 | 已实现 XLSX;注册按钮改用正确的注册导出权限 |
| 缺业务数据或查询失败 | 明确提示,清除旧行与旧合计,不导出假空业务数据 |
验证:`ledger-http-2026-11-04.json`、`ledger-page-2026-11-04.json`,实际 TCP HTTP、工作簿内容检查及全量 Go/前端构建。真实服务导出验证仅使用确定无匹配数据的筛选。
## 预警规则
+1 -1
View File
@@ -11,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase)
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase)
+239
View File
@@ -0,0 +1,239 @@
package business
import (
"context"
"strconv"
"strings"
"time"
"github.com/go-kratos/kratos/v3/errors"
)
var (
ErrLedgerInput = errors.BadRequest("SW_LEDGER_INPUT", "明细查询参数不正确")
ErrLedgerUnavailable = errors.BadRequest("SW_LEDGER_UNAVAILABLE", "明细数据尚未接入,无法导出")
ErrLedgerExportLimit = errors.BadRequest("SW_LEDGER_EXPORT_LIMIT", "导出数据超过10000条,请缩小时间范围或筛选条件")
ErrGameOptionsUnavailable = errors.BadRequest("SW_GAME_OPTIONS_UNAVAILABLE", "游戏数据尚未接入")
)
type LedgerFilter struct {
Sources, GameNames []string
UserID *int64
PromoteAccount, BusierAccount, PlayerName, OrderNumber string
PayStatus, PayWay *int
StartDate, EndDate *time.Time
BeginUnix, EndUnix int64
SortField string
Ascending bool
}
type LedgerScope struct {
Viewer UserScope
IncludeSub, Unrestricted bool
}
type LedgerListOptions struct {
Offset, Limit int
RejectAbove int64
}
type LedgerNames struct {
PromoteID *int64
PromoteAccount, BindingAccount, BindingUserName string
}
func (n *LedgerNames) DisplayPromote() string {
if strings.TrimSpace(n.PromoteAccount) != "" {
return n.PromoteAccount
}
if n.PromoteID == nil {
return "未知渠道"
}
if *n.PromoteID == 0 {
return "官方渠道"
}
return "未知渠道(ID:" + strconv.FormatInt(*n.PromoteID, 10) + ")"
}
func (n *LedgerNames) DisplayBusiness() string {
return (&Channel{BindingAccount: n.BindingAccount, BindingUserName: n.BindingUserName}).BusinessDisplayName()
}
type Registration struct {
ChannelKey
LedgerNames
RegisterTime *int64
FirstGameID *int64
FirstGameName, RegisterIP, DeviceName string
TotalPayAmount *string
}
type Spend struct {
ChannelKey
LedgerNames
UserID, GameID *int64
OrderNumber, GameName, ServerID, ServerName, PlayerName, IP string
PayAmount *string
PayTime *int64
PayWay, PayStatus *int
}
type RegistrationPage struct {
Rows []*Registration
Total int64
Available bool
}
type SpendPage struct {
Rows []*Spend
Total int64
TotalPayAmount *string
Available bool
}
type GameOption struct {
SourceDB string
ID uint64
Name string
}
type LedgerRepo interface {
Registrations(context.Context, LedgerScope, LedgerFilter, LedgerListOptions) (*RegistrationPage, error)
Spends(context.Context, LedgerScope, LedgerFilter, LedgerListOptions) (*SpendPage, error)
Games(context.Context, []string, string, int) ([]*GameOption, error)
}
type LedgerUsecase struct {
repo LedgerRepo
accounts *AccountUsecase
preferences *UserPreferenceUsecase
now func() time.Time
}
func NewLedgerUsecase(repo LedgerRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *LedgerUsecase {
return &LedgerUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
}
func (uc *LedgerUsecase) prepare(ctx context.Context, filter LedgerFilter) (LedgerScope, LedgerFilter, error) {
actor, a, err := uc.accounts.actor(ctx)
if err != nil {
return LedgerScope{}, filter, err
}
pref, err := uc.preferences.Current(ctx)
if err != nil {
return LedgerScope{}, filter, err
}
viewer := portraitUserScope(a)
viewer.All = actor.AuthorityID == 888
scope := LedgerScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, Unrestricted: viewer.All && strings.TrimSpace(filter.BusierAccount) == ""}
begin, end := filter.StartDate, filter.EndDate
if begin == nil || end == nil {
now := uc.now().In(portraitLocation)
yesterday := time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, portraitLocation)
first := yesterday.AddDate(0, 0, -6)
begin, end = &first, &yesterday
}
if end.Before(*begin) {
return scope, filter, ErrLedgerInput
}
filter.BeginUnix = begin.Unix()
filter.EndUnix = end.AddDate(0, 0, 1).Unix() - 1
return scope, filter, nil
}
func registrationSort(field string) string {
switch field {
case "totalPayAmount", "total_pay_amount", "totalPay", "payAmount":
return "totalPayAmount"
}
return "registerTime"
}
func spendSort(field string) string {
switch field {
case "payAmount", "pay_amount", "amount":
return "payAmount"
}
return "payTime"
}
func (uc *LedgerUsecase) Registrations(ctx context.Context, filter LedgerFilter, options LedgerListOptions) (*RegistrationPage, error) {
scope, filter, err := uc.prepare(ctx, filter)
if err != nil {
return nil, err
}
filter.SortField = registrationSort(filter.SortField)
page, err := uc.repo.Registrations(ctx, scope, filter, options)
if err != nil {
return nil, err
}
for _, row := range page.Rows {
row.PromoteAccount = row.DisplayPromote()
if row.TotalPayAmount != nil {
n, err := portraitNumber(*row.TotalPayAmount)
if err != nil {
return nil, err
}
row.TotalPayAmount = stringPointer(portraitDecimal(n, 2))
}
}
return page, nil
}
func (uc *LedgerUsecase) Spends(ctx context.Context, filter LedgerFilter, options LedgerListOptions) (*SpendPage, error) {
scope, filter, err := uc.prepare(ctx, filter)
if err != nil {
return nil, err
}
filter.SortField = spendSort(filter.SortField)
page, err := uc.repo.Spends(ctx, scope, filter, options)
if err != nil {
return nil, err
}
for _, row := range page.Rows {
row.PromoteAccount = row.DisplayPromote()
if row.PayAmount != nil {
n, err := portraitNumber(*row.PayAmount)
if err != nil {
return nil, err
}
row.PayAmount = stringPointer(portraitDecimal(n, 2))
}
}
if page.TotalPayAmount != nil {
n, err := portraitNumber(*page.TotalPayAmount)
if err != nil {
return nil, err
}
page.TotalPayAmount = stringPointer(portraitDecimal(n, 2))
}
return page, nil
}
func (uc *LedgerUsecase) ExportRegistrations(ctx context.Context, filter LedgerFilter) ([]*Registration, error) {
page, err := uc.Registrations(ctx, filter, LedgerListOptions{Limit: 10000, RejectAbove: 10000})
if err != nil {
return nil, err
}
if !page.Available {
return nil, ErrLedgerUnavailable
}
if page.Total > 10000 {
return nil, ErrLedgerExportLimit
}
for _, row := range page.Rows {
if row.TotalPayAmount == nil {
return nil, ErrLedgerUnavailable
}
}
return page.Rows, nil
}
func (uc *LedgerUsecase) ExportSpends(ctx context.Context, filter LedgerFilter) ([]*Spend, error) {
page, err := uc.Spends(ctx, filter, LedgerListOptions{Limit: 10000, RejectAbove: 10000})
if err != nil {
return nil, err
}
if !page.Available {
return nil, ErrLedgerUnavailable
}
if page.Total > 10000 {
return nil, ErrLedgerExportLimit
}
return page.Rows, nil
}
func (uc *LedgerUsecase) Games(ctx context.Context, sources []string, keyword string, limit int) ([]*GameOption, error) {
if _, _, err := uc.accounts.actor(ctx); err != nil {
return nil, err
}
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 10000
}
return uc.repo.Games(ctx, sources, strings.TrimSpace(keyword), limit)
}
+92
View File
@@ -0,0 +1,92 @@
package business
import (
"context"
"testing"
"time"
)
type ledgerRepoFake struct {
LedgerRepo
scope LedgerScope
filter LedgerFilter
options LedgerListOptions
total int64
available bool
gameLimit int
}
func (r *ledgerRepoFake) Registrations(_ context.Context, s LedgerScope, f LedgerFilter, o LedgerListOptions) (*RegistrationPage, error) {
r.scope = s
r.filter = f
r.options = o
return &RegistrationPage{Rows: []*Registration{}, Total: r.total, Available: r.available}, nil
}
func (r *ledgerRepoFake) Spends(_ context.Context, s LedgerScope, f LedgerFilter, o LedgerListOptions) (*SpendPage, error) {
r.scope = s
r.filter = f
r.options = o
return &SpendPage{Rows: []*Spend{}, Total: r.total, Available: r.available, TotalPayAmount: stringPointer("0")}, nil
}
func (r *ledgerRepoFake) Games(_ context.Context, _ []string, _ string, limit int) ([]*GameOption, error) {
r.gameLimit = limit
return []*GameOption{}, nil
}
func ledgerFixture() (*LedgerUsecase, *ledgerRepoFake, *channelPreferenceFake) {
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1, Level: 0}, 7: {ID: 7, Level: 1, IDPath: "0"}, 9: {ID: 9, Level: 3, IDPath: "0,7"}}}
r := &ledgerRepoFake{available: true}
p := &channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}
uc := NewLedgerUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(p))
uc.now = func() time.Time { return time.Date(2026, 11, 4, 14, 0, 0, 0, portraitLocation) }
return uc, r, p
}
func TestLedgerDateDefaultsAndBusinessScope(t *testing.T) {
uc, r, p := ledgerFixture()
if _, err := uc.Registrations(accountContext(7, 901), LedgerFilter{SortField: "payAmount"}, LedgerListOptions{Limit: 10}); err != nil {
t.Fatal(err)
}
if time.Unix(r.filter.BeginUnix, 0).In(portraitLocation).Format("2006-01-02 15:04:05") != "2026-10-28 00:00:00" || time.Unix(r.filter.EndUnix, 0).In(portraitLocation).Format("2006-01-02 15:04:05") != "2026-11-03 23:59:59" {
t.Fatal("未传日期应取截至昨日的七天")
}
if r.scope.Unrestricted || !r.scope.Viewer.IncludeSelf || r.filter.SortField != "totalPayAmount" {
t.Fatal("商务范围或排序别名错误")
}
p.pref = UserPreference{IncludeSubPromote: false, IDCardDedup: false}
uc.Spends(accountContext(9, 903), LedgerFilter{}, LedgerListOptions{Limit: 10})
if !r.scope.Viewer.OnlySelf || r.scope.IncludeSub {
t.Fatal("三级本人范围或下级开关丢失")
}
uc.Spends(accountContext(1, 888), LedgerFilter{}, LedgerListOptions{Limit: 10})
if !r.scope.Unrestricted {
t.Fatal("超管默认全平台")
}
uc.Spends(accountContext(1, 888), LedgerFilter{BusierAccount: "商务"}, LedgerListOptions{Limit: 10})
if r.scope.Unrestricted {
t.Fatal("超管指定商务后必须收窄")
}
}
func TestLedgerExportLimitAndDisplayFallback(t *testing.T) {
uc, r, _ := ledgerFixture()
r.total = 10001
if _, err := uc.ExportRegistrations(accountContext(7, 901), LedgerFilter{}); err != ErrLedgerExportLimit || r.options.RejectAbove != 10000 {
t.Fatal("导出没有执行一万条上限")
}
r.total = 0
r.available = false
if _, err := uc.ExportSpends(accountContext(7, 901), LedgerFilter{}); err != ErrLedgerUnavailable {
t.Fatal("缺数据不能导出空文件冒充成功")
}
zero, unknown := int64(0), int64(99)
for _, tc := range []struct {
names LedgerNames
want string
}{{LedgerNames{PromoteID: &zero}, "官方渠道"}, {LedgerNames{PromoteID: &unknown}, "未知渠道(ID:99)"}, {LedgerNames{}, "未知渠道"}, {LedgerNames{PromoteID: &zero, PromoteAccount: "历史账号"}, "历史账号"}} {
if tc.names.DisplayPromote() != tc.want {
t.Fatal("渠道展示回退错误")
}
}
uc.Games(accountContext(1, 888), nil, "", 51)
if r.gameLimit != 10000 {
t.Fatal("原游戏选项的特殊批量上限丢失")
}
}
+398
View File
@@ -0,0 +1,398 @@
package business
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"kra/internal/biz/business"
)
type ledgerRepo struct{ data Provider }
func NewLedgerRepo(data Provider) business.LedgerRepo { return &ledgerRepo{data: data} }
func ledgerBasePromotes(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, alias string) *gorm.DB {
q := db.Table("tab_promote " + alias)
if len(filter.Sources) > 0 {
q = q.Where(alias+".source_db IN ?", filter.Sources)
}
if !scope.IncludeSub {
q = q.Where(alias + ".promote_level = 1")
}
if !scope.Unrestricted {
bindings := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{Sources: filter.Sources, BusierAccount: filter.BusierAccount}).Select("1").Where("b.source_db=" + alias + ".source_db AND b.platform_busier_id=" + alias + ".busier_id")
q = q.Where("EXISTS (?)", bindings)
}
return q
}
func ledgerPromotes(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, alias string) *gorm.DB {
q := ledgerBasePromotes(db, scope, filter, alias)
if filter.PromoteAccount == "" {
return q
}
hits := ledgerBasePromotes(db, scope, filter, "hit")
exact := "TRIM(hit.account) = ? COLLATE BINARY"
if db.Dialector.Name() == "mysql" {
exact = "BINARY TRIM(hit.account) = ?"
}
hits = hits.Where(exact, filter.PromoteAccount).Where("hit.source_db=" + alias + ".source_db")
if scope.IncludeSub {
root := "CASE WHEN hit.promote_level=1 THEN hit.id WHEN hit.top_promote_id>0 THEN hit.top_promote_id ELSE hit.id END"
hits = hits.Where("(" + alias + ".id = (" + root + ") OR " + alias + ".top_promote_id = (" + root + "))")
} else {
hits = hits.Where("hit.id=" + alias + ".id")
}
return q.Where("EXISTS (?)", hits.Select("1"))
}
func prepareLedger(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter) (empty, available bool, err error) {
if !scope.Unrestricted {
var bindings []struct{ UserID uint }
if err := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{Sources: filter.Sources, BusierAccount: filter.BusierAccount}).Select("b.user_id").Limit(1).Scan(&bindings).Error; err != nil {
return false, false, business.ErrStorage
}
if len(bindings) == 0 {
return true, true, nil
}
}
available, err = businessTableAvailable(db, "tab_promote")
if err != nil || !available {
return false, available, err
}
var rows []struct{ ID uint64 }
if err := ledgerPromotes(db, scope, filter, "p").Select("p.id").Limit(1).Scan(&rows).Error; err != nil {
return false, false, business.ErrStorage
}
return len(rows) == 0, true, nil
}
func ledgerScopeQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, table string) *gorm.DB {
q := db.Table(table + " r")
if len(filter.Sources) > 0 {
q = q.Where("r.source_db IN ?", filter.Sources)
}
if scope.Unrestricted && scope.IncludeSub && filter.PromoteAccount == "" {
return q
}
return q.Where("EXISTS (?)", ledgerPromotes(db, scope, filter, "p").Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
}
type registrationPO struct {
ID uint64
SourceDB string `gorm:"column:source_db"`
RegisterTime *string
FirstGameID *int64 `gorm:"column:fgame_id"`
FirstGameName string `gorm:"column:fgame_name"`
RegisterIP string `gorm:"column:register_ip"`
DeviceName string
PromoteID *int64
}
func registrationQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter) *gorm.DB {
cast := "CAST(r.register_time AS INTEGER)"
if db.Dialector.Name() == "mysql" {
cast = "CAST(r.register_time AS UNSIGNED)"
}
q := ledgerScopeQuery(db, scope, filter, "tab_user").Where("r.puid = 0").Where(cast+" BETWEEN ? AND ?", filter.BeginUnix, filter.EndUnix)
if filter.UserID != nil {
q = q.Where("r.id = ?", *filter.UserID)
}
if len(filter.GameNames) > 0 {
q = q.Where("EXISTS (?)", db.Table("tab_game g").Select("1").Where("g.source_db=r.source_db AND g.id=r.fgame_id AND g.game_name IN ?", filter.GameNames))
}
return q
}
func (r *ledgerRepo) Registrations(ctx context.Context, scope business.LedgerScope, filter business.LedgerFilter, options business.LedgerListOptions) (*business.RegistrationPage, error) {
db := r.data.DB().WithContext(ctx)
page := &business.RegistrationPage{Rows: []*business.Registration{}, Available: true}
empty, available, err := prepareLedger(db, scope, filter)
if err != nil {
return nil, err
}
page.Available = available
if empty || !available {
return page, nil
}
for _, table := range append([]string{"tab_user"}, func() []string {
if len(filter.GameNames) > 0 {
return []string{"tab_game"}
}
return nil
}()...) {
available, err := businessTableAvailable(db, table)
if err != nil {
return nil, err
}
if !available {
page.Available = false
return page, nil
}
}
payAvailable, err := businessTableAvailable(db, "tab_spend")
if err != nil {
return nil, err
}
if filter.SortField == "totalPayAmount" && !payAvailable {
page.Available = false
return page, nil
}
q := registrationQuery(db, scope, filter)
if err := q.Count(&page.Total).Error; err != nil {
return nil, business.ErrStorage
}
if options.RejectAbove > 0 && page.Total > options.RejectAbove {
return page, nil
}
direction := " DESC"
if filter.Ascending {
direction = " ASC"
}
order := "CAST(r.register_time AS INTEGER)"
if db.Dialector.Name() == "mysql" {
order = "CAST(r.register_time AS UNSIGNED)"
}
if filter.SortField == "totalPayAmount" {
order = "(SELECT COALESCE(SUM(s.pay_amount),0) FROM tab_spend s WHERE s.source_db=r.source_db AND s.user_id=r.id AND s.pay_status=1)"
}
var pos []registrationPO
if err := q.Select("r.id,r.source_db,r.register_time,r.fgame_id,r.fgame_name,r.register_ip,r.device_name,r.promote_id").Order(order + direction).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit).Scan(&pos).Error; err != nil {
return nil, business.ErrStorage
}
meta := []ledgerMetaTarget{}
keys := []business.ChannelKey{}
for _, po := range pos {
row := &business.Registration{ChannelKey: business.ChannelKey{SourceDB: po.SourceDB, ID: po.ID}, LedgerNames: business.LedgerNames{PromoteID: po.PromoteID}, FirstGameID: po.FirstGameID, FirstGameName: po.FirstGameName, RegisterIP: po.RegisterIP, DeviceName: po.DeviceName}
if po.RegisterTime != nil {
value, err := strconv.ParseInt(strings.TrimSpace(*po.RegisterTime), 10, 64)
if err == nil {
row.RegisterTime = &value
}
}
if payAvailable {
zero := "0"
row.TotalPayAmount = &zero
}
page.Rows = append(page.Rows, row)
meta = append(meta, ledgerMetaTarget{Source: row.SourceDB, Names: &row.LedgerNames})
keys = append(keys, row.ChannelKey)
}
if err := fillLedgerNames(db, scope, filter, meta); err != nil {
return nil, err
}
if payAvailable && len(keys) > 0 {
amounts := map[business.ChannelKey]string{}
for start := 0; start < len(keys); start += 300 {
var values []struct {
SourceDB string `gorm:"column:source_db"`
UserID uint64
Amount string
}
if err := db.Table("tab_spend").Select("source_db,user_id,COALESCE(SUM(pay_amount),0) AS amount").Where("pay_status=1 AND (source_db,user_id) IN ?", channelPairs(keys[start:min(start+300, len(keys))])).Group("source_db,user_id").Scan(&values).Error; err != nil {
return nil, business.ErrStorage
}
for _, value := range values {
amounts[business.ChannelKey{SourceDB: value.SourceDB, ID: value.UserID}] = value.Amount
}
}
for _, row := range page.Rows {
if amount, ok := amounts[row.ChannelKey]; ok {
row.TotalPayAmount = &amount
}
}
}
return page, nil
}
type spendPO struct {
ID uint64
SourceDB string `gorm:"column:source_db"`
UserID, GameID, PromoteID *int64
PayOrderNumber, GameName, ServerID, ServerName, GamePlayerName, SpendIP, PromoteAccount string
PayAmount *string
PayTime *int64
PayWay, PayStatus *int
}
func spendQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, successOnly bool) *gorm.DB {
q := ledgerScopeQuery(db, scope, filter, "tab_spend").Where("r.pay_time BETWEEN ? AND ?", filter.BeginUnix, filter.EndUnix)
if successOnly {
q = q.Where("r.pay_status = 1")
} else if filter.PayStatus != nil {
q = q.Where("r.pay_status = ?", *filter.PayStatus)
}
if len(filter.GameNames) > 0 {
q = q.Where("r.game_name IN ?", filter.GameNames)
}
if filter.UserID != nil {
q = q.Where("r.user_id = ?", *filter.UserID)
}
if filter.PayWay != nil {
q = q.Where("r.pay_way = ?", *filter.PayWay)
}
if filter.PlayerName != "" {
q = q.Where("r.game_player_name LIKE ?", "%"+filter.PlayerName+"%")
}
if filter.OrderNumber != "" {
q = q.Where("r.pay_order_number LIKE ?", "%"+filter.OrderNumber+"%")
}
return q
}
func (r *ledgerRepo) Spends(ctx context.Context, scope business.LedgerScope, filter business.LedgerFilter, options business.LedgerListOptions) (*business.SpendPage, error) {
db := r.data.DB().WithContext(ctx)
zero := "0"
page := &business.SpendPage{Rows: []*business.Spend{}, Available: true, TotalPayAmount: &zero}
empty, available, err := prepareLedger(db, scope, filter)
if err != nil {
return nil, err
}
page.Available = available
if !available {
page.TotalPayAmount = nil
}
if empty || !available {
return page, nil
}
available, err = businessTableAvailable(db, "tab_spend")
if err != nil {
return nil, err
}
if !available {
page.Available = false
page.TotalPayAmount = nil
return page, nil
}
q := spendQuery(db, scope, filter, false)
if err := q.Count(&page.Total).Error; err != nil {
return nil, business.ErrStorage
}
if options.RejectAbove > 0 && page.Total > options.RejectAbove {
return page, nil
}
if err := spendQuery(db, scope, filter, true).Select("COALESCE(SUM(r.pay_amount),0)").Scan(page.TotalPayAmount).Error; err != nil {
return nil, business.ErrStorage
}
order := "r.pay_time"
if filter.SortField == "payAmount" {
order = "r.pay_amount"
}
if filter.Ascending {
order += " ASC"
} else {
order += " DESC"
}
var pos []spendPO
if err := q.Select("r.id,r.source_db,r.user_id,r.pay_order_number,r.game_id,r.game_name,r.server_id,r.server_name,r.game_player_name,r.pay_amount,r.pay_time,r.pay_way,r.pay_status,r.spend_ip,r.promote_id,r.promote_account").Order(order).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit).Scan(&pos).Error; err != nil {
return nil, business.ErrStorage
}
meta := []ledgerMetaTarget{}
for _, po := range pos {
row := &business.Spend{ChannelKey: business.ChannelKey{SourceDB: po.SourceDB, ID: po.ID}, LedgerNames: business.LedgerNames{PromoteID: po.PromoteID, PromoteAccount: po.PromoteAccount}, UserID: po.UserID, GameID: po.GameID, OrderNumber: po.PayOrderNumber, GameName: po.GameName, ServerID: po.ServerID, ServerName: po.ServerName, PlayerName: po.GamePlayerName, PayAmount: po.PayAmount, PayTime: po.PayTime, PayWay: po.PayWay, PayStatus: po.PayStatus, IP: po.SpendIP}
page.Rows = append(page.Rows, row)
meta = append(meta, ledgerMetaTarget{Source: row.SourceDB, Names: &row.LedgerNames})
}
if err := fillLedgerNames(db, scope, filter, meta); err != nil {
return nil, err
}
return page, nil
}
type ledgerMetaTarget struct {
Source string
Names *business.LedgerNames
}
func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, targets []ledgerMetaTarget) error {
keys := []business.ChannelKey{}
seen := map[business.ChannelKey]bool{}
for _, target := range targets {
if target.Names.PromoteID == nil || *target.Names.PromoteID < 0 {
continue
}
key := business.ChannelKey{SourceDB: target.Source, ID: uint64(*target.Names.PromoteID)}
if !seen[key] {
keys = append(keys, key)
seen[key] = true
}
}
channels := map[business.ChannelKey]channelPO{}
pairs := []business.PlatformKey{}
pairSeen := map[business.PlatformKey]bool{}
for start := 0; start < len(keys); start += 300 {
var rows []channelPO
if err := ledgerPromotes(db, scope, filter, "p").Select("p.id,p.source_db,p.account,p.busier_id").Where("(p.source_db,p.id) IN ?", channelPairs(keys[start:min(start+300, len(keys))])).Scan(&rows).Error; err != nil {
return business.ErrStorage
}
for _, row := range rows {
channels[business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID}] = row
key := business.PlatformKey{SourceDB: row.SourceDB, BusierID: row.BusierID}
if !pairSeen[key] {
pairs = append(pairs, key)
pairSeen[key] = true
}
}
}
type bindName struct {
SourceDB string `gorm:"column:source_db"`
PlatformBusierID uint64
PlatformBusierAccount, Username string
}
names := map[business.PlatformKey]bindName{}
for start := 0; start < len(pairs); start += 300 {
var rows []bindName
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,u.username").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
if err := q.Scan(&rows).Error; err != nil {
return business.ErrStorage
}
for _, row := range rows {
key := business.PlatformKey{SourceDB: row.SourceDB, BusierID: row.PlatformBusierID}
if _, ok := names[key]; !ok {
names[key] = row
}
}
}
for _, target := range targets {
n := target.Names
if n.PromoteID == nil || *n.PromoteID < 0 {
continue
}
if c, ok := channels[business.ChannelKey{SourceDB: target.Source, ID: uint64(*n.PromoteID)}]; ok {
if strings.TrimSpace(n.PromoteAccount) == "" {
n.PromoteAccount = c.Account
}
if binding, ok := names[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}]; ok {
n.BindingAccount = binding.PlatformBusierAccount
n.BindingUserName = binding.Username
}
}
}
return nil
}
func (r *ledgerRepo) Games(ctx context.Context, sources []string, keyword string, limit int) ([]*business.GameOption, error) {
db := r.data.DB().WithContext(ctx)
available, err := businessTableAvailable(db, "tab_game")
if err != nil {
return nil, err
}
if !available {
return nil, business.ErrGameOptionsUnavailable
}
q := db.Table("tab_game")
if len(sources) > 0 {
q = q.Where("source_db IN ?", sources)
}
if keyword != "" {
q = q.Where("game_name LIKE ?", "%"+keyword+"%")
}
var rows []struct {
ID uint64
SourceDB string `gorm:"column:source_db"`
GameName string
}
if err := q.Select("id,source_db,game_name").Order("game_name,id,source_db").Limit(limit).Scan(&rows).Error; err != nil {
return nil, business.ErrStorage
}
result := make([]*business.GameOption, 0, len(rows))
for _, row := range rows {
result = append(result, &business.GameOption{ID: row.ID, SourceDB: row.SourceDB, Name: row.GameName})
}
return result, nil
}
+127
View File
@@ -0,0 +1,127 @@
package business
import (
"context"
"fmt"
"testing"
"gorm.io/gorm"
"kra/internal/biz/business"
)
func prepareLedgerData(t *testing.T) (*gorm.DB, business.LedgerRepo, business.LedgerScope, business.LedgerFilter) {
t.Helper()
db, _ := prepareChannels(t)
for _, sql := range []string{
"DROP TABLE tab_user", "DROP TABLE tab_spend",
"CREATE TABLE tab_user(id INTEGER,source_db TEXT,register_time TEXT,fgame_id INTEGER,fgame_name TEXT,register_ip TEXT,device_name TEXT,promote_id INTEGER,puid INTEGER)",
"INSERT INTO tab_user VALUES(1,'lx','1500',1,'历史游戏名','192.0.2.1','机型甲',100,0),(2,'lx','1500',1,'历史游戏名','','',101,NULL),(3,'lx','1500',1,'历史游戏名','','',102,0),(4,'lx','1500',1,'历史游戏名','','',101,1),(5,'lx','1500',1,'历史游戏名','','',103,0),(6,'lx','1500',1,'历史游戏名','','',0,0),(7,'lx','1500',1,'历史游戏名','','',999,0),(1,'hw','1500',1,'其他平台','','',100,0)",
"CREATE TABLE tab_spend(id INTEGER,source_db TEXT,user_id INTEGER,pay_order_number TEXT,game_id INTEGER,game_name TEXT,server_id TEXT,server_name TEXT,game_player_name TEXT,pay_amount DECIMAL(20,2),pay_time INTEGER,pay_way INTEGER,pay_status INTEGER,spend_ip TEXT,promote_id INTEGER,promote_account TEXT)",
"INSERT INTO tab_spend VALUES(901,'lx',1,'00001',1,'游戏甲','s1','区服甲','角色甲',10.10,1500,3,1,'192.0.2.2',100,''),(902,'lx',3,'00002',1,'游戏甲','s1','区服甲','角色乙',30.30,1501,4,1,'',102,''),(903,'lx',5,'00003',1,'游戏甲','s1','区服甲','角色丙',40.40,1502,3,1,'',103,''),(904,'lx',1,'failed',1,'游戏甲','s1','区服甲','角色甲',999,1503,3,0,'',100,''),(905,'lx',1,'outside',1,'游戏甲','s1','区服甲','角色甲',700,500,3,1,'',999,'历史渠道'),(906,'hw',1,'other',1,'同号其他游戏','s1','其他','角色甲',9999,1500,3,1,'',100,''),(907,'lx',6,'official',1,'游戏甲','s1','官方','角色甲',5,1500,3,1,'',0,''),(908,'lx',7,'orphan',1,'游戏甲','s1','未知','角色甲',6,1500,3,1,'',999,''),(909,'lx',1,'pending',1,'游戏甲','s1','区服甲','角色甲',8,1504,3,2,'',100,'旧渠道名')",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
scope := business.LedgerScope{Viewer: channelTestScope(), IncludeSub: true}
filter := business.LedgerFilter{Sources: []string{"lx"}, BeginUnix: 1000, EndUnix: 2000, SortField: "registerTime"}
return db, NewLedgerRepo(testData{db}), scope, filter
}
func TestRegistrationScopePuidAndUnboundedPlayerPay(t *testing.T) {
_, repo, scope, filter := prepareLedgerData(t)
ctx := context.Background()
page, err := repo.Registrations(ctx, scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 3 {
t.Fatalf("注册范围或 puid=0 规则错误: %+v %v", page, err)
}
var first *business.Registration
for _, row := range page.Rows {
if row.ID == 1 {
first = row
}
}
if first == nil || first.TotalPayAmount == nil || portraitTestMoney(*first.TotalPayAmount) != "710.10" || first.FirstGameName != "历史游戏名" || first.DisplayBusiness() != "lx-business(leaf)" {
t.Fatalf("累计充值不能受本次时间/渠道限制,且需保留游戏快照: %+v", first)
}
filter.SortField = "totalPayAmount"
page, err = repo.Registrations(ctx, scope, filter, business.LedgerListOptions{Limit: 2})
if err != nil || page.Rows[0].ID != 1 || page.Rows[1].ID != 5 {
t.Fatal("累计金额排序关联到了错误的支付 ID 或平台")
}
filter.GameNames = []string{"同号其他游戏"}
page, err = repo.Registrations(ctx, scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 0 {
t.Fatal("游戏名称筛选串到了另一个平台")
}
filter.GameNames = []string{"游戏甲"}
filter.PromoteAccount = "child-channel"
page, err = repo.Registrations(ctx, scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 2 {
t.Fatal("精确子渠道账号应扩到同一一级树内的可见渠道")
}
filter.PromoteAccount = "CHILD-channel"
page, err = repo.Registrations(ctx, scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 0 {
t.Fatal("渠道账号应保持区分大小写的精确匹配")
}
}
func TestSpendSummaryIgnoresSelectedFailureStatusAndKeepsSnapshot(t *testing.T) {
_, repo, scope, filter := prepareLedgerData(t)
status := 0
filter.PayStatus = &status
page, err := repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 1 || page.Rows[0].ID != 904 || portraitTestMoney(*page.TotalPayAmount) != "80.80" {
t.Fatalf("失败列表与成功支付合计不能使用同一个状态条件: %+v %v", page, err)
}
status = 2
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Rows[0].PromoteAccount != "旧渠道名" {
t.Fatal("支付明细不应覆盖历史渠道账号")
}
filter.PayStatus = nil
scope.Unrestricted = true
scope.Viewer = business.UserScope{All: true}
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 7 || portraitTestMoney(*page.TotalPayAmount) != "91.80" {
t.Fatalf("超管应包含官方和失去当前渠道资料的历史订单: %+v %v", page, err)
}
unknown := map[uint64]string{}
for _, row := range page.Rows {
unknown[row.ID] = row.DisplayPromote()
}
if unknown[907] != "官方渠道" || unknown[908] != "未知渠道(ID:999)" {
t.Fatal("渠道资料缺失时显示不明确")
}
scope.IncludeSub = false
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 3 {
t.Fatal("不含下级时超管只保留真实一级渠道")
}
}
func TestLedgerLargeTreeFilterDoesNotFallBackToAll(t *testing.T) {
db, repo, scope, filter := prepareLedgerData(t)
for i := 0; i < 205; i++ {
if err := db.Exec("INSERT INTO tab_promote(id,source_db,account,promote_level,parent_id,top_promote_id,busier_id) VALUES(?,'lx',?,3,100,100,14)", 1000+i, fmt.Sprintf("child-%d", i)).Error; err != nil {
t.Fatal(err)
}
}
scope.Unrestricted = true
scope.Viewer = business.UserScope{All: true}
filter.PromoteAccount = "root-channel"
page, err := repo.Registrations(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 2 {
t.Fatalf("超过200渠道后筛选被放宽到全平台: %+v %v", page, err)
}
filter.PromoteAccount = ""
page, err = repo.Registrations(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20, RejectAbove: 1})
if err != nil || page.Total != 5 || len(page.Rows) != 0 {
t.Fatal("导出超过上限应在读取完整结果前停止")
}
if err := db.Exec("DROP TABLE tab_promote").Error; err != nil {
t.Fatal(err)
}
page, err = repo.Registrations(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Available {
t.Fatal("缺渠道范围表不能把限制解释为全量")
}
}
+1
View File
@@ -41,6 +41,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewChannelWriteRepo,
databusiness.NewBusinessPortraitRepo,
databusiness.NewChannelPortraitRepo,
databusiness.NewLedgerRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
+3
View File
@@ -9,6 +9,9 @@ import (
func Definition() module.Definition {
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
Menus: []module.Menu{
{Name: "sw_DataAnalysis", Path: "sw-DataAnalysis", Component: "view/routerHolder.vue", Title: "数据分析", Icon: "data-analysis", Sort: 3},
{Name: "sw_RegistrationDetails", Path: "RegistrationDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RegistrationDetails.vue", Title: "注册明细", Icon: "user", Sort: 3},
{Name: "sw_RechargeDetails", Path: "RechargeDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RechargeDetails.vue", Title: "充值明细", Icon: "money", Sort: 2},
{Name: "sw_ChannelList", Path: "ChannelList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelList.vue", Title: "渠道列表", Icon: "company", Sort: 2},
{Name: "sw_ChannelActivationList", Path: "ChannelActivationList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelActivationList.vue", Title: "渠道激活", Icon: "company", Sort: 3},
{Name: "sw_AlertList", Path: "AlertList", ParentName: "sw_AlertCenter", Component: "view/sw/AlertList.vue", Title: "预警列表", Icon: "warn", Sort: 1},
+2 -2
View File
@@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs)
}
if menus != 11 || apis != 53 {
t.Fatalf("admin surface = %d menus/%d APIs, want 11/53", menus, apis)
if menus != 14 || apis != 58 {
t.Fatalf("admin surface = %d menus/%d APIs, want 14/58", menus, apis)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got))
+5
View File
@@ -41,6 +41,11 @@ type routeValue struct {
// Gin router files still bind handlers; tests keep those registrations aligned
// with this catalog while public, audit and API metadata live only here.
var routes = map[string]routeValue{
"GET /sw/register/list": {group: "注册明细", description: "查询注册明细"},
"POST /sw/register/export": {group: "注册明细", description: "导出注册明细", audit: true},
"GET /sw/spend/list": {group: "充值明细", description: "查询充值明细与成功支付合计"},
"POST /sw/spend/export": {group: "充值明细", description: "导出充值明细", audit: true},
"GET /sw/serverAnalysis/games": {group: "数据分析", description: "查询平台游戏选项"},
"GET /sw/promote/portrait": {group: "渠道画像", description: "查询渠道画像和玩家付费分层"},
"GET /sw/business/:userId/portrait": {group: "商务画像", description: "查询商务画像"},
"GET /sw/business/bindPlatform/portrait": {group: "商务画像", description: "查询 SDK 商务画像"},
+2 -1
View File
@@ -26,6 +26,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
func emptyHandlers() *handler.Set {
return &handler.Set{
Ledger: &handler.Ledger{},
ChannelPortrait: &handler.ChannelPortrait{},
BusinessPortrait: &handler.BusinessPortrait{},
Channel: &handler.Channel{},
@@ -105,7 +106,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":236`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":241`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
+84
View File
@@ -0,0 +1,84 @@
package handler
import (
"mime"
"net/http"
"net/url"
"github.com/gin-gonic/gin"
"github.com/go-kratos/kratos/v3/errors"
"kra/internal/service/business"
"kra/internal/service/dto"
)
type Ledger struct{ service *business.LedgerService }
func NewLedger(service *business.LedgerService) *Ledger { return &Ledger{service: service} }
func (h *Ledger) Registrations(c *gin.Context) {
var req dto.LedgerQuery
if c.ShouldBindQuery(&req) != nil {
Fail(c, "明细查询参数不正确")
return
}
data, err := h.service.Registrations(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, data)
}
func (h *Ledger) Spends(c *gin.Context) {
var req dto.LedgerQuery
if c.ShouldBindQuery(&req) != nil {
Fail(c, "明细查询参数不正确")
return
}
data, err := h.service.Spends(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, data)
}
func (h *Ledger) Games(c *gin.Context) {
var req dto.GameOptionQuery
if c.ShouldBindQuery(&req) != nil {
Fail(c, "游戏查询参数不正确")
return
}
data, err := h.service.Games(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, data)
}
func (h *Ledger) Export(spend bool) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
var req dto.LedgerQuery
if c.ShouldBind(&req) != nil {
c.Header("msg", url.PathEscape("明细查询参数不正确"))
Fail(c, "明细查询参数不正确")
return
}
var data []byte
var err error
name := "注册明细.xlsx"
if spend {
name = "充值明细.xlsx"
data, err = h.service.ExportSpends(c.Request.Context(), &req)
} else {
data, err = h.service.ExportRegistrations(c.Request.Context(), &req)
}
if err != nil {
message := errors.FromError(err).Message
c.Header("msg", url.PathEscape(message))
Fail(c, message)
return
}
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
c.Header("success", "true")
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
}
}
+1
View File
@@ -14,6 +14,7 @@ var ProviderSet = wire.NewSet(
NewChannel,
NewBusinessPortrait,
NewChannelPortrait,
NewLedger,
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
+1
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
Ledger *Ledger
ChannelPortrait *ChannelPortrait
BusinessPortrait *BusinessPortrait
Channel *Channel
+14
View File
@@ -0,0 +1,14 @@
package router
import (
"github.com/gin-gonic/gin"
"kra/internal/server/handler"
)
func RegisterLedger(private *gin.RouterGroup, h *handler.Ledger) {
private.GET("/sw/register/list", h.Registrations)
private.POST("/sw/register/export", h.Export(false))
private.GET("/sw/spend/list", h.Spends)
private.POST("/sw/spend/export", h.Export(true))
private.GET("/sw/serverAnalysis/games", h.Games)
}
+1
View File
@@ -20,6 +20,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
return
}
registrations := []func(){
func() { RegisterLedger(private, r.handlers.Ledger) },
func() { private.GET("/sw/promote/portrait", r.handlers.ChannelPortrait.Portrait) },
func() {
private.GET("/sw/business/:userId/portrait", r.handlers.BusinessPortrait.ForUser)
+115
View File
@@ -0,0 +1,115 @@
package business
import (
"context"
"kra/internal/biz/business"
"kra/internal/service/dto"
"strings"
"time"
)
type LedgerUsecase interface {
Registrations(context.Context, business.LedgerFilter, business.LedgerListOptions) (*business.RegistrationPage, error)
Spends(context.Context, business.LedgerFilter, business.LedgerListOptions) (*business.SpendPage, error)
ExportRegistrations(context.Context, business.LedgerFilter) ([]*business.Registration, error)
ExportSpends(context.Context, business.LedgerFilter) ([]*business.Spend, error)
Games(context.Context, []string, string, int) ([]*business.GameOption, error)
}
type LedgerService struct{ uc LedgerUsecase }
func NewLedgerService(uc *business.LedgerUsecase) *LedgerService { return &LedgerService{uc: uc} }
func ledgerRequest(req *dto.LedgerQuery, spend bool) (business.LedgerFilter, business.LedgerListOptions, error) {
if req == nil {
return business.LedgerFilter{}, business.LedgerListOptions{}, business.ErrLedgerInput
}
sources, err := normalizePlatforms(req.Sources)
if err != nil {
return business.LedgerFilter{}, business.LedgerListOptions{}, err
}
f := business.LedgerFilter{Sources: sources, GameNames: splitQueryValues(req.GameNames), UserID: req.UserID, PromoteAccount: strings.TrimSpace(req.PromoteAccount), BusierAccount: strings.TrimSpace(req.BusierAccount), PlayerName: strings.TrimSpace(req.PlayerName), OrderNumber: strings.TrimSpace(req.OrderNumber), PayStatus: req.PayStatus, PayWay: req.PayWay, SortField: strings.TrimSpace(req.SortField), Ascending: strings.EqualFold(strings.TrimSpace(req.SortOrder), "asc") || strings.EqualFold(strings.TrimSpace(req.SortOrder), "ascending")}
begin, end := req.BeginRegisterTime, req.EndRegisterTime
if spend {
begin, end = req.BeginPayTime, req.EndPayTime
}
if strings.TrimSpace(begin) != "" && strings.TrimSpace(end) != "" {
location := time.FixedZone("Asia/Shanghai", 8*3600)
b, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(begin), location)
if err != nil {
return f, business.LedgerListOptions{}, business.ErrLedgerInput
}
e, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(end), location)
if err != nil {
return f, business.LedgerListOptions{}, business.ErrLedgerInput
}
f.StartDate = &b
f.EndDate = &e
}
page, size := req.Page, req.PageSize
if page < 1 {
page = 1
}
if size < 1 {
size = 10
}
if size > 500 {
size = 500
}
if page > int(^uint(0)>>1)/size {
return f, business.LedgerListOptions{}, business.ErrLedgerInput
}
return f, business.LedgerListOptions{Offset: (page - 1) * size, Limit: size}, nil
}
func registrationResponse(r *business.Registration) *dto.RegistrationResponse {
return &dto.RegistrationResponse{SourceDB: r.SourceDB, SourceDBLabel: business.PlatformName(r.SourceDB), ID: r.ID, UserID: r.ID, RegisterTime: r.RegisterTime, FirstGameID: r.FirstGameID, FirstGameName: r.FirstGameName, TotalPayAmount: r.TotalPayAmount, RegisterIP: r.RegisterIP, DeviceName: r.DeviceName, PromoteID: r.PromoteID, PromoteAccount: r.PromoteAccount, BusierName: r.DisplayBusiness()}
}
func spendResponse(r *business.Spend) *dto.SpendResponse {
return &dto.SpendResponse{SourceDB: r.SourceDB, SourceDBLabel: business.PlatformName(r.SourceDB), ID: r.ID, UserID: r.UserID, OrderNumber: r.OrderNumber, GameID: r.GameID, GameName: r.GameName, ServerID: r.ServerID, ServerName: r.ServerName, PlayerName: r.PlayerName, PayAmount: r.PayAmount, PayTime: r.PayTime, PayWay: r.PayWay, PayStatus: r.PayStatus, IP: r.IP, PromoteID: r.PromoteID, PromoteAccount: r.PromoteAccount, BusierName: r.DisplayBusiness()}
}
func (s *LedgerService) Registrations(ctx context.Context, req *dto.LedgerQuery) (*dto.RegistrationPage, error) {
f, o, err := ledgerRequest(req, false)
if err != nil {
return nil, err
}
page, err := s.uc.Registrations(ctx, f, o)
if err != nil {
return nil, err
}
out := &dto.RegistrationPage{Rows: []*dto.RegistrationResponse{}, Total: page.Total, Available: page.Available}
for _, row := range page.Rows {
out.Rows = append(out.Rows, registrationResponse(row))
}
return out, nil
}
func (s *LedgerService) Spends(ctx context.Context, req *dto.LedgerQuery) (*dto.SpendPage, error) {
f, o, err := ledgerRequest(req, true)
if err != nil {
return nil, err
}
page, err := s.uc.Spends(ctx, f, o)
if err != nil {
return nil, err
}
out := &dto.SpendPage{Rows: []*dto.SpendResponse{}, Total: page.Total, TotalPayAmount: page.TotalPayAmount, Available: page.Available}
for _, row := range page.Rows {
out.Rows = append(out.Rows, spendResponse(row))
}
return out, nil
}
func (s *LedgerService) Games(ctx context.Context, req *dto.GameOptionQuery) ([]*dto.GameOptionResponse, error) {
if req == nil {
return nil, business.ErrLedgerInput
}
sources, err := normalizePlatforms(req.Sources)
if err != nil {
return nil, err
}
rows, err := s.uc.Games(ctx, sources, req.Keyword, req.Limit)
if err != nil {
return nil, err
}
result := make([]*dto.GameOptionResponse, 0, len(rows))
for _, row := range rows {
result = append(result, &dto.GameOptionResponse{SourceDB: row.SourceDB, SourceDBLabel: business.PlatformName(row.SourceDB), GameID: row.ID, GameName: row.Name})
}
return result, nil
}
+101
View File
@@ -0,0 +1,101 @@
package business
import (
"context"
"strconv"
"github.com/xuri/excelize/v2"
"kra/internal/service/dto"
)
type ledgerCell struct {
value string
numeric bool
}
func textCell(value string) ledgerCell { return ledgerCell{value: value} }
func numericCell(value string) ledgerCell { return ledgerCell{value: value, numeric: true} }
func optionalNumber[T ~int | ~int64](value *T) ledgerCell {
if value == nil {
return textCell("")
}
return numericCell(strconv.FormatInt(int64(*value), 10))
}
func optionalMoney(value *string) ledgerCell {
if value == nil {
return textCell("")
}
return numericCell(*value)
}
func ledgerWorkbook(sheet string, headers []string, rows [][]ledgerCell) ([]byte, error) {
file := excelize.NewFile()
defer file.Close()
if err := file.SetSheetName(file.GetSheetName(0), sheet); err != nil {
return nil, err
}
for i, label := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
if err := file.SetCellStr(sheet, cell, label); err != nil {
return nil, err
}
}
for i, row := range rows {
for j, value := range row {
cell, _ := excelize.CoordinatesToCellName(j+1, i+2)
var err error
// 金额直接写入数值 XML,避免先转 float64;文本始终作为文本,保留订单号前导零。
if value.numeric {
err = file.SetCellDefault(sheet, cell, value.value)
} else {
err = file.SetCellStr(sheet, cell, value.value)
}
if err != nil {
return nil, err
}
}
}
last, _ := excelize.ColumnNumberToName(len(headers))
if err := file.SetColWidth(sheet, "A", last, 20); err != nil {
return nil, err
}
if err := file.SetPanes(sheet, &excelize.Panes{Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft"}); err != nil {
return nil, err
}
buffer, err := file.WriteToBuffer()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func (s *LedgerService) ExportRegistrations(ctx context.Context, req *dto.LedgerQuery) ([]byte, error) {
f, _, err := ledgerRequest(req, false)
if err != nil {
return nil, err
}
items, err := s.uc.ExportRegistrations(ctx, f)
if err != nil {
return nil, err
}
rows := make([][]ledgerCell, 0, len(items))
for _, item := range items {
r := registrationResponse(item)
rows = append(rows, []ledgerCell{textCell(r.SourceDB), textCell(r.SourceDBLabel), numericCell(strconv.FormatUint(r.ID, 10)), numericCell(strconv.FormatUint(r.UserID, 10)), optionalNumber(r.RegisterTime), optionalNumber(r.FirstGameID), textCell(r.FirstGameName), optionalMoney(r.TotalPayAmount), textCell(r.RegisterIP), textCell(r.DeviceName), textCell(r.PromoteAccount), optionalNumber(r.PromoteID), textCell(r.BusierName)})
}
return ledgerWorkbook("注册明细", []string{"sourceDb", "所属平台", "ID", "玩家ID", "注册时间", "fgameId", "游戏", "累计充值", "注册IP", "机型", "渠道", "promoteId", "所属商务"}, rows)
}
func (s *LedgerService) ExportSpends(ctx context.Context, req *dto.LedgerQuery) ([]byte, error) {
f, _, err := ledgerRequest(req, true)
if err != nil {
return nil, err
}
items, err := s.uc.ExportSpends(ctx, f)
if err != nil {
return nil, err
}
rows := make([][]ledgerCell, 0, len(items))
for _, item := range items {
r := spendResponse(item)
rows = append(rows, []ledgerCell{textCell(r.SourceDB), textCell(r.SourceDBLabel), numericCell(strconv.FormatUint(r.ID, 10)), optionalNumber(r.UserID), textCell(r.OrderNumber), optionalNumber(r.GameID), textCell(r.GameName), textCell(r.ServerID), textCell(r.ServerName), textCell(r.PlayerName), optionalMoney(r.PayAmount), optionalNumber(r.PayTime), optionalNumber(r.PayWay), optionalNumber(r.PayStatus), textCell(r.IP), textCell(r.PromoteAccount), optionalNumber(r.PromoteID), textCell(r.BusierName)})
}
return ledgerWorkbook("充值明细", []string{"sourceDb", "所属平台", "ID", "玩家ID", "订单号", "gameId", "游戏", "serverId", "区服", "角色", "实付金额", "完成时间", "方式", "状态", "IP", "渠道", "promoteId", "所属商务"}, rows)
}
@@ -0,0 +1,58 @@
package business
import (
"bytes"
"context"
"testing"
"github.com/xuri/excelize/v2"
"kra/internal/biz/business"
"kra/internal/service/dto"
)
type ledgerExportFake struct{ LedgerUsecase }
func (ledgerExportFake) ExportRegistrations(context.Context, business.LedgerFilter) ([]*business.Registration, error) {
amount := "9007199254740993.10"
stamp := int64(1793577600)
return []*business.Registration{{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 7}, RegisterTime: &stamp, FirstGameName: "游戏", TotalPayAmount: &amount, LedgerNames: business.LedgerNames{PromoteAccount: "=1+1"}}}, nil
}
func (ledgerExportFake) ExportSpends(context.Context, business.LedgerFilter) ([]*business.Spend, error) {
amount := "123.45"
return []*business.Spend{{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 8}, OrderNumber: "000000000000000001", PayAmount: &amount}}, nil
}
func TestLedgerWorkbookPreservesNumbersAndText(t *testing.T) {
s := &LedgerService{uc: ledgerExportFake{}}
data, err := s.ExportRegistrations(context.Background(), &dto.LedgerQuery{})
if err != nil {
t.Fatal(err)
}
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
defer f.Close()
amount, _ := f.GetCellValue("注册明细", "H2", excelize.Options{RawCellValue: true})
formula, _ := f.GetCellFormula("注册明细", "K2")
text, _ := f.GetCellValue("注册明细", "K2")
if amount != "9007199254740993.10" || formula != "" || text != "=1+1" {
t.Fatalf("导出丢失金额精度或把账号当成公式: %s %s %s", amount, formula, text)
}
rows, err := f.GetRows("注册明细")
if err != nil || len(rows) != 2 || rows[0][0] != "sourceDb" || rows[0][12] != "所属商务" {
t.Fatal("原导出字段与顺序丢失")
}
data, err = s.ExportSpends(context.Background(), &dto.LedgerQuery{})
if err != nil {
t.Fatal(err)
}
pay, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
t.Fatal(err)
}
defer pay.Close()
order, _ := pay.GetCellValue("充值明细", "E2")
if order != "000000000000000001" {
t.Fatal("订单号前导零丢失")
}
}
+78
View File
@@ -0,0 +1,78 @@
package dto
type LedgerQuery struct {
Page int `form:"pageNum" json:"pageNum"`
PageSize int `form:"pageSize" json:"pageSize"`
Sources []string `form:"sourceDb" json:"sourceDb"`
GameNames []string `form:"gameName" json:"gameName"`
UserID *int64 `form:"userId" json:"userId"`
PromoteAccount string `form:"promoteAccount" json:"promoteAccount"`
BusierAccount string `form:"busierAccount" json:"busierAccount"`
PlayerName string `form:"gamePlayerName" json:"gamePlayerName"`
OrderNumber string `form:"payOrderNumber" json:"payOrderNumber"`
PayStatus *int `form:"payStatus" json:"payStatus"`
PayWay *int `form:"payWay" json:"payWay"`
BeginRegisterTime string `form:"beginRegisterTime" json:"beginRegisterTime"`
EndRegisterTime string `form:"endRegisterTime" json:"endRegisterTime"`
BeginPayTime string `form:"beginPayTime" json:"beginPayTime"`
EndPayTime string `form:"endPayTime" json:"endPayTime"`
SortField string `form:"sortField" json:"sortField"`
SortOrder string `form:"sortOrder" json:"sortOrder"`
}
type RegistrationResponse struct {
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
ID uint64 `json:"id"`
UserID uint64 `json:"userId"`
RegisterTime *int64 `json:"registerTime"`
FirstGameID *int64 `json:"fgameId"`
FirstGameName string `json:"fgameName"`
TotalPayAmount *string `json:"totalPayAmount"`
RegisterIP string `json:"registerIp"`
DeviceName string `json:"deviceName"`
PromoteAccount string `json:"promoteAccount"`
PromoteID *int64 `json:"promoteId"`
BusierName string `json:"busierName"`
}
type SpendResponse struct {
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
ID uint64 `json:"id"`
UserID *int64 `json:"userId"`
OrderNumber string `json:"payOrderNumber"`
GameID *int64 `json:"gameId"`
GameName string `json:"gameName"`
ServerID string `json:"serverId"`
ServerName string `json:"serverName"`
PlayerName string `json:"gamePlayerName"`
PayAmount *string `json:"payAmount"`
PayTime *int64 `json:"payTime"`
PayWay *int `json:"payWay"`
PayStatus *int `json:"payStatus"`
IP string `json:"spendIp"`
PromoteAccount string `json:"promoteAccount"`
PromoteID *int64 `json:"promoteId"`
BusierName string `json:"busierName"`
}
type RegistrationPage struct {
Rows []*RegistrationResponse `json:"rows"`
Total int64 `json:"total"`
Available bool `json:"available"`
}
type SpendPage struct {
Rows []*SpendResponse `json:"rows"`
Total int64 `json:"total"`
TotalPayAmount *string `json:"totalPayAmount"`
Available bool `json:"available"`
}
type GameOptionQuery struct {
Sources []string `form:"sourceDb"`
Keyword string `form:"keyword"`
Limit int `form:"limit"`
}
type GameOptionResponse struct {
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
GameID uint64 `json:"gameId"`
GameName string `json:"gameName"`
}
+1
View File
@@ -23,6 +23,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewChannelService,
businessservice.NewBusinessPortraitService,
businessservice.NewChannelPortraitService,
businessservice.NewLedgerService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
+59
View File
@@ -0,0 +1,59 @@
"""只读检查明细接口,并用确定为空的商务筛选验证真实 XLSX 导出。"""
import argparse
import io
import json
import urllib.parse
import urllib.request
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="http://127.0.0.1:18000")
parser.add_argument("--token-file", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
token = args.token_file.read_text(encoding="utf-8").strip()
checks = []
keyword = "__kra_empty_export_probe_20261104__"
def call(method, path, payload=None, expected=0, excel=False):
body = urllib.parse.urlencode(payload).encode() if payload is not None else None
request = urllib.request.Request(args.base_url + path, method=method, data=body,
headers={"x-token": token, "Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
item = {"method": method, "path": path, "http": response.status}
if excel:
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
sheet = ET.fromstring(archive.read("xl/worksheets/sheet1.xml"))
rows = sheet.findall(".//{http://schemas.openxmlformats.org/spreadsheetml/2006/main}row")
item.update(xlsx=True, worksheet_rows=len(rows), passed=response.headers.get("success") == "true" and len(rows) == 1)
else:
value = json.loads(raw)
data = value.get("data", {})
item.update(code=value.get("code"), msg=value.get("msg"), passed=value.get("code") == expected)
if isinstance(data, dict) and "rows" in data:
item.update(total=data.get("total"), available=data.get("available"))
checks.append(item)
return item
for kind in ["register", "spend"]:
item = call("GET", f"/sw/{kind}/list?sourceDb=lx,hw,yq")
empty = call("GET", f"/sw/{kind}/list?" + urllib.parse.urlencode({"busierAccount": keyword}))
empty["passed"] &= empty.get("total") == 0 and empty.get("available") is True
call("POST", f"/sw/{kind}/export", {"busierAccount": keyword}, excel=True)
if item.get("available") is False:
call("POST", f"/sw/{kind}/export", {"sourceDb": "lx"}, expected=7)
call("GET", "/sw/register/list?beginRegisterTime=bad&endRegisterTime=2026-11-04", expected=7)
report = {"scope": "真实 sw7 会话,只读查询;导出使用确定为空的商务筛选,仅校验表头工作簿,不导出真实业务行。", "checks": checks, "passed": all(item["passed"] for item in checks)}
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
if not report["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
// 明细页面采用受控列表数据;下载通过真实服务的确定空查询生成 XLSX,不读取实际业务行。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright')
;(async () => {
const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim()
const browser = await chromium.launch({ channel: 'chrome', headless: true })
const reports = []
try {
for (const kind of ['register', 'spend']) {
const context = await browser.newContext({ viewport: { width: 1650, height: 1000 }, acceptDownloads: true })
await context.addInitScript(value => localStorage.setItem('token', value), token)
const page = await context.newPage()
const errors = [], queries = [], exports = []
let failed = false
page.on('pageerror', error => errors.push(error.message))
await page.route('http://127.0.0.1:8080/api/**', async route => {
const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4)
if (api === `/sw/${kind}/list`) {
queries.push(Object.fromEntries(url.searchParams))
if (failed) return route.fulfill({ json: { code: 7, msg: '测试查询失败', data: {} } })
const row = kind === 'register'
? { id: 7, userId: 7, sourceDb: 'lx', sourceDbLabel: '龙翔', registerTime: 1793664000, fgameName: '测试游戏', totalPayAmount: '9007199254740993.10', registerIp: '192.0.2.1', deviceName: '测试机型', promoteAccount: '测试渠道', busierName: 'SDK(测试商务)' }
: { id: 71, userId: 7, sourceDb: 'lx', sourceDbLabel: '龙翔', payOrderNumber: '00000000000071', gameName: '测试游戏', serverName: '测试区服', gamePlayerName: '测试角色', payAmount: '5000.00', payTime: 1793664000, payWay: 3, payStatus: 0, spendIp: '192.0.2.2', promoteAccount: '测试渠道', busierName: 'SDK(测试商务)' }
return route.fulfill({ json: { code: 0, msg: '成功', data: { rows: [row], total: 1, totalPayAmount: '2300.00', available: true } } })
}
if (api === '/sw/serverAnalysis/games') return route.fulfill({ json: { code: 0, data: [{ sourceDb: 'lx', gameId: 1, gameName: '可选游戏' }, { sourceDb: 'hw', gameId: 2, gameName: '可选游戏' }] } })
if (api === `/sw/${kind}/export`) {
exports.push(Object.fromEntries(new URLSearchParams(request.postData())))
const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api, method: 'POST', postData: 'busierAccount=__kra_empty_export_probe_20261104__', headers: { ...request.headers(), 'content-type': 'application/x-www-form-urlencoded', 'x-token': token } })
return route.fulfill({ response })
}
const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } })
return route.fulfill({ response })
})
await page.goto(`http://127.0.0.1:8080/#/layout/sw-DataAnalysis/${kind === 'register' ? 'RegistrationDetails' : 'RechargeDetails'}`)
await page.getByText('测试渠道', { exact: true }).waitFor()
const first = queries[0], begin = first[kind === 'register' ? 'beginRegisterTime' : 'beginPayTime'], end = first[kind === 'register' ? 'endRegisterTime' : 'endPayTime']
assert.equal((Date.parse(end) - Date.parse(begin)) / 86400000, 29)
if (kind === 'register') {
assert.equal(await page.getByRole('columnheader', { name: 'ID', exact: true }).count(), 0)
await page.getByText('¥9,007,199,254,740,993.10', { exact: true }).waitFor()
} else {
assert.equal(await page.locator('.large-recharge-row').count(), 1)
await page.getByText('¥2,300.00', { exact: true }).waitFor()
}
const form = page.locator('.kra-search-box')
await form.locator('.el-form-item').filter({ has: page.locator('label').getByText('游戏', { exact: true }) }).locator('.el-select__wrapper').click()
await page.getByRole('option', { name: '可选游戏', exact: true }).click()
await page.keyboard.press('Escape')
await form.getByPlaceholder('玩家ID', { exact: true }).fill('42')
await Promise.all([page.waitForResponse(response => response.url().includes(`/sw/${kind}/list`) && response.url().includes('userId=42')), form.getByRole('button', { name: '搜索', exact: true }).click()])
assert.equal(queries.at(-1).gameName, '可选游戏')
const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: '导出', exact: true }).click()])
const bytes = await fs.readFile(await download.path())
assert.equal(bytes.subarray(0, 2).toString(), 'PK')
assert.equal(exports.at(-1).userId, '42')
assert.equal(exports.at(-1).gameName, '可选游戏')
await page.screenshot({ path: path.join(process.env.TEMP, `sw-${kind}-details.png`), animations: 'disabled' })
failed = true
await form.getByRole('button', { name: '搜索', exact: true }).click()
await page.locator('.el-alert').getByText('测试查询失败', { exact: true }).waitFor()
if (kind === 'spend') assert.equal(await page.getByText('¥2,300.00', { exact: true }).count(), 0)
assert.deepEqual(errors, [])
const report = { kind, passed: true, defaultThirtyDays: true, gameNameSelection: true, exactAmountOrSuccessSummary: true, xlsxDownload: true, exportKeepsFilters: true, failedQueryClearsOldData: true, pageErrors: errors }
reports.push(report); console.log(JSON.stringify(report)); await context.close()
}
await fs.writeFile('docs/sw6-to-kra/ledger-page-2026-11-04.json', JSON.stringify({ method: '真实 Chrome,列表数据受控;导出使用真实服务的空筛选生成 XLSX,不读取业务数据。', reports }, null, 2) + '\n')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
+9
View File
@@ -0,0 +1,9 @@
import service from '@/utils/request'
export const listLedger = (kind, params) => service({ url: `/sw/${kind}/list`, method: 'get', params })
export const exportLedger = (kind, data) => {
const form = new URLSearchParams()
Object.entries(data).forEach(([key, value]) => { if (value != null && value !== '') form.set(key, Array.isArray(value) ? value.join(',') : String(value)) })
return service({ url: `/sw/${kind}/export`, method: 'post', data: form, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, responseType: 'blob' })
}
export const listLedgerGames = params => service({ url: '/sw/serverAnalysis/games', method: 'get', params, donNotShowLoading: true })
+2
View File
@@ -57,6 +57,8 @@
"/src/view/sw/BusinessList.vue": "SwBusinessList",
"/src/view/sw/ChannelActivationList.vue": "SwChannelActivationList",
"/src/view/sw/ChannelList.vue": "SwChannelList",
"/src/view/sw/RechargeDetails.vue": "SwRechargeDetails",
"/src/view/sw/RegistrationDetails.vue": "SwRegistrationDetails",
"/src/view/sw/components/BusinessBindings.vue": "SwBusinessBindings",
"/src/view/sw/components/BusinessChannels.vue": "SwBusinessChannels",
"/src/view/system/security/forceChangePassword.vue": "ForceChangePassword",
+5
View File
@@ -0,0 +1,5 @@
<template><ledger-details kind="spend" /></template>
<script setup>
import LedgerDetails from './components/LedgerDetails.vue'
defineOptions({ name: 'SwRechargeDetails' })
</script>
+5
View File
@@ -0,0 +1,5 @@
<template><ledger-details kind="register" /></template>
<script setup>
import LedgerDetails from './components/LedgerDetails.vue'
defineOptions({ name: 'SwRegistrationDetails' })
</script>
@@ -0,0 +1,129 @@
<template>
<div>
<div class="kra-search-box">
<el-form :model="query" :inline="true" @submit.prevent="search">
<el-form-item label="所属平台"><el-select v-model="sources" multiple collapse-tags clearable placeholder="选择平台" style="width: 210px" @change="sourceChanged"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="商务账号"><el-input v-model="query.busierAccount" clearable placeholder="商务账号" /></el-form-item>
<el-form-item label="玩家ID"><el-input v-model="playerID" clearable placeholder="玩家ID" /></el-form-item>
<el-form-item v-if="spend" label="角色名"><el-input v-model="query.gamePlayerName" clearable placeholder="角色名" /></el-form-item>
<el-form-item label="渠道账号"><el-input v-model="query.promoteAccount" clearable placeholder="渠道账号(精确匹配)" /></el-form-item>
<el-form-item v-if="spend" label="订单号"><el-input v-model="query.payOrderNumber" clearable placeholder="订单号" /></el-form-item>
<el-form-item label="游戏"><el-select v-model="gameNames" multiple collapse-tags clearable filterable remote reserve-keyword :remote-method="loadGames" :loading="gamesLoading" :disabled="!sources.length" placeholder="全部游戏" style="width: 200px" @visible-change="open => open && loadGames('')"><el-option v-for="name in gameOptions" :key="name" :label="name" :value="name" /></el-select></el-form-item>
<el-form-item v-if="spend" label="方式"><el-select v-model="query.payWay" clearable placeholder="全部方式" style="width: 140px"><el-option v-for="(label, value) in payWays" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
<el-form-item v-if="spend" label="状态"><el-select v-model="query.payStatus" clearable placeholder="全部状态" style="width: 140px"><el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
<el-form-item label="日期"><el-date-picker v-model="dates" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" :clearable="false" :shortcuts="shortcuts" style="width: 300px" /></el-form-item>
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button></el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<div class="kra-btn-list"><el-button v-if="canExport" type="warning" plain icon="download" :loading="exporting" @click="download">导出</el-button></div>
<el-alert v-if="error" class="mb-4" type="error" :closable="false" :title="error" />
<el-alert v-else-if="!available" class="mb-4" type="warning" :closable="false" title="明细所需业务数据尚未接入。" />
<el-table v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.id}`" :row-class-name="rowClass" :default-sort="{ prop: spend ? 'payTime' : 'registerTime', order: 'descending' }" @sort-change="sortChanged">
<el-table-column prop="sourceDbLabel" label="所属平台" min-width="100" />
<el-table-column v-if="spend" prop="id" label="ID" min-width="90" />
<el-table-column prop="userId" label="玩家ID" min-width="110" />
<el-table-column v-if="!spend" prop="registerTime" label="注册时间" min-width="145" sortable="custom"><template #default="{ row }">{{ unixTime(row.registerTime) }}</template></el-table-column>
<el-table-column v-if="spend" prop="payOrderNumber" label="订单号" min-width="190" show-overflow-tooltip />
<el-table-column :prop="spend ? 'gameName' : 'fgameName'" label="游戏" min-width="150" show-overflow-tooltip />
<el-table-column v-if="spend" prop="serverName" label="区服" min-width="140" show-overflow-tooltip />
<el-table-column v-if="spend" prop="gamePlayerName" label="角色" min-width="130" show-overflow-tooltip />
<el-table-column :prop="spend ? 'payAmount' : 'totalPayAmount'" :label="spend ? '实付金额' : '累计充值'" min-width="150" sortable="custom"><template #default="{ row }">{{ channelMoney(spend ? row.payAmount ?? '0.00' : row.totalPayAmount) }}</template></el-table-column>
<el-table-column v-if="spend" prop="payTime" label="充值时间" min-width="145" sortable="custom"><template #default="{ row }">{{ unixTime(row.payTime) }}</template></el-table-column>
<el-table-column v-if="spend" label="方式" min-width="100"><template #default="{ row }">{{ payWays[row.payWay] ?? row.payWay ?? '—' }}</template></el-table-column>
<el-table-column v-if="spend" label="状态" min-width="110"><template #default="{ row }"><el-tag :type="{ 1: 'success', 2: 'warning', 0: 'danger' }[row.payStatus] || 'info'">{{ statuses[row.payStatus] ?? row.payStatus ?? '—' }}</el-tag></template></el-table-column>
<el-table-column :label="spend ? 'IP' : '注册IP'" min-width="130"><template #default="{ row }">{{ (spend ? row.spendIp : row.registerIp) || '—' }}</template></el-table-column>
<el-table-column v-if="!spend" prop="deviceName" label="机型" min-width="140" show-overflow-tooltip />
<el-table-column prop="promoteAccount" label="渠道" min-width="170" show-overflow-tooltip />
<el-table-column prop="busierName" label="归属商务" min-width="190" show-overflow-tooltip />
</el-table>
<p v-if="spend" class="mt-4 text-right">合计:成功支付总额 <strong>{{ error ? '未获取' : available ? channelMoney(totalPayAmount) : '未接入' }}</strong></p>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
import { listLedger, exportLedger, listLedgerGames } from '@/api/ledger'
import { channelMoney } from '../channel-display'
const props = defineProps({ kind: { type: String, required: true } })
const spend = computed(() => props.kind === 'spend')
const user = useUserStore(), buttons = useBtnAuth()
const canExport = computed(() => Number(user.userInfo.authorityId) === 888 || !!buttons[`sw:${props.kind}:export`])
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const payWays = { 1: '绑币', 2: '平台币', 3: '支付宝', 4: '微信', 5: '金猪', 6: '苹果支付', 11: '盒子币', 12: '龙翔币', 14: '抖音小游戏' }
const statuses = { 1: '成功', 2: '处理中', 0: '失败' }
const today = () => { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate()) }
const ymd = date => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const end = today(), begin = today(); begin.setDate(begin.getDate() - 29)
const dates = ref([ymd(begin), ymd(end)])
const shortcuts = [
{ text: '今日', value: () => [today(), today()] },
{ text: '昨日', value: () => { const d = today(); d.setDate(d.getDate() - 1); return [d, new Date(d)] } },
{ text: '本周', value: () => { const e = today(), b = today(); b.setDate(b.getDate() - (b.getDay() || 7) + 1); return [b, e] } },
{ text: '本月', value: () => { const e = today(); return [new Date(e.getFullYear(), e.getMonth(), 1), e] } }
]
const sources = ref(Object.keys(platforms)), gameNames = ref([]), gameOptions = ref([]), playerID = ref('')
const gamesLoading = ref(false), exporting = ref(false), loading = ref(false), available = ref(true)
const error = ref('')
const rows = ref([]), total = ref(0), totalPayAmount = ref('0.00')
const query = reactive({ pageNum: 1, pageSize: 10, busierAccount: '', promoteAccount: '', gamePlayerName: '', payOrderNumber: '', payStatus: undefined, payWay: undefined, sortField: spend.value ? 'payTime' : 'registerTime', sortOrder: 'desc' })
let listVersion = 0, gameVersion = 0
const buildQuery = () => {
const player = playerID.value.trim()
if (player && !/^\d+$/.test(player)) { ElMessage.warning('玩家ID应为整数'); return null }
const result = { ...query, sourceDb: sources.value.join(','), gameName: gameNames.value.join(','), userId: player || undefined, busierAccount: query.busierAccount.trim(), promoteAccount: query.promoteAccount.trim(), gamePlayerName: query.gamePlayerName.trim(), payOrderNumber: query.payOrderNumber.trim() }
result[spend.value ? 'beginPayTime' : 'beginRegisterTime'] = dates.value?.[0]
result[spend.value ? 'endPayTime' : 'endRegisterTime'] = dates.value?.[1]
return result
}
const load = async () => {
const version = ++listVersion
if (!sources.value.length) { rows.value = []; total.value = 0; totalPayAmount.value = '0.00'; available.value = true; loading.value = false; error.value = ''; return }
const params = buildQuery(); if (!params) { loading.value = false; return }
loading.value = true; error.value = ''
try {
const res = await listLedger(props.kind, params)
if (version !== listVersion) return
if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total; totalPayAmount.value = res.data.totalPayAmount; available.value = res.data.available !== false }
else { rows.value = []; total.value = 0; totalPayAmount.value = null; error.value = res.msg || '明细查询失败' }
} catch { if (version === listVersion) { rows.value = []; total.value = 0; totalPayAmount.value = null; error.value = '明细查询失败,请重试' } } finally { if (version === listVersion) loading.value = false }
}
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
const sortChanged = ({ prop, order }) => { query.sortField = prop || (spend.value ? 'payTime' : 'registerTime'); query.sortOrder = order === 'ascending' ? 'asc' : 'desc'; return search() }
const sourceChanged = () => { ++gameVersion; gameNames.value = []; gameOptions.value = []; gamesLoading.value = false }
const loadGames = async (keyword = '') => {
const version = ++gameVersion; if (!sources.value.length) return
gamesLoading.value = true
try { const res = await listLedgerGames({ sourceDb: sources.value.join(','), keyword, limit: 50 }); if (version === gameVersion && res.code === 0) gameOptions.value = [...new Set([...gameNames.value, ...res.data.map(row => row.gameName).filter(Boolean)])] } finally { if (version === gameVersion) gamesLoading.value = false }
}
const unixTime = value => {
if (value == null) return '—'
const date = new Date(Number(value) * 1000)
if (Number.isNaN(date.getTime())) return '—'
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
const rowClass = ({ row }) => spend.value && Number(row.payAmount) >= 5000 ? 'large-recharge-row' : ''
const download = async () => {
if (!sources.value.length) { ElMessage.warning('请选择平台'); return }
const params = buildQuery(); if (!params) return
exporting.value = true
try {
const result = await exportLedger(props.kind, params)
const blob = result instanceof Blob ? result : result.data
if (!(blob instanceof Blob)) return
if (blob.type.includes('json')) return
const url = URL.createObjectURL(blob), link = document.createElement('a')
link.href = url; link.download = spend.value ? '充值明细.xlsx' : '注册明细.xlsx'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
} finally { exporting.value = false }
}
onMounted(load)
</script>
<style scoped>
:deep(.large-recharge-row) { --el-table-tr-bg-color: var(--el-color-warning-light-9); }
</style>