From 0bef29ae4fdfd02b7f160534156ef7f0e2cc36f7 Mon Sep 17 00:00:00 2001
From: Yvan <8574526@qq,com>
Date: Wed, 4 Nov 2026 01:16:23 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E5=8C=BA=E6=9C=8D?=
=?UTF-8?q?=E5=88=86=E6=9E=90=E4=B8=8E=E6=B8=B8=E6=88=8F=E5=8C=BA=E6=9C=8D?=
=?UTF-8?q?=E6=9F=A5=E8=AF=A2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
cmd/server_analysis_http_test.go | 148 +++++++++++++
cmd/wire_gen.go | 7 +-
docs/sw6-to-kra/implementation.md | 6 +-
docs/sw6-to-kra/page-parity.md | 6 +
.../server-analysis-http-2026-11-04.json | 70 +++++++
.../server-analysis-source-notes.md | 16 ++
internal/biz/biz.go | 2 +-
internal/biz/business/server_analysis.go | 198 ++++++++++++++++++
internal/biz/business/server_analysis_test.go | 69 ++++++
internal/data/business/server_analysis.go | 174 +++++++++++++++
.../data/business/server_analysis_test.go | 87 ++++++++
internal/data/data.go | 1 +
internal/modules/business/definition.go | 1 +
internal/modules/catalog_test.go | 4 +-
internal/routecatalog/catalog.go | 2 +
internal/server/gin_test.go | 3 +-
internal/server/handler/provider.go | 1 +
internal/server/handler/server_analysis.go | 42 ++++
internal/server/handler/set.go | 1 +
internal/server/router/routes.go | 4 +
internal/service/business/server_analysis.go | 63 ++++++
internal/service/dto/server_analysis.go | 44 ++++
internal/service/service.go | 1 +
scripts/sw_server_analysis_http_smoke.py | 44 ++++
web/src/api/serverAnalysis.js | 4 +
web/src/pathInfo.json | 1 +
web/src/view/sw/ServerAnalysis.vue | 120 +++++++++++
27 files changed, 1113 insertions(+), 6 deletions(-)
create mode 100644 cmd/server_analysis_http_test.go
create mode 100644 docs/sw6-to-kra/server-analysis-http-2026-11-04.json
create mode 100644 docs/sw6-to-kra/server-analysis-source-notes.md
create mode 100644 internal/biz/business/server_analysis.go
create mode 100644 internal/biz/business/server_analysis_test.go
create mode 100644 internal/data/business/server_analysis.go
create mode 100644 internal/data/business/server_analysis_test.go
create mode 100644 internal/server/handler/server_analysis.go
create mode 100644 internal/service/business/server_analysis.go
create mode 100644 internal/service/dto/server_analysis.go
create mode 100644 scripts/sw_server_analysis_http_smoke.py
create mode 100644 web/src/api/serverAnalysis.js
create mode 100644 web/src/view/sw/ServerAnalysis.vue
diff --git a/cmd/server_analysis_http_test.go b/cmd/server_analysis_http_test.go
new file mode 100644
index 0000000..f8fd53b
--- /dev/null
+++ b/cmd/server_analysis_http_test.go
@@ -0,0 +1,148 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "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 TestServerAnalysisHTTP(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "servers.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,status) VALUES(9,'lx',14,'0'),(99,'hw',20,'0')",
+ "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,promote_level INTEGER,busier_id INTEGER)",
+ "INSERT INTO tab_promote VALUES(100,'lx',1,14),(101,'lx',2,14),(100,'hw',1,20)",
+ "CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
+ "INSERT INTO tab_game VALUES(1,'lx','游戏'),(1,'hw','其他游戏')",
+ "CREATE TABLE tab_game_server(id INTEGER,source_db TEXT,game_id INTEGER,server_num TEXT,server_name TEXT,start_time INTEGER)",
+ "INSERT INTO tab_game_server VALUES(1,'lx',1,'001','一区',100),(2,'lx',1,'002','二区',200),(3,'hw',1,'001','外部一区',300)",
+ "CREATE TABLE tab_sw_report_server_day(source_db TEXT,game_id INTEGER,game_name TEXT,promote_id INTEGER,server_num TEXT,stat_date DATE,dedup_mode INTEGER,total_pay DECIMAL(20,2))",
+ "INSERT INTO tab_sw_report_server_day VALUES('lx',1,'游戏',100,'001','2026-11-04',0,10),('lx',1,'游戏',101,'001','2026-11-04',0,20),('lx',1,'游戏',100,'002','2026-11-04',0,40),('hw',1,'游戏',100,'001','2026-11-04',0,50),('lx',1,'游戏',100,'001','2026-11-04',1,9999)",
+ "CREATE TABLE tab_sw_report_server_day_key(source_db TEXT,game_id INTEGER,game_name TEXT,promote_id INTEGER,server_num TEXT,stat_date DATE,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)",
+ } {
+ if err := db.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, s := range []struct {
+ source, num string
+ promote int
+ }{{"lx", "001", 100}, {"lx", "001", 101}, {"lx", "002", 100}, {"hw", "001", 100}} {
+ for _, metric := range []int{1, 2, 3} {
+ for _, mode := range []int{0, 1} {
+ identity := "A"
+ if mode == 0 {
+ identity = strconv.Itoa(s.promote)
+ }
+ if err := db.Exec("INSERT INTO tab_sw_report_server_day_key VALUES(?,1,'游戏',?,?,'2026-11-04',?,?,?)", s.source, s.promote, s.num, mode, metric, identity).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ }
+ provider := businessHTTPData{db}
+ uc := businessbiz.NewServerAnalysisUsecase(businessdata.NewServerAnalysisRepo(provider), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)))
+ h := handler.NewServerAnalysis(businessservice.NewServerAnalysisService(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/serverAnalysis/list", h.List)
+ engine.GET("/sw/serverAnalysis/servers", h.Servers)
+ srv := httptest.NewServer(engine)
+ defer srv.Close()
+ call := func(user int, path, query string, code int) any {
+ t.Helper()
+ req, _ := http.NewRequest("GET", srv.URL+"/sw/serverAnalysis/"+path+"?"+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("接口响应错误: %+v", body)
+ }
+ return body["data"]
+ }
+ q := "gameName=" + url.QueryEscape("游戏") + "&sourceDb=lx,hw&beginTime=2026-11-04&endTime=2026-11-04"
+ p := call(7, "list", q+"&pageSize=1", 0).(map[string]any)
+ sum := p["summary"].(map[string]any)
+ if p["total"] != float64(2) || len(p["rows"].([]any)) != 1 || sum["totalPay"] != "70.00" || sum["activeCount"] != float64(2) || sum["arpu"] != "35.00" {
+ t.Fatalf("分页或全量汇总错误: %+v", p)
+ }
+ p = call(7, "list", q+"&serverNum=001", 0).(map[string]any)
+ row := p["rows"].([]any)[0].(map[string]any)
+ if row["serverNum"] != "001" || row["serverName"] != "一区" || row["openTime"] != float64(100) || row["newRegisterCount"] != float64(1) || row["totalPay"] != "30.00" {
+ t.Fatalf("区服身份、去重或金额错误: %+v", row)
+ }
+ p = call(1, "list", q, 0).(map[string]any)
+ if p["total"] != float64(3) || p["summary"].(map[string]any)["totalPay"] != "120.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)
+ }
+ p = call(7, "list", q, 0).(map[string]any)
+ if p["summary"].(map[string]any)["activeCount"] != float64(3) || p["summary"].(map[string]any)["totalPay"] != "70.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)
+ }
+ p = call(7, "list", q, 0).(map[string]any)
+ if p["summary"].(map[string]any)["totalPay"] != "50.00" {
+ t.Fatal("下级开关未生效")
+ }
+ options := call(7, "servers", "gameName="+url.QueryEscape("游戏")+"&sourceDb=lx&keyword="+url.QueryEscape("一区"), 0).([]any)
+ if len(options) != 1 || options[0].(map[string]any)["serverNum"] != "001" {
+ t.Fatal("区服选项映射错误")
+ }
+ call(7, "list", "", 7)
+ call(7, "servers", "", 7)
+ call(7, "list", q+"&pageNum=bad", 7)
+ call(7, "list", "gameName=x&beginTime=bad&endTime=2026-11-04", 7)
+ call(7, "list", "gameName=x&beginTime=2026-11-05&endTime=2026-11-04", 7)
+ if err := db.Exec("DROP TABLE tab_sw_report_server_day_key").Error; err != nil {
+ t.Fatal(err)
+ }
+ p = call(7, "list", q, 0).(map[string]any)
+ if p["available"] != false || p["summary"] != nil {
+ t.Fatal("缺表不能返回伪造零")
+ }
+}
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index be8bc3e..06a1ae2 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()
- analyticsRepo := business.NewAnalyticsRepo(dataData)
+ serverAnalysisRepo := business.NewServerAnalysisRepo(dataData)
accountRepo := business.NewAccountRepo(dataData)
accountUsecase := business2.NewAccountUsecase(accountRepo)
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
+ serverAnalysisUsecase := business2.NewServerAnalysisUsecase(serverAnalysisRepo, accountUsecase, userPreferenceUsecase)
+ serverAnalysisService := business3.NewServerAnalysisService(serverAnalysisUsecase)
+ serverAnalysis := handler.NewServerAnalysis(serverAnalysisService)
+ analyticsRepo := business.NewAnalyticsRepo(dataData)
analyticsUsecase := business2.NewAnalyticsUsecase(analyticsRepo, accountUsecase, userPreferenceUsecase)
analyticsService := business3.NewAnalyticsService(analyticsUsecase)
analytics := handler.NewAnalytics(analyticsService)
@@ -235,6 +239,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
set := &handler.Set{
+ ServerAnalysis: serverAnalysis,
Analytics: analytics,
Ledger: ledger,
ChannelPortrait: channelPortrait,
diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md
index 4e7b4bc..ac95f54 100644
--- a/docs/sw6-to-kra/implementation.md
+++ b/docs/sw6-to-kra/implementation.md
@@ -13,6 +13,8 @@
用户补充:允许直接优化旧接口、SQL 和逻辑。优化时记录行为差异并验证业务结果;各批实现必须实际请求 HTTP 接口,不能只以单元测试和构建作为完成依据。
+2026-11-04 用户调整验证要求:后续不需要检查页面交互,只验证接口并继续迁移,包括工作台/仪表盘。后续保留旧页面源码核对、接口实测、数据断言和构建检查,不再执行 Chrome 交互验收。
+
用户再次指出页面偏差后,明确按旧前端实际页面核对入口,不能从后端接口推断 UI 功能。详见 `page-parity.md`。商务列表已移除错误增加的编辑入口,恢复“绑定列表”名称及仅三级显示规则,并对齐按审核状态显示行操作。
用户已有的 `configs/config.yaml` 修改与迁移 SQL 保留。未经最后阶段的数据库确认,不连接、复制或修改用户的旧数据库。
@@ -25,7 +27,7 @@
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 |
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 |
| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 功能已接入;包含渠道画像,完整业务数据和源平台联调仍待最终验收 |
-| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出、渠道/游戏统计已接入,区服、排行、仪表盘业务数据与任务待迁移 |
+| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出、渠道/游戏/区服统计已接入,排行、仪表盘业务数据与任务待迁移 |
| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 |
| 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 |
@@ -92,3 +94,5 @@
- 明细批次通过全量 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`。
+- 区服列表与区服选项接口、Kra 区服分析页面已接入。统计按平台+游戏+区服编号分组,人数区服内去重、金额固定模式 0;页尾汇总全部筛选行。游戏必选,选项按当前游戏名,报表按历史游戏名,区服编号保留前导零。
+- 区服批次通过全量 Go、隔离数据库真实 TCP HTTP、前端 ESLint/构建和真实 sw7 只读接口请求,结果见 `server-analysis-http-2026-11-04.json`。覆盖跨平台同号、全量汇总、零行处理、偏好切换、资料补全及缺表;按用户新要求未执行页面交互检查。最终旧库验收仍未开始。
diff --git a/docs/sw6-to-kra/page-parity.md b/docs/sw6-to-kra/page-parity.md
index e303f94..4133006 100644
--- a/docs/sw6-to-kra/page-parity.md
+++ b/docs/sw6-to-kra/page-parity.md
@@ -180,3 +180,9 @@
| 缺报表数据 | 明确显示未接入,清空过期汇总,指标不伪造零 |
验证:`analytics-http-2026-11-04.json`、`analytics-page-2026-11-04.json`;Chrome 使用受控业务数据,完整计算和权限使用隔离数据库的真实 TCP HTTP 验证。
+
+## 区服分析
+
+按旧 `ServerAnalysis.vue` 源码迁移平台多选、游戏多选、区服单选、近 30 天默认日期、首个游戏自动查询、列排序/分页及全量汇总;沿用 Kra 控件,没有新增导出入口。切换平台清空游戏/区服,切换游戏清空区服,过期异步请求不覆盖新选项。统计字段保持所属平台、区服编号/名称、开服日期、新增注册、活跃玩家、充值金额、付费玩家和 ARPU。
+
+接口通过真实 sw7 请求及隔离数据库 TCP HTTP 验证,前端 ESLint/生产构建通过。按用户 2026-11-04 新要求,此后只检查接口,不再检查页面交互。详见 `server-analysis-source-notes.md`。
diff --git a/docs/sw6-to-kra/server-analysis-http-2026-11-04.json b/docs/sw6-to-kra/server-analysis-http-2026-11-04.json
new file mode 100644
index 0000000..73ede71
--- /dev/null
+++ b/docs/sw6-to-kra/server-analysis-http-2026-11-04.json
@@ -0,0 +1,70 @@
+{
+ "scope": "真实 sw7 会话只读检查,当前缺少业务报表和游戏区服表;未创建实际业务数据。",
+ "checks": [
+ {
+ "endpoint": "list",
+ "query": {},
+ "http": 200,
+ "code": 7,
+ "msg": "请选择游戏",
+ "passed": true
+ },
+ {
+ "endpoint": "servers",
+ "query": {},
+ "http": 200,
+ "code": 7,
+ "msg": "请选择游戏",
+ "passed": true
+ },
+ {
+ "endpoint": "list",
+ "query": {
+ "gameName": "隔离核验不存在游戏",
+ "sourceDb": "lx,hw,yq"
+ },
+ "http": 200,
+ "code": 0,
+ "msg": "成功",
+ "passed": true,
+ "available": false,
+ "total": 0
+ },
+ {
+ "endpoint": "list",
+ "query": {
+ "gameName": "x",
+ "beginTime": "bad",
+ "endTime": "2026-11-04"
+ },
+ "http": 200,
+ "code": 7,
+ "msg": "统计周期格式错误,应为 yyyy-MM-dd",
+ "passed": true
+ },
+ {
+ "endpoint": "list",
+ "query": {
+ "gameName": "x",
+ "beginTime": "2026-11-05",
+ "endTime": "2026-11-04"
+ },
+ "http": 200,
+ "code": 7,
+ "msg": "统计开始日期不能晚于结束日期",
+ "passed": true
+ },
+ {
+ "endpoint": "servers",
+ "query": {
+ "gameName": "隔离核验不存在游戏",
+ "sourceDb": "lx"
+ },
+ "http": 200,
+ "code": 7,
+ "msg": "游戏或区服数据尚未接入",
+ "passed": true
+ }
+ ],
+ "passed": true
+}
diff --git a/docs/sw6-to-kra/server-analysis-source-notes.md b/docs/sw6-to-kra/server-analysis-source-notes.md
new file mode 100644
index 0000000..6ca46be
--- /dev/null
+++ b/docs/sw6-to-kra/server-analysis-source-notes.md
@@ -0,0 +1,16 @@
+# 区服分析迁移核对
+
+核对旧 main 的 `ServerAnalysisServiceImpl`、`SwReportServerDayQueryServiceImpl`、两个区服日汇总 Mapper,以及 dev/dev1.0 的 `ServerAnalysis.vue`。
+
+- `/sw/serverAnalysis/games` 复用已迁移游戏选项;新增 `/servers`、`/list`。两者均要求游戏名称;来源支持逗号分隔或重复参数。
+- 选项通过当前 `tab_game.game_name` 确定平台+游戏,匹配区服名称;保留区服编号的字符串及前导零。原接口不按商务绑定裁剪资料选项,统计查询独立按可见范围隔离。
+- 选项默认 50,超过 100 时允许 100000,沿用旧批量选项请求。使用 EXISTS 代替为每个游戏生成 OR,保持成对关联并避免重复行。
+- 统计读取 `tab_sw_report_server_day` 和身份键表,按平台+游戏+区服编号分组;空白编号不展示。不额外加日游戏报表的 `game_id > 0` 限制。
+- 人数在每个区服内 COUNT DISTINCT,金额固定模式 0;筛选历史报表游戏名。展示优先金额汇总的历史游戏名,无金额行时回退当前游戏名,保留旧行为。
+- 区服名称/开服日期按三元组批量补充;重复资料固定取最小 ID,提高原不确定顺序的稳定性。相同主排序后保留区服编号顺序,再按平台、游戏 ID 稳定排列。
+- 未指定区服时忽略新增/活跃/付费人数和四舍五入金额均为零的行;指定区服保留报表已有零行,不凭资料表制造新行。
+- 汇总是全部筛选后展示行之和,不随分页变化;ARPU 的分母是活跃人数。逐行人数保留 Java int 上限,summary 改用 int64 避免原累加溢出;金额与比率精确处理。
+- 缺任一必要业务表时明确未接入,summary 为 null;不能混用不存在区服和缺表错误。未知开服时间保持 null。
+- 后端默认上海当天,前端默认近 30 天;非法日期/倒置周期明确拒绝。页长沿用最大 500。
+
+已通过领域/仓储测试、独立数据库真实 TCP HTTP、全量 Go、ESLint/生产构建与真实 sw7 接口只读请求。用户已明确不需要页面交互检查;没有执行旧数据库复制或最终同源比对。
diff --git a/internal/biz/biz.go b/internal/biz/biz.go
index 5dbfefb..9104c27 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)
+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)
diff --git a/internal/biz/business/server_analysis.go b/internal/biz/business/server_analysis.go
new file mode 100644
index 0000000..bca9b17
--- /dev/null
+++ b/internal/biz/business/server_analysis.go
@@ -0,0 +1,198 @@
+package business
+
+import (
+ "context"
+ "math/big"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/go-kratos/kratos/v3/errors"
+)
+
+var (
+ ErrServerAnalysisGame = errors.BadRequest("SW_SERVER_ANALYSIS_GAME", "请选择游戏")
+ ErrServerOptionsUnavailable = errors.BadRequest("SW_SERVER_OPTIONS_UNAVAILABLE", "游戏或区服数据尚未接入")
+)
+
+type ServerAnalysisKey struct {
+ SourceDB string
+ GameID int64
+ ServerNum string
+}
+type ServerAnalysisFilter struct {
+ AnalyticsFilter
+ ServerNum string
+}
+type ServerAnalysisRow struct {
+ ServerAnalysisKey
+ GameName, ServerName string
+ OpenTime *int64
+ Counts PortraitCounts
+ Pay, ARPU string
+}
+type ServerAnalysisData struct {
+ Rows []*ServerAnalysisRow
+ Available bool
+}
+type ServerAnalysisPage struct {
+ ServerAnalysisData
+ Total int64
+ Summary *ServerAnalysisRow
+ Begin, End string
+}
+type ServerOption struct {
+ Number, Name string
+ OpenTime *int64
+}
+type ServerAnalysisRepo interface {
+ Aggregate(context.Context, AnalyticsScope, ServerAnalysisFilter) (*ServerAnalysisData, error)
+ Servers(context.Context, []string, []string, string, int) ([]*ServerOption, error)
+}
+type ServerAnalysisUsecase struct {
+ repo ServerAnalysisRepo
+ accounts *AccountUsecase
+ preferences *UserPreferenceUsecase
+ now func() time.Time
+}
+
+func NewServerAnalysisUsecase(repo ServerAnalysisRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *ServerAnalysisUsecase {
+ return &ServerAnalysisUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
+}
+func (uc *ServerAnalysisUsecase) Servers(ctx context.Context, sources, games []string, keyword string, limit int) ([]*ServerOption, error) {
+ if _, _, err := uc.accounts.actor(ctx); err != nil {
+ return nil, err
+ }
+ if len(games) == 0 {
+ return nil, ErrServerAnalysisGame
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+ if limit > 100 {
+ limit = 100000
+ }
+ return uc.repo.Servers(ctx, sources, games, strings.TrimSpace(keyword), limit)
+}
+func (uc *ServerAnalysisUsecase) List(ctx context.Context, f ServerAnalysisFilter, page, size int) (*ServerAnalysisPage, error) {
+ actor, account, err := uc.accounts.actor(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if len(f.GameNames) == 0 {
+ return nil, ErrServerAnalysisGame
+ }
+ pref, err := uc.preferences.Current(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if f.StartDate == nil || f.EndDate == nil {
+ now := uc.now().In(portraitLocation)
+ day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation)
+ 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")
+ f.ServerNum = strings.TrimSpace(f.ServerNum)
+ viewer := portraitUserScope(account)
+ viewer.All = actor.AuthorityID == 888
+ data, err := uc.repo.Aggregate(ctx, AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, f)
+ if err != nil {
+ return nil, err
+ }
+ out := &ServerAnalysisPage{ServerAnalysisData: ServerAnalysisData{Rows: []*ServerAnalysisRow{}, Available: data.Available}, Begin: f.Begin, End: f.End}
+ if !data.Available {
+ return out, nil
+ }
+ rows := make([]*ServerAnalysisRow, 0, len(data.Rows))
+ summary := &ServerAnalysisRow{ServerName: "合计"}
+ sumPay := new(big.Rat)
+ for _, row := range data.Rows {
+ if strings.TrimSpace(row.ServerNum) == "" {
+ continue
+ }
+ row.Counts = analyticsCap(row.Counts)
+ pay, err := portraitNumber(row.Pay)
+ if err != nil {
+ return nil, err
+ }
+ row.Pay = portraitDecimal(pay, 2)
+ pay, _ = portraitNumber(row.Pay)
+ // 未指定区服时忽略全零行;指定区服仍只返回报表中已有的分组。
+ if f.ServerNum == "" && row.Counts.Register == 0 && row.Counts.Active == 0 && row.Counts.Pay == 0 && pay.Sign() == 0 {
+ continue
+ }
+ row.ARPU = analyticsRatio(pay, row.Counts.Active, false)
+ summary.Counts = analyticsAdd(summary.Counts, row.Counts)
+ sumPay.Add(sumPay, pay)
+ rows = append(rows, row)
+ }
+ summary.Pay = portraitDecimal(sumPay, 2)
+ summary.ARPU = analyticsRatio(sumPay, summary.Counts.Active, false)
+ out.Summary, out.Total = summary, int64(len(rows))
+ if err := sortServerAnalysis(rows, f.SortField, f.Ascending); err != nil {
+ return nil, err
+ }
+ if page < 1 {
+ page = 1
+ }
+ if size <= 0 || size > 500 {
+ size = 500
+ }
+ if page > len(rows)/size+1 {
+ return out, nil
+ }
+ from := (page - 1) * size
+ if from < len(rows) {
+ out.Rows = rows[from:min(from+size, len(rows))]
+ }
+ return out, nil
+}
+func sortServerAnalysis(rows []*ServerAnalysisRow, field string, ascending bool) error {
+ values := map[*ServerAnalysisRow]*big.Rat{}
+ for _, row := range rows {
+ var n *big.Rat
+ var err error
+ switch field {
+ case "openTime":
+ v := int64(0)
+ if row.OpenTime != nil {
+ v = *row.OpenTime
+ }
+ n = big.NewRat(v, 1)
+ 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 "arpu":
+ n, err = portraitNumber(row.ARPU)
+ 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]
+ if c := values[a].Cmp(values[b]); c != 0 {
+ if ascending {
+ return c < 0
+ }
+ return c > 0
+ }
+ if a.ServerNum != b.ServerNum {
+ return strings.TrimSpace(a.ServerNum) < strings.TrimSpace(b.ServerNum)
+ }
+ if a.SourceDB != b.SourceDB {
+ return a.SourceDB < b.SourceDB
+ }
+ return a.GameID < b.GameID
+ })
+ return nil
+}
diff --git a/internal/biz/business/server_analysis_test.go b/internal/biz/business/server_analysis_test.go
new file mode 100644
index 0000000..3837118
--- /dev/null
+++ b/internal/biz/business/server_analysis_test.go
@@ -0,0 +1,69 @@
+package business
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+type serverAnalysisFake struct {
+ data *ServerAnalysisData
+ scope AnalyticsScope
+ filter ServerAnalysisFilter
+ limit int
+}
+
+func (r *serverAnalysisFake) Aggregate(_ context.Context, s AnalyticsScope, f ServerAnalysisFilter) (*ServerAnalysisData, error) {
+ r.scope, r.filter = s, f
+ out := &ServerAnalysisData{Available: r.data.Available}
+ for _, row := range r.data.Rows {
+ c := *row
+ out.Rows = append(out.Rows, &c)
+ }
+ return out, nil
+}
+func (r *serverAnalysisFake) Servers(_ context.Context, _, _ []string, _ string, limit int) ([]*ServerOption, error) {
+ r.limit = limit
+ return []*ServerOption{}, nil
+}
+func TestServerAnalysisSummaryZerosSortingAndLimits(t *testing.T) {
+ r := &serverAnalysisFake{data: &ServerAnalysisData{Available: true, Rows: []*ServerAnalysisRow{
+ {ServerAnalysisKey: ServerAnalysisKey{SourceDB: "lx", GameID: 1, ServerNum: "02"}, Counts: PortraitCounts{Register: 2, Active: 3, Pay: 1}, Pay: "10.005"},
+ {ServerAnalysisKey: ServerAnalysisKey{SourceDB: "hw", GameID: 1, ServerNum: "01"}, Counts: PortraitCounts{Register: 3, Active: 2, Pay: 1}, Pay: "20"},
+ {ServerAnalysisKey: ServerAnalysisKey{SourceDB: "lx", GameID: 1, ServerNum: "zero"}, Pay: "0"},
+ }}}
+ a := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Level: 1, IDPath: "0"}}}
+ uc := NewServerAnalysisUsecase(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) }
+ f := ServerAnalysisFilter{AnalyticsFilter: AnalyticsFilter{GameNames: []string{"游戏"}}}
+ p, err := uc.List(accountContext(7, 901), f, 1, 1)
+ if err != nil || p.Total != 2 || len(p.Rows) != 1 || p.Rows[0].ServerNum != "01" || p.Summary.Pay != "30.01" || p.Summary.Counts.Register != 5 || p.Summary.ARPU != "6.00" || p.Begin != "2026-11-04" {
+ t.Fatalf("汇总、四舍五入或分页错误: %+v %v", p, err)
+ }
+ if !r.scope.IncludeSub || !r.scope.IDCardDedup || r.scope.Viewer.All {
+ t.Fatal("查看者偏好或范围未传递")
+ }
+ p, err = uc.List(accountContext(7, 901), f, 999999999, 10)
+ if err != nil || len(p.Rows) != 0 || p.Summary.Pay != "30.01" {
+ t.Fatal("越界分页不应丢失汇总")
+ }
+ r.data.Rows = r.data.Rows[2:]
+ f.ServerNum = "zero"
+ p, err = uc.List(accountContext(7, 901), f, 1, 0)
+ if err != nil || p.Total != 1 {
+ t.Fatal("指定区服时应保留已有全零行")
+ }
+ r.data.Available = false
+ p, err = uc.List(accountContext(7, 901), f, 1, 10)
+ if err != nil || p.Summary != nil || p.Available {
+ t.Fatal("未接入不能返回零汇总")
+ }
+ if _, err := uc.List(accountContext(7, 901), ServerAnalysisFilter{}, 1, 10); err != ErrServerAnalysisGame {
+ t.Fatal("区服查询必须选择游戏")
+ }
+ for _, sample := range []struct{ in, want int }{{0, 50}, {100, 100}, {101, 100000}} {
+ if _, err := uc.Servers(accountContext(7, 901), nil, []string{"游戏"}, "", sample.in); err != nil || r.limit != sample.want {
+ t.Fatal("区服选项上限与旧行为不符")
+ }
+ }
+}
diff --git a/internal/data/business/server_analysis.go b/internal/data/business/server_analysis.go
new file mode 100644
index 0000000..69468df
--- /dev/null
+++ b/internal/data/business/server_analysis.go
@@ -0,0 +1,174 @@
+package business
+
+import (
+ "context"
+ "strings"
+
+ "gorm.io/gorm"
+ "kra/internal/biz/business"
+)
+
+type serverAnalysisRepo struct{ data Provider }
+
+func NewServerAnalysisRepo(data Provider) business.ServerAnalysisRepo {
+ return &serverAnalysisRepo{data: data}
+}
+
+func serverReportQuery(db *gorm.DB, scope business.AnalyticsScope, f business.ServerAnalysisFilter, table string, mode int) *gorm.DB {
+ q := ledgerScopeQuery(db, business.LedgerScope{Viewer: scope.Viewer, IncludeSub: scope.IncludeSub, Unrestricted: scope.Viewer.All}, business.LedgerFilter{Sources: f.Sources}, table)
+ q = q.Where("r.stat_date >= ? AND r.stat_date <= ? AND r.dedup_mode=? AND r.server_num != ''", f.Begin, f.End, mode).Where("r.game_name IN ?", f.GameNames)
+ if f.ServerNum != "" {
+ q = q.Where("r.server_num=?", f.ServerNum)
+ }
+ return q
+}
+
+type serverAnalysisPO struct {
+ SourceDB string `gorm:"column:source_db"`
+ GameID int64
+ ServerNum, GameName, ServerName string
+ StartTime *int64
+ Metric int
+ Count int64
+ Amount string
+}
+
+func (r *serverAnalysisRepo) Aggregate(ctx context.Context, scope business.AnalyticsScope, f business.ServerAnalysisFilter) (*business.ServerAnalysisData, error) {
+ db := r.data.DB().WithContext(ctx)
+ out := &business.ServerAnalysisData{Rows: []*business.ServerAnalysisRow{}, Available: true}
+ empty, available, err := prepareAnalytics(db, scope, business.AnalyticsFilter{Sources: f.Sources})
+ if err != nil {
+ return nil, err
+ }
+ out.Available = available
+ if empty || !available {
+ return out, nil
+ }
+ for _, table := range []string{"tab_sw_report_server_day", "tab_sw_report_server_day_key", "tab_game_server", "tab_game"} {
+ available, err := businessTableAvailable(db, table)
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ out.Available = false
+ return out, nil
+ }
+ }
+ rows := map[business.ServerAnalysisKey]*business.ServerAnalysisRow{}
+ rowFor := func(po serverAnalysisPO) *business.ServerAnalysisRow {
+ key := business.ServerAnalysisKey{SourceDB: po.SourceDB, GameID: po.GameID, ServerNum: po.ServerNum}
+ if rows[key] == nil {
+ rows[key] = &business.ServerAnalysisRow{ServerAnalysisKey: key, Pay: "0"}
+ }
+ return rows[key]
+ }
+ mode := 0
+ if scope.IDCardDedup {
+ mode = 1
+ }
+ var counts []serverAnalysisPO
+ if err := serverReportQuery(db, scope, f, "tab_sw_report_server_day_key", mode).Select("r.source_db,r.game_id,r.server_num,r.metric,COUNT(DISTINCT r.identity_key) AS count").Group("r.source_db,r.game_id,r.server_num,r.metric").Scan(&counts).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, po := range counts {
+ if strings.TrimSpace(po.ServerNum) != "" {
+ putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count)
+ }
+ }
+ var pays []serverAnalysisPO
+ if err := serverReportQuery(db, scope, f, "tab_sw_report_server_day", 0).Select("r.source_db,r.game_id,r.server_num,MAX(r.game_name) AS game_name,COALESCE(SUM(r.total_pay),0) AS amount").Group("r.source_db,r.game_id,r.server_num").Scan(&pays).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, po := range pays {
+ if strings.TrimSpace(po.ServerNum) == "" {
+ continue
+ }
+ row := rowFor(po)
+ row.Pay = po.Amount
+ row.GameName = strings.TrimSpace(po.GameName)
+ }
+ if err := fillServerAnalysisNames(db, rows); err != nil {
+ return nil, err
+ }
+ for _, row := range rows {
+ out.Rows = append(out.Rows, row)
+ }
+ return out, nil
+}
+func fillServerAnalysisNames(db *gorm.DB, rows map[business.ServerAnalysisKey]*business.ServerAnalysisRow) error {
+ keys := make([][]any, 0, len(rows))
+ missingGames := map[business.AnalyticsKey]bool{}
+ for key, row := range rows {
+ keys = append(keys, []any{key.SourceDB, key.GameID, key.ServerNum})
+ if row.GameName == "" {
+ missingGames[business.AnalyticsKey{SourceDB: key.SourceDB, ID: key.GameID}] = true
+ }
+ }
+ // 三元组限定资料查询,避免同号游戏或同名区服跨平台串联。
+ for start := 0; start < len(keys); start += 250 {
+ var meta []serverAnalysisPO
+ if err := db.Table("tab_game_server").Select("source_db,game_id,server_num,server_name,start_time").Where("(source_db,game_id,server_num) IN ?", keys[start:min(start+250, len(keys))]).Order("id").Scan(&meta).Error; err != nil {
+ return business.ErrStorage
+ }
+ seen := map[business.ServerAnalysisKey]bool{}
+ for _, po := range meta {
+ key := business.ServerAnalysisKey{SourceDB: po.SourceDB, GameID: po.GameID, ServerNum: po.ServerNum}
+ if row := rows[key]; row != nil && !seen[key] {
+ row.ServerName, row.OpenTime = po.ServerName, po.StartTime
+ seen[key] = true
+ }
+ }
+ }
+ pairs := make([]business.AnalyticsKey, 0, len(missingGames))
+ for key := range missingGames {
+ pairs = append(pairs, key)
+ }
+ names := map[business.AnalyticsKey]string{}
+ for start := 0; start < len(pairs); start += 300 {
+ var meta []struct {
+ SourceDB string `gorm:"column:source_db"`
+ ID int64
+ GameName string
+ }
+ if err := db.Table("tab_game").Select("source_db,id,game_name").Where("(source_db,id) IN ?", analyticsPairs(pairs[start:min(start+300, len(pairs))])).Scan(&meta).Error; err != nil {
+ return business.ErrStorage
+ }
+ for _, po := range meta {
+ names[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}] = po.GameName
+ }
+ }
+ for _, row := range rows {
+ if row.GameName == "" {
+ row.GameName = names[business.AnalyticsKey{SourceDB: row.SourceDB, ID: row.GameID}]
+ }
+ }
+ return nil
+}
+func (r *serverAnalysisRepo) Servers(ctx context.Context, sources, games []string, keyword string, limit int) ([]*business.ServerOption, error) {
+ db := r.data.DB().WithContext(ctx)
+ for _, table := range []string{"tab_game", "tab_game_server"} {
+ available, err := businessTableAvailable(db, table)
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ return nil, business.ErrServerOptionsUnavailable
+ }
+ }
+ q := db.Table("tab_game_server s").Where("EXISTS (?)", db.Table("tab_game g").Select("1").Where("g.source_db=s.source_db AND g.id=s.game_id AND g.game_name IN ?", games))
+ if len(sources) > 0 {
+ q = q.Where("s.source_db IN ?", sources)
+ }
+ if keyword != "" {
+ q = q.Where("s.server_name LIKE ?", "%"+keyword+"%")
+ }
+ var rows []serverAnalysisPO
+ if err := q.Select("s.server_num,s.server_name,s.start_time").Order("s.server_num,s.source_db,s.game_id,s.id").Limit(limit).Scan(&rows).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ out := make([]*business.ServerOption, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, &business.ServerOption{Number: row.ServerNum, Name: row.ServerName, OpenTime: row.StartTime})
+ }
+ return out, nil
+}
diff --git a/internal/data/business/server_analysis_test.go b/internal/data/business/server_analysis_test.go
new file mode 100644
index 0000000..642ac47
--- /dev/null
+++ b/internal/data/business/server_analysis_test.go
@@ -0,0 +1,87 @@
+package business
+
+import (
+ "context"
+ "kra/internal/biz/business"
+ "testing"
+)
+
+func TestServerAnalysisStorageScopeSnapshotAndPreference(t *testing.T) {
+ db, _ := prepareChannels(t)
+ for _, sql := range []string{
+ "CREATE TABLE tab_game_server(id INTEGER,source_db TEXT,game_id INTEGER,server_num TEXT,server_name TEXT,start_time INTEGER)",
+ "INSERT INTO tab_game_server VALUES(1,'lx',1,'001','龙翔一区',100),(2,'hw',1,'001','汇玩一区',200),(3,'lx',2,'001','游戏乙一区',300),(4,'yq',1,'001','无权区服',400)",
+ "CREATE TABLE tab_sw_report_server_day(source_db TEXT,game_id INTEGER,game_name TEXT,promote_id INTEGER,server_num TEXT,stat_date DATE,dedup_mode INTEGER,total_pay DECIMAL(20,2))",
+ "INSERT INTO tab_sw_report_server_day VALUES('lx',1,'历史游戏',100,'001','2026-11-04',0,10),('lx',1,'历史游戏',101,'001','2026-11-04',0,20),('hw',1,'历史游戏',100,'001','2026-11-04',0,40),('yq',1,'历史游戏',100,'001','2026-11-04',0,999),('lx',1,'历史游戏',100,'001','2026-11-04',1,9999),('lx',1,'历史游戏',0,'official','2026-11-04',0,5),('lx',1,'历史游戏',100,'','2026-11-04',0,9999)",
+ "CREATE TABLE tab_sw_report_server_day_key(source_db TEXT,game_id INTEGER,game_name TEXT,promote_id INTEGER,server_num TEXT,stat_date DATE,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)",
+ "INSERT INTO tab_sw_report_server_day_key VALUES('lx',1,'历史游戏',100,'001','2026-11-04',1,1,'A'),('lx',1,'历史游戏',101,'001','2026-11-04',1,1,'A'),('lx',1,'历史游戏',100,'001','2026-11-04',0,1,'1'),('lx',1,'历史游戏',101,'001','2026-11-04',0,1,'2'),('hw',1,'历史游戏',100,'001','2026-11-04',1,1,'A'),('lx',2,'游戏乙',100,'001','2026-11-04',1,1,'B')",
+ } {
+ if err := db.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ r := NewServerAnalysisRepo(testData{db})
+ ctx := context.Background()
+ s := business.AnalyticsScope{Viewer: channelTestScope(), IncludeSub: true, IDCardDedup: true}
+ f := business.ServerAnalysisFilter{AnalyticsFilter: business.AnalyticsFilter{Sources: []string{"lx", "hw", "yq"}, GameNames: []string{"历史游戏", "游戏乙"}, Begin: "2026-11-04", End: "2026-11-04"}}
+ p, err := r.Aggregate(ctx, s, f)
+ if err != nil || len(p.Rows) != 3 {
+ t.Fatalf("区服范围错误: %+v %v", p, err)
+ }
+ for _, row := range p.Rows {
+ switch {
+ case row.SourceDB == "lx" && row.GameID == 1:
+ if row.GameName != "历史游戏" || row.ServerName != "龙翔一区" || row.OpenTime == nil || *row.OpenTime != 100 || row.Counts.Register != 1 || portraitTestMoney(row.Pay) != "30.00" {
+ t.Fatalf("去重、资料或金额错误: %+v", row)
+ }
+ case row.SourceDB == "hw":
+ if row.ServerName != "汇玩一区" || portraitTestMoney(row.Pay) != "40.00" {
+ t.Fatalf("跨平台同号串联: %+v", row)
+ }
+ case row.GameID == 2:
+ if row.GameName != "游戏乙" || row.ServerName != "游戏乙一区" {
+ t.Fatalf("只有人数的行应补当前游戏名: %+v", row)
+ }
+ default:
+ t.Fatalf("出现无权行: %+v", row)
+ }
+ }
+ s.IDCardDedup = false
+ f.Sources = []string{"lx"}
+ f.GameNames = []string{"历史游戏"}
+ p, err = r.Aggregate(ctx, s, f)
+ if err != nil || len(p.Rows) != 1 || p.Rows[0].Counts.Register != 2 || portraitTestMoney(p.Rows[0].Pay) != "30.00" {
+ t.Fatal("身份证偏好影响了金额或未切换人数")
+ }
+ s.IncludeSub = false
+ p, err = r.Aggregate(ctx, s, f)
+ if err != nil || p.Rows[0].Counts.Register != 1 || portraitTestMoney(p.Rows[0].Pay) != "10.00" {
+ t.Fatal("下级开关未生效")
+ }
+ s = business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true, IDCardDedup: true}
+ p, err = r.Aggregate(ctx, s, f)
+ if err != nil || len(p.Rows) != 2 {
+ t.Fatal("超管漏掉官方区服流水")
+ }
+ f.ServerNum = "001"
+ p, err = r.Aggregate(ctx, s, f)
+ if err != nil || len(p.Rows) != 1 {
+ t.Fatal("区服编号筛选错误")
+ }
+ options, err := r.Servers(ctx, []string{"lx"}, []string{"游戏甲"}, "一区", 50)
+ if err != nil || len(options) != 1 || options[0].Number != "001" || options[0].Name != "龙翔一区" {
+ t.Fatal("选项应按当前游戏名、同平台游戏关联及区服名称筛选")
+ }
+ f.GameNames = []string{"游戏甲"}
+ p, err = r.Aggregate(ctx, s, f)
+ if err != nil || len(p.Rows) != 0 {
+ t.Fatal("报表必须按快照游戏名筛选")
+ }
+ if err := db.Exec("DROP TABLE tab_sw_report_server_day_key").Error; err != nil {
+ t.Fatal(err)
+ }
+ p, err = r.Aggregate(ctx, s, f)
+ if err != nil || p.Available {
+ t.Fatal("缺身份表应明确未接入")
+ }
+}
diff --git a/internal/data/data.go b/internal/data/data.go
index c28b8b0..a199895 100644
--- a/internal/data/data.go
+++ b/internal/data/data.go
@@ -43,6 +43,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewChannelPortraitRepo,
databusiness.NewLedgerRepo,
databusiness.NewAnalyticsRepo,
+ databusiness.NewServerAnalysisRepo,
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 6518e99..da6cf10 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_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},
{Name: "sw_DataAnalysis", Path: "sw-DataAnalysis", Component: "view/routerHolder.vue", Title: "数据分析", Icon: "data-analysis", Sort: 3},
diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go
index 2941e76..fac1029 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 != 16 || apis != 60 {
- t.Fatalf("admin surface = %d menus/%d APIs, want 16/60", menus, apis)
+ if menus != 17 || apis != 62 {
+ t.Fatalf("admin surface = %d menus/%d APIs, want 17/62", 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 63863fc..0f8ee7c 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/serverAnalysis/servers": {group: "区服分析", description: "查询游戏区服选项"},
+ "GET /sw/serverAnalysis/list": {group: "区服分析", description: "查询区服分析与汇总"},
"GET /sw/channelData/list": {group: "渠道统计", description: "查询渠道数据与汇总"},
"GET /sw/gameData/list": {group: "游戏统计", description: "查询游戏数据与汇总"},
"GET /sw/register/list": {group: "注册明细", description: "查询注册明细"},
diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go
index fbdd507..ff78bde 100644
--- a/internal/server/gin_test.go
+++ b/internal/server/gin_test.go
@@ -27,6 +27,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
func emptyHandlers() *handler.Set {
return &handler.Set{
Analytics: &handler.Analytics{},
+ ServerAnalysis: &handler.ServerAnalysis{},
Ledger: &handler.Ledger{},
ChannelPortrait: &handler.ChannelPortrait{},
BusinessPortrait: &handler.BusinessPortrait{},
@@ -107,7 +108,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":243`) {
+ if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":245`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go
index dd4330c..bceb264 100644
--- a/internal/server/handler/provider.go
+++ b/internal/server/handler/provider.go
@@ -16,6 +16,7 @@ var ProviderSet = wire.NewSet(
NewChannelPortrait,
NewLedger,
NewAnalytics,
+ NewServerAnalysis,
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
diff --git a/internal/server/handler/server_analysis.go b/internal/server/handler/server_analysis.go
new file mode 100644
index 0000000..b837e0c
--- /dev/null
+++ b/internal/server/handler/server_analysis.go
@@ -0,0 +1,42 @@
+package handler
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/go-kratos/kratos/v3/errors"
+ "kra/internal/service/business"
+ "kra/internal/service/dto"
+)
+
+type ServerAnalysis struct {
+ service *business.ServerAnalysisService
+}
+
+func NewServerAnalysis(service *business.ServerAnalysisService) *ServerAnalysis {
+ return &ServerAnalysis{service: service}
+}
+func (h *ServerAnalysis) List(c *gin.Context) {
+ var req dto.ServerAnalysisQuery
+ if c.ShouldBindQuery(&req) != nil {
+ Fail(c, "区服查询参数不正确")
+ return
+ }
+ data, err := h.service.List(c.Request.Context(), &req)
+ if err != nil {
+ Fail(c, errors.FromError(err).Message)
+ return
+ }
+ OKWithData(c, data)
+}
+func (h *ServerAnalysis) Servers(c *gin.Context) {
+ var req dto.ServerOptionQuery
+ if c.ShouldBindQuery(&req) != nil {
+ Fail(c, "区服查询参数不正确")
+ return
+ }
+ data, err := h.service.Servers(c.Request.Context(), &req)
+ if err != nil {
+ Fail(c, errors.FromError(err).Message)
+ return
+ }
+ OKWithData(c, data)
+}
diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go
index 2d74fe3..25cf9b8 100644
--- a/internal/server/handler/set.go
+++ b/internal/server/handler/set.go
@@ -1,6 +1,7 @@
package handler
type Set struct {
+ ServerAnalysis *ServerAnalysis
Analytics *Analytics
Ledger *Ledger
ChannelPortrait *ChannelPortrait
diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go
index c772b1a..cbb1bf3 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/serverAnalysis/servers", r.handlers.ServerAnalysis.Servers)
+ private.GET("/sw/serverAnalysis/list", r.handlers.ServerAnalysis.List)
+ },
func() {
private.GET("/sw/channelData/list", r.handlers.Analytics.Channels)
private.GET("/sw/gameData/list", r.handlers.Analytics.Games)
diff --git a/internal/service/business/server_analysis.go b/internal/service/business/server_analysis.go
new file mode 100644
index 0000000..881f3b8
--- /dev/null
+++ b/internal/service/business/server_analysis.go
@@ -0,0 +1,63 @@
+package business
+
+import (
+ "context"
+ "kra/internal/biz/business"
+ "kra/internal/service/dto"
+)
+
+type ServerAnalysisUsecase interface {
+ List(context.Context, business.ServerAnalysisFilter, int, int) (*business.ServerAnalysisPage, error)
+ Servers(context.Context, []string, []string, string, int) ([]*business.ServerOption, error)
+}
+type ServerAnalysisService struct{ uc ServerAnalysisUsecase }
+
+func NewServerAnalysisService(uc *business.ServerAnalysisUsecase) *ServerAnalysisService {
+ return &ServerAnalysisService{uc: uc}
+}
+func serverAnalysisResponse(row *business.ServerAnalysisRow, summary bool) *dto.ServerAnalysisRow {
+ out := &dto.ServerAnalysisRow{SourceDB: row.SourceDB, SourceDBLabel: business.PlatformName(row.SourceDB), GameName: row.GameName, ServerOptionResponse: dto.ServerOptionResponse{Number: row.ServerNum, Name: row.ServerName, OpenTime: row.OpenTime}, Register: row.Counts.Register, Active: row.Counts.Active, PayUsers: row.Counts.Pay, Pay: row.Pay, ARPU: row.ARPU}
+ if !summary {
+ out.GameID = &row.GameID
+ }
+ return out
+}
+func (s *ServerAnalysisService) List(ctx context.Context, req *dto.ServerAnalysisQuery) (*dto.ServerAnalysisPage, error) {
+ if req == nil {
+ return nil, business.ErrAnalyticsInput
+ }
+ f, err := analyticsRequest(&dto.AnalyticsQuery{Sources: req.Sources, GameNames: req.GameNames, BeginTime: req.BeginTime, EndTime: req.EndTime, SortField: req.SortField, SortOrder: req.SortOrder})
+ if err != nil {
+ return nil, err
+ }
+ page, err := s.uc.List(ctx, business.ServerAnalysisFilter{AnalyticsFilter: f, ServerNum: req.ServerNum}, req.Page, req.PageSize)
+ if err != nil {
+ return nil, err
+ }
+ out := &dto.ServerAnalysisPage{Rows: []*dto.ServerAnalysisRow{}, Total: page.Total, Begin: page.Begin, End: page.End, Available: page.Available}
+ for _, row := range page.Rows {
+ out.Rows = append(out.Rows, serverAnalysisResponse(row, false))
+ }
+ if page.Summary != nil {
+ out.Summary = serverAnalysisResponse(page.Summary, true)
+ }
+ return out, nil
+}
+func (s *ServerAnalysisService) Servers(ctx context.Context, req *dto.ServerOptionQuery) ([]*dto.ServerOptionResponse, error) {
+ if req == nil {
+ return nil, business.ErrAnalyticsInput
+ }
+ sources, err := normalizePlatforms(req.Sources)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := s.uc.Servers(ctx, sources, splitQueryValues(req.GameNames), req.Keyword, req.Limit)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]*dto.ServerOptionResponse, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, &dto.ServerOptionResponse{Number: row.Number, Name: row.Name, OpenTime: row.OpenTime})
+ }
+ return out, nil
+}
diff --git a/internal/service/dto/server_analysis.go b/internal/service/dto/server_analysis.go
new file mode 100644
index 0000000..ac67c43
--- /dev/null
+++ b/internal/service/dto/server_analysis.go
@@ -0,0 +1,44 @@
+package dto
+
+type ServerAnalysisQuery struct {
+ Page int `form:"pageNum"`
+ PageSize int `form:"pageSize"`
+ Sources []string `form:"sourceDb"`
+ GameNames []string `form:"gameName"`
+ ServerNum string `form:"serverNum"`
+ BeginTime string `form:"beginTime"`
+ EndTime string `form:"endTime"`
+ SortField string `form:"sortField"`
+ SortOrder string `form:"sortOrder"`
+}
+type ServerOptionQuery struct {
+ Sources []string `form:"sourceDb"`
+ GameNames []string `form:"gameName"`
+ Keyword string `form:"keyword"`
+ Limit int `form:"limit"`
+}
+type ServerOptionResponse struct {
+ Number string `json:"serverNum"`
+ Name string `json:"serverName"`
+ OpenTime *int64 `json:"openTime"`
+}
+type ServerAnalysisRow struct {
+ SourceDB string `json:"sourceDb"`
+ SourceDBLabel string `json:"sourceDbLabel"`
+ GameID *int64 `json:"gameId"`
+ GameName string `json:"gameName"`
+ ServerOptionResponse
+ Register int64 `json:"newRegisterCount"`
+ Active int64 `json:"activeCount"`
+ PayUsers int64 `json:"payUserCount"`
+ Pay string `json:"totalPay"`
+ ARPU string `json:"arpu"`
+}
+type ServerAnalysisPage struct {
+ Rows []*ServerAnalysisRow `json:"rows"`
+ Total int64 `json:"total"`
+ Summary *ServerAnalysisRow `json:"summary"`
+ 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 1cd43e5..889b0da 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -25,6 +25,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewChannelPortraitService,
businessservice.NewLedgerService,
businessservice.NewAnalyticsService,
+ businessservice.NewServerAnalysisService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
diff --git a/scripts/sw_server_analysis_http_smoke.py b/scripts/sw_server_analysis_http_smoke.py
new file mode 100644
index 0000000..75c9254
--- /dev/null
+++ b/scripts/sw_server_analysis_http_smoke.py
@@ -0,0 +1,44 @@
+"""只读验证区服分析接口;完整统计由隔离数据库的真实 TCP HTTP 测试覆盖。"""
+import argparse
+import json
+import urllib.parse
+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 = []
+ cases = [
+ ('list', {}, 7), ('servers', {}, 7),
+ ('list', {'gameName': '隔离核验不存在游戏', 'sourceDb': 'lx,hw,yq'}, 0),
+ ('list', {'gameName': 'x', 'beginTime': 'bad', 'endTime': '2026-11-04'}, 7),
+ ('list', {'gameName': 'x', 'beginTime': '2026-11-05', 'endTime': '2026-11-04'}, 7),
+ ('servers', {'gameName': '隔离核验不存在游戏', 'sourceDb': 'lx'}, 7),
+ ]
+ for endpoint, query, expected in cases:
+ path = '/sw/serverAnalysis/' + endpoint + '?' + urllib.parse.urlencode(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 = {'endpoint': endpoint, 'query': query, 'http': response.status, 'code': payload.get('code'), 'msg': payload.get('msg'), 'passed': payload.get('code') == expected}
+ if isinstance(data, dict) and 'available' in data:
+ item.update(available=data['available'], total=data.get('total'))
+ if data['available'] is False:
+ item['passed'] &= data.get('summary') is None
+ checks.append(item)
+ report = {'scope': '真实 sw7 会话只读检查,当前缺少业务报表和游戏区服表;未创建实际业务数据。', 'checks': checks, 'passed': all(item['passed'] for item in checks)}
+ args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
+ print(json.dumps(report, ensure_ascii=False))
+ if not report['passed']:
+ raise SystemExit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/web/src/api/serverAnalysis.js b/web/src/api/serverAnalysis.js
new file mode 100644
index 0000000..bf30b08
--- /dev/null
+++ b/web/src/api/serverAnalysis.js
@@ -0,0 +1,4 @@
+import service from '@/utils/request'
+
+export const listServerAnalysis = params => service({ url: '/sw/serverAnalysis/list', method: 'get', params })
+export const listServerOptions = params => service({ url: '/sw/serverAnalysis/servers', method: 'get', params, donNotShowLoading: true })
diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json
index 5049758..c6743ad 100644
--- a/web/src/pathInfo.json
+++ b/web/src/pathInfo.json
@@ -61,6 +61,7 @@
"/src/view/sw/GameAnalytics.vue": "SwGameAnalytics",
"/src/view/sw/RechargeDetails.vue": "SwRechargeDetails",
"/src/view/sw/RegistrationDetails.vue": "SwRegistrationDetails",
+ "/src/view/sw/ServerAnalysis.vue": "ServerAnalysis",
"/src/view/sw/components/BusinessBindings.vue": "SwBusinessBindings",
"/src/view/sw/components/BusinessChannels.vue": "SwBusinessChannels",
"/src/view/system/security/forceChangePassword.vue": "ForceChangePassword",
diff --git a/web/src/view/sw/ServerAnalysis.vue b/web/src/view/sw/ServerAnalysis.vue
new file mode 100644
index 0000000..b7882b4
--- /dev/null
+++ b/web/src/view/sw/ServerAnalysis.vue
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+ 搜索
+
+
+
+
区服数据统计周期:{{ period }}
+
+
+
+
+
+
+ {{ openDate(row.openTime) }}
+ {{ format(column, row[column.key]) }}
+
+
+
+
+
+
+
+
+