diff --git a/cmd/analytics_http_test.go b/cmd/analytics_http_test.go new file mode 100644 index 0000000..a5f58c0 --- /dev/null +++ b/cmd/analytics_http_test.go @@ -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("缺汇总数据不应提供假零卡片") + } +} diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 62dc356..be8bc3e 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -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, diff --git a/docs/sw6-to-kra/analytics-http-2026-11-04.json b/docs/sw6-to-kra/analytics-http-2026-11-04.json new file mode 100644 index 0000000..a006c1d --- /dev/null +++ b/docs/sw6-to-kra/analytics-http-2026-11-04.json @@ -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 +} diff --git a/docs/sw6-to-kra/analytics-page-2026-11-04.json b/docs/sw6-to-kra/analytics-page-2026-11-04.json new file mode 100644 index 0000000..b02c262 --- /dev/null +++ b/docs/sw6-to-kra/analytics-page-2026-11-04.json @@ -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": [] + } + ] +} diff --git a/docs/sw6-to-kra/analytics-source-notes.md b/docs/sw6-to-kra/analytics-source-notes.md new file mode 100644 index 0000000..2518314 --- /dev/null +++ b/docs/sw6-to-kra/analytics-source-notes.md @@ -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 库只做只读请求;当前缺少完整业务报表,最终旧库复制与同源数据比较仍未开始。 diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md index 57ef3b8..4e7b4bc 100644 --- a/docs/sw6-to-kra/implementation.md +++ b/docs/sw6-to-kra/implementation.md @@ -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`。 diff --git a/docs/sw6-to-kra/page-parity.md b/docs/sw6-to-kra/page-parity.md index 9c97d51..e303f94 100644 --- a/docs/sw6-to-kra/page-parity.md +++ b/docs/sw6-to-kra/page-parity.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 验证。 diff --git a/internal/biz/biz.go b/internal/biz/biz.go index c63ea2f..5dbfefb 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -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) diff --git a/internal/biz/business/analytics.go b/internal/biz/business/analytics.go new file mode 100644 index 0000000..df84c82 --- /dev/null +++ b/internal/biz/business/analytics.go @@ -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 +} diff --git a/internal/biz/business/analytics_test.go b/internal/biz/business/analytics_test.go new file mode 100644 index 0000000..3485d72 --- /dev/null +++ b/internal/biz/business/analytics_test.go @@ -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("倒置日期应被拒绝") + } +} diff --git a/internal/data/business/analytics.go b/internal/data/business/analytics.go new file mode 100644 index 0000000..d5132da --- /dev/null +++ b/internal/data/business/analytics.go @@ -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 +} diff --git a/internal/data/business/analytics_test.go b/internal/data/business/analytics_test.go new file mode 100644 index 0000000..a9a1c84 --- /dev/null +++ b/internal/data/business/analytics_test.go @@ -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("无绑定应为确定空范围") + } +} diff --git a/internal/data/data.go b/internal/data/data.go index 68d1bef..c28b8b0 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -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)), diff --git a/internal/modules/business/definition.go b/internal/modules/business/definition.go index 5645768..6518e99 100644 --- a/internal/modules/business/definition.go +++ b/internal/modules/business/definition.go @@ -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}, diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 1b78be0..2941e76 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -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)) diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index b594105..63863fc 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -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: "查询充值明细与成功支付合计"}, diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index 2d1647c..fbdd507 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -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) } } diff --git a/internal/server/handler/analytics.go b/internal/server/handler/analytics.go new file mode 100644 index 0000000..a603a0a --- /dev/null +++ b/internal/server/handler/analytics.go @@ -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) +} diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go index abb997f..dd4330c 100644 --- a/internal/server/handler/provider.go +++ b/internal/server/handler/provider.go @@ -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, diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go index 0b0da37..2d74fe3 100644 --- a/internal/server/handler/set.go +++ b/internal/server/handler/set.go @@ -1,6 +1,7 @@ package handler type Set struct { + Analytics *Analytics Ledger *Ledger ChannelPortrait *ChannelPortrait BusinessPortrait *BusinessPortrait diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go index fa5d036..c772b1a 100644 --- a/internal/server/router/routes.go +++ b/internal/server/router/routes.go @@ -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() { diff --git a/internal/service/business/analytics.go b/internal/service/business/analytics.go new file mode 100644 index 0000000..9c70ebd --- /dev/null +++ b/internal/service/business/analytics.go @@ -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 +} diff --git a/internal/service/dto/analytics.go b/internal/service/dto/analytics.go new file mode 100644 index 0000000..c47d16f --- /dev/null +++ b/internal/service/dto/analytics.go @@ -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"` +} diff --git a/internal/service/service.go b/internal/service/service.go index 0ee2535..1cd43e5 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -24,6 +24,7 @@ var ProviderSet = wire.NewSet( businessservice.NewBusinessPortraitService, businessservice.NewChannelPortraitService, businessservice.NewLedgerService, + businessservice.NewAnalyticsService, systemservice.ProviderSet, paymentservice.ProviderSet, integrationservice.ProviderSet, diff --git a/scripts/sw_analytics_http_smoke.py b/scripts/sw_analytics_http_smoke.py new file mode 100644 index 0000000..dc7a52e --- /dev/null +++ b/scripts/sw_analytics_http_smoke.py @@ -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() diff --git a/scripts/sw_analytics_page_smoke.cjs b/scripts/sw_analytics_page_smoke.cjs new file mode 100644 index 0000000..91dcaf4 --- /dev/null +++ b/scripts/sw_analytics_page_smoke.cjs @@ -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 }) diff --git a/web/src/api/analytics.js b/web/src/api/analytics.js new file mode 100644 index 0000000..ccd0a37 --- /dev/null +++ b/web/src/api/analytics.js @@ -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 }) diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index 01a7fa8..5049758 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -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", diff --git a/web/src/view/sw/ChannelDataStatistics.vue b/web/src/view/sw/ChannelDataStatistics.vue new file mode 100644 index 0000000..5587390 --- /dev/null +++ b/web/src/view/sw/ChannelDataStatistics.vue @@ -0,0 +1,5 @@ + + diff --git a/web/src/view/sw/GameAnalytics.vue b/web/src/view/sw/GameAnalytics.vue new file mode 100644 index 0000000..c5b6907 --- /dev/null +++ b/web/src/view/sw/GameAnalytics.vue @@ -0,0 +1,5 @@ + + diff --git a/web/src/view/sw/components/ReportAnalytics.vue b/web/src/view/sw/components/ReportAnalytics.vue new file mode 100644 index 0000000..816adc4 --- /dev/null +++ b/web/src/view/sw/components/ReportAnalytics.vue @@ -0,0 +1,90 @@ + + + + +