From ac4e13e61004b48b44ca565a050b5435ab17ddbc Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Wed, 4 Nov 2026 01:34:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E5=95=86=E5=8A=A1=E6=B8=B8=E6=88=8F=E6=8E=92=E8=A1=8C=E6=A6=9C?= =?UTF-8?q?=E5=8F=8A=E5=90=8C=E6=9C=9F=E6=AF=94=E8=BE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/ranking_http_test.go | 161 +++++++++++++ cmd/wire_gen.go | 7 +- docs/sw6-to-kra/implementation.md | 4 +- docs/sw6-to-kra/page-parity.md | 6 + docs/sw6-to-kra/ranking-http-2026-11-04.json | 72 ++++++ docs/sw6-to-kra/ranking-source-notes.md | 16 ++ internal/biz/biz.go | 2 +- internal/biz/business/ranking.go | 235 +++++++++++++++++++ internal/biz/business/ranking_test.go | 76 ++++++ internal/data/business/ranking.go | 201 ++++++++++++++++ internal/data/business/ranking_test.go | 71 ++++++ internal/data/data.go | 1 + internal/modules/business/definition.go | 1 + internal/modules/catalog_test.go | 4 +- internal/routecatalog/catalog.go | 3 + internal/server/gin_test.go | 3 +- internal/server/handler/provider.go | 1 + internal/server/handler/ranking.go | 29 +++ internal/server/handler/set.go | 1 + internal/server/router/routes.go | 5 + internal/service/business/ranking.go | 40 ++++ internal/service/dto/ranking.go | 27 +++ internal/service/service.go | 1 + scripts/sw_ranking_http_smoke.py | 36 +++ web/src/api/ranking.js | 3 + web/src/pathInfo.json | 1 + web/src/view/sw/RankingAnalysis.vue | 78 ++++++ 27 files changed, 1079 insertions(+), 6 deletions(-) create mode 100644 cmd/ranking_http_test.go create mode 100644 docs/sw6-to-kra/ranking-http-2026-11-04.json create mode 100644 docs/sw6-to-kra/ranking-source-notes.md create mode 100644 internal/biz/business/ranking.go create mode 100644 internal/biz/business/ranking_test.go create mode 100644 internal/data/business/ranking.go create mode 100644 internal/data/business/ranking_test.go create mode 100644 internal/server/handler/ranking.go create mode 100644 internal/service/business/ranking.go create mode 100644 internal/service/dto/ranking.go create mode 100644 scripts/sw_ranking_http_smoke.py create mode 100644 web/src/api/ranking.js create mode 100644 web/src/view/sw/RankingAnalysis.vue diff --git a/cmd/ranking_http_test.go b/cmd/ranking_http_test.go new file mode 100644 index 0000000..3e61412 --- /dev/null +++ b/cmd/ranking_http_test.go @@ -0,0 +1,161 @@ +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 TestRankingHTTP(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ranking.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),(8,'branch','branch',2,7,'0,7',1),(9,'leaf','leaf',3,8,'0,7,8',1),(10,'leaf2','leaf2',3,8,'0,7,8',1),(99,'other','other',1,0,'0',1)", + "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(9,'lx',14,'0'),(10,'hw',14,'0'),(99,'yq',14,'0')", + "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER)", + "INSERT INTO tab_promote VALUES(100,'lx','渠道甲',1,0,14),(101,'lx','渠道甲下级',2,100,14),(100,'hw','汇玩同号渠道',1,0,14),(100,'yq','其他商务渠道',1,0,14)", + "CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)", + "INSERT INTO tab_game VALUES(1,'lx','游戏甲'),(1,'hw','同号游戏'),(1,'yq','无权游戏')", + "CREATE TABLE tab_sw_report_day(source_db TEXT,promote_id INTEGER,game_id INTEGER,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),('hw',100,1,0,'2026-11-04',40),('yq',100,1,0,'2026-11-04',80),('lx',100,1,0,'2026-11-03',10),('lx',100,1,1,'2026-11-04',9999)", + "CREATE TABLE tab_sw_report_day_key(source_db TEXT,promote_id INTEGER,game_id INTEGER,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 int + }{{"lx", 100}, {"lx", 101}, {"hw", 100}, {"yq", 100}} { + for _, metric := range []int{1, 2, 3} { + if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES(?,?,1,1,'2026-11-04',?,'A')", row.source, row.promote, metric).Error; err != nil { + t.Fatal(err) + } + } + } + if err := db.Exec("UPDATE tab_sw_report_day_key SET identity_key='B' WHERE source_db='lx' AND promote_id=101 AND metric=2").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO tab_sw_report_day_key SELECT source_db,promote_id,game_id,dedup_mode,'2026-11-03',metric,identity_key FROM tab_sw_report_day_key WHERE source_db='lx' AND promote_id=100").Error; err != nil { + t.Fatal(err) + } + provider := businessHTTPData{db} + uc := businessbiz.NewRankingUsecase(businessdata.NewRankingRepo(provider), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider))) + h := handler.NewRanking(businessservice.NewRankingService(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/rank/channel", h.Channel) + engine.GET("/sw/rank/business", h.Business) + engine.GET("/sw/rank/game", h.Game) + srv := httptest.NewServer(engine) + defer srv.Close() + call := func(user int, kind, query string, code int) map[string]any { + t.Helper() + req, _ := http.NewRequest("GET", srv.URL+"/sw/rank/"+kind+"?"+query, nil) + req.Header.Set("X-Test-User", strconv.Itoa(user)) + res, err := srv.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 res.StatusCode != 200 || body["code"] != float64(code) { + t.Fatalf("%s: %+v", kind, body) + } + if code != 0 { + return nil + } + return body["data"].(map[string]any) + } + query := "beginTime=2026-11-04&endTime=2026-11-04&sourceDb=lx,hw,yq" + p := call(7, "channel", query, 0) + regs := p["registerRankList"].([]any) + if len(regs) != 2 { + t.Fatal("其他商务数据不应出现") + } + var lx map[string]any + for _, value := range p["payRateRankList"].([]any) { + row := value.(map[string]any) + if row["sourceDb"] == "lx" { + lx = row + } + } + if lx == nil || lx["metricValue"] != "50.00" || lx["momValue"] != "-50.00" || lx["momStatus"] != float64(-1) { + t.Fatalf("付费率应计算百分点差: %+v", lx) + } + p = call(7, "business", query, 0) + rows := p["registerRankList"].([]any) + row := rows[0].(map[string]any) + if len(rows) != 1 || row["objectId"] != "8" || row["objectName"] != "branch (L2)" || row["metricValue"] != "3" || row["sourceDb"] != nil { + t.Fatalf("商务范围或逐渠道加总错误: %+v", p) + } + row = p["rechargeRankList"].([]any)[0].(map[string]any) + if row["metricValue"] != "70.00" || row["momValue"] != "600.00" { + t.Fatal("商务金额环比错误") + } + p = call(8, "business", query, 0) + if len(p["registerRankList"].([]any)) != 2 { + t.Fatal("二级应看直属三级") + } + p = call(9, "business", query, 0) + if len(p["registerRankList"].([]any)) != 0 { + t.Fatal("三级不应展示商务排行") + } + p = call(1, "business", query, 0) + if len(p["registerRankList"].([]any)) != 5 { + t.Fatal("超管应展示所有有数据的商务层级") + } + p = call(7, "game", query+"&gameId=999", 0) + if len(p["registerRankList"].([]any)) != 2 { + t.Fatal("旧 gameId 参数未用于筛选,不能悄悄改变范围") + } + for _, v := range p["registerRankList"].([]any) { + row := v.(map[string]any) + if row["objectId"] != row["sourceDb"].(string)+"|1" { + t.Fatal("游戏键应包含平台") + } + } + call(7, "channel", "beginTime=bad&endTime=2026-11-04", 7) + call(7, "game", "beginTime=2026-11-05&endTime=2026-11-04", 7) + if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil { + t.Fatal(err) + } + p = call(7, "channel", query, 0) + if p["available"] != false || len(p["registerRankList"].([]any)) != 0 { + t.Fatal("缺表不应返回假排行") + } +} diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index ee900a2..aeff065 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() - workbenchRepo := business.NewWorkbenchRepo(dataData) + rankingRepo := business.NewRankingRepo(dataData) accountRepo := business.NewAccountRepo(dataData) accountUsecase := business2.NewAccountUsecase(accountRepo) userPreferenceRepo := business.NewUserPreferenceRepo(dataData) userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo) + rankingUsecase := business2.NewRankingUsecase(rankingRepo, accountUsecase, userPreferenceUsecase) + rankingService := business3.NewRankingService(rankingUsecase) + ranking := handler.NewRanking(rankingService) + workbenchRepo := business.NewWorkbenchRepo(dataData) workbenchUsecase := business2.NewWorkbenchUsecase(workbenchRepo, accountUsecase, userPreferenceUsecase) workbenchService := business3.NewWorkbenchService(workbenchUsecase) workbench := handler.NewWorkbench(workbenchService) @@ -243,6 +247,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase) integrationConfig := handler.NewIntegrationConfig(integrationConfigService) set := &handler.Set{ + Ranking: ranking, Workbench: workbench, ServerAnalysis: serverAnalysis, Analytics: analytics, diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md index 9185b74..d719865 100644 --- a/docs/sw6-to-kra/implementation.md +++ b/docs/sw6-to-kra/implementation.md @@ -27,7 +27,7 @@ | 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 | | 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 | | 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 功能已接入;包含渠道画像,完整业务数据和源平台联调仍待最终验收 | -| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出、渠道/游戏/区服统计、工作台仪表盘已接入,独立排行与任务待迁移 | +| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出、渠道/游戏/区服统计、独立排行榜与工作台仪表盘已接入,汇总构建及预警任务待迁移 | | 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 | | 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 | @@ -99,3 +99,5 @@ - 工作台四个接口及 Kra 仪表盘业务区域已接入:六项指标、注册/充值 TOP10、未登录 TOP10、最低充值 TOP10 和已有预警的最新五条轮播。保留 Kra 欢迎区域、快捷入口与公告,平台和周期按旧页面设置;请求失败清空旧结果并提示,不能显示其他平台的缓存值。 - 工作台保留本月对上月同日(截断月末)、模式 0 跨平台 ID 隔离、渠道树注册去重、金额固定模式 0、活跃渠道按自身注册/正充值计数、最低榜含零一级渠道,以及未登录回退创建时间和 99999 哨兵值。详见 `workbench-source-notes.md`。 - 工作台批次全量 Go、隔离 TCP HTTP、领域/仓储测试、前端 ESLint/构建通过;真实 sw7 请求记录见 `workbench-http-2026-11-04.json`。缺业务表时六卡返回 null、榜单明确报未接入。未执行页面交互检查或最终旧库复制。 +- 独立排行榜渠道、商务、游戏三个接口和 Kra 页面已接入,各有注册/充值/付费率/活跃 TOP20,保留平台、维度独立日期记忆、近 7 天默认周期和等长上期。付费率用百分点差,其余用百分比环比。商务按直属下级展示、合并其整个下级绑定树,再累加各渠道人数;三级不展示商务排行。 +- 排行批次全量 Go、隔离 TCP HTTP、领域/仓储测试、前端 ESLint/构建、真实 sw7 接口检查通过,见 `ranking-http-2026-11-04.json` 与 `ranking-source-notes.md`。当前 SQL 清单 63 个目标接口中 51 个已注册;其余 12 个均为 `SysUser` 历史系统别名,详见 `system-api-audit.md`,不能将接口注册数量当作全迁移完成证明。汇总和预警任务、最终旧库迁移验收仍待完成。 diff --git a/docs/sw6-to-kra/page-parity.md b/docs/sw6-to-kra/page-parity.md index fd9bf1b..434efa3 100644 --- a/docs/sw6-to-kra/page-parity.md +++ b/docs/sw6-to-kra/page-parity.md @@ -192,3 +192,9 @@ 直接核对旧 `views/index.vue`,在原 Kra 仪表盘中加入六项商务指标、平台多选、注册/充值两组横向榜图、未登录/最低充值两组榜表,以及最新五条待处理预警的四秒轮播和查看全部入口。注册/最高充值默认今日,最低充值默认近 30 天;每个榜单单独切换周期。保留 Kra 欢迎、快捷入口和公告,使用 Kra 卡片、颜色与 Element Plus 控件。没有移植旧页面的自定义图表样式。 四个工作台接口和已有预警接口负责真实业务数据;接口缺表或错误明确提示,不将空选择、未接入和真实零混淆。按用户要求仅做接口实测、数据断言和构建,未做页面交互检查。 + +## 独立排行榜 + +按旧 `RankingAnalysis.vue` 保留渠道/商务/游戏三个页签、平台多选(空选表示全部)、各维度独立记忆的日期、默认近 7 天,以及注册/充值/付费率/活跃四组 TOP20。不足 20 条显示占位符,旧请求不覆盖新维度。界面采用 Kra 表格和配色;付费率的环比文案明确为百分点,修复旧页面将百分点也显示成百分比的歧义。没有额外增加分页、游戏选择或导出入口。 + +接口实测、隔离数据 HTTP、全量 Go 和前端构建通过。按用户要求未检查页面交互。详见 `ranking-source-notes.md`。 diff --git a/docs/sw6-to-kra/ranking-http-2026-11-04.json b/docs/sw6-to-kra/ranking-http-2026-11-04.json new file mode 100644 index 0000000..6f29b0a --- /dev/null +++ b/docs/sw6-to-kra/ranking-http-2026-11-04.json @@ -0,0 +1,72 @@ +{ + "scope": "真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向排行和权限通过隔离 TCP HTTP 测试。", + "checks": [ + { + "path": "/sw/rank/channel?sourceDb=lx,hw,yq", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true, + "available": false + }, + { + "path": "/sw/rank/channel?beginTime=bad&endTime=2026-11-04", + "http": 200, + "code": 7, + "msg": "统计周期格式错误,应为 yyyy-MM-dd", + "passed": true + }, + { + "path": "/sw/rank/channel?beginTime=2026-11-05&endTime=2026-11-04", + "http": 200, + "code": 7, + "msg": "统计开始日期不能晚于结束日期", + "passed": true + }, + { + "path": "/sw/rank/business?sourceDb=lx,hw,yq", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true, + "available": false + }, + { + "path": "/sw/rank/business?beginTime=bad&endTime=2026-11-04", + "http": 200, + "code": 7, + "msg": "统计周期格式错误,应为 yyyy-MM-dd", + "passed": true + }, + { + "path": "/sw/rank/business?beginTime=2026-11-05&endTime=2026-11-04", + "http": 200, + "code": 7, + "msg": "统计开始日期不能晚于结束日期", + "passed": true + }, + { + "path": "/sw/rank/game?sourceDb=lx,hw,yq", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true, + "available": false + }, + { + "path": "/sw/rank/game?beginTime=bad&endTime=2026-11-04", + "http": 200, + "code": 7, + "msg": "统计周期格式错误,应为 yyyy-MM-dd", + "passed": true + }, + { + "path": "/sw/rank/game?beginTime=2026-11-05&endTime=2026-11-04", + "http": 200, + "code": 7, + "msg": "统计开始日期不能晚于结束日期", + "passed": true + } + ], + "passed": true +} diff --git a/docs/sw6-to-kra/ranking-source-notes.md b/docs/sw6-to-kra/ranking-source-notes.md new file mode 100644 index 0000000..c194803 --- /dev/null +++ b/docs/sw6-to-kra/ranking-source-notes.md @@ -0,0 +1,16 @@ +# 独立排行榜迁移核对 + +来源为旧 main `RankServiceImpl`、`RankController`、日汇总和身份键 Mapper,以及 dev/dev1.0 `RankingAnalysis.vue`。 + +- `/sw/rank/channel`、`business`、`game` 各返回注册、充值、付费率、活跃四组 TOP20。日期缺一项时默认上海当天,前端默认近 7 天。上期为当前开始日之前的等长连续区间。 +- 渠道榜金额和人数均使用旧 `COALESCE(NULLIF(top_promote_id,0),promote_id)` 分组,即使不包含下级也保留根算法。不能复用工作台的 CASE 根算法或注册榜 INNER JOIN;真实历史异常 top ID 已有测试覆盖。 +- 游戏榜按平台+游戏 ID,展示当前游戏名,缺名为“游戏ID”。objectId 保留 `source|id`;渠道 objectId 为 ID 字符串。旧 query 的 gameId 实际没有用于筛选,新接口保留其无筛选行为,页面不添加旧模板不存在的游戏选择器。 +- 商务榜超管列出一/二/三级商务,普通商务看直属下级,三级空榜。每个被展示商务的数据来自本人及全部下级的正常绑定渠道。一次关系查询展开目标、下级、绑定、渠道,代替旧每个目标单独查询下级的 N+1;查询条件保持各目标自己的祖先路径范围。 +- 商务人数累加各渠道人数,不做跨渠道全局去重;金额累加同一组渠道。逐渠道保留 Java int 上限,商务总数采用 int64 避免旧 int 溢出。祖先与下级可以分别上榜;不是互斥分区。 +- 注册、充值、活跃只展示正值;付费率只要求活跃大于 0,保留 0% 行。金额固定模式 0,人数取当前偏好。 +- 注册/充值/活跃环比需要上期正值,否则为 null;付费率只要求上期存在行,上期活跃为零按 0% 比较,结果为百分点差。金额展示先保留两位,金额环比用未舍入汇总,与旧服务一致。 +- 环比保留两位,绝对值小于 0.01 为持平。数值排序用精确有理数,同值追加平台/ID 稳定排序,截取后连续编号。 +- 源码还核对了空平台含义:独立排行榜空选表示全部,区别于工作台必须选平台。非法/倒置日期明确拒绝。缺报表时 available=false 和空数组,不伪造零指标或空查询成功。 +- 当前菜单名称、父级和路径已通过实际菜单接口核实,仍为 `sw_DataAnalysis` 下 `sw_RankingAnalysis` / `RankingAnalysis`。 + +已通过全量 Go、领域/仓储测试、独立 SQLite 的真实 TCP HTTP、ESLint/生产构建、真实 sw7 只读请求。没有页面交互检查,也没有进行最终旧库复制或同源比较。 diff --git a/internal/biz/biz.go b/internal/biz/biz.go index d38013a..f126cd9 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, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase) +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, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase) diff --git a/internal/biz/business/ranking.go b/internal/biz/business/ranking.go new file mode 100644 index 0000000..ab8d5f8 --- /dev/null +++ b/internal/biz/business/ranking.go @@ -0,0 +1,235 @@ +package business + +import ( + "context" + "fmt" + "math/big" + "sort" + "strconv" + "strings" + "time" +) + +type RankingKind string + +const ( + ChannelRanking RankingKind = "channel" + BusinessRanking RankingKind = "business" + GameRanking RankingKind = "game" +) + +type RankingRange struct { + AnalyticsFilter + PreviousBegin, PreviousEnd string +} +type RankingMetrics struct { + Counts PortraitCounts + Pay, Name string +} +type RankingBusiness struct { + ID uint + Name string + Level int + Channels []AnalyticsKey +} +type RankingData struct { + Current, Previous map[AnalyticsKey]*RankingMetrics + Businesses []*RankingBusiness + Available bool +} +type RankingItem struct { + AnalyticsKey + ObjectID, Name, Value string + Rank int + MoM *string + MoMStatus *int +} +type RankingPage struct { + Register, Recharge, PayRate, Active []*RankingItem + Begin, End string + Available bool +} +type RankingRepo interface { + Collect(context.Context, RankingKind, AnalyticsScope, RankingRange) (*RankingData, error) +} +type RankingUsecase struct { + repo RankingRepo + accounts *AccountUsecase + preferences *UserPreferenceUsecase + now func() time.Time +} + +func NewRankingUsecase(repo RankingRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *RankingUsecase { + return &RankingUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now} +} +func (uc *RankingUsecase) List(ctx context.Context, kind RankingKind, f AnalyticsFilter) (*RankingPage, error) { + actor, account, err := uc.accounts.actor(ctx) + if err != nil { + return nil, err + } + if kind != ChannelRanking && kind != BusinessRanking && kind != GameRanking { + return nil, ErrAnalyticsInput + } + if f.StartDate == nil || f.EndDate == nil { + day := workbenchWindow(uc.now()).Today + f.StartDate, f.EndDate = &day, &day + } + if f.EndDate.Before(*f.StartDate) { + return nil, ErrAnalyticsDateOrder + } + f.Begin, f.End = f.StartDate.Format("2006-01-02"), f.EndDate.Format("2006-01-02") + out := &RankingPage{Register: []*RankingItem{}, Recharge: []*RankingItem{}, PayRate: []*RankingItem{}, Active: []*RankingItem{}, Begin: f.Begin, End: f.End, Available: true} + if kind == BusinessRanking && actor.AuthorityID != 888 && account.Level >= 3 { + return out, nil + } + pref, err := uc.preferences.Current(ctx) + if err != nil { + return nil, err + } + viewer := portraitUserScope(account) + viewer.All = actor.AuthorityID == 888 + days := int((f.EndDate.Unix()-f.StartDate.Unix())/86400) + 1 + rangeFilter := RankingRange{AnalyticsFilter: f, PreviousBegin: f.StartDate.AddDate(0, 0, -days).Format("2006-01-02"), PreviousEnd: f.StartDate.AddDate(0, 0, -1).Format("2006-01-02")} + data, err := uc.repo.Collect(ctx, kind, AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, rangeFilter) + if err != nil { + return nil, err + } + out.Available = data.Available + if !data.Available { + return out, nil + } + for _, period := range []map[AnalyticsKey]*RankingMetrics{data.Current, data.Previous} { + for _, row := range period { + row.Counts = analyticsCap(row.Counts) + } + } + if kind == BusinessRanking { + data.Current, err = aggregateRankingBusinesses(data.Businesses, data.Current) + if err != nil { + return nil, err + } + data.Previous, err = aggregateRankingBusinesses(data.Businesses, data.Previous) + if err != nil { + return nil, err + } + } + for _, metric := range []struct { + name string + target *[]*RankingItem + }{{"register", &out.Register}, {"pay", &out.Recharge}, {"rate", &out.PayRate}, {"active", &out.Active}} { + rows, err := buildRankingItems(kind, metric.name, data.Current, data.Previous) + if err != nil { + return nil, err + } + *metric.target = rows + } + return out, nil +} +func aggregateRankingBusinesses(businesses []*RankingBusiness, channels map[AnalyticsKey]*RankingMetrics) (map[AnalyticsKey]*RankingMetrics, error) { + out := map[AnalyticsKey]*RankingMetrics{} + for _, target := range businesses { + if len(target.Channels) == 0 { + continue + } + row := &RankingMetrics{Name: fmt.Sprintf("%s (L%d)", target.Name, target.Level)} + pay := new(big.Rat) + for _, key := range target.Channels { + if c := channels[key]; c != nil { + row.Counts = analyticsAdd(row.Counts, c.Counts) + n, err := portraitNumber(c.Pay) + if err != nil { + return nil, err + } + pay.Add(pay, n) + } + } + // 商务人数沿用各渠道人数相加;使用 int64 避免原 Java int 溢出,不跨渠道重新去重。 + row.Pay = pay.RatString() + out[AnalyticsKey{ID: int64(target.ID)}] = row + } + return out, nil +} +func rankingValue(row *RankingMetrics, metric string) (*big.Rat, bool, error) { + if row == nil { + return new(big.Rat), false, nil + } + switch metric { + case "register": + return big.NewRat(row.Counts.Register, 1), row.Counts.Register > 0, nil + case "active": + return big.NewRat(row.Counts.Active, 1), row.Counts.Active > 0, nil + case "rate": + value := analyticsRatio(big.NewRat(row.Counts.Pay, 1), row.Counts.Active, true) + n, _ := portraitNumber(value) + return n, row.Counts.Active > 0, nil + default: + n, err := portraitNumber(row.Pay) + return n, err == nil && n.Sign() > 0, err + } +} +func buildRankingItems(kind RankingKind, metric string, current, previous map[AnalyticsKey]*RankingMetrics) ([]*RankingItem, error) { + rows := []*RankingItem{} + values := map[*RankingItem]*big.Rat{} + for key, row := range current { + value, keep, err := rankingValue(row, metric) + if err != nil { + return nil, err + } + if !keep { + continue + } + item := &RankingItem{AnalyticsKey: key, ObjectID: strconv.FormatInt(key.ID, 10), Name: row.Name} + if kind == GameRanking { + item.ObjectID = key.SourceDB + "|" + item.ObjectID + if strings.TrimSpace(item.Name) == "" { + item.Name = "游戏" + strconv.FormatInt(key.ID, 10) + } + } + if kind == ChannelRanking { + item.Name = (&LedgerNames{PromoteID: &key.ID, PromoteAccount: row.Name}).DisplayPromote() + } + digits := 0 + if metric == "pay" || metric == "rate" { + digits = 2 + } + item.Value = portraitDecimal(value, digits) + values[item], _ = portraitNumber(item.Value) + prev := previous[key] + if prev != nil { + prior, valid, err := rankingValue(prev, metric) + if err != nil { + return nil, err + } + if metric == "rate" || valid { + change := new(big.Rat).Sub(value, prior) + if metric != "rate" { + change.Mul(change, big.NewRat(100, 1)) + change.Quo(change, prior) + } + item.MoM = stringPointer(portraitDecimal(change, 2)) + rounded, _ := portraitNumber(*item.MoM) + status := 0 + if new(big.Rat).Abs(rounded).Cmp(big.NewRat(1, 100)) >= 0 { + status = rounded.Sign() + } + item.MoMStatus = &status + } + } + rows = append(rows, item) + } + sort.SliceStable(rows, func(i, j int) bool { + a, b := rows[i], rows[j] + if c := values[a].Cmp(values[b]); c != 0 { + return c > 0 + } + if a.SourceDB != b.SourceDB { + return a.SourceDB < b.SourceDB + } + return a.ID < b.ID + }) + rows = rows[:min(20, len(rows))] + for i, row := range rows { + row.Rank = i + 1 + } + return rows, nil +} diff --git a/internal/biz/business/ranking_test.go b/internal/biz/business/ranking_test.go new file mode 100644 index 0000000..4a40765 --- /dev/null +++ b/internal/biz/business/ranking_test.go @@ -0,0 +1,76 @@ +package business + +import ( + "context" + "testing" + "time" +) + +type rankingRepoFake struct { + data *RankingData + filter RankingRange + calls int +} + +func (r *rankingRepoFake) Collect(_ context.Context, _ RankingKind, _ AnalyticsScope, f RankingRange) (*RankingData, error) { + r.filter = f + r.calls++ + out := &RankingData{Available: r.data.Available, Businesses: r.data.Businesses, Current: map[AnalyticsKey]*RankingMetrics{}, Previous: map[AnalyticsKey]*RankingMetrics{}} + for k, v := range r.data.Current { + c := *v + out.Current[k] = &c + } + for k, v := range r.data.Previous { + c := *v + out.Previous[k] = &c + } + return out, nil +} +func TestRankingComparisonsAndBusinessCountSemantics(t *testing.T) { + k := AnalyticsKey{SourceDB: "lx", ID: 1} + zero := AnalyticsKey{SourceDB: "lx", ID: 2} + r := &rankingRepoFake{data: &RankingData{Available: true, Current: map[AnalyticsKey]*RankingMetrics{k: {Counts: PortraitCounts{Register: 3, Active: 4, Pay: 2}, Pay: "20.005"}, zero: {Counts: PortraitCounts{Active: 1}}}, Previous: map[AnalyticsKey]*RankingMetrics{k: {Counts: PortraitCounts{Register: 2, Active: 2, Pay: 1}, Pay: "10"}}}} + a := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Level: 1, IDPath: "0"}, 9: {ID: 9, Level: 3, IDPath: "0,7"}}} + uc := NewRankingUsecase(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) } + begin, end := time.Date(2026, 10, 29, 0, 0, 0, 0, portraitLocation), time.Date(2026, 11, 4, 0, 0, 0, 0, portraitLocation) + p, err := uc.List(accountContext(7, 901), GameRanking, AnalyticsFilter{StartDate: &begin, EndDate: &end}) + if err != nil || len(p.Register) != 1 || p.Register[0].ObjectID != "lx|1" || p.Register[0].Name != "游戏1" || *p.Register[0].MoM != "50.00" || p.Recharge[0].Value != "20.01" || *p.Recharge[0].MoM != "100.05" || len(p.PayRate) != 2 || p.PayRate[1].Value != "0.00" || p.PayRate[1].MoM != nil { + t.Fatalf("排行筛选、命名或环比错误: %+v %v", p, err) + } + if *p.PayRate[0].MoM != "0.00" || *p.PayRate[0].MoMStatus != 0 || r.filter.PreviousBegin != "2026-10-22" || r.filter.PreviousEnd != "2026-10-28" { + t.Fatal("等长上期或百分点差错误") + } + r.data.Businesses = []*RankingBusiness{{ID: 8, Name: "二级商务", Level: 2, Channels: []AnalyticsKey{k, zero}}} + p, err = uc.List(accountContext(7, 901), BusinessRanking, AnalyticsFilter{}) + if err != nil || p.Active[0].Value != "5" || p.PayRate[0].Value != "40.00" || p.Register[0].Name != "二级商务 (L2)" || p.Register[0].SourceDB != "" { + t.Fatal("商务需加总各渠道人数,不全局去重") + } + calls := r.calls + p, err = uc.List(accountContext(9, 903), BusinessRanking, AnalyticsFilter{}) + if err != nil || len(p.Register) != 0 || r.calls != calls { + t.Fatal("三级商务不得展开商务榜查询") + } + r.data.Available = false + p, err = uc.List(accountContext(7, 901), ChannelRanking, AnalyticsFilter{}) + if err != nil || p.Available || len(p.Active) != 0 { + t.Fatal("缺数据不能产生伪造排行") + } +} +func TestRankingTopTwentyAndStatusThreshold(t *testing.T) { + current, previous := map[AnalyticsKey]*RankingMetrics{}, map[AnalyticsKey]*RankingMetrics{} + for i := int64(1); i <= 25; i++ { + k := AnalyticsKey{SourceDB: "lx", ID: i} + current[k] = &RankingMetrics{Counts: PortraitCounts{Active: i}, Pay: "9007199254740992.99"} + previous[k] = &RankingMetrics{Counts: PortraitCounts{Active: i}} + } + current[AnalyticsKey{SourceDB: "lx", ID: 25}].Pay = "9007199254740993.00" + rows, err := buildRankingItems(ChannelRanking, "pay", current, previous) + if err != nil || len(rows) != 20 || rows[0].ID != 25 || rows[19].Rank != 20 { + t.Fatal("TOP20 或精确金额排序错误") + } + rows, err = buildRankingItems(ChannelRanking, "active", current, previous) + if err != nil || *rows[0].MoMStatus != 0 || *rows[0].MoM != "0.00" { + t.Fatal("持平环比错误") + } +} diff --git a/internal/data/business/ranking.go b/internal/data/business/ranking.go new file mode 100644 index 0000000..da58566 --- /dev/null +++ b/internal/data/business/ranking.go @@ -0,0 +1,201 @@ +package business + +import ( + "context" + "gorm.io/gorm" + "kra/internal/biz/business" +) + +type rankingRepo struct{ data Provider } + +func NewRankingRepo(data Provider) business.RankingRepo { return &rankingRepo{data: data} } + +// 通过一次关系查询展开各上榜商务的下级绑定,避免旧服务对每个商务查询下级。 +func rankingBusinessBindings(db *gorm.DB, scope business.AnalyticsScope, sources []string) *gorm.DB { + q := db.Table("sys_users target").Where("target.deleted_at IS NULL") + if scope.Viewer.All { + q = q.Where("target.level IN (1,2,3)") + } else { + q = q.Where("target.parent_id=?", scope.Viewer.SelfID) + } + prefix := "(COALESCE(NULLIF(target.id_path,''),'0') || ',' || target.id)" + if db.Dialector.Name() == "mysql" { + prefix = "CONCAT(COALESCE(NULLIF(target.id_path,''),'0'),',',target.id)" + } + like := prefix + " || ',%'" + if db.Dialector.Name() == "mysql" { + like = "CONCAT(" + prefix + ",',%')" + } + q = q.Joins("JOIN sys_users child ON child.deleted_at IS NULL AND (child.id=target.id OR (target.level<3 AND (child.id_path=" + prefix + " OR child.id_path LIKE (" + like + "))))").Joins("JOIN sys_user_bind_platform b ON b.user_id=child.id AND b.status='0'") + if len(sources) > 0 { + q = q.Where("b.source_db IN ?", sources) + } + return q +} +func rankingBusinessPromotes(db *gorm.DB, scope business.AnalyticsScope, sources []string) *gorm.DB { + q := rankingBusinessBindings(db, scope, sources).Joins("JOIN tab_promote p ON p.source_db=b.source_db AND p.busier_id=b.platform_busier_id") + if !scope.IncludeSub { + q = q.Where("p.promote_level=1") + } + return q +} +func rankingReportQuery(db *gorm.DB, kind business.RankingKind, scope business.AnalyticsScope, f business.AnalyticsFilter, table string, mode int) *gorm.DB { + if kind != business.BusinessRanking { + return analyticsQuery(db, scope, f, table, mode) + } + all := business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true} + return analyticsQuery(db, all, f, table, mode).Where("EXISTS (?)", rankingBusinessPromotes(db, scope, f.Sources).Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id")) +} +func (r *rankingRepo) Collect(ctx context.Context, kind business.RankingKind, scope business.AnalyticsScope, f business.RankingRange) (*business.RankingData, error) { + db := r.data.DB().WithContext(ctx) + out := &business.RankingData{Current: map[business.AnalyticsKey]*business.RankingMetrics{}, Previous: map[business.AnalyticsKey]*business.RankingMetrics{}, Available: true} + if kind == business.BusinessRanking { + var found []struct{ ID uint } + if err := rankingBusinessBindings(db, scope, f.Sources).Select("target.id").Limit(1).Scan(&found).Error; err != nil { + return nil, business.ErrStorage + } + if len(found) == 0 { + return out, nil + } + available, err := businessTableAvailable(db, "tab_promote") + if err != nil { + return nil, err + } + if !available { + out.Available = false + return out, nil + } + var pairs []struct { + UserID uint + Username string + Level int + SourceDB string `gorm:"column:source_db"` + PromoteID int64 + } + if err := rankingBusinessPromotes(db, scope, f.Sources).Select("DISTINCT target.id AS user_id,target.username,target.level,p.source_db,p.id AS promote_id").Scan(&pairs).Error; err != nil { + return nil, business.ErrStorage + } + businesses := map[uint]*business.RankingBusiness{} + for _, po := range pairs { + if businesses[po.UserID] == nil { + businesses[po.UserID] = &business.RankingBusiness{ID: po.UserID, Name: po.Username, Level: po.Level} + } + target := businesses[po.UserID] + target.Channels = append(target.Channels, business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.PromoteID}) + } + for _, target := range businesses { + out.Businesses = append(out.Businesses, target) + } + if len(out.Businesses) == 0 { + return out, nil + } + } else { + empty, available, err := prepareAnalytics(db, scope, f.AnalyticsFilter) + if err != nil { + return nil, err + } + out.Available = available + if empty || !available { + return out, 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 { + out.Available = false + return out, nil + } + } + for _, period := range []struct { + begin, end string + target map[business.AnalyticsKey]*business.RankingMetrics + }{{f.Begin, f.End, out.Current}, {f.PreviousBegin, f.PreviousEnd, out.Previous}} { + query := f.AnalyticsFilter + query.Begin, query.End = period.begin, period.end + rowFor := func(po analyticsAggPO) *business.RankingMetrics { + key := business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID} + if period.target[key] == nil { + period.target[key] = &business.RankingMetrics{} + } + return period.target[key] + } + for _, pay := range []bool{true, false} { + table, mode := "tab_sw_report_day", 0 + if !pay { + table = "tab_sw_report_day_key" + if scope.IDCardDedup { + mode = 1 + } + } + q := rankingReportQuery(db, kind, scope, query, table, mode) + id := "r.promote_id" + if kind == business.GameRanking { + id = "r.game_id" + } + if kind == business.ChannelRanking { + 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)" + } + selects := "r.source_db," + id + " AS entity_id" + group := "r.source_db," + id + if pay { + selects += ",COALESCE(SUM(r.total_pay),0) AS amount" + } else { + selects += ",r.metric,COUNT(DISTINCT r.identity_key) AS count" + group += ",r.metric" + } + var rows []analyticsAggPO + if err := q.Select(selects).Group(group).Scan(&rows).Error; err != nil { + return nil, business.ErrStorage + } + for _, po := range rows { + if po.ID == nil || (pay && *po.ID < 0) { + continue + } + row := rowFor(po) + if pay { + row.Pay = po.Amount + } else { + putAnalyticsCount(&row.Counts, po.Metric, po.Count) + } + } + } + } + if kind != business.BusinessRanking && len(out.Current) > 0 { + table, column := "tab_promote", "account" + if kind == business.GameRanking { + table, column = "tab_game", "game_name" + } + available, err := businessTableAvailable(db, table) + if err != nil { + return nil, err + } + if !available { + out.Available = false + return out, nil + } + keys := make([]business.AnalyticsKey, 0, len(out.Current)) + for key := range out.Current { + keys = append(keys, key) + } + for start := 0; start < len(keys); start += 300 { + var rows []struct { + ID int64 + SourceDB string `gorm:"column:source_db"` + Name string + } + if err := db.Table(table).Select("id,source_db,"+column+" AS name").Where("(source_db,id) IN ?", analyticsPairs(keys[start:min(start+300, len(keys))])).Scan(&rows).Error; err != nil { + return nil, business.ErrStorage + } + for _, po := range rows { + if row := out.Current[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}]; row != nil { + row.Name = po.Name + } + } + } + } + return out, nil +} diff --git a/internal/data/business/ranking_test.go b/internal/data/business/ranking_test.go new file mode 100644 index 0000000..8358460 --- /dev/null +++ b/internal/data/business/ranking_test.go @@ -0,0 +1,71 @@ +package business + +import ( + "context" + "kra/internal/biz/business" + "testing" +) + +func TestRankingStorageDifferentRootAndBusinessScopes(t *testing.T) { + db, _, scope, filter := prepareAnalyticsData(t) + r := NewRankingRepo(testData{db}) + ctx := context.Background() + f := business.RankingRange{AnalyticsFilter: filter, PreviousBegin: "2026-10-01", PreviousEnd: "2026-10-03"} + data, err := r.Collect(ctx, business.GameRanking, scope, f) + if err != nil || len(data.Current) != 3 || data.Current[business.AnalyticsKey{SourceDB: "lx", ID: 1}].Name != "游戏甲" { + t.Fatalf("游戏排行或当前名称错误: %+v %v", data, err) + } + scope.Viewer = business.UserScope{SelfID: 7, IncludeSelf: true, DescendantPath: "0,7"} + data, err = r.Collect(ctx, business.BusinessRanking, scope, f) + if err != nil { + t.Fatal(err) + } + var child *business.RankingBusiness + for _, target := range data.Businesses { + if target.ID == 8 { + child = target + } + if target.ID != 8 && target.ID != 10 { + t.Fatal("一级只能排行直属下级") + } + } + if child == nil || len(child.Channels) != 5 { + t.Fatalf("二级本人的 hw 和下属三级的 lx 渠道应合并: %+v", child) + } + if _, ok := data.Current[business.AnalyticsKey{SourceDB: "lx", ID: 0}]; ok { + t.Fatal("商务榜渠道映射不应包含官方流水") + } + scope.Viewer = business.UserScope{SelfID: 8, IncludeSelf: true, DescendantPath: "0,7,8"} + data, err = r.Collect(ctx, business.BusinessRanking, scope, f) + if err != nil || len(data.Businesses) != 1 || data.Businesses[0].ID != 9 || len(data.Businesses[0].Channels) != 4 { + t.Fatal("二级商务榜只能展示直属三级及其渠道") + } + scope.Viewer = business.UserScope{All: true} + data, err = r.Collect(ctx, business.BusinessRanking, scope, f) + if err != nil { + t.Fatal(err) + } + seen := map[uint]bool{} + for _, b := range data.Businesses { + seen[b.ID] = true + } + if !seen[7] || !seen[8] || !seen[9] || seen[1] || seen[99] { + t.Fatal("超管应展示所选平台有渠道的各级商务,不能包含普通框架管理员") + } + if err := db.Exec("UPDATE tab_promote SET top_promote_id=777 WHERE source_db='lx' AND id=100").Error; err != nil { + t.Fatal(err) + } + scope = business.AnalyticsScope{Viewer: channelTestScope(), IncludeSub: false, IDCardDedup: true} + data, err = r.Collect(ctx, business.ChannelRanking, scope, f) + root := data.Current[business.AnalyticsKey{SourceDB: "lx", ID: 777}] + if err != nil || root == nil || portraitTestMoney(root.Pay) != "10.10" || root.Counts.Register != 1 { + t.Fatalf("独立渠道榜使用 COALESCE(top,id),不应误用工作台 CASE 根算法: %+v %v", root, err) + } + if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil { + t.Fatal(err) + } + data, err = r.Collect(ctx, business.ChannelRanking, scope, f) + if err != nil || data.Available { + t.Fatal("缺表应明确未接入") + } +} diff --git a/internal/data/data.go b/internal/data/data.go index ef36edf..7316bfe 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -45,6 +45,7 @@ var ProviderSet = wire.NewSet( databusiness.NewAnalyticsRepo, databusiness.NewServerAnalysisRepo, databusiness.NewWorkbenchRepo, + databusiness.NewRankingRepo, 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 da6cf10..7348f17 100644 --- a/internal/modules/business/definition.go +++ b/internal/modules/business/definition.go @@ -9,6 +9,7 @@ import ( func Definition() module.Definition { return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{ Menus: []module.Menu{ + {Name: "sw_RankingAnalysis", Path: "RankingAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/RankingAnalysis.vue", Title: "排行榜", Icon: "star", Sort: 6}, {Name: "sw_ServerAnalysis", Path: "ServerAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/ServerAnalysis.vue", Title: "区服分析", Icon: "data-analysis", Sort: 5}, {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}, diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 9d5c7ff..19ce0e3 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 != 17 || apis != 66 { - t.Fatalf("admin surface = %d menus/%d APIs, want 17/66", menus, apis) + if menus != 18 || apis != 69 { + t.Fatalf("admin surface = %d menus/%d APIs, want 18/69", 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 33f672e..6e39cc7 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -41,6 +41,9 @@ 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/rank/channel": {group: "排行榜", description: "查询渠道四项排行与环比", audit: true}, + "GET /sw/rank/business": {group: "排行榜", description: "查询商务四项排行与环比", audit: true}, + "GET /sw/rank/game": {group: "排行榜", description: "查询游戏四项排行与环比", audit: true}, "GET /sw/workbench/summary": {group: "工作台", description: "查询商务六项指标与同期对照"}, "GET /sw/workbench/registerTop": {group: "工作台", description: "查询渠道注册排行"}, "GET /sw/workbench/payRank": {group: "工作台", description: "查询渠道充值最高与最低排行"}, diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index 7a56a47..de7f7ff 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -29,6 +29,7 @@ func emptyHandlers() *handler.Set { Analytics: &handler.Analytics{}, ServerAnalysis: &handler.ServerAnalysis{}, Workbench: &handler.Workbench{}, + Ranking: &handler.Ranking{}, Ledger: &handler.Ledger{}, ChannelPortrait: &handler.ChannelPortrait{}, BusinessPortrait: &handler.BusinessPortrait{}, @@ -109,7 +110,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":249`) { + if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":252`) { t.Fatalf("startup route summary is missing: %s", text) } } diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go index 57c614f..43a5add 100644 --- a/internal/server/handler/provider.go +++ b/internal/server/handler/provider.go @@ -18,6 +18,7 @@ var ProviderSet = wire.NewSet( NewAnalytics, NewServerAnalysis, NewWorkbench, + NewRanking, NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization, NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit, NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken, diff --git a/internal/server/handler/ranking.go b/internal/server/handler/ranking.go new file mode 100644 index 0000000..a5484be --- /dev/null +++ b/internal/server/handler/ranking.go @@ -0,0 +1,29 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "github.com/go-kratos/kratos/v3/errors" + businessbiz "kra/internal/biz/business" + "kra/internal/service/business" + "kra/internal/service/dto" +) + +type Ranking struct{ service *business.RankingService } + +func NewRanking(service *business.RankingService) *Ranking { return &Ranking{service: service} } +func (h *Ranking) list(c *gin.Context, kind businessbiz.RankingKind) { + var req dto.RankingQuery + if c.ShouldBindQuery(&req) != nil { + Fail(c, "排行榜查询参数不正确") + return + } + data, err := h.service.List(c.Request.Context(), kind, &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, data) +} +func (h *Ranking) Channel(c *gin.Context) { h.list(c, businessbiz.ChannelRanking) } +func (h *Ranking) Business(c *gin.Context) { h.list(c, businessbiz.BusinessRanking) } +func (h *Ranking) Game(c *gin.Context) { h.list(c, businessbiz.GameRanking) } diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go index b1a0388..5ab5f44 100644 --- a/internal/server/handler/set.go +++ b/internal/server/handler/set.go @@ -1,6 +1,7 @@ package handler type Set struct { + Ranking *Ranking Workbench *Workbench ServerAnalysis *ServerAnalysis Analytics *Analytics diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go index 9f4fa46..76d6ee4 100644 --- a/internal/server/router/routes.go +++ b/internal/server/router/routes.go @@ -20,6 +20,11 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En return } registrations := []func(){ + func() { + private.GET("/sw/rank/channel", r.handlers.Ranking.Channel) + private.GET("/sw/rank/business", r.handlers.Ranking.Business) + private.GET("/sw/rank/game", r.handlers.Ranking.Game) + }, func() { private.GET("/sw/workbench/summary", r.handlers.Workbench.Summary) private.GET("/sw/workbench/registerTop", r.handlers.Workbench.RegisterTop) diff --git a/internal/service/business/ranking.go b/internal/service/business/ranking.go new file mode 100644 index 0000000..8cf4fb8 --- /dev/null +++ b/internal/service/business/ranking.go @@ -0,0 +1,40 @@ +package business + +import ( + "context" + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +type RankingUsecase interface { + List(context.Context, business.RankingKind, business.AnalyticsFilter) (*business.RankingPage, error) +} +type RankingService struct{ uc RankingUsecase } + +func NewRankingService(uc *business.RankingUsecase) *RankingService { return &RankingService{uc: uc} } +func rankingItems(rows []*business.RankingItem, kind business.RankingKind) []*dto.RankingItem { + out := make([]*dto.RankingItem, 0, len(rows)) + for _, row := range rows { + item := &dto.RankingItem{Rank: row.Rank, ObjectID: row.ObjectID, ObjectName: row.Name, MetricValue: row.Value, MoM: row.MoM, MoMStatus: row.MoMStatus} + if kind != business.BusinessRanking { + source, label := row.SourceDB, business.PlatformName(row.SourceDB) + item.SourceDB, item.SourceDBLabel = &source, &label + } + out = append(out, item) + } + return out +} +func (s *RankingService) List(ctx context.Context, kind business.RankingKind, req *dto.RankingQuery) (*dto.RankingPage, error) { + if req == nil { + return nil, business.ErrAnalyticsInput + } + f, err := analyticsRequest(&dto.AnalyticsQuery{Sources: req.Sources, BeginTime: req.BeginTime, EndTime: req.EndTime}) + if err != nil { + return nil, err + } + page, err := s.uc.List(ctx, kind, f) + if err != nil { + return nil, err + } + return &dto.RankingPage{Register: rankingItems(page.Register, kind), Recharge: rankingItems(page.Recharge, kind), PayRate: rankingItems(page.PayRate, kind), Active: rankingItems(page.Active, kind), Begin: page.Begin, End: page.End, Available: page.Available}, nil +} diff --git a/internal/service/dto/ranking.go b/internal/service/dto/ranking.go new file mode 100644 index 0000000..fb8a895 --- /dev/null +++ b/internal/service/dto/ranking.go @@ -0,0 +1,27 @@ +package dto + +type RankingQuery struct { + Sources []string `form:"sourceDb"` + BeginTime string `form:"beginTime"` + EndTime string `form:"endTime"` + GameID *int64 `form:"gameId"` +} +type RankingItem struct { + Rank int `json:"rank"` + ObjectID string `json:"objectId"` + ObjectName string `json:"objectName"` + SourceDB *string `json:"sourceDb"` + SourceDBLabel *string `json:"sourceDbLabel"` + MetricValue string `json:"metricValue"` + MoM *string `json:"momValue"` + MoMStatus *int `json:"momStatus"` +} +type RankingPage struct { + Register []*RankingItem `json:"registerRankList"` + Recharge []*RankingItem `json:"rechargeRankList"` + PayRate []*RankingItem `json:"payRateRankList"` + Active []*RankingItem `json:"activeRankList"` + Begin string `json:"beginTime"` + End string `json:"endTime"` + Available bool `json:"available"` +} diff --git a/internal/service/service.go b/internal/service/service.go index 3db23ac..7d9ce92 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -27,6 +27,7 @@ var ProviderSet = wire.NewSet( businessservice.NewAnalyticsService, businessservice.NewServerAnalysisService, businessservice.NewWorkbenchService, + businessservice.NewRankingService, systemservice.ProviderSet, paymentservice.ProviderSet, integrationservice.ProviderSet, diff --git a/scripts/sw_ranking_http_smoke.py b/scripts/sw_ranking_http_smoke.py new file mode 100644 index 0000000..c78bda2 --- /dev/null +++ b/scripts/sw_ranking_http_smoke.py @@ -0,0 +1,36 @@ +"""真实会话只读验证独立排行榜接口,不创建业务数据。""" +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 ['channel', 'business', 'game']: + for query, expected in [('sourceDb=lx,hw,yq', 0), ('beginTime=bad&endTime=2026-11-04', 7), ('beginTime=2026-11-05&endTime=2026-11-04', 7)]: + path = '/sw/rank/' + kind + '?' + 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) + item = {'path': path, 'http': response.status, 'code': payload.get('code'), 'msg': payload.get('msg'), 'passed': payload.get('code') == expected} + if expected == 0: + data = payload.get('data', {}) + item['available'] = data.get('available') + item['passed'] &= data.get('available') is False and all(data.get(key) == [] for key in ['registerRankList', 'rechargeRankList', 'payRateRankList', 'activeRankList']) + 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/web/src/api/ranking.js b/web/src/api/ranking.js new file mode 100644 index 0000000..c60d1f8 --- /dev/null +++ b/web/src/api/ranking.js @@ -0,0 +1,3 @@ +import service from '@/utils/request' + +export const listRankings = (dimension, params) => service({ url: `/sw/rank/${dimension}`, method: 'get', params, timeout: 30000, donNotShowLoading: true }) diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index c6743ad..9911dc5 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -59,6 +59,7 @@ "/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics", "/src/view/sw/ChannelList.vue": "SwChannelList", "/src/view/sw/GameAnalytics.vue": "SwGameAnalytics", + "/src/view/sw/RankingAnalysis.vue": "RankingAnalysis", "/src/view/sw/RechargeDetails.vue": "SwRechargeDetails", "/src/view/sw/RegistrationDetails.vue": "SwRegistrationDetails", "/src/view/sw/ServerAnalysis.vue": "ServerAnalysis", diff --git a/web/src/view/sw/RankingAnalysis.vue b/web/src/view/sw/RankingAnalysis.vue new file mode 100644 index 0000000..532acf1 --- /dev/null +++ b/web/src/view/sw/RankingAnalysis.vue @@ -0,0 +1,78 @@ + + + + + + {{ label }}排行 + {{ item.label }} + + + 统计周期:{{ dates.join(' 至 ') }} + + + + + + {{ panel.title }} + + {{ row.sourceDbLabel || '—' }} + {{ row.placeholder ? '—' : row.rank }} + {{ row.objectName || '—' }} + {{ metric(panel, row.metricValue) }} + {{ change(panel, row) }} + + + + + + + + +