feat: 迁移渠道与游戏统计及全量汇总
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"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"
|
||||
businessservice "kra/internal/service/business"
|
||||
)
|
||||
|
||||
func TestChannelAndGameAnalyticsHTTP(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "analytics.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,'其他账号','0')",
|
||||
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER,register_type INTEGER)",
|
||||
"INSERT INTO tab_promote VALUES(100,'lx','root-channel',1,0,14,0),(101,'lx','child-channel',2,100,14,0),(200,'lx','guild-channel',1,0,14,1),(100,'hw','other-platform',1,0,20,0)",
|
||||
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
|
||||
"INSERT INTO tab_game VALUES(1,'lx','当前游戏'),(2,'lx','公会游戏'),(1,'hw','外服游戏')",
|
||||
"CREATE TABLE tab_sw_report_day(source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,stat_date DATE,total_pay DECIMAL(20,2))",
|
||||
"INSERT INTO tab_sw_report_day VALUES('lx',100,1,'旧游戏名',0,'2026-11-04',10),('lx',101,1,'旧游戏名',0,'2026-11-04',20),('lx',200,2,'公会游戏',0,'2026-11-04',30),('hw',100,1,'旧游戏名',0,'2026-11-04',40),('lx',0,1,'旧游戏名',0,'2026-11-04',5),('lx',999,1,'旧游戏名',0,'2026-11-04',7),('lx',100,1,'旧游戏名',1,'2026-11-04',9999)",
|
||||
"CREATE TABLE tab_sw_report_day_key(source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,stat_date DATE,metric INTEGER,identity_key TEXT)",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, row := range []struct {
|
||||
source string
|
||||
promote, game int
|
||||
key string
|
||||
}{{"lx", 100, 1, "A"}, {"lx", 101, 1, "A"}, {"lx", 200, 2, "A"}, {"hw", 100, 1, "A"}, {"lx", 0, 1, "official"}, {"lx", 999, 1, "orphan"}} {
|
||||
name := "旧游戏名"
|
||||
if row.game == 2 {
|
||||
name = "公会游戏"
|
||||
}
|
||||
for _, metric := range []int{1, 2, 3, 4} {
|
||||
if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES(?,?,?,?,1,'2026-11-04',?,?)", row.source, row.promote, row.game, name, metric, row.key).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES('lx',101,1,'旧游戏名',1,'2026-11-04',2,'B')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
promote, game int
|
||||
id string
|
||||
}{{100, 1, "1"}, {101, 1, "2"}, {200, 2, "3"}} {
|
||||
for _, metric := range []int{1, 2, 3, 4} {
|
||||
if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES('lx',?,?,'旧游戏名',0,'2026-11-04',?,?)", row.promote, row.game, metric, row.id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
provider := businessHTTPData{db}
|
||||
uc := businessbiz.NewAnalyticsUsecase(businessdata.NewAnalyticsRepo(provider), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)))
|
||||
h := handler.NewAnalytics(businessservice.NewAnalyticsService(uc))
|
||||
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()
|
||||
})
|
||||
engine.GET("/sw/channelData/list", h.Channels)
|
||||
engine.GET("/sw/gameData/list", h.Games)
|
||||
server := httptest.NewServer(engine)
|
||||
defer server.Close()
|
||||
call := func(user int, kind, query string, expected int) map[string]any {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", server.URL+"/sw/"+kind+"/list?beginTime=2026-11-04&endTime=2026-11-04&"+query, nil)
|
||||
req.Header.Set("X-Test-User", strconv.Itoa(user))
|
||||
res, err := server.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["code"] != float64(expected) {
|
||||
t.Fatalf("%s %s: %+v", kind, query, body)
|
||||
}
|
||||
return body["data"].(map[string]any)
|
||||
}
|
||||
data := call(7, "channelData", "sourceDb=lx,hw&pageSize=1", 0)
|
||||
if data["total"] != float64(2) || len(data["rows"].([]any)) != 1 || data["channelCount"] != float64(2) || data["totalRegisterCount"] != float64(2) || data["totalPayAmount"] != "60.00" || data["avgPayRate"] != "66.67" {
|
||||
t.Fatalf("普通商务渠道汇总或分页错误: %+v", data)
|
||||
}
|
||||
first := data["rows"].([]any)[0].(map[string]any)
|
||||
if first["busierName"] != "SDK账号" || first["newRegisterCount"] != float64(1) {
|
||||
t.Fatal("渠道树去重或商务展示未保留")
|
||||
}
|
||||
data = call(1, "channelData", "sourceDb=lx,hw", 0)
|
||||
if data["total"] != float64(5) || data["totalRegisterCount"] != float64(3) || data["totalPayAmount"] != "112.00" || data["avgPayRate"] != "75.00" {
|
||||
t.Fatalf("超管应全局去重并包含官方/未知渠道: %+v", data)
|
||||
}
|
||||
data = call(7, "gameData", "sourceDb=lx,hw&pageSize=1", 0)
|
||||
sum := data["summary"].(map[string]any)
|
||||
if data["total"] != float64(2) || len(data["rows"].([]any)) != 1 || sum["totalPay"] != "60.00" || sum["newRegisterCount"] != float64(2) || sum["arpu"] != "20.00" || sum["arppu"] != "30.00" {
|
||||
t.Fatalf("游戏汇总应覆盖整份筛选结果并使用活跃/付费分母: %+v", data)
|
||||
}
|
||||
data = call(1, "gameData", "sourceDb=lx,hw", 0)
|
||||
if data["summary"].(map[string]any)["newRegisterCount"] != float64(5) {
|
||||
t.Fatal("游戏汇总不能偷换成全局去重人数")
|
||||
}
|
||||
data = call(7, "channelData", "sourceDb=lx&promoteName=child", 0)
|
||||
if data["total"] != float64(1) || data["totalPayAmount"] != "30.00" {
|
||||
t.Fatal("模糊命中子渠道后应统计可见的同树数据")
|
||||
}
|
||||
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, "channelData", "sourceDb=lx", 0)
|
||||
if data["totalRegisterCount"] != float64(3) || data["totalPayAmount"] != "60.00" {
|
||||
t.Fatal("关闭身份证去重改变了金额或未改变人数")
|
||||
}
|
||||
if err := db.Exec("UPDATE sys_user_preference SET include_sub_promote=0 WHERE user_id=7").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = call(7, "channelData", "sourceDb=lx", 0)
|
||||
if data["totalPayAmount"] != "40.00" {
|
||||
t.Fatal("不包含下级仍计入子渠道金额")
|
||||
}
|
||||
data = call(99, "gameData", "sourceDb=lx", 0)
|
||||
if data["total"] != float64(0) {
|
||||
t.Fatal("其他平台绑定用户越权查询 lx")
|
||||
}
|
||||
if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = call(7, "channelData", "sourceDb=lx", 0)
|
||||
if data["available"] != false || data["summary"] != nil || data["totalPayAmount"] != nil {
|
||||
t.Fatal("缺汇总数据不应提供假零卡片")
|
||||
}
|
||||
}
|
||||
Generated
+6
-1
@@ -82,11 +82,15 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
|
||||
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
|
||||
registry := taskRegistry()
|
||||
ledgerRepo := business.NewLedgerRepo(dataData)
|
||||
analyticsRepo := business.NewAnalyticsRepo(dataData)
|
||||
accountRepo := business.NewAccountRepo(dataData)
|
||||
accountUsecase := business2.NewAccountUsecase(accountRepo)
|
||||
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
|
||||
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
|
||||
analyticsUsecase := business2.NewAnalyticsUsecase(analyticsRepo, accountUsecase, userPreferenceUsecase)
|
||||
analyticsService := business3.NewAnalyticsService(analyticsUsecase)
|
||||
analytics := handler.NewAnalytics(analyticsService)
|
||||
ledgerRepo := business.NewLedgerRepo(dataData)
|
||||
ledgerUsecase := business2.NewLedgerUsecase(ledgerRepo, accountUsecase, userPreferenceUsecase)
|
||||
ledgerService := business3.NewLedgerService(ledgerUsecase)
|
||||
ledger := handler.NewLedger(ledgerService)
|
||||
@@ -231,6 +235,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
|
||||
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
||||
set := &handler.Set{
|
||||
Analytics: analytics,
|
||||
Ledger: ledger,
|
||||
ChannelPortrait: channelPortrait,
|
||||
BusinessPortrait: businessPortrait,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"scope": "真实 sw7 会话,只读请求;完整统计及权限口径另由隔离 TCP HTTP 测试验证。",
|
||||
"checks": [
|
||||
{
|
||||
"path": "/sw/channelData/list?sourceDb=lx,hw,yq&pageNum=1&pageSize=10",
|
||||
"http": 200,
|
||||
"code": 0,
|
||||
"msg": "成功",
|
||||
"passed": true,
|
||||
"available": false,
|
||||
"total": 0,
|
||||
"begin": "2026-11-04",
|
||||
"end": "2026-11-04"
|
||||
},
|
||||
{
|
||||
"path": "/sw/channelData/list?beginTime=bad&endTime=2026-11-04",
|
||||
"http": 200,
|
||||
"code": 7,
|
||||
"msg": "统计周期格式错误,应为 yyyy-MM-dd",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"path": "/sw/channelData/list?beginTime=2026-11-05&endTime=2026-11-04",
|
||||
"http": 200,
|
||||
"code": 7,
|
||||
"msg": "统计开始日期不能晚于结束日期",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"path": "/sw/gameData/list?sourceDb=lx,hw,yq&pageNum=1&pageSize=10",
|
||||
"http": 200,
|
||||
"code": 0,
|
||||
"msg": "成功",
|
||||
"passed": true,
|
||||
"available": false,
|
||||
"total": 0,
|
||||
"begin": "2026-11-04",
|
||||
"end": "2026-11-04"
|
||||
},
|
||||
{
|
||||
"path": "/sw/gameData/list?beginTime=bad&endTime=2026-11-04",
|
||||
"http": 200,
|
||||
"code": 7,
|
||||
"msg": "统计周期格式错误,应为 yyyy-MM-dd",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"path": "/sw/gameData/list?beginTime=2026-11-05&endTime=2026-11-04",
|
||||
"http": 200,
|
||||
"code": 7,
|
||||
"msg": "统计开始日期不能晚于结束日期",
|
||||
"passed": true
|
||||
}
|
||||
],
|
||||
"passed": true
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"method": "真实 Chrome 渲染,受控业务数据;未写入真实数据",
|
||||
"reports": [
|
||||
{
|
||||
"kind": "channel",
|
||||
"passed": true,
|
||||
"fullSummaryAcrossPages": true,
|
||||
"filtersResetPage": true,
|
||||
"platformAndGameFilters": true,
|
||||
"noInventedExport": true,
|
||||
"missingDataNotZero": true,
|
||||
"pageErrors": []
|
||||
},
|
||||
{
|
||||
"kind": "game",
|
||||
"passed": true,
|
||||
"fullSummaryAcrossPages": true,
|
||||
"filtersResetPage": true,
|
||||
"platformAndGameFilters": true,
|
||||
"noInventedExport": true,
|
||||
"missingDataNotZero": true,
|
||||
"pageErrors": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# 渠道统计与游戏分析核对
|
||||
|
||||
核对基线至旧后端 main 最终代码中的 `ChannelDataServiceImpl`、`GameDataServiceImpl`、`BusinessBindScopeServiceImpl`、`ReportQueryScopes` 及日汇总/身份键 Mapper;页面直接核对旧 `ChannelDataStatistics.vue` 与 `GameAnalytics.vue`。
|
||||
|
||||
## 数据与范围
|
||||
|
||||
- 数据来自 `tab_sw_report_day` 和 `tab_sw_report_day_key`,金额固定 `dedup_mode=0`,人数按查看者的身份证偏好。金额用精确十进制处理,保留两位。
|
||||
- 渠道查询保留本人/下级绑定范围和“包含下级渠道”开关;模糊查询匹配账号,不匹配昵称。开启下级时,命中的下级扩展到同根渠道树,再与原权限范围、类型筛选取交集。
|
||||
- 金额根渠道:一级用自身,否则正 top ID,再否则自身。人数根渠道沿用旧 SQL 的 `COALESCE(NULLIF(top_promote_id,0),promote_id)`;两者在异常历史层级数据上可能不同,不能擅自统一。
|
||||
- 超管全平台、包含下级且无渠道筛选时包含官方 0 号和已无渠道资料的历史行;普通商务只返回允许展示的根渠道。
|
||||
- 游戏分组按平台+游戏 ID,过滤 `game_id > 0`。游戏名称筛选使用报表中的历史名,展示使用 `tab_game` 当前名。
|
||||
- 渠道商务名称先用平台商务账号,否则 SW 用户名;此处不追加画像/明细页面使用的括号用户名。
|
||||
|
||||
## 汇总与计算
|
||||
|
||||
- 游戏 summary 累加全部筛选后游戏行,与当前页无关。相同身份出现在不同游戏时分别计数,不做全局去重。
|
||||
- 普通商务渠道 summary 累加可见行。超管渠道 summary 使用筛选范围内全局去重;模式 0 按平台前缀区分身份,模式 1 跨平台去重。
|
||||
- 付费率=充值人数÷活跃人数×100,ARPU=充值金额÷活跃人数,ARPPU=充值金额÷充值人数。分母为零时为零。
|
||||
- 保留原逐行人数的 Java int 上限,游戏汇总改用 int64 避免相加溢出。相同排序值使用平台、ID 稳定排序。
|
||||
- 后端任一日期缺失时周期为上海当天;前端默认近 30 天含今天。后端页长缺失、非正或超过上限时为 500,页面显式传 10。
|
||||
- 缺少必要数据表时 `available=false`、`summary=null`,顶部指标为 null,不把缺表当成零。
|
||||
|
||||
## 验证与优化
|
||||
|
||||
- 使用聚合查询和批量资料查询代替逐行查询,不改变业务口径。日期非法或倒置返回明确错误。
|
||||
- 隔离 TCP HTTP 和仓储测试覆盖全量汇总与分页、跨平台同号、官方/孤立渠道、根渠道树去重、类型交集、历史游戏名过滤、当前资料展示、偏好切换及空范围。
|
||||
- 领域测试覆盖大金额精确排序、分母和人数上限。前端检查筛选回第一页、30 天默认值、汇总不随分页变化、缺数据提示、没有额外导出入口。
|
||||
- 真实 sw7 库只做只读请求;当前缺少完整业务报表,最终旧库复制与同源数据比较仍未开始。
|
||||
@@ -25,7 +25,7 @@
|
||||
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 |
|
||||
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 |
|
||||
| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 功能已接入;包含渠道画像,完整业务数据和源平台联调仍待最终验收 |
|
||||
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出已接入,其余统计与任务待迁移 |
|
||||
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出、渠道/游戏统计已接入,区服、排行、仪表盘业务数据与任务待迁移 |
|
||||
| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 |
|
||||
| 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 |
|
||||
|
||||
@@ -90,3 +90,5 @@
|
||||
- 注册/充值明细、两个 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`。
|
||||
- 渠道/游戏统计两个接口与 Kra 页面已接入,保留游戏筛选、渠道账号/类型筛选、平台、日期、排序、分页及全量汇总。金额固定模式 0,人数按当前身份证偏好;游戏汇总累加各游戏,渠道超管汇总全局去重、普通商务累加可见渠道,不互相替代。渠道模糊匹配扩到可见同树渠道,并保持类型交集;超管全平台范围包含官方和历史孤立渠道。
|
||||
- 统计批次通过全量 Go、隔离数据库真实 TCP HTTP、领域/仓储测试、ESLint/生产构建;真实 sw7 请求和 Chrome 检查见 `analytics-http-2026-11-04.json`、`analytics-page-2026-11-04.json`。分页不改变汇总,缺表明确未接入;非法日期/倒置周期明确报错。没有添加旧统计页面不存在的导出按钮。详细口径与优化见 `analytics-source-notes.md`。
|
||||
|
||||
@@ -162,3 +162,21 @@
|
||||
| 缺业务表 | 保留能读取的基本资料,明确提示未接入,不生成假零分层 |
|
||||
|
||||
验证:`channel-portrait-http-2026-11-03.json`、`channel-portrait-page-2026-11-03.json`、隔离数据真实 TCP HTTP 和完整 Go/前端构建检查。
|
||||
|
||||
## 渠道统计与游戏分析
|
||||
|
||||
来源及统计细节见 `analytics-source-notes.md`。
|
||||
|
||||
| 旧页面行为 | 新页面状态 |
|
||||
| --- | --- |
|
||||
| 平台默认全选,游戏多选、日期近 30 天,搜索/查询 | 已实现,查询和筛选回到第一页 |
|
||||
| 渠道另有账号模糊、个人/公会类型筛选 | 已实现,文案明确按账号匹配 |
|
||||
| 渠道顶部渠道数、注册、金额、付费率四卡片 | 已实现,使用全量后端汇总,与当前分页无关 |
|
||||
| 渠道表平台、渠道、归属商务、注册/活跃/充值人数、新付费、金额和付费率 | 已实现,沿用 Kra 表格样式 |
|
||||
| 游戏表平台、游戏、注册/活跃/付费人数、新付费、金额、付费率、ARPU、ARPPU | 已实现,游戏 ID 无无效排序箭头 |
|
||||
| 游戏表尾汇总来自全部筛选结果 | 已实现,跨页保留全量 summary;渠道页使用顶部卡片 |
|
||||
| 列排序和分页 | 已实现,服务器排序,同值增加平台/ID 保证稳定 |
|
||||
| 旧页面无导出接口/按钮 | 不增加导出入口 |
|
||||
| 缺报表数据 | 明确显示未接入,清空过期汇总,指标不伪造零 |
|
||||
|
||||
验证:`analytics-http-2026-11-04.json`、`analytics-page-2026-11-04.json`;Chrome 使用受控业务数据,完整计算和权限使用隔离数据库的真实 TCP HTTP 验证。
|
||||
|
||||
+1
-1
@@ -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, business.NewLedgerUsecase)
|
||||
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, business.NewAnalyticsUsecase)
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAnalyticsInput = errors.BadRequest("SW_ANALYTICS_INPUT", "统计查询参数不正确")
|
||||
ErrAnalyticsDate = errors.BadRequest("SW_ANALYTICS_DATE", "统计周期格式错误,应为 yyyy-MM-dd")
|
||||
ErrAnalyticsDateOrder = errors.BadRequest("SW_ANALYTICS_DATE_ORDER", "统计开始日期不能晚于结束日期")
|
||||
)
|
||||
|
||||
type AnalyticsKind string
|
||||
|
||||
const (
|
||||
ChannelAnalytics AnalyticsKind = "channel"
|
||||
GameAnalytics AnalyticsKind = "game"
|
||||
)
|
||||
|
||||
type AnalyticsKey struct {
|
||||
SourceDB string
|
||||
ID int64
|
||||
}
|
||||
type AnalyticsFilter struct {
|
||||
Sources, GameNames []string
|
||||
PromoteName string
|
||||
PromoteType *int
|
||||
StartDate, EndDate *time.Time
|
||||
Begin, End, SortField string
|
||||
Ascending bool
|
||||
}
|
||||
type AnalyticsScope struct {
|
||||
Viewer UserScope
|
||||
IncludeSub, IDCardDedup bool
|
||||
}
|
||||
type AnalyticsRow struct {
|
||||
AnalyticsKey
|
||||
Name string
|
||||
PromoteType *int
|
||||
BindingAccount, BindingUserName string
|
||||
Counts PortraitCounts
|
||||
Pay, PayRate, ARPU, ARPPU string
|
||||
}
|
||||
|
||||
func (r *AnalyticsRow) BusinessName() string {
|
||||
if value := strings.TrimSpace(r.BindingAccount); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(r.BindingUserName)
|
||||
}
|
||||
|
||||
type AnalyticsData struct {
|
||||
Rows []*AnalyticsRow
|
||||
GlobalCounts *PortraitCounts
|
||||
GlobalPay string
|
||||
Available bool
|
||||
}
|
||||
type AnalyticsPage struct {
|
||||
Rows []*AnalyticsRow
|
||||
Total int64
|
||||
Summary *AnalyticsRow
|
||||
Begin, End string
|
||||
Available bool
|
||||
}
|
||||
type AnalyticsRepo interface {
|
||||
Aggregate(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter) (*AnalyticsData, error)
|
||||
}
|
||||
type AnalyticsUsecase struct {
|
||||
repo AnalyticsRepo
|
||||
accounts *AccountUsecase
|
||||
preferences *UserPreferenceUsecase
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewAnalyticsUsecase(repo AnalyticsRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *AnalyticsUsecase {
|
||||
return &AnalyticsUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
|
||||
}
|
||||
func analyticsCap(c PortraitCounts) PortraitCounts {
|
||||
return PortraitCounts{Register: min(c.Register, 2147483647), Active: min(c.Active, 2147483647), Pay: min(c.Pay, 2147483647), NewPay: min(c.NewPay, 2147483647)}
|
||||
}
|
||||
func analyticsAdd(a, b PortraitCounts) PortraitCounts {
|
||||
return PortraitCounts{Register: a.Register + b.Register, Active: a.Active + b.Active, Pay: a.Pay + b.Pay, NewPay: a.NewPay + b.NewPay}
|
||||
}
|
||||
func analyticsRatio(numerator *big.Rat, denominator int64, percent bool) string {
|
||||
if denominator <= 0 {
|
||||
return "0.00"
|
||||
}
|
||||
n := new(big.Rat).Quo(numerator, big.NewRat(denominator, 1))
|
||||
if percent {
|
||||
n.Mul(n, big.NewRat(100, 1))
|
||||
}
|
||||
return portraitDecimal(n, 2)
|
||||
}
|
||||
func analyticsValues(row *AnalyticsRow) error {
|
||||
pay, err := portraitNumber(row.Pay)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.Pay = portraitDecimal(pay, 2)
|
||||
pay, _ = portraitNumber(row.Pay)
|
||||
row.PayRate = analyticsRatio(big.NewRat(row.Counts.Pay, 1), row.Counts.Active, true)
|
||||
row.ARPU = analyticsRatio(pay, row.Counts.Active, false)
|
||||
row.ARPPU = analyticsRatio(pay, row.Counts.Pay, false)
|
||||
return nil
|
||||
}
|
||||
func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter AnalyticsFilter, page, size int) (*AnalyticsPage, error) {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pref, err := uc.preferences.Current(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind != ChannelAnalytics && kind != GameAnalytics {
|
||||
return nil, ErrAnalyticsInput
|
||||
}
|
||||
viewer := portraitUserScope(account)
|
||||
viewer.All = actor.AuthorityID == 888
|
||||
if kind == GameAnalytics {
|
||||
filter.PromoteName = ""
|
||||
filter.PromoteType = nil
|
||||
}
|
||||
if filter.StartDate == nil || filter.EndDate == nil {
|
||||
now := uc.now().In(portraitLocation)
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation)
|
||||
filter.StartDate = &day
|
||||
filter.EndDate = &day
|
||||
}
|
||||
if filter.EndDate.Before(*filter.StartDate) {
|
||||
return nil, ErrAnalyticsDateOrder
|
||||
}
|
||||
filter.Begin = filter.StartDate.Format("2006-01-02")
|
||||
filter.End = filter.EndDate.Format("2006-01-02")
|
||||
out := &AnalyticsPage{Rows: []*AnalyticsRow{}, Begin: filter.Begin, End: filter.End, Available: true}
|
||||
data, err := uc.repo.Aggregate(ctx, kind, AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !data.Available {
|
||||
out.Available = false
|
||||
return out, nil
|
||||
}
|
||||
counts := PortraitCounts{}
|
||||
pay := new(big.Rat)
|
||||
for _, row := range data.Rows {
|
||||
row.Counts = analyticsCap(row.Counts)
|
||||
raw, err := portraitNumber(row.Pay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind == ChannelAnalytics {
|
||||
pay.Add(pay, raw)
|
||||
}
|
||||
if err := analyticsValues(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind == GameAnalytics {
|
||||
rounded, _ := portraitNumber(row.Pay)
|
||||
pay.Add(pay, rounded)
|
||||
}
|
||||
counts = analyticsAdd(counts, row.Counts)
|
||||
if kind == ChannelAnalytics {
|
||||
row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote()
|
||||
}
|
||||
}
|
||||
if kind == ChannelAnalytics {
|
||||
counts = analyticsCap(counts)
|
||||
if viewer.All && data.GlobalCounts != nil {
|
||||
counts = analyticsCap(*data.GlobalCounts)
|
||||
pay, err = portraitNumber(data.GlobalPay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
label := "全部渠道"
|
||||
if kind == GameAnalytics {
|
||||
label = "全部游戏"
|
||||
}
|
||||
out.Summary = &AnalyticsRow{Name: label, Counts: counts, Pay: portraitDecimal(pay, 2)}
|
||||
if err := analyticsValues(out.Summary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sortAnalytics(data.Rows, kind, filter.SortField, filter.Ascending); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Total = int64(len(data.Rows))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 500 {
|
||||
size = 500
|
||||
}
|
||||
if page > len(data.Rows)/size+1 {
|
||||
return out, nil
|
||||
}
|
||||
from := (page - 1) * size
|
||||
to := min(from+size, len(data.Rows))
|
||||
if from < len(data.Rows) {
|
||||
out.Rows = data.Rows[from:to]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func sortAnalytics(rows []*AnalyticsRow, kind AnalyticsKind, field string, ascending bool) error {
|
||||
allowed := map[string]bool{"newRegisterCount": true, "activeCount": true, "payUserCount": true, "newPayUserCount": true, "totalPay": true, "payRate": true}
|
||||
if kind == GameAnalytics {
|
||||
allowed["arpu"] = true
|
||||
allowed["arppu"] = true
|
||||
} else {
|
||||
allowed["promoteId"] = true
|
||||
allowed["promoteName"] = true
|
||||
allowed["promoteType"] = true
|
||||
}
|
||||
if !allowed[field] {
|
||||
field = "totalPay"
|
||||
}
|
||||
values := map[*AnalyticsRow]*big.Rat{}
|
||||
for _, row := range rows {
|
||||
var n *big.Rat
|
||||
var err error
|
||||
switch field {
|
||||
case "newRegisterCount":
|
||||
n = big.NewRat(row.Counts.Register, 1)
|
||||
case "activeCount":
|
||||
n = big.NewRat(row.Counts.Active, 1)
|
||||
case "payUserCount":
|
||||
n = big.NewRat(row.Counts.Pay, 1)
|
||||
case "newPayUserCount":
|
||||
n = big.NewRat(row.Counts.NewPay, 1)
|
||||
case "promoteId":
|
||||
n = big.NewRat(row.ID, 1)
|
||||
case "promoteType":
|
||||
value := int64(0)
|
||||
if row.PromoteType != nil {
|
||||
value = int64(*row.PromoteType)
|
||||
}
|
||||
n = big.NewRat(value, 1)
|
||||
case "payRate":
|
||||
n, err = portraitNumber(row.PayRate)
|
||||
case "arpu":
|
||||
n, err = portraitNumber(row.ARPU)
|
||||
case "arppu":
|
||||
n, err = portraitNumber(row.ARPPU)
|
||||
case "promoteName":
|
||||
continue
|
||||
default:
|
||||
n, err = portraitNumber(row.Pay)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values[row] = n
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
comparison := 0
|
||||
if field == "promoteName" {
|
||||
comparison = strings.Compare(a.Name, b.Name)
|
||||
} else {
|
||||
comparison = values[a].Cmp(values[b])
|
||||
}
|
||||
if comparison != 0 {
|
||||
if ascending {
|
||||
return comparison < 0
|
||||
}
|
||||
return comparison > 0
|
||||
}
|
||||
if a.SourceDB != b.SourceDB {
|
||||
return a.SourceDB < b.SourceDB
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type analyticsRepoFake struct {
|
||||
data *AnalyticsData
|
||||
scope AnalyticsScope
|
||||
filter AnalyticsFilter
|
||||
}
|
||||
|
||||
func (r *analyticsRepoFake) Aggregate(_ context.Context, _ AnalyticsKind, s AnalyticsScope, f AnalyticsFilter) (*AnalyticsData, error) {
|
||||
r.scope = s
|
||||
r.filter = f
|
||||
copy := *r.data
|
||||
copy.Rows = []*AnalyticsRow{}
|
||||
for _, row := range r.data.Rows {
|
||||
c := *row
|
||||
copy.Rows = append(copy.Rows, &c)
|
||||
}
|
||||
return ©, nil
|
||||
}
|
||||
func analyticsFixture() (*AnalyticsUsecase, *analyticsRepoFake) {
|
||||
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1}, 7: {ID: 7, Level: 1, IDPath: "0"}}}
|
||||
r := &analyticsRepoFake{data: &AnalyticsData{Available: true, Rows: []*AnalyticsRow{{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 1}, Name: "甲", Counts: PortraitCounts{Register: 2, Active: 4, Pay: 2, NewPay: 1}, Pay: "20.00"}, {AnalyticsKey: AnalyticsKey{SourceDB: "hw", ID: 2}, Name: "乙", Counts: PortraitCounts{Register: 3, Active: 6, Pay: 1, NewPay: 1}, Pay: "30.00"}}, GlobalCounts: &PortraitCounts{Register: 4, Active: 8, Pay: 3}, GlobalPay: "45.00"}}
|
||||
uc := NewAnalyticsUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}))
|
||||
uc.now = func() time.Time { return time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation) }
|
||||
return uc, r
|
||||
}
|
||||
func TestGameAnalyticsSummaryUsesAllRowsAndCorrectDenominators(t *testing.T) {
|
||||
uc, _ := analyticsFixture()
|
||||
page, err := uc.List(accountContext(1, 888), GameAnalytics, AnalyticsFilter{}, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Rows) != 1 || page.Total != 2 || page.Rows[0].ID != 2 || page.Summary.Counts.Register != 5 || page.Summary.Pay != "50.00" || page.Summary.PayRate != "30.00" || page.Summary.ARPU != "5.00" || page.Summary.ARPPU != "16.67" {
|
||||
t.Fatalf("游戏汇总、分页或分母错误: %+v", page)
|
||||
}
|
||||
if page.Begin != "2026-11-04" || page.End != "2026-11-04" {
|
||||
t.Fatal("后台缺省统计日期应为当天")
|
||||
}
|
||||
}
|
||||
func TestChannelAnalyticsSummaryDependsOnViewer(t *testing.T) {
|
||||
uc, _ := analyticsFixture()
|
||||
page, err := uc.List(accountContext(7, 901), ChannelAnalytics, AnalyticsFilter{}, 1, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Summary.Counts.Register != 5 || page.Summary.Pay != "50.00" {
|
||||
t.Fatal("普通商务应汇总可见展示行")
|
||||
}
|
||||
page, err = uc.List(accountContext(1, 888), ChannelAnalytics, AnalyticsFilter{}, 1, 10)
|
||||
if err != nil || page.Summary.Counts.Register != 4 || page.Summary.Pay != "45.00" || page.Summary.PayRate != "37.50" {
|
||||
t.Fatal("超管应使用筛选范围内全局去重汇总")
|
||||
}
|
||||
}
|
||||
func TestAnalyticsSortAndMissingData(t *testing.T) {
|
||||
uc, r := analyticsFixture()
|
||||
r.data.Rows[0].Pay = "9007199254740993.00"
|
||||
r.data.Rows[1].Pay = "9007199254740992.99"
|
||||
p, err := uc.List(accountContext(7, 901), GameAnalytics, AnalyticsFilter{SortField: "totalPay"}, 1, 10)
|
||||
if err != nil || p.Rows[0].ID != 1 {
|
||||
t.Fatal("金额排序不能经浮点数失真")
|
||||
}
|
||||
r.data.Available = false
|
||||
p, err = uc.List(accountContext(7, 901), ChannelAnalytics, AnalyticsFilter{}, 1, 10)
|
||||
if err != nil || p.Available || p.Summary != nil {
|
||||
t.Fatal("缺汇总表不能伪造零卡片")
|
||||
}
|
||||
begin := time.Date(2026, 11, 4, 0, 0, 0, 0, portraitLocation)
|
||||
end := begin.AddDate(0, 0, -1)
|
||||
if _, err := uc.List(accountContext(1, 888), GameAnalytics, AnalyticsFilter{StartDate: &begin, EndDate: &end}, 1, 10); err != ErrAnalyticsDateOrder {
|
||||
t.Fatal("倒置日期应被拒绝")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
type analyticsRepo struct{ data Provider }
|
||||
|
||||
func NewAnalyticsRepo(data Provider) business.AnalyticsRepo { return &analyticsRepo{data: data} }
|
||||
func analyticsBasePromotes(db *gorm.DB, scope business.AnalyticsScope, f business.AnalyticsFilter, alias string) *gorm.DB {
|
||||
q := ledgerBasePromotes(db, business.LedgerScope{Viewer: scope.Viewer, IncludeSub: scope.IncludeSub, Unrestricted: scope.Viewer.All}, business.LedgerFilter{Sources: f.Sources}, alias)
|
||||
if f.PromoteType != nil {
|
||||
q = q.Where(alias+".register_type = ?", *f.PromoteType)
|
||||
}
|
||||
return q
|
||||
}
|
||||
func analyticsPromotes(db *gorm.DB, scope business.AnalyticsScope, f business.AnalyticsFilter, alias string) *gorm.DB {
|
||||
q := analyticsBasePromotes(db, scope, f, alias)
|
||||
if f.PromoteName == "" {
|
||||
return q
|
||||
}
|
||||
hits := analyticsBasePromotes(db, scope, f, "hit").Select("1").Where("hit.account LIKE ?", "%"+f.PromoteName+"%").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)
|
||||
}
|
||||
func prepareAnalytics(db *gorm.DB, scope business.AnalyticsScope, f business.AnalyticsFilter) (bool, bool, error) {
|
||||
if !scope.Viewer.All {
|
||||
var binds []struct{ UserID uint }
|
||||
if err := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{Sources: f.Sources}).Select("b.user_id").Limit(1).Scan(&binds).Error; err != nil {
|
||||
return false, false, business.ErrStorage
|
||||
}
|
||||
if len(binds) == 0 {
|
||||
return true, true, nil
|
||||
}
|
||||
}
|
||||
available, err := businessTableAvailable(db, "tab_promote")
|
||||
if err != nil || !available {
|
||||
return false, available, err
|
||||
}
|
||||
var rows []struct{ ID int64 }
|
||||
if err := analyticsPromotes(db, scope, f, "p").Select("p.id").Limit(1).Scan(&rows).Error; err != nil {
|
||||
return false, false, business.ErrStorage
|
||||
}
|
||||
return len(rows) == 0, true, nil
|
||||
}
|
||||
func analyticsQuery(db *gorm.DB, scope business.AnalyticsScope, f business.AnalyticsFilter, table string, mode int) *gorm.DB {
|
||||
q := db.Table(table+" r").Where("r.stat_date >= ? AND r.stat_date <= ? AND r.dedup_mode = ? AND r.game_id > 0", f.Begin, f.End, mode)
|
||||
if len(f.Sources) > 0 {
|
||||
q = q.Where("r.source_db IN ?", f.Sources)
|
||||
}
|
||||
if len(f.GameNames) > 0 {
|
||||
q = q.Where("r.game_name IN ?", f.GameNames)
|
||||
}
|
||||
if scope.Viewer.All && scope.IncludeSub && f.PromoteType == nil && f.PromoteName == "" {
|
||||
return q
|
||||
}
|
||||
return q.Where("EXISTS (?)", analyticsPromotes(db, scope, f, "p").Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
|
||||
}
|
||||
|
||||
type analyticsAggPO struct {
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
ID *int64 `gorm:"column:entity_id"`
|
||||
Metric int
|
||||
Count int64
|
||||
Amount string
|
||||
}
|
||||
|
||||
func analyticsRoot(db *gorm.DB, kind business.AnalyticsKind, scope business.AnalyticsScope, f business.AnalyticsFilter, table string, mode int, pay bool) (*gorm.DB, string) {
|
||||
q := analyticsQuery(db, scope, f, table, mode)
|
||||
id := "r.game_id"
|
||||
if kind == business.ChannelAnalytics {
|
||||
id = "r.promote_id"
|
||||
if scope.IncludeSub {
|
||||
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
|
||||
id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)"
|
||||
if pay {
|
||||
id = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE COALESCE(root_p.id,r.promote_id) END"
|
||||
}
|
||||
}
|
||||
}
|
||||
return q, id
|
||||
}
|
||||
func putAnalyticsCount(counts *business.PortraitCounts, metric int, count int64) {
|
||||
switch metric {
|
||||
case 1:
|
||||
counts.Register = count
|
||||
case 2:
|
||||
counts.Active = count
|
||||
case 3:
|
||||
counts.Pay = count
|
||||
case 4:
|
||||
counts.NewPay = count
|
||||
}
|
||||
}
|
||||
func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKind, scope business.AnalyticsScope, f business.AnalyticsFilter) (*business.AnalyticsData, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
result := &business.AnalyticsData{Rows: []*business.AnalyticsRow{}, GlobalPay: "0", Available: true}
|
||||
empty, available, err := prepareAnalytics(db, scope, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Available = available
|
||||
if empty || !available {
|
||||
return result, nil
|
||||
}
|
||||
for _, table := range []string{"tab_sw_report_day", "tab_sw_report_day_key"} {
|
||||
available, err := businessTableAvailable(db, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
result.Available = false
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
mode := 0
|
||||
if scope.IDCardDedup {
|
||||
mode = 1
|
||||
}
|
||||
rowMap := map[business.AnalyticsKey]*business.AnalyticsRow{}
|
||||
rowFor := func(po analyticsAggPO) *business.AnalyticsRow {
|
||||
key := business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}
|
||||
if rowMap[key] == nil {
|
||||
rowMap[key] = &business.AnalyticsRow{AnalyticsKey: key, Pay: "0"}
|
||||
}
|
||||
return rowMap[key]
|
||||
}
|
||||
var pays []analyticsAggPO
|
||||
q, id := analyticsRoot(db, kind, scope, f, "tab_sw_report_day", 0, true)
|
||||
if err := q.Select("r.source_db," + id + " AS entity_id,COALESCE(SUM(r.total_pay),0) AS amount").Group("r.source_db," + id).Scan(&pays).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, po := range pays {
|
||||
if po.ID == nil {
|
||||
continue
|
||||
}
|
||||
rowFor(po).Pay = po.Amount
|
||||
}
|
||||
var counts []analyticsAggPO
|
||||
q, id = analyticsRoot(db, kind, scope, f, "tab_sw_report_day_key", mode, false)
|
||||
if err := q.Select("r.source_db," + id + " AS entity_id,r.metric,COUNT(DISTINCT r.identity_key) AS count").Group("r.source_db," + id + ",r.metric").Scan(&counts).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, po := range counts {
|
||||
if po.ID == nil {
|
||||
continue
|
||||
}
|
||||
putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count)
|
||||
}
|
||||
if kind == business.ChannelAnalytics && !scope.Viewer.All {
|
||||
if err := filterAnalyticsRows(db, scope, f, rowMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if kind == business.ChannelAnalytics && scope.Viewer.All {
|
||||
var global []portraitCountPO
|
||||
q := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode)
|
||||
if err := q.Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&global).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
totals := toPortraitCounts(global)
|
||||
result.GlobalCounts = &totals
|
||||
if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(&result.GlobalPay).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
}
|
||||
if len(rowMap) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if kind == business.GameAnalytics {
|
||||
available, err := businessTableAvailable(db, "tab_game")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
result.Available = false
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
if err := fillAnalyticsNames(db, kind, scope, f, rowMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rowMap {
|
||||
result.Rows = append(result.Rows, row)
|
||||
}
|
||||
sort.Slice(result.Rows, func(i, j int) bool {
|
||||
a, b := result.Rows[i], result.Rows[j]
|
||||
if a.SourceDB != b.SourceDB {
|
||||
return a.SourceDB < b.SourceDB
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
func analyticsPairs(keys []business.AnalyticsKey) [][]any {
|
||||
result := make([][]any, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
result = append(result, []any{key.SourceDB, key.ID})
|
||||
}
|
||||
return result
|
||||
}
|
||||
func filterAnalyticsRows(db *gorm.DB, scope business.AnalyticsScope, f business.AnalyticsFilter, rows map[business.AnalyticsKey]*business.AnalyticsRow) error {
|
||||
keys := []business.AnalyticsKey{}
|
||||
for key := range rows {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
allowed := map[business.AnalyticsKey]bool{}
|
||||
root := "p.id"
|
||||
if scope.IncludeSub {
|
||||
root = "CASE WHEN p.promote_level=1 THEN p.id WHEN p.top_promote_id>0 THEN p.top_promote_id ELSE p.id END"
|
||||
}
|
||||
for start := 0; start < len(keys); start += 300 {
|
||||
var selected []analyticsAggPO
|
||||
q := analyticsPromotes(db, scope, f, "p").Select("DISTINCT p.source_db,"+root+" AS entity_id").Where("(p.source_db,"+root+") IN ?", analyticsPairs(keys[start:min(start+300, len(keys))]))
|
||||
if err := q.Scan(&selected).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
for _, row := range selected {
|
||||
if row.ID != nil {
|
||||
allowed[business.AnalyticsKey{SourceDB: row.SourceDB, ID: *row.ID}] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for key := range rows {
|
||||
if !allowed[key] {
|
||||
delete(rows, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func fillAnalyticsNames(db *gorm.DB, kind business.AnalyticsKind, scope business.AnalyticsScope, f business.AnalyticsFilter, rows map[business.AnalyticsKey]*business.AnalyticsRow) error {
|
||||
keys := []business.AnalyticsKey{}
|
||||
for key := range rows {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
type entityPO struct {
|
||||
ID int64
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
Name string
|
||||
RegisterType *int
|
||||
BusierID *int64
|
||||
}
|
||||
promotes := map[business.AnalyticsKey]entityPO{}
|
||||
bindings := map[business.PlatformKey]struct{ account, user string }{}
|
||||
pairs := []business.PlatformKey{}
|
||||
pairSeen := map[business.PlatformKey]bool{}
|
||||
for start := 0; start < len(keys); start += 300 {
|
||||
var entities []entityPO
|
||||
q := db.Table("tab_game").Select("id,source_db,game_name AS name")
|
||||
if kind == business.ChannelAnalytics {
|
||||
q = db.Table("tab_promote").Select("id,source_db,account AS name,register_type,busier_id")
|
||||
}
|
||||
if err := q.Where("(source_db,id) IN ?", analyticsPairs(keys[start:min(start+300, len(keys))])).Scan(&entities).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
for _, entity := range entities {
|
||||
key := business.AnalyticsKey{SourceDB: entity.SourceDB, ID: entity.ID}
|
||||
if row := rows[key]; row != nil {
|
||||
row.Name = entity.Name
|
||||
row.PromoteType = entity.RegisterType
|
||||
promotes[key] = entity
|
||||
}
|
||||
if kind == business.ChannelAnalytics && entity.BusierID != nil && *entity.BusierID > 0 {
|
||||
pair := business.PlatformKey{SourceDB: entity.SourceDB, BusierID: uint64(*entity.BusierID)}
|
||||
if !pairSeen[pair] {
|
||||
pairs = append(pairs, pair)
|
||||
pairSeen[pair] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for start := 0; start < len(pairs); start += 300 {
|
||||
var names []struct {
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
PlatformBusierID uint64
|
||||
PlatformBusierAccount, Username string
|
||||
}
|
||||
if err := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{Sources: f.Sources}).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").Scan(&names).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
for _, name := range names {
|
||||
key := business.PlatformKey{SourceDB: name.SourceDB, BusierID: name.PlatformBusierID}
|
||||
if _, ok := bindings[key]; !ok {
|
||||
bindings[key] = struct{ account, user string }{name.PlatformBusierAccount, name.Username}
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, entity := range promotes {
|
||||
if entity.BusierID == nil || *entity.BusierID <= 0 {
|
||||
continue
|
||||
}
|
||||
if name, ok := bindings[business.PlatformKey{SourceDB: entity.SourceDB, BusierID: uint64(*entity.BusierID)}]; ok {
|
||||
rows[key].BindingAccount = name.account
|
||||
rows[key].BindingUserName = name.user
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
func prepareAnalyticsData(t *testing.T) (*gorm.DB, business.AnalyticsRepo, business.AnalyticsScope, business.AnalyticsFilter) {
|
||||
t.Helper()
|
||||
db, _ := prepareChannels(t)
|
||||
for _, sql := range []string{
|
||||
"ALTER TABLE tab_sw_report_day ADD COLUMN game_name TEXT",
|
||||
"ALTER TABLE tab_sw_report_day_key ADD COLUMN game_name TEXT",
|
||||
"UPDATE tab_sw_report_day SET game_name=CASE WHEN game_id=1 THEN '历史游戏甲' ELSE '游戏乙' END",
|
||||
"UPDATE tab_sw_report_day_key SET game_name=CASE WHEN game_id=1 THEN '历史游戏甲' ELSE '游戏乙' END",
|
||||
"UPDATE tab_sw_report_day_key SET identity_key='A' WHERE (source_db='hw' OR promote_id=103) AND metric=1",
|
||||
"INSERT INTO tab_sw_report_day(source_db,promote_id,game_id,dedup_mode,stat_date,total_pay,game_name) VALUES('lx',0,1,0,'2026-11-01',5,'历史游戏甲'),('lx',999,1,0,'2026-11-01',7,'历史游戏甲')",
|
||||
"INSERT INTO tab_sw_report_day_key(source_db,promote_id,game_id,dedup_mode,stat_date,metric,identity_key,game_name) VALUES('lx',0,1,1,'2026-11-01',1,'official','历史游戏甲'),('lx',999,1,1,'2026-11-01',1,'orphan','历史游戏甲'),('lx',100,1,1,'2026-11-01',2,'A','历史游戏甲'),('lx',103,1,1,'2026-11-02',2,'A','历史游戏甲'),('hw',100,1,1,'2026-11-01',2,'A','历史游戏甲'),('lx',100,1,1,'2026-11-01',3,'A','历史游戏甲'),('lx',103,1,1,'2026-11-02',3,'A','历史游戏甲'),('hw',100,1,1,'2026-11-01',3,'A','历史游戏甲')",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
scope := business.AnalyticsScope{Viewer: channelTestScope(), IncludeSub: true, IDCardDedup: true}
|
||||
filter := business.AnalyticsFilter{Sources: []string{"lx", "hw"}, Begin: "2026-11-01", End: "2026-11-03"}
|
||||
return db, NewAnalyticsRepo(testData{db}), scope, filter
|
||||
}
|
||||
func TestAnalyticsChannelTreeAndAdminOfficialRows(t *testing.T) {
|
||||
_, repo, scope, filter := prepareAnalyticsData(t)
|
||||
data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data.Rows) != 3 || data.GlobalCounts != nil {
|
||||
t.Fatalf("普通商务统计范围错误: %+v", data)
|
||||
}
|
||||
var lx *business.AnalyticsRow
|
||||
for _, row := range data.Rows {
|
||||
if row.SourceDB == "lx" && row.ID == 100 {
|
||||
lx = row
|
||||
}
|
||||
if row.ID == 0 || row.ID == 999 {
|
||||
t.Fatal("受约束商务看到官方/未知渠道")
|
||||
}
|
||||
}
|
||||
if lx == nil || lx.Counts.Register != 2 || portraitTestMoney(lx.Pay) != "60.60" || lx.Name != "root-channel" || lx.BusinessName() != "lx-business" {
|
||||
t.Fatalf("渠道树去重、充值或商务展示错误: %+v", lx)
|
||||
}
|
||||
scope.Viewer = business.UserScope{All: true}
|
||||
data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil || len(data.Rows) != 5 || data.GlobalCounts.Register != 4 || data.GlobalCounts.Active != 2 || data.GlobalCounts.Pay != 1 || portraitTestMoney(data.GlobalPay) != "2070.60" {
|
||||
t.Fatalf("超管全局去重或官方/历史渠道遗漏: %+v %v", data, err)
|
||||
}
|
||||
}
|
||||
func TestAnalyticsFiltersKeepHierarchyAndSnapshotNames(t *testing.T) {
|
||||
db, repo, scope, filter := prepareAnalyticsData(t)
|
||||
filter.PromoteName = "child"
|
||||
data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil || len(data.Rows) != 1 || data.Rows[0].ID != 100 || data.Rows[0].Counts.Register != 2 || portraitTestMoney(data.Rows[0].Pay) != "60.60" {
|
||||
t.Fatalf("模糊渠道筛选应扩到同树可见渠道: %+v %v", data, err)
|
||||
}
|
||||
if err := db.Exec("UPDATE tab_promote SET register_type=1 WHERE source_db='lx' AND id=101").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filter.PromoteName = ""
|
||||
kind := 1
|
||||
filter.PromoteType = &kind
|
||||
data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil || len(data.Rows) != 1 || data.Rows[0].ID != 100 || portraitTestMoney(data.Rows[0].Pay) != "20.20" || data.Rows[0].PromoteType == nil || *data.Rows[0].PromoteType != 0 {
|
||||
t.Fatalf("类型筛选应限制参与渠道,同时显示归根后的当前资料: %+v %v", data, err)
|
||||
}
|
||||
filter.PromoteType = nil
|
||||
filter.GameNames = []string{"历史游戏甲"}
|
||||
data, err = repo.Aggregate(context.Background(), business.GameAnalytics, scope, filter)
|
||||
if err != nil || len(data.Rows) != 2 {
|
||||
t.Fatalf("按游戏快照名筛选错误: %+v %v", data, err)
|
||||
}
|
||||
for _, row := range data.Rows {
|
||||
if row.SourceDB == "lx" && (row.ID != 1 || row.Name != "游戏甲" || row.Counts.Register != 1) {
|
||||
t.Fatalf("应按报表快照筛选、用当前游戏名展示: %+v", row)
|
||||
}
|
||||
}
|
||||
filter.GameNames = []string{"游戏甲"}
|
||||
data, err = repo.Aggregate(context.Background(), business.GameAnalytics, scope, filter)
|
||||
if err != nil || len(data.Rows) != 0 {
|
||||
t.Fatal("不能用当前游戏表名替代报表快照过滤")
|
||||
}
|
||||
}
|
||||
func TestAnalyticsMissingScopeAndTablesDoNotBroaden(t *testing.T) {
|
||||
db, repo, scope, filter := prepareAnalyticsData(t)
|
||||
scope.IncludeSub = false
|
||||
data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil || len(data.Rows) != 2 {
|
||||
t.Fatal("关闭下级仅保留一级渠道统计")
|
||||
}
|
||||
if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil || data.Available {
|
||||
t.Fatal("缺身份键表不能返回零汇总")
|
||||
}
|
||||
scope.Viewer = business.UserScope{SelfID: 500, OnlySelf: true}
|
||||
data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil || !data.Available || len(data.Rows) != 0 {
|
||||
t.Fatal("无绑定应为确定空范围")
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ var ProviderSet = wire.NewSet(
|
||||
databusiness.NewBusinessPortraitRepo,
|
||||
databusiness.NewChannelPortraitRepo,
|
||||
databusiness.NewLedgerRepo,
|
||||
databusiness.NewAnalyticsRepo,
|
||||
NewIntegrationRuntime,
|
||||
wire.Bind(new(datasystem.Provider), new(*Data)),
|
||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
func Definition() module.Definition {
|
||||
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
|
||||
Menus: []module.Menu{
|
||||
{Name: "sw_ChannelDataStatistics", Path: "ChannelDataStatistics", ParentName: "sw_DataAnalysis", Component: "view/sw/ChannelDataStatistics.vue", Title: "渠道数据统计", Icon: "data-analysis", Sort: 1},
|
||||
{Name: "sw_GameAnalytics", Path: "GameAnalytics", ParentName: "sw_DataAnalysis", Component: "view/sw/GameAnalytics.vue", Title: "游戏数据", Icon: "data-analysis", Sort: 4},
|
||||
{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},
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 14 || apis != 58 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 14/58", menus, apis)
|
||||
if menus != 16 || apis != 60 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 16/60", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -41,6 +41,8 @@ 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/channelData/list": {group: "渠道统计", description: "查询渠道数据与汇总"},
|
||||
"GET /sw/gameData/list": {group: "游戏统计", description: "查询游戏数据与汇总"},
|
||||
"GET /sw/register/list": {group: "注册明细", description: "查询注册明细"},
|
||||
"POST /sw/register/export": {group: "注册明细", description: "导出注册明细", audit: true},
|
||||
"GET /sw/spend/list": {group: "充值明细", description: "查询充值明细与成功支付合计"},
|
||||
|
||||
@@ -26,6 +26,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
|
||||
|
||||
func emptyHandlers() *handler.Set {
|
||||
return &handler.Set{
|
||||
Analytics: &handler.Analytics{},
|
||||
Ledger: &handler.Ledger{},
|
||||
ChannelPortrait: &handler.ChannelPortrait{},
|
||||
BusinessPortrait: &handler.BusinessPortrait{},
|
||||
@@ -106,7 +107,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":241`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":243`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"kra/internal/service/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type Analytics struct{ service *business.AnalyticsService }
|
||||
|
||||
func NewAnalytics(service *business.AnalyticsService) *Analytics { return &Analytics{service: service} }
|
||||
func (h *Analytics) Channels(c *gin.Context) {
|
||||
var req dto.AnalyticsQuery
|
||||
if c.ShouldBindQuery(&req) != nil {
|
||||
Fail(c, "统计查询参数不正确")
|
||||
return
|
||||
}
|
||||
data, err := h.service.Channels(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
OKWithData(c, data)
|
||||
}
|
||||
func (h *Analytics) Games(c *gin.Context) {
|
||||
var req dto.AnalyticsQuery
|
||||
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)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewBusinessPortrait,
|
||||
NewChannelPortrait,
|
||||
NewLedger,
|
||||
NewAnalytics,
|
||||
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
|
||||
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
Analytics *Analytics
|
||||
Ledger *Ledger
|
||||
ChannelPortrait *ChannelPortrait
|
||||
BusinessPortrait *BusinessPortrait
|
||||
|
||||
@@ -20,6 +20,10 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
return
|
||||
}
|
||||
registrations := []func(){
|
||||
func() {
|
||||
private.GET("/sw/channelData/list", r.handlers.Analytics.Channels)
|
||||
private.GET("/sw/gameData/list", r.handlers.Analytics.Games)
|
||||
},
|
||||
func() { RegisterLedger(private, r.handlers.Ledger) },
|
||||
func() { private.GET("/sw/promote/portrait", r.handlers.ChannelPortrait.Portrait) },
|
||||
func() {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/business"
|
||||
"kra/internal/service/dto"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AnalyticsUsecase interface {
|
||||
List(context.Context, business.AnalyticsKind, business.AnalyticsFilter, int, int) (*business.AnalyticsPage, error)
|
||||
}
|
||||
type AnalyticsService struct{ uc AnalyticsUsecase }
|
||||
|
||||
func NewAnalyticsService(uc *business.AnalyticsUsecase) *AnalyticsService {
|
||||
return &AnalyticsService{uc: uc}
|
||||
}
|
||||
func analyticsRequest(req *dto.AnalyticsQuery) (business.AnalyticsFilter, error) {
|
||||
if req == nil {
|
||||
return business.AnalyticsFilter{}, business.ErrAnalyticsInput
|
||||
}
|
||||
sources, err := normalizePlatforms(req.Sources)
|
||||
if err != nil {
|
||||
return business.AnalyticsFilter{}, err
|
||||
}
|
||||
f := business.AnalyticsFilter{Sources: sources, GameNames: splitQueryValues(req.GameNames), PromoteName: strings.TrimSpace(req.PromoteName), PromoteType: req.PromoteType, SortField: strings.TrimSpace(req.SortField), Ascending: strings.EqualFold(strings.TrimSpace(req.SortOrder), "asc")}
|
||||
begin, end := strings.TrimSpace(req.BeginTime), strings.TrimSpace(req.EndTime)
|
||||
if begin != "" && end != "" {
|
||||
location := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
b, err := time.ParseInLocation("2006-01-02", begin, location)
|
||||
if err != nil {
|
||||
return f, business.ErrAnalyticsDate
|
||||
}
|
||||
e, err := time.ParseInLocation("2006-01-02", end, location)
|
||||
if err != nil {
|
||||
return f, business.ErrAnalyticsDate
|
||||
}
|
||||
f.StartDate = &b
|
||||
f.EndDate = &e
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
func analyticsMetrics(row *business.AnalyticsRow) dto.AnalyticsMetrics {
|
||||
return dto.AnalyticsMetrics{Register: row.Counts.Register, Active: row.Counts.Active, PayUsers: row.Counts.Pay, NewPayUsers: row.Counts.NewPay, TotalPay: row.Pay, PayRate: row.PayRate}
|
||||
}
|
||||
func channelAnalyticsRow(row *business.AnalyticsRow, summary bool) *dto.ChannelAnalyticsRow {
|
||||
result := &dto.ChannelAnalyticsRow{SourceDB: row.SourceDB, SourceDBLabel: business.PlatformName(row.SourceDB), PromoteName: row.Name, PromoteType: row.PromoteType, BusierName: row.BusinessName(), AnalyticsMetrics: analyticsMetrics(row)}
|
||||
if !summary {
|
||||
result.PromoteID = &row.ID
|
||||
}
|
||||
return result
|
||||
}
|
||||
func gameAnalyticsRow(row *business.AnalyticsRow, summary bool) *dto.GameAnalyticsRow {
|
||||
result := &dto.GameAnalyticsRow{SourceDB: row.SourceDB, SourceDBLabel: business.PlatformName(row.SourceDB), GameName: row.Name, AnalyticsMetrics: analyticsMetrics(row), ARPU: row.ARPU, ARPPU: row.ARPPU}
|
||||
if !summary {
|
||||
result.GameID = &row.ID
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (s *AnalyticsService) Channels(ctx context.Context, req *dto.AnalyticsQuery) (*dto.ChannelAnalyticsPage, error) {
|
||||
f, err := analyticsRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, err := s.uc.List(ctx, business.ChannelAnalytics, f, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &dto.ChannelAnalyticsPage{Rows: []*dto.ChannelAnalyticsRow{}, Total: page.Total, BeginTime: page.Begin, EndTime: page.End, Available: page.Available}
|
||||
for _, row := range page.Rows {
|
||||
result.Rows = append(result.Rows, channelAnalyticsRow(row, false))
|
||||
}
|
||||
if page.Summary != nil {
|
||||
result.Summary = channelAnalyticsRow(page.Summary, true)
|
||||
result.ChannelCount = &page.Total
|
||||
result.TotalRegisterCount = &page.Summary.Counts.Register
|
||||
result.TotalPayAmount = &page.Summary.Pay
|
||||
result.AvgPayRate = &page.Summary.PayRate
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *AnalyticsService) Games(ctx context.Context, req *dto.AnalyticsQuery) (*dto.GameAnalyticsPage, error) {
|
||||
f, err := analyticsRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, err := s.uc.List(ctx, business.GameAnalytics, f, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &dto.GameAnalyticsPage{Rows: []*dto.GameAnalyticsRow{}, Total: page.Total, BeginTime: page.Begin, EndTime: page.End, Available: page.Available}
|
||||
for _, row := range page.Rows {
|
||||
result.Rows = append(result.Rows, gameAnalyticsRow(row, false))
|
||||
}
|
||||
if page.Summary != nil {
|
||||
result.Summary = gameAnalyticsRow(page.Summary, true)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dto
|
||||
|
||||
type AnalyticsQuery struct {
|
||||
Page int `form:"pageNum"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Sources []string `form:"sourceDb"`
|
||||
GameNames []string `form:"gameName"`
|
||||
PromoteType *int `form:"promoteType"`
|
||||
PromoteName string `form:"promoteName"`
|
||||
BeginTime string `form:"beginTime"`
|
||||
EndTime string `form:"endTime"`
|
||||
SortField string `form:"sortField"`
|
||||
SortOrder string `form:"sortOrder"`
|
||||
}
|
||||
type AnalyticsMetrics struct {
|
||||
Register int64 `json:"newRegisterCount"`
|
||||
Active int64 `json:"activeCount"`
|
||||
PayUsers int64 `json:"payUserCount"`
|
||||
NewPayUsers int64 `json:"newPayUserCount"`
|
||||
TotalPay string `json:"totalPay"`
|
||||
PayRate string `json:"payRate"`
|
||||
}
|
||||
type ChannelAnalyticsRow struct {
|
||||
SourceDB string `json:"sourceDb"`
|
||||
SourceDBLabel string `json:"sourceDbLabel"`
|
||||
PromoteID *int64 `json:"promoteId"`
|
||||
PromoteName string `json:"promoteName"`
|
||||
PromoteType *int `json:"promoteType"`
|
||||
BusierName string `json:"busierName"`
|
||||
AnalyticsMetrics
|
||||
}
|
||||
type GameAnalyticsRow struct {
|
||||
SourceDB string `json:"sourceDb"`
|
||||
SourceDBLabel string `json:"sourceDbLabel"`
|
||||
GameID *int64 `json:"gameId"`
|
||||
GameName string `json:"gameName"`
|
||||
AnalyticsMetrics
|
||||
ARPU string `json:"arpu"`
|
||||
ARPPU string `json:"arppu"`
|
||||
}
|
||||
type ChannelAnalyticsPage struct {
|
||||
Rows []*ChannelAnalyticsRow `json:"rows"`
|
||||
Total int64 `json:"total"`
|
||||
ChannelCount *int64 `json:"channelCount"`
|
||||
TotalRegisterCount *int64 `json:"totalRegisterCount"`
|
||||
TotalPayAmount *string `json:"totalPayAmount"`
|
||||
AvgPayRate *string `json:"avgPayRate"`
|
||||
Summary *ChannelAnalyticsRow `json:"summary"`
|
||||
BeginTime string `json:"beginTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
type GameAnalyticsPage struct {
|
||||
Rows []*GameAnalyticsRow `json:"rows"`
|
||||
Total int64 `json:"total"`
|
||||
Summary *GameAnalyticsRow `json:"summary"`
|
||||
BeginTime string `json:"beginTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
@@ -24,6 +24,7 @@ var ProviderSet = wire.NewSet(
|
||||
businessservice.NewBusinessPortraitService,
|
||||
businessservice.NewChannelPortraitService,
|
||||
businessservice.NewLedgerService,
|
||||
businessservice.NewAnalyticsService,
|
||||
systemservice.ProviderSet,
|
||||
paymentservice.ProviderSet,
|
||||
integrationservice.ProviderSet,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""只读检查渠道/游戏统计接口,区分空结果与业务表未接入。"""
|
||||
import argparse
|
||||
import json
|
||||
import urllib.request
|
||||
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 = []
|
||||
for kind in ["channelData", "gameData"]:
|
||||
for query, expected in [("sourceDb=lx,hw,yq&pageNum=1&pageSize=10", 0), ("beginTime=bad&endTime=2026-11-04", 7), ("beginTime=2026-11-05&endTime=2026-11-04", 7)]:
|
||||
path = f"/sw/{kind}/list?{query}"
|
||||
req = urllib.request.Request(args.base_url + path, headers={"x-token": token})
|
||||
with urllib.request.urlopen(req, timeout=30) as response:
|
||||
payload = json.load(response)
|
||||
data = payload.get("data", {})
|
||||
item = {"path": path, "http": response.status, "code": payload.get("code"), "msg": payload.get("msg"), "passed": payload.get("code") == expected}
|
||||
if "available" in data:
|
||||
item.update(available=data["available"], total=data.get("total"), begin=data.get("beginTime"), end=data.get("endTime"))
|
||||
if data["available"] is False:
|
||||
item["passed"] &= data.get("summary") is None
|
||||
checks.append(item)
|
||||
report = {"scope": "真实 sw7 会话,只读请求;完整统计及权限口径另由隔离 TCP HTTP 测试验证。", "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()
|
||||
@@ -0,0 +1,62 @@
|
||||
// 真实 Chrome 核对统计筛选、排序、分页和全量汇总;业务接口使用受控数据。
|
||||
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 ['channel', 'game']) {
|
||||
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } })
|
||||
await context.addInitScript(value => localStorage.setItem('token', value), token)
|
||||
const page = await context.newPage(), queries = [], errors = []
|
||||
let unavailable = false
|
||||
const routeName = kind === 'channel' ? 'channelData' : 'gameData'
|
||||
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/${routeName}/list`) {
|
||||
queries.push(Object.fromEntries(url.searchParams))
|
||||
const pageNum = Number(url.searchParams.get('pageNum') || 1)
|
||||
const metrics = { newRegisterCount: 2, activeCount: 4, payUserCount: 1, newPayUserCount: 1, totalPay: '10.00', payRate: '25.00', arpu: '2.50', arppu: '10.00' }
|
||||
const row = { ...metrics, sourceDb: 'lx', sourceDbLabel: '龙翔', promoteId: pageNum, promoteName: `测试渠道${pageNum}`, promoteType: 1, busierName: '游戏商务', gameId: pageNum, gameName: `测试游戏${pageNum}` }
|
||||
const summary = { ...metrics, gameName: '全部游戏', promoteName: '全部渠道', newRegisterCount: 60, activeCount: 100, payUserCount: 20, newPayUserCount: 10, totalPay: '1000.00', payRate: '20.00', arpu: '10.00', arppu: '50.00' }
|
||||
return route.fulfill({ json: { code: 0, data: { rows: unavailable ? [] : [row], total: unavailable ? 0 : 21, channelCount: unavailable ? null : 21, totalRegisterCount: unavailable ? null : 60, totalPayAmount: unavailable ? null : '1000.00', avgPayRate: unavailable ? null : '20.00', summary: unavailable ? null : summary, beginTime: url.searchParams.get('beginTime'), endTime: url.searchParams.get('endTime'), available: !unavailable } } })
|
||||
}
|
||||
if (api === '/sw/serverAnalysis/games') return route.fulfill({ json: { code: 0, data: [{ sourceDb: 'lx', gameId: 1, gameName: '筛选游戏' }] } })
|
||||
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 === 'channel' ? 'ChannelDataStatistics' : 'GameAnalytics'}`)
|
||||
await page.getByText(kind === 'channel' ? '测试渠道1' : '测试游戏1', { exact: true }).waitFor()
|
||||
assert.equal((Date.parse(queries[0].endTime) - Date.parse(queries[0].beginTime)) / 86400000, 29)
|
||||
const totals = kind === 'channel' ? page.locator('.report-cards') : page.locator('.el-table__footer-wrapper')
|
||||
await totals.getByText('¥1,000.00', { exact: true }).waitFor()
|
||||
assert.equal(await page.getByRole('button', { name: '导出', exact: true }).count(), 0)
|
||||
await page.locator('.el-pagination .btn-next').click()
|
||||
await page.getByText(kind === 'channel' ? '测试渠道2' : '测试游戏2', { exact: true }).waitFor()
|
||||
assert.equal(queries.at(-1).pageNum, '2')
|
||||
await totals.getByText('¥1,000.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')
|
||||
if (kind === 'channel') await form.getByPlaceholder('渠道账号(模糊匹配)', { exact: true }).fill('测试')
|
||||
await Promise.all([page.waitForResponse(response => response.url().includes(`/sw/${routeName}/list`) && response.url().includes('gameName=')), form.getByRole('button', { name: kind === 'channel' ? '搜索' : '查询', exact: true }).click()])
|
||||
assert.equal(queries.at(-1).pageNum, '1'); assert.equal(queries.at(-1).gameName, '筛选游戏')
|
||||
if (kind === 'channel') assert.equal(queries.at(-1).promoteName, '测试')
|
||||
await page.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
|
||||
await page.screenshot({ path: path.join(process.env.TEMP, `sw-${kind}-analytics.png`), animations: 'disabled' })
|
||||
unavailable = true
|
||||
await form.getByRole('button', { name: kind === 'channel' ? '搜索' : '查询', exact: true }).click()
|
||||
await page.getByText('统计所需业务汇总尚未接入。', { exact: true }).waitFor()
|
||||
assert.equal(await totals.getByText('¥1,000.00', { exact: true }).count(), 0)
|
||||
assert.deepEqual(errors, [])
|
||||
const report = { kind, passed: true, fullSummaryAcrossPages: true, filtersResetPage: true, platformAndGameFilters: true, noInventedExport: true, missingDataNotZero: true, pageErrors: errors }
|
||||
reports.push(report); console.log(JSON.stringify(report)); await context.close()
|
||||
}
|
||||
await fs.writeFile('docs/sw6-to-kra/analytics-page-2026-11-04.json', JSON.stringify({ method: '真实 Chrome 渲染,受控业务数据;未写入真实数据', reports }, null, 2) + '\n')
|
||||
} finally { await browser.close() }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||
@@ -0,0 +1,2 @@
|
||||
import service from '@/utils/request'
|
||||
export const listAnalytics = (kind, params) => service({ url: `/sw/${kind === 'channel' ? 'channelData' : 'gameData'}/list`, method: 'get', params })
|
||||
@@ -56,7 +56,9 @@
|
||||
"/src/view/sw/AlertRules.vue": "SwAlertRules",
|
||||
"/src/view/sw/BusinessList.vue": "SwBusinessList",
|
||||
"/src/view/sw/ChannelActivationList.vue": "SwChannelActivationList",
|
||||
"/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics",
|
||||
"/src/view/sw/ChannelList.vue": "SwChannelList",
|
||||
"/src/view/sw/GameAnalytics.vue": "SwGameAnalytics",
|
||||
"/src/view/sw/RechargeDetails.vue": "SwRechargeDetails",
|
||||
"/src/view/sw/RegistrationDetails.vue": "SwRegistrationDetails",
|
||||
"/src/view/sw/components/BusinessBindings.vue": "SwBusinessBindings",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<template><report-analytics kind="channel" /></template>
|
||||
<script setup>
|
||||
import ReportAnalytics from './components/ReportAnalytics.vue'
|
||||
defineOptions({ name: 'SwChannelDataStatistics' })
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template><report-analytics kind="game" /></template>
|
||||
<script setup>
|
||||
import ReportAnalytics from './components/ReportAnalytics.vue'
|
||||
defineOptions({ name: 'SwGameAnalytics' })
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="isChannel" v-loading="loading" class="report-cards">
|
||||
<el-card v-for="card in cards" :key="card.label" shadow="never"><div class="report-card-label">{{ card.label }}</div><strong>{{ card.value }}</strong></el-card>
|
||||
</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-select v-model="games" multiple collapse-tags clearable filterable remote reserve-keyword :remote-method="loadGames" :loading="gameLoading" :disabled="!sources.length" placeholder="全部游戏" style="width: 190px" @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="isChannel" label="渠道类型"><el-select v-model="query.promoteType" clearable placeholder="全部渠道类型" style="width: 150px"><el-option label="公会" :value="1" /><el-option label="个人" :value="0" /></el-select></el-form-item>
|
||||
<el-form-item v-if="isChannel" label="渠道账号"><el-input v-model="query.promoteName" clearable placeholder="渠道账号(模糊匹配)" /></el-form-item>
|
||||
<el-form-item label="日期"><el-date-picker v-model="dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" :clearable="false" :shortcuts="shortcuts" style="width: 300px" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="search" native-type="submit">{{ isChannel ? '搜索' : '查询' }}</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="kra-table-box">
|
||||
<div v-if="!isChannel" class="report-heading"><strong>核心指标</strong><el-text type="info">统计周期:{{ period }}</el-text></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}:${isChannel ? row.promoteId : row.gameId}`" :default-sort="{ prop: 'totalPay', order: 'descending' }" :show-summary="!isChannel" :summary-method="summaries" @sort-change="sortChanged">
|
||||
<el-table-column prop="sourceDbLabel" label="所属平台" min-width="100" />
|
||||
<el-table-column :prop="isChannel ? 'promoteName' : 'gameName'" :label="isChannel ? '渠道名称' : '游戏名称'" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column v-if="isChannel" prop="busierName" label="归属商务" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column v-if="isChannel" label="渠道类型" min-width="100"><template #default="{ row }">{{ { 0: '个人', 1: '公会' }[row.promoteType] ?? row.promoteType ?? '—' }}</template></el-table-column>
|
||||
<el-table-column v-else prop="gameId" label="游戏ID" min-width="100" />
|
||||
<el-table-column v-for="column in columns" :key="column.key" :prop="column.key" :label="column.label" :min-width="column.width || 110" sortable="custom"><template #default="{ row }">{{ format(column.type, row[column.key]) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<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 { listAnalytics } from '@/api/analytics'
|
||||
import { listLedgerGames } from '@/api/ledger'
|
||||
import { channelCount, channelMoney } from '../channel-display'
|
||||
const props = defineProps({ kind: { type: String, required: true } })
|
||||
const isChannel = computed(() => props.kind === 'channel')
|
||||
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||
const day = () => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), n.getDate()) }
|
||||
const ymd = value => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`
|
||||
const end = day(), begin = day(); begin.setDate(begin.getDate() - 29)
|
||||
const dates = ref([ymd(begin), ymd(end)])
|
||||
const shortcuts = [
|
||||
{ text: '今日', value: () => [day(), day()] }, { text: '昨日', value: () => { const d = day(); d.setDate(d.getDate() - 1); return [d, new Date(d)] } },
|
||||
{ text: '本周', value: () => { const e = day(), b = day(); b.setDate(b.getDate() - (b.getDay() || 7) + 1); return [b, e] } },
|
||||
{ text: '本月', value: () => { const e = day(); return [new Date(e.getFullYear(), e.getMonth(), 1), e] } }
|
||||
]
|
||||
const sources = ref(Object.keys(platforms)), games = ref([]), gameOptions = ref([]), gameLoading = ref(false)
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, promoteType: undefined, promoteName: '', sortField: 'totalPay', sortOrder: 'desc' })
|
||||
const rows = ref([]), total = ref(0), summary = ref(null), result = ref(null), loading = ref(false), available = ref(true), error = ref('')
|
||||
let version = 0, gameVersion = 0
|
||||
const period = computed(() => result.value?.beginTime && result.value?.endTime ? `${result.value.beginTime} 至 ${result.value.endTime}` : dates.value.join(' 至 '))
|
||||
const fields = { newRegisterCount: { key: 'newRegisterCount', label: '新增注册', type: 'count' }, activeCount: { key: 'activeCount', label: '活跃玩家', type: 'count' }, payUserCount: { key: 'payUserCount', label: '付费玩家', type: 'count' }, newPayUserCount: { key: 'newPayUserCount', label: '新增付费', type: 'count' }, totalPay: { key: 'totalPay', label: '充值金额', type: 'money', width: 145 }, payRate: { key: 'payRate', label: '付费率', type: 'percent' }, arpu: { key: 'arpu', label: 'ARPU', type: 'money' }, arppu: { key: 'arppu', label: 'ARPPU', type: 'money' } }
|
||||
const columns = computed(() => (isChannel.value ? ['newRegisterCount', 'activeCount', 'totalPay', 'payUserCount', 'newPayUserCount', 'payRate'] : ['newRegisterCount', 'activeCount', 'payUserCount', 'totalPay', 'payRate', 'arpu', 'arppu', 'newPayUserCount']).map(key => fields[key]))
|
||||
const format = (type, value) => value == null ? '未接入' : type === 'money' ? channelMoney(value) : type === 'percent' ? `${value}%` : channelCount(value)
|
||||
const cards = computed(() => [{ label: '渠道总数', key: 'channelCount', type: 'count' }, { label: '渠道总注册玩家', key: 'totalRegisterCount', type: 'count' }, { label: '渠道总充值', key: 'totalPayAmount', type: 'money' }, { label: '平均付费率', key: 'avgPayRate', type: 'percent' }].map(card => ({ label: card.label, value: error.value ? '未获取' : format(card.type, result.value?.[card.key]) })))
|
||||
const summaries = ({ columns: tableColumns }) => tableColumns.map((column, index) => { if (index === 0) return '合计'; if (index === 1) return summary.value?.gameName || ''; const field = fields[column.property]; return field ? format(field.type, summary.value?.[column.property]) : '' })
|
||||
const clear = () => { rows.value = []; total.value = 0; summary.value = null; result.value = null }
|
||||
const load = async () => {
|
||||
const current = ++version
|
||||
if (!sources.value.length) { clear(); loading.value = false; available.value = true; return }
|
||||
loading.value = true; error.value = ''
|
||||
try {
|
||||
const res = await listAnalytics(props.kind, { ...query, promoteName: query.promoteName.trim(), sourceDb: sources.value.join(','), gameName: games.value.join(','), beginTime: dates.value[0], endTime: dates.value[1] })
|
||||
if (current !== version) return
|
||||
if (res.code === 0) { result.value = res.data; rows.value = res.data.rows; total.value = res.data.total; summary.value = res.data.summary; available.value = res.data.available !== false }
|
||||
else { clear(); error.value = res.msg || '统计查询失败' }
|
||||
} catch { if (current === version) { clear(); error.value = '统计查询失败,请重试' } } finally { if (current === version) loading.value = false }
|
||||
}
|
||||
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
|
||||
const sortChanged = ({ prop, order }) => { query.sortField = prop || 'totalPay'; query.sortOrder = order === 'ascending' ? 'asc' : 'desc'; return search() }
|
||||
const sourceChanged = () => { ++gameVersion; games.value = []; gameOptions.value = []; gameLoading.value = false }
|
||||
const loadGames = async (keyword = '') => {
|
||||
const current = ++gameVersion; if (!sources.value.length) return
|
||||
gameLoading.value = true
|
||||
try { const res = await listLedgerGames({ sourceDb: sources.value.join(','), keyword, limit: 50 }); if (current === gameVersion && res.code === 0) gameOptions.value = [...new Set([...games.value, ...res.data.map(row => row.gameName).filter(Boolean)])] } finally { if (current === gameVersion) gameLoading.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
|
||||
.report-card-label { color: var(--el-text-color-secondary); font-size: 13px; margin-bottom: 12px; }
|
||||
.report-cards strong { font-size: 25px; overflow-wrap: anywhere; }
|
||||
.report-heading { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
|
||||
@media (max-width: 800px) { .report-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user