From cbf0ee9be3b3c07d12664f48e51db8afc5145594 Mon Sep 17 00:00:00 2001
From: Yvan <8574526@qq,com>
Date: Wed, 4 Nov 2026 01:25:51 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E5=95=86=E5=8A=A1?=
=?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E6=8C=87=E6=A0=87=E5=92=8C=E4=BB=AA?=
=?UTF-8?q?=E8=A1=A8=E7=9B=98=E6=A6=9C=E5=8D=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
cmd/wire_gen.go | 7 +-
cmd/workbench_http_test.go | 169 ++++++++++++
docs/sw6-to-kra/implementation.md | 5 +-
docs/sw6-to-kra/page-parity.md | 6 +
.../sw6-to-kra/workbench-http-2026-11-04.json | 84 ++++++
docs/sw6-to-kra/workbench-source-notes.md | 26 ++
internal/biz/biz.go | 2 +-
internal/biz/business/workbench.go | 244 ++++++++++++++++++
internal/biz/business/workbench_test.go | 91 +++++++
internal/data/business/workbench.go | 181 +++++++++++++
internal/data/business/workbench_test.go | 87 +++++++
internal/data/data.go | 1 +
internal/modules/catalog_test.go | 4 +-
internal/routecatalog/catalog.go | 4 +
internal/server/gin_test.go | 3 +-
internal/server/handler/provider.go | 1 +
internal/server/handler/set.go | 1 +
internal/server/handler/workbench.go | 42 +++
internal/server/router/routes.go | 6 +
internal/service/business/workbench.go | 56 ++++
internal/service/dto/workbench.go | 31 +++
internal/service/service.go | 1 +
scripts/sw_workbench_http_smoke.py | 41 +++
web/src/api/workbench.js | 4 +
.../components/BusinessDashboard.vue | 125 +++++++++
web/src/view/dashboard/index.vue | 20 +-
26 files changed, 1220 insertions(+), 22 deletions(-)
create mode 100644 cmd/workbench_http_test.go
create mode 100644 docs/sw6-to-kra/workbench-http-2026-11-04.json
create mode 100644 docs/sw6-to-kra/workbench-source-notes.md
create mode 100644 internal/biz/business/workbench.go
create mode 100644 internal/biz/business/workbench_test.go
create mode 100644 internal/data/business/workbench.go
create mode 100644 internal/data/business/workbench_test.go
create mode 100644 internal/server/handler/workbench.go
create mode 100644 internal/service/business/workbench.go
create mode 100644 internal/service/dto/workbench.go
create mode 100644 scripts/sw_workbench_http_smoke.py
create mode 100644 web/src/api/workbench.js
create mode 100644 web/src/view/dashboard/components/BusinessDashboard.vue
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index 06a1ae2..ee900a2 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()
- serverAnalysisRepo := business.NewServerAnalysisRepo(dataData)
+ workbenchRepo := business.NewWorkbenchRepo(dataData)
accountRepo := business.NewAccountRepo(dataData)
accountUsecase := business2.NewAccountUsecase(accountRepo)
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
+ workbenchUsecase := business2.NewWorkbenchUsecase(workbenchRepo, accountUsecase, userPreferenceUsecase)
+ workbenchService := business3.NewWorkbenchService(workbenchUsecase)
+ workbench := handler.NewWorkbench(workbenchService)
+ serverAnalysisRepo := business.NewServerAnalysisRepo(dataData)
serverAnalysisUsecase := business2.NewServerAnalysisUsecase(serverAnalysisRepo, accountUsecase, userPreferenceUsecase)
serverAnalysisService := business3.NewServerAnalysisService(serverAnalysisUsecase)
serverAnalysis := handler.NewServerAnalysis(serverAnalysisService)
@@ -239,6 +243,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
set := &handler.Set{
+ Workbench: workbench,
ServerAnalysis: serverAnalysis,
Analytics: analytics,
Ledger: ledger,
diff --git a/cmd/workbench_http_test.go b/cmd/workbench_http_test.go
new file mode 100644
index 0000000..6368179
--- /dev/null
+++ b/cmd/workbench_http_test.go
@@ -0,0 +1,169 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ "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 TestWorkbenchHTTP(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "workbench.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,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER,create_time INTEGER,last_login_time INTEGER)",
+ "INSERT INTO tab_promote VALUES(100,'lx','渠道甲',1,0,14,0,0),(101,'lx','渠道甲下级',2,100,14,0,0),(200,'lx','零充值渠道',1,0,14,0,0),(100,'hw','外部商务渠道',1,0,20,0,0)",
+ "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))",
+ "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)
+ }
+ }
+ location := time.FixedZone("Asia/Shanghai", 8*3600)
+ now := time.Now().In(location)
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
+ for _, row := range []struct {
+ source string
+ promote int
+ amount int
+ }{{"lx", 100, 10}, {"lx", 101, 20}, {"hw", 100, 40}} {
+ if err := db.Exec("INSERT INTO tab_sw_report_day VALUES(?,?,1,0,?,?)", row.source, row.promote, today.Format("2006-01-02"), row.amount).Error; err != nil {
+ t.Fatal(err)
+ }
+ for _, metric := range []int{1, 2} {
+ for _, mode := range []int{0, 1} {
+ key := "A"
+ if mode == 0 {
+ key = strconv.Itoa(row.promote)
+ }
+ if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES(?,?,1,?,?,?,?)", row.source, row.promote, mode, today.Format("2006-01-02"), metric, key).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ }
+ if err := db.Exec("UPDATE tab_promote SET create_time=?,last_login_time=? WHERE source_db='lx' AND id IN(100,101)", today.Unix(), today.AddDate(0, 0, -5).Unix()).Error; err != nil {
+ t.Fatal(err)
+ }
+ provider := businessHTTPData{db}
+ uc := businessbiz.NewWorkbenchUsecase(businessdata.NewWorkbenchRepo(provider), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)))
+ h := handler.NewWorkbench(businessservice.NewWorkbenchService(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/workbench/summary", h.Summary)
+ engine.GET("/sw/workbench/registerTop", h.RegisterTop)
+ engine.GET("/sw/workbench/payRank", h.PayRank)
+ engine.GET("/sw/workbench/unloginTop", h.UnloginTop)
+ srv := httptest.NewServer(engine)
+ defer srv.Close()
+ call := func(user int, endpoint, query string, code int) any {
+ t.Helper()
+ req, _ := http.NewRequest("GET", srv.URL+"/sw/workbench/"+endpoint+"?"+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", endpoint, body)
+ }
+ return body["data"]
+ }
+ q := "sourceDbList=lx,hw"
+ card := func(data map[string]any, name, field string) any { return data[name].(map[string]any)[field] }
+ summary := call(7, "summary", q, 0).(map[string]any)
+ if card(summary, "todayRegister", "value") != "1.00" || card(summary, "todayPay", "value") != "30.00" || card(summary, "activePromote", "value") != "2.00" || card(summary, "newPromote", "value") != "2.00" || card(summary, "todayPay", "changeRate") != nil {
+ t.Fatalf("工作台六卡口径错误: %+v", summary)
+ }
+ summary = call(1, "summary", q, 0).(map[string]any)
+ if card(summary, "todayRegister", "value") != "1.00" || card(summary, "todayPay", "value") != "70.00" {
+ t.Fatal("超管跨平台汇总错误")
+ }
+ rows := call(7, "registerTop", q, 0).([]any)
+ if len(rows) != 1 || rows[0].(map[string]any)["value"] != "1.00" || rows[0].(map[string]any)["promoteName"] != "渠道甲" {
+ t.Fatal("注册榜应按整棵树去重")
+ }
+ rows = call(7, "payRank", q+"&period=today&order=desc", 0).([]any)
+ if len(rows) != 1 || rows[0].(map[string]any)["value"] != "30.00" {
+ t.Fatal("充值榜范围或金额错误")
+ }
+ rows = call(7, "payRank", q+"&order=asc", 0).([]any)
+ if len(rows) != 2 || rows[0].(map[string]any)["value"] != "0.00" || rows[0].(map[string]any)["promoteName"] != "零充值渠道" {
+ t.Fatal("最低榜漏掉零充值一级渠道")
+ }
+ rows = call(7, "unloginTop", q, 0).([]any)
+ if len(rows) != 2 || rows[0].(map[string]any)["value"] != "99999" || rows[1].(map[string]any)["value"] != "5" {
+ t.Fatal("未登录榜不应包含子渠道")
+ }
+ rows = call(99, "payRank", "sourceDbList=lx", 0).([]any)
+ if len(rows) != 0 {
+ 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)
+ }
+ summary = call(7, "summary", q, 0).(map[string]any)
+ if card(summary, "todayRegister", "value") != "2.00" || card(summary, "todayPay", "value") != "30.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)
+ }
+ summary = call(7, "summary", q, 0).(map[string]any)
+ if card(summary, "todayPay", "value") != "10.00" || card(summary, "newPromote", "value") != "1.00" {
+ t.Fatal("渠道数和金额未排除下级")
+ }
+ for _, endpoint := range []string{"summary", "registerTop", "payRank", "unloginTop"} {
+ call(7, endpoint, "", 7)
+ }
+ call(7, "payRank", q+"&period=invalid", 7)
+ call(7, "summary", "sourceDbList=invalid", 7)
+ if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
+ t.Fatal(err)
+ }
+ summary = call(7, "summary", q, 0).(map[string]any)
+ if summary["available"] != false || card(summary, "todayPay", "value") != nil {
+ t.Fatal("缺表不能伪造零")
+ }
+ call(7, "registerTop", q, 7)
+ call(7, "payRank", q, 0)
+ call(7, "unloginTop", q, 0)
+}
diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md
index ac95f54..9185b74 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 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 |
@@ -96,3 +96,6 @@
- 统计批次通过全量 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`。覆盖跨平台同号、全量汇总、零行处理、偏好切换、资料补全及缺表;按用户新要求未执行页面交互检查。最终旧库验收仍未开始。
+- 工作台四个接口及 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、榜单明确报未接入。未执行页面交互检查或最终旧库复制。
diff --git a/docs/sw6-to-kra/page-parity.md b/docs/sw6-to-kra/page-parity.md
index 4133006..fd9bf1b 100644
--- a/docs/sw6-to-kra/page-parity.md
+++ b/docs/sw6-to-kra/page-parity.md
@@ -186,3 +186,9 @@
按旧 `ServerAnalysis.vue` 源码迁移平台多选、游戏多选、区服单选、近 30 天默认日期、首个游戏自动查询、列排序/分页及全量汇总;沿用 Kra 控件,没有新增导出入口。切换平台清空游戏/区服,切换游戏清空区服,过期异步请求不覆盖新选项。统计字段保持所属平台、区服编号/名称、开服日期、新增注册、活跃玩家、充值金额、付费玩家和 ARPU。
接口通过真实 sw7 请求及隔离数据库 TCP HTTP 验证,前端 ESLint/生产构建通过。按用户 2026-11-04 新要求,此后只检查接口,不再检查页面交互。详见 `server-analysis-source-notes.md`。
+
+## 工作台 / Kra 仪表盘
+
+直接核对旧 `views/index.vue`,在原 Kra 仪表盘中加入六项商务指标、平台多选、注册/充值两组横向榜图、未登录/最低充值两组榜表,以及最新五条待处理预警的四秒轮播和查看全部入口。注册/最高充值默认今日,最低充值默认近 30 天;每个榜单单独切换周期。保留 Kra 欢迎、快捷入口和公告,使用 Kra 卡片、颜色与 Element Plus 控件。没有移植旧页面的自定义图表样式。
+
+四个工作台接口和已有预警接口负责真实业务数据;接口缺表或错误明确提示,不将空选择、未接入和真实零混淆。按用户要求仅做接口实测、数据断言和构建,未做页面交互检查。
diff --git a/docs/sw6-to-kra/workbench-http-2026-11-04.json b/docs/sw6-to-kra/workbench-http-2026-11-04.json
new file mode 100644
index 0000000..f7cebeb
--- /dev/null
+++ b/docs/sw6-to-kra/workbench-http-2026-11-04.json
@@ -0,0 +1,84 @@
+{
+ "scope": "真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向六卡、榜单与权限通过隔离 TCP HTTP 测试。",
+ "checks": [
+ {
+ "path": "/sw/workbench/summary?",
+ "http": 200,
+ "code": 7,
+ "msg": "请选择所属平台后查看数据",
+ "passed": true
+ },
+ {
+ "path": "/sw/workbench/summary?sourceDbList=lx,hw,yq",
+ "http": 200,
+ "code": 0,
+ "msg": "成功",
+ "passed": true,
+ "available": false
+ },
+ {
+ "path": "/sw/workbench/registerTop?",
+ "http": 200,
+ "code": 7,
+ "msg": "请选择所属平台后查看数据",
+ "passed": true
+ },
+ {
+ "path": "/sw/workbench/registerTop?sourceDbList=lx,hw,yq",
+ "http": 200,
+ "code": 7,
+ "msg": "工作台所需业务数据尚未接入",
+ "passed": true
+ },
+ {
+ "path": "/sw/workbench/registerTop?sourceDbList=lx&period=invalid",
+ "http": 200,
+ "code": 7,
+ "msg": "时间维度无效,应为 today/yesterday/last7/last30",
+ "passed": true
+ },
+ {
+ "path": "/sw/workbench/payRank?",
+ "http": 200,
+ "code": 7,
+ "msg": "请选择所属平台后查看数据",
+ "passed": true
+ },
+ {
+ "path": "/sw/workbench/payRank?sourceDbList=lx,hw,yq",
+ "http": 200,
+ "code": 7,
+ "msg": "工作台所需业务数据尚未接入",
+ "passed": true
+ },
+ {
+ "path": "/sw/workbench/payRank?sourceDbList=lx&period=invalid",
+ "http": 200,
+ "code": 7,
+ "msg": "时间维度无效,应为 today/yesterday/last7/last30",
+ "passed": true
+ },
+ {
+ "path": "/sw/workbench/payRank?sourceDbList=lx,hw,yq&order=asc&period=last30",
+ "http": 200,
+ "code": 7,
+ "msg": "工作台所需业务数据尚未接入",
+ "passed": true
+ },
+ {
+ "path": "/sw/workbench/unloginTop?",
+ "http": 200,
+ "code": 7,
+ "msg": "请选择所属平台后查看数据",
+ "passed": true
+ },
+ {
+ "path": "/sw/workbench/unloginTop?sourceDbList=lx,hw,yq",
+ "http": 200,
+ "code": 7,
+ "msg": "工作台所需业务数据尚未接入",
+ "passed": true
+ }
+ ],
+ "passed": true
+}
diff --git a/docs/sw6-to-kra/workbench-source-notes.md b/docs/sw6-to-kra/workbench-source-notes.md
new file mode 100644
index 0000000..54ff13e
--- /dev/null
+++ b/docs/sw6-to-kra/workbench-source-notes.md
@@ -0,0 +1,26 @@
+# 工作台与仪表盘业务数据
+
+核对旧 main `WorkbenchServiceImpl`、`ChannelTreeMetricsSupport`、日汇总 Mapper,以及 dev/dev1.0 的 `views/index.vue`,迁移 `/sw/workbench/summary`、`registerTop`、`payRank`、`unloginTop`。
+
+## 六项指标
+
+- `sourceDbList` 至少选择一个平台;商务范围为本人及下级正常绑定,超管不限;是否包含下级渠道与身份证去重读取当前偏好。
+- 今日注册、活跃人数对比昨日,今日充值对比昨日,本月充值对比上月 1 日至同日(上月无该日期时取月末)。金额固定模式 0,人数模式 0 在全平台汇总时加平台前缀,模式 1 跨平台去重。
+- 活跃渠道数按每个渠道自身当日注册人数大于 0 或汇总充值金额大于 0 计算,不合并一级;同一渠道同时命中只计一次。新增渠道数按可见实体的创建时间统计,包含下级开关同时影响两项。
+- 本期/上期值先保留两位,再算一位小数环比;上期为零时 changeRate=null。changeDiff 保留字段但为 null,与旧服务实际输出一致。
+
+## 四组榜单
+
+- 注册榜 TOP10:包含下级时使用旧 `countByChannelRoot` 的 INNER JOIN+CASE,在渠道树内去重;不会将缺少实体的官方/历史孤立记录纳入注册榜。此处与渠道统计页的 LEFT JOIN 根口径不同。
+- 充值最高/最低榜按金额固定模式 0,再归并到根渠道。最高榜只保留正值,最低榜额外包含可见一级渠道的零充值实体;负值沿用旧最低榜行为,不擅自清零。官方/缺账号名称按旧规则回退。
+- 注册/最高充值默认今日,最低充值默认近 30 天,支持 today/yesterday/last7/last30;包括今天。最高/最低共用 payRank,通过 order 区分。
+- 未登录榜仅取可见一级实体,先用最后登录时间,无有效登录时用创建时间,两者都无效为 99999;未来日期为 0,按上海自然日计算。
+- 数值排序使用精确有理数;同值按 ID,跨平台同号追加平台稳定排序。充值显示两位小数,未登录天数为整数字符串;所有榜单排名连续 1 至 10。
+
+## 实现与验证
+
+- 仪表盘直接调用四个接口,并复用已有预警列表查询 status=1/pageSize=5,实现原最新五条轮播。Kra 快捷入口和公告保留。
+- 聚合、资料查询批量执行,未对每个渠道发起独立 SQL。注册榜不依赖金额表,未登录榜不依赖报表,缺少必要表返回明确不可用。
+- 六卡扩展 available=false 并返回 null 值;榜单保留数组成功协议,缺表返回业务错误,避免把未知榜单当作真实空榜。
+- 全量 Go、隔离数据库真实 TCP HTTP、前端 ESLint/构建通过;测试覆盖月末对照、零分母、大金额、TOP10、身份去重、下级偏好、负金额/零渠道、未登录回退和跨商务拒绝。实际 sw7 只读结果见 `workbench-http-2026-11-04.json`。
+- 按用户要求不检查页面交互。当前没有复制旧业务库,最终同源验收尚未开始。
diff --git a/internal/biz/biz.go b/internal/biz/biz.go
index 9104c27..d38013a 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)
+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)
diff --git a/internal/biz/business/workbench.go b/internal/biz/business/workbench.go
new file mode 100644
index 0000000..0a20ec5
--- /dev/null
+++ b/internal/biz/business/workbench.go
@@ -0,0 +1,244 @@
+package business
+
+import (
+ "context"
+ "math/big"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/go-kratos/kratos/v3/errors"
+)
+
+var (
+ ErrWorkbenchSource = errors.BadRequest("SW_WORKBENCH_SOURCE", "请选择所属平台后查看数据")
+ ErrWorkbenchPeriod = errors.BadRequest("SW_WORKBENCH_PERIOD", "时间维度无效,应为 today/yesterday/last7/last30")
+ ErrWorkbenchUnavailable = errors.BadRequest("SW_WORKBENCH_UNAVAILABLE", "工作台所需业务数据尚未接入")
+)
+
+type WorkbenchRankKind string
+
+const (
+ WorkbenchRegister WorkbenchRankKind = "register"
+ WorkbenchPayHigh WorkbenchRankKind = "payHigh"
+ WorkbenchPayLow WorkbenchRankKind = "payLow"
+ WorkbenchUnlogin WorkbenchRankKind = "unlogin"
+)
+
+type WorkbenchWindow struct{ Today, Yesterday, MonthStart, PreviousMonthStart, PreviousMonthEnd time.Time }
+type WorkbenchDay struct {
+ Counts PortraitCounts
+ Pay string
+ ActiveChannels, NewChannels int64
+}
+type WorkbenchTotals struct {
+ Today, Yesterday WorkbenchDay
+ MonthPay, PreviousMonthPay string
+ Available bool
+}
+type WorkbenchCard struct {
+ Value, CompareValue, ChangeRate *string
+ CompareLabel string
+}
+type WorkbenchSummary struct {
+ TodayRegister, TodayActive, TodayPay, MonthPay, ActivePromote, NewPromote *WorkbenchCard
+ Available bool
+}
+type WorkbenchRankRow struct {
+ AnalyticsKey
+ Rank int
+ Name, Value string
+ CreatedUnix, LastLoginUnix *int64
+}
+type WorkbenchRankData struct {
+ Rows []*WorkbenchRankRow
+ Available bool
+}
+type WorkbenchRepo interface {
+ Summary(context.Context, AnalyticsScope, []string, WorkbenchWindow) (*WorkbenchTotals, error)
+ Ranks(context.Context, AnalyticsScope, AnalyticsFilter, WorkbenchRankKind) (*WorkbenchRankData, error)
+}
+type WorkbenchUsecase struct {
+ repo WorkbenchRepo
+ accounts *AccountUsecase
+ preferences *UserPreferenceUsecase
+ now func() time.Time
+}
+
+func NewWorkbenchUsecase(repo WorkbenchRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *WorkbenchUsecase {
+ return &WorkbenchUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
+}
+func (uc *WorkbenchUsecase) scope(ctx context.Context, sources []string) (AnalyticsScope, error) {
+ actor, account, err := uc.accounts.actor(ctx)
+ if err != nil {
+ return AnalyticsScope{}, err
+ }
+ if len(sources) == 0 {
+ return AnalyticsScope{}, ErrWorkbenchSource
+ }
+ pref, err := uc.preferences.Current(ctx)
+ if err != nil {
+ return AnalyticsScope{}, err
+ }
+ viewer := portraitUserScope(account)
+ viewer.All = actor.AuthorityID == 888
+ return AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, nil
+}
+func workbenchWindow(now time.Time) WorkbenchWindow {
+ now = now.In(portraitLocation)
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation)
+ month := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, portraitLocation)
+ previous := month.AddDate(0, -1, 0)
+ lastDay := month.AddDate(0, 0, -1).Day()
+ previousEnd := previous.AddDate(0, 0, min(today.Day(), lastDay)-1)
+ return WorkbenchWindow{Today: today, Yesterday: today.AddDate(0, 0, -1), MonthStart: month, PreviousMonthStart: previous, PreviousMonthEnd: previousEnd}
+}
+func workbenchCard(current, previous, label string, available bool) (*WorkbenchCard, error) {
+ card := &WorkbenchCard{CompareLabel: label}
+ if !available {
+ return card, nil
+ }
+ cur, err := portraitNumber(current)
+ if err != nil {
+ return nil, err
+ }
+ prev, err := portraitNumber(previous)
+ if err != nil {
+ return nil, err
+ }
+ card.Value, card.CompareValue = stringPointer(portraitDecimal(cur, 2)), stringPointer(portraitDecimal(prev, 2))
+ cur, _ = portraitNumber(*card.Value)
+ prev, _ = portraitNumber(*card.CompareValue)
+ if prev.Sign() != 0 {
+ diff := new(big.Rat).Sub(cur, prev)
+ diff.Mul(diff, big.NewRat(100, 1))
+ diff.Quo(diff, prev)
+ card.ChangeRate = stringPointer(portraitDecimal(diff, 1))
+ }
+ return card, nil
+}
+func (uc *WorkbenchUsecase) Summary(ctx context.Context, sources []string) (*WorkbenchSummary, error) {
+ scope, err := uc.scope(ctx, sources)
+ if err != nil {
+ return nil, err
+ }
+ data, err := uc.repo.Summary(ctx, scope, sources, workbenchWindow(uc.now()))
+ if err != nil {
+ return nil, err
+ }
+ data.Today.Counts, data.Yesterday.Counts = analyticsCap(data.Today.Counts), analyticsCap(data.Yesterday.Counts)
+ integer := func(n int64) string { return big.NewInt(n).String() }
+ out := &WorkbenchSummary{Available: data.Available}
+ for _, item := range []struct {
+ field **WorkbenchCard
+ current, previous, label string
+ }{
+ {&out.TodayRegister, integer(data.Today.Counts.Register), integer(data.Yesterday.Counts.Register), "环比昨日"},
+ {&out.TodayActive, integer(data.Today.Counts.Active), integer(data.Yesterday.Counts.Active), "环比昨日"},
+ {&out.TodayPay, data.Today.Pay, data.Yesterday.Pay, "环比昨日"},
+ {&out.MonthPay, data.MonthPay, data.PreviousMonthPay, "环比上月"},
+ {&out.ActivePromote, integer(data.Today.ActiveChannels), integer(data.Yesterday.ActiveChannels), "环比昨日"},
+ {&out.NewPromote, integer(data.Today.NewChannels), integer(data.Yesterday.NewChannels), "环比昨日"},
+ } {
+ card, err := workbenchCard(item.current, item.previous, item.label, data.Available)
+ if err != nil {
+ return nil, err
+ }
+ *item.field = card
+ }
+ return out, nil
+}
+func (uc *WorkbenchUsecase) Ranks(ctx context.Context, sources []string, period string, kind WorkbenchRankKind) ([]*WorkbenchRankRow, error) {
+ scope, err := uc.scope(ctx, sources)
+ if err != nil {
+ return nil, err
+ }
+ if kind != WorkbenchRegister && kind != WorkbenchPayHigh && kind != WorkbenchPayLow && kind != WorkbenchUnlogin {
+ return nil, ErrAnalyticsInput
+ }
+ period = strings.ToLower(strings.TrimSpace(period))
+ if period == "" {
+ period = "today"
+ if kind == WorkbenchPayLow {
+ period = "last30"
+ }
+ }
+ w := workbenchWindow(uc.now())
+ begin, end := w.Today, w.Today
+ if kind != WorkbenchUnlogin {
+ switch period {
+ case "today":
+ case "yesterday":
+ begin, end = w.Yesterday, w.Yesterday
+ case "last7":
+ begin = end.AddDate(0, 0, -6)
+ case "last30":
+ begin = end.AddDate(0, 0, -29)
+ default:
+ return nil, ErrWorkbenchPeriod
+ }
+ }
+ data, err := uc.repo.Ranks(ctx, scope, AnalyticsFilter{Sources: sources, Begin: begin.Format("2006-01-02"), End: end.Format("2006-01-02")}, kind)
+ if err != nil {
+ return nil, err
+ }
+ if !data.Available {
+ return nil, ErrWorkbenchUnavailable
+ }
+ rows := make([]*WorkbenchRankRow, 0, len(data.Rows))
+ values := map[*WorkbenchRankRow]*big.Rat{}
+ for _, row := range data.Rows {
+ if kind == WorkbenchUnlogin {
+ row.Value = big.NewInt(workbenchUnloginDays(row, w.Today)).String()
+ }
+ n, err := portraitNumber(row.Value)
+ if err != nil {
+ return nil, err
+ }
+ if kind == WorkbenchRegister && n.Cmp(big.NewRat(2147483647, 1)) > 0 {
+ n = big.NewRat(2147483647, 1)
+ }
+ if (kind == WorkbenchRegister || kind == WorkbenchPayHigh) && n.Sign() <= 0 {
+ continue
+ }
+ values[row] = n
+ rows = append(rows, row)
+ }
+ sort.SliceStable(rows, func(i, j int) bool {
+ a, b := rows[i], rows[j]
+ if c := values[a].Cmp(values[b]); c != 0 {
+ if kind == WorkbenchPayLow {
+ return c < 0
+ }
+ return c > 0
+ }
+ if a.ID != b.ID {
+ return a.ID < b.ID
+ }
+ return a.SourceDB < b.SourceDB
+ })
+ rows = rows[:min(10, len(rows))]
+ for i, row := range rows {
+ row.Rank = i + 1
+ row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote()
+ if kind != WorkbenchUnlogin {
+ row.Value = portraitDecimal(values[row], 2)
+ }
+ }
+ return rows, nil
+}
+func workbenchUnloginDays(row *WorkbenchRankRow, today time.Time) int64 {
+ stamp := row.LastLoginUnix
+ if stamp == nil || *stamp <= 0 {
+ stamp = row.CreatedUnix
+ }
+ if stamp == nil || *stamp <= 0 {
+ return 99999
+ }
+ date := time.Unix(*stamp, 0).In(portraitLocation)
+ date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, portraitLocation)
+ if date.After(today) {
+ return 0
+ }
+ return min(int64(today.Sub(date).Hours()/24), 2147483647)
+}
diff --git a/internal/biz/business/workbench_test.go b/internal/biz/business/workbench_test.go
new file mode 100644
index 0000000..20e4c2c
--- /dev/null
+++ b/internal/biz/business/workbench_test.go
@@ -0,0 +1,91 @@
+package business
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+)
+
+type workbenchRepoFake struct {
+ totals *WorkbenchTotals
+ data *WorkbenchRankData
+ window WorkbenchWindow
+ filter AnalyticsFilter
+ scope AnalyticsScope
+}
+
+func (r *workbenchRepoFake) Summary(_ context.Context, s AnalyticsScope, _ []string, w WorkbenchWindow) (*WorkbenchTotals, error) {
+ r.window, r.scope = w, s
+ c := *r.totals
+ return &c, nil
+}
+func (r *workbenchRepoFake) Ranks(_ context.Context, s AnalyticsScope, f AnalyticsFilter, _ WorkbenchRankKind) (*WorkbenchRankData, error) {
+ r.filter, r.scope = f, s
+ out := &WorkbenchRankData{Available: r.data.Available}
+ for _, row := range r.data.Rows {
+ c := *row
+ out.Rows = append(out.Rows, &c)
+ }
+ return out, nil
+}
+func workbenchFixture() (*WorkbenchUsecase, *workbenchRepoFake) {
+ r := &workbenchRepoFake{totals: &WorkbenchTotals{Available: true, Today: WorkbenchDay{Counts: PortraitCounts{Register: 3, Active: 4}, Pay: "20.005", ActiveChannels: 2, NewChannels: 1}, Yesterday: WorkbenchDay{Counts: PortraitCounts{Register: 2}, Pay: "10", ActiveChannels: 1}, MonthPay: "300", PreviousMonthPay: "200"}, data: &WorkbenchRankData{Available: true}}
+ a := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Level: 1, IDPath: "0"}}}
+ uc := NewWorkbenchUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}))
+ uc.now = func() time.Time { return time.Date(2026, 3, 31, 9, 0, 0, 0, portraitLocation) }
+ return uc, r
+}
+func TestWorkbenchSummaryCalendarAndRatios(t *testing.T) {
+ uc, r := workbenchFixture()
+ out, err := uc.Summary(accountContext(7, 901), []string{"lx"})
+ if err != nil || *out.TodayRegister.Value != "3.00" || *out.TodayRegister.ChangeRate != "50.0" || out.TodayActive.ChangeRate != nil || *out.TodayPay.Value != "20.01" || *out.TodayPay.ChangeRate != "100.1" || *out.MonthPay.ChangeRate != "50.0" || *out.ActivePromote.ChangeRate != "100.0" {
+ t.Fatalf("卡片精度或环比错误: %+v %v", out, err)
+ }
+ if r.window.PreviousMonthEnd.Format("2006-01-02") != "2026-02-28" || r.window.PreviousMonthStart.Format("2006-01-02") != "2026-02-01" {
+ t.Fatal("上月同期需要截断至月末")
+ }
+ r.totals.Available = false
+ out, err = uc.Summary(accountContext(7, 901), []string{"lx"})
+ if err != nil || out.TodayPay.Value != nil || out.TodayPay.CompareValue != nil || out.Available {
+ t.Fatal("缺表不能返回零卡片")
+ }
+ if _, err := uc.Summary(accountContext(7, 901), nil); err != ErrWorkbenchSource {
+ t.Fatal("必须选择平台")
+ }
+}
+func TestWorkbenchRankLimitsExactSortDefaultsAndUnlogin(t *testing.T) {
+ uc, r := workbenchFixture()
+ ctx := accountContext(7, 901)
+ for i := 0; i < 14; i++ {
+ r.data.Rows = append(r.data.Rows, &WorkbenchRankRow{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: int64(i)}, Value: fmt.Sprint(i)})
+ }
+ rows, err := uc.Ranks(ctx, []string{"lx"}, "", WorkbenchPayHigh)
+ if err != nil || len(rows) != 10 || rows[0].ID != 13 || rows[9].Rank != 10 {
+ t.Fatal("榜单排序或 TOP10 截断错误")
+ }
+ rows, err = uc.Ranks(ctx, []string{"lx"}, "", WorkbenchPayLow)
+ if err != nil || rows[0].ID != 0 || rows[0].Value != "0.00" || rows[0].Name != "官方渠道" || r.filter.Begin != "2026-03-02" {
+ t.Fatal("最低榜应包含零值,默认近30天")
+ }
+ r.data.Rows = []*WorkbenchRankRow{{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 1}, Value: "9007199254740993.00"}, {AnalyticsKey: AnalyticsKey{SourceDB: "hw", ID: 2}, Value: "9007199254740992.99"}}
+ rows, err = uc.Ranks(ctx, []string{"lx", "hw"}, "last7", WorkbenchPayHigh)
+ if err != nil || rows[0].ID != 1 || r.filter.Begin != "2026-03-25" {
+ t.Fatal("金额排序丢失精度或近7天范围错误")
+ }
+ if _, err := uc.Ranks(ctx, []string{"lx"}, "invalid", WorkbenchRegister); err != ErrWorkbenchPeriod {
+ t.Fatal("无效时间维度必须拒绝")
+ }
+ today := workbenchWindow(uc.now()).Today
+ created := today.AddDate(0, 0, -5).Unix()
+ future := today.AddDate(0, 0, 1).Unix()
+ r.data.Rows = []*WorkbenchRankRow{{AnalyticsKey: AnalyticsKey{ID: 1}}, {AnalyticsKey: AnalyticsKey{ID: 2}, CreatedUnix: &created}, {AnalyticsKey: AnalyticsKey{ID: 3}, CreatedUnix: &created, LastLoginUnix: &future}}
+ rows, err = uc.Ranks(ctx, []string{"lx"}, "ignored", WorkbenchUnlogin)
+ if err != nil || rows[0].Value != "99999" || rows[1].Value != "5" || rows[2].Value != "0" {
+ t.Fatal("未登录回退创建时间、无时间或未来时间错误")
+ }
+ r.data.Available = false
+ if _, err := uc.Ranks(ctx, []string{"lx"}, "", WorkbenchUnlogin); err != ErrWorkbenchUnavailable {
+ t.Fatal("缺数据不能返回空榜单")
+ }
+}
diff --git a/internal/data/business/workbench.go b/internal/data/business/workbench.go
new file mode 100644
index 0000000..a792e7e
--- /dev/null
+++ b/internal/data/business/workbench.go
@@ -0,0 +1,181 @@
+package business
+
+import (
+ "context"
+ "time"
+
+ "gorm.io/gorm"
+ "kra/internal/biz/business"
+)
+
+type workbenchRepo struct{ data Provider }
+
+func NewWorkbenchRepo(data Provider) business.WorkbenchRepo { return &workbenchRepo{data: data} }
+func workbenchAvailable(db *gorm.DB, scope business.AnalyticsScope, sources []string, kind business.WorkbenchRankKind) (bool, bool, error) {
+ empty, available, err := prepareAnalytics(db, scope, business.AnalyticsFilter{Sources: sources})
+ if err != nil || empty || !available {
+ return empty, available, err
+ }
+ tables := []string{}
+ if kind == "" || kind == business.WorkbenchPayHigh || kind == business.WorkbenchPayLow {
+ tables = append(tables, "tab_sw_report_day")
+ }
+ if kind == "" || kind == business.WorkbenchRegister {
+ tables = append(tables, "tab_sw_report_day_key")
+ }
+ for _, table := range tables {
+ available, err := businessTableAvailable(db, table)
+ if err != nil || !available {
+ return false, available, err
+ }
+ }
+ return false, true, nil
+}
+func (r *workbenchRepo) Summary(ctx context.Context, scope business.AnalyticsScope, sources []string, w business.WorkbenchWindow) (*business.WorkbenchTotals, error) {
+ db := r.data.DB().WithContext(ctx)
+ out := &business.WorkbenchTotals{Available: true}
+ empty, available, err := workbenchAvailable(db, scope, sources, "")
+ if err != nil {
+ return nil, err
+ }
+ out.Available = available
+ if empty || !available {
+ return out, nil
+ }
+ mode := 0
+ if scope.IDCardDedup {
+ mode = 1
+ }
+ for _, period := range []struct {
+ day time.Time
+ target *business.WorkbenchDay
+ }{{w.Today, &out.Today}, {w.Yesterday, &out.Yesterday}} {
+ f := business.AnalyticsFilter{Sources: sources, Begin: period.day.Format("2006-01-02"), End: period.day.Format("2006-01-02")}
+ var counts []portraitCountPO
+ if err := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode).Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Where("r.metric IN (1,2)").Group("r.metric").Scan(&counts).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ period.target.Counts = toPortraitCounts(counts)
+ if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(&period.target.Pay).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ // 活跃渠道按自身注册或正充值统计,不能归并一级渠道后再计数。
+ active := map[business.AnalyticsKey]bool{}
+ var registrations, pays []analyticsAggPO
+ if err := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode).Where("r.metric=1").Select("r.source_db,r.promote_id AS entity_id").Group("r.source_db,r.promote_id").Having("COUNT(DISTINCT r.identity_key)>0").Scan(®istrations).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("r.source_db,r.promote_id AS entity_id").Group("r.source_db,r.promote_id").Having("SUM(r.total_pay)>0").Scan(&pays).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, po := range append(registrations, pays...) {
+ if po.ID != nil {
+ active[business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}] = true
+ }
+ }
+ period.target.ActiveChannels = int64(len(active))
+ if err := analyticsBasePromotes(db, scope, f, "p").Where("p.create_time BETWEEN ? AND ?", period.day.Unix(), period.day.AddDate(0, 0, 1).Unix()-1).Count(&period.target.NewChannels).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ }
+ for _, period := range []struct {
+ begin, end time.Time
+ target *string
+ }{{w.MonthStart, w.Today, &out.MonthPay}, {w.PreviousMonthStart, w.PreviousMonthEnd, &out.PreviousMonthPay}} {
+ f := business.AnalyticsFilter{Sources: sources, Begin: period.begin.Format("2006-01-02"), End: period.end.Format("2006-01-02")}
+ if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(period.target).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ }
+ return out, nil
+}
+
+type workbenchPromotePO struct {
+ ID int64
+ SourceDB string `gorm:"column:source_db"`
+ Account string
+ CreateTime, LastLoginTime *int64
+}
+
+func (r *workbenchRepo) Ranks(ctx context.Context, scope business.AnalyticsScope, f business.AnalyticsFilter, kind business.WorkbenchRankKind) (*business.WorkbenchRankData, error) {
+ db := r.data.DB().WithContext(ctx)
+ out := &business.WorkbenchRankData{Rows: []*business.WorkbenchRankRow{}, Available: true}
+ empty, available, err := workbenchAvailable(db, scope, f.Sources, kind)
+ if err != nil {
+ return nil, err
+ }
+ out.Available = available
+ if empty || !available {
+ return out, nil
+ }
+ rows := map[business.AnalyticsKey]*business.WorkbenchRankRow{}
+ rowFor := func(source string, id int64) *business.WorkbenchRankRow {
+ key := business.AnalyticsKey{SourceDB: source, ID: id}
+ if rows[key] == nil {
+ rows[key] = &business.WorkbenchRankRow{AnalyticsKey: key, Value: "0"}
+ }
+ return rows[key]
+ }
+ if kind == business.WorkbenchPayLow || kind == business.WorkbenchUnlogin {
+ var channels []workbenchPromotePO
+ if err := analyticsBasePromotes(db, scope, f, "p").Select("p.id,p.source_db,p.account,p.create_time,p.last_login_time").Where("p.promote_level=1").Scan(&channels).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, po := range channels {
+ row := rowFor(po.SourceDB, po.ID)
+ row.Name, row.CreatedUnix, row.LastLoginUnix = po.Account, po.CreateTime, po.LastLoginTime
+ }
+ }
+ if kind != business.WorkbenchUnlogin {
+ mode, table := 0, "tab_sw_report_day"
+ if kind == business.WorkbenchRegister {
+ table = "tab_sw_report_day_key"
+ if scope.IDCardDedup {
+ mode = 1
+ }
+ }
+ q := analyticsQuery(db, scope, f, table, mode)
+ id := "r.promote_id"
+ if kind == business.WorkbenchRegister {
+ q = q.Where("r.metric=1")
+ if scope.IncludeSub {
+ // 旧工作台的注册榜使用 INNER JOIN + CASE,区别于渠道统计的历史 LEFT JOIN 口径。
+ q = q.Joins("INNER JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
+ id = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE root_p.id END"
+ }
+ } else {
+ 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 = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE COALESCE(root_p.id,r.promote_id) END"
+ }
+ value := "COALESCE(SUM(r.total_pay),0)"
+ if kind == business.WorkbenchRegister {
+ value = "COUNT(DISTINCT r.identity_key)"
+ }
+ var agg []analyticsAggPO
+ if err := q.Select("r.source_db," + id + " AS entity_id," + value + " AS amount").Group("r.source_db," + id).Scan(&agg).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, po := range agg {
+ if po.ID != nil {
+ rowFor(po.SourceDB, *po.ID).Value = po.Amount
+ }
+ }
+ keys := make([]business.AnalyticsKey, 0, len(rows))
+ for key := range rows {
+ keys = append(keys, key)
+ }
+ for start := 0; start < len(keys); start += 300 {
+ var names []workbenchPromotePO
+ if err := db.Table("tab_promote").Select("id,source_db,account").Where("(source_db,id) IN ?", analyticsPairs(keys[start:min(start+300, len(keys))])).Scan(&names).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, po := range names {
+ rowFor(po.SourceDB, po.ID).Name = po.Account
+ }
+ }
+ }
+ for _, row := range rows {
+ out.Rows = append(out.Rows, row)
+ }
+ return out, nil
+}
diff --git a/internal/data/business/workbench_test.go b/internal/data/business/workbench_test.go
new file mode 100644
index 0000000..e43b396
--- /dev/null
+++ b/internal/data/business/workbench_test.go
@@ -0,0 +1,87 @@
+package business
+
+import (
+ "context"
+ "kra/internal/biz/business"
+ "testing"
+ "time"
+)
+
+func TestWorkbenchStorageSummaryAndRankRules(t *testing.T) {
+ db, _ := prepareChannels(t)
+ ctx := context.Background()
+ day := time.Date(2026, 11, 4, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
+ for _, sql := range []string{
+ "ALTER TABLE tab_promote ADD COLUMN last_login_time INTEGER",
+ "INSERT INTO tab_promote(id,source_db,account,promote_level,parent_id,top_promote_id,busier_id,create_time) VALUES(105,'lx','zero-channel',1,0,0,14,1000)",
+ "DELETE FROM tab_sw_report_day",
+ "DELETE FROM tab_sw_report_day_key",
+ "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),('lx',103,1,0,'2026-11-04',-5),('lx',0,1,0,'2026-11-04',5),('lx',999,1,0,'2026-11-04',7),('lx',100,1,1,'2026-11-04',9999),('lx',100,1,0,'2026-11-03',5),('hw',100,1,0,'2026-11-03',5),('lx',100,1,0,'2026-11-01',1),('lx',100,1,0,'2026-10-01',8),('lx',100,1,0,'2026-10-04',12),('lx',100,1,0,'2026-10-05',999)",
+ "INSERT INTO tab_sw_report_day_key VALUES('lx',100,1,1,'2026-11-04',1,'A'),('lx',101,1,1,'2026-11-04',1,'A'),('hw',100,1,1,'2026-11-04',1,'A'),('lx',100,1,1,'2026-11-04',2,'A'),('lx',101,1,1,'2026-11-04',2,'B'),('hw',100,1,1,'2026-11-04',2,'A'),('lx',100,1,0,'2026-11-04',1,'1'),('lx',101,1,0,'2026-11-04',1,'2'),('hw',100,1,0,'2026-11-04',1,'1'),('lx',0,1,1,'2026-11-04',1,'official'),('lx',999,1,1,'2026-11-04',1,'orphan')",
+ } {
+ if err := db.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := db.Exec("UPDATE tab_promote SET create_time=? WHERE source_db='lx' AND id IN(100,101)", day.Unix()).Error; err != nil {
+ t.Fatal(err)
+ }
+ w := business.WorkbenchWindow{Today: day, Yesterday: day.AddDate(0, 0, -1), MonthStart: day.AddDate(0, 0, -3), PreviousMonthStart: day.AddDate(0, -1, -3), PreviousMonthEnd: day.AddDate(0, -1, 0)}
+ r := NewWorkbenchRepo(testData{db})
+ scope := business.AnalyticsScope{Viewer: channelTestScope(), IncludeSub: true, IDCardDedup: true}
+ sources := []string{"lx", "hw"}
+ out, err := r.Summary(ctx, scope, sources, w)
+ if err != nil || out.Today.Counts.Register != 1 || out.Today.Counts.Active != 2 || portraitTestMoney(out.Today.Pay) != "65.00" || out.Today.ActiveChannels != 3 || out.Today.NewChannels != 2 || portraitTestMoney(out.MonthPay) != "76.00" || portraitTestMoney(out.PreviousMonthPay) != "20.00" {
+ t.Fatalf("工作台统计口径错误: %+v %v", out, err)
+ }
+ scope.IDCardDedup = false
+ out, err = r.Summary(ctx, scope, sources, w)
+ if err != nil || out.Today.Counts.Register != 3 || portraitTestMoney(out.Today.Pay) != "65.00" {
+ t.Fatal("模式0应按平台前缀去重,金额不变")
+ }
+ scope.IncludeSub = false
+ out, err = r.Summary(ctx, scope, sources, w)
+ if err != nil || out.Today.Counts.Register != 2 || out.Today.NewChannels != 1 || out.Today.ActiveChannels != 2 || portraitTestMoney(out.Today.Pay) != "50.00" {
+ t.Fatal("下级偏好未作用到渠道数和金额")
+ }
+ scope = business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true, IDCardDedup: true}
+ f := business.AnalyticsFilter{Sources: sources, Begin: "2026-11-04", End: "2026-11-04"}
+ rows, err := r.Ranks(ctx, scope, f, business.WorkbenchRegister)
+ if err != nil || len(rows.Rows) != 2 {
+ t.Fatalf("注册榜含下级时应排除无渠道实体的官方/孤立记录: %+v %v", rows, err)
+ }
+ for _, row := range rows.Rows {
+ if row.ID != 100 || row.Value != "1" {
+ t.Fatal("注册榜应整棵树去重")
+ }
+ }
+ rows, err = r.Ranks(ctx, scope, f, business.WorkbenchPayLow)
+ if err != nil {
+ t.Fatal(err)
+ }
+ values := map[business.AnalyticsKey]string{}
+ for _, row := range rows.Rows {
+ values[row.AnalyticsKey] = portraitTestMoney(row.Value)
+ }
+ for id, want := range map[int64]string{0: "5.00", 100: "30.00", 103: "-5.00", 105: "0.00", 999: "7.00"} {
+ if values[business.AnalyticsKey{SourceDB: "lx", ID: id}] != want {
+ t.Fatalf("最低榜漏掉零实体或汇总错误: %+v", values)
+ }
+ }
+ scope.Viewer = channelTestScope()
+ rows, err = r.Ranks(ctx, scope, f, business.WorkbenchUnlogin)
+ if err != nil || len(rows.Rows) != 3 {
+ t.Fatal("未登录榜只取可见一级实体")
+ }
+ if err := db.Exec("DROP TABLE tab_sw_report_day").Error; err != nil {
+ t.Fatal(err)
+ }
+ rows, err = r.Ranks(ctx, scope, f, business.WorkbenchRegister)
+ if err != nil || !rows.Available {
+ t.Fatal("注册榜不应依赖金额汇总表")
+ }
+ out, err = r.Summary(ctx, scope, sources, w)
+ if err != nil || out.Available {
+ t.Fatal("六卡缺表应明确未接入")
+ }
+}
diff --git a/internal/data/data.go b/internal/data/data.go
index a199895..ef36edf 100644
--- a/internal/data/data.go
+++ b/internal/data/data.go
@@ -44,6 +44,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewLedgerRepo,
databusiness.NewAnalyticsRepo,
databusiness.NewServerAnalysisRepo,
+ databusiness.NewWorkbenchRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go
index fac1029..9d5c7ff 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 != 62 {
- t.Fatalf("admin surface = %d menus/%d APIs, want 17/62", menus, apis)
+ if menus != 17 || apis != 66 {
+ t.Fatalf("admin surface = %d menus/%d APIs, want 17/66", 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 0f8ee7c..33f672e 100644
--- a/internal/routecatalog/catalog.go
+++ b/internal/routecatalog/catalog.go
@@ -41,6 +41,10 @@ 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/workbench/summary": {group: "工作台", description: "查询商务六项指标与同期对照"},
+ "GET /sw/workbench/registerTop": {group: "工作台", description: "查询渠道注册排行"},
+ "GET /sw/workbench/payRank": {group: "工作台", description: "查询渠道充值最高与最低排行"},
+ "GET /sw/workbench/unloginTop": {group: "工作台", description: "查询未登录渠道排行"},
"GET /sw/serverAnalysis/servers": {group: "区服分析", description: "查询游戏区服选项"},
"GET /sw/serverAnalysis/list": {group: "区服分析", description: "查询区服分析与汇总"},
"GET /sw/channelData/list": {group: "渠道统计", description: "查询渠道数据与汇总"},
diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go
index ff78bde..7a56a47 100644
--- a/internal/server/gin_test.go
+++ b/internal/server/gin_test.go
@@ -28,6 +28,7 @@ func emptyHandlers() *handler.Set {
return &handler.Set{
Analytics: &handler.Analytics{},
ServerAnalysis: &handler.ServerAnalysis{},
+ Workbench: &handler.Workbench{},
Ledger: &handler.Ledger{},
ChannelPortrait: &handler.ChannelPortrait{},
BusinessPortrait: &handler.BusinessPortrait{},
@@ -108,7 +109,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":245`) {
+ if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":249`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go
index bceb264..57c614f 100644
--- a/internal/server/handler/provider.go
+++ b/internal/server/handler/provider.go
@@ -17,6 +17,7 @@ var ProviderSet = wire.NewSet(
NewLedger,
NewAnalytics,
NewServerAnalysis,
+ NewWorkbench,
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go
index 25cf9b8..b1a0388 100644
--- a/internal/server/handler/set.go
+++ b/internal/server/handler/set.go
@@ -1,6 +1,7 @@
package handler
type Set struct {
+ Workbench *Workbench
ServerAnalysis *ServerAnalysis
Analytics *Analytics
Ledger *Ledger
diff --git a/internal/server/handler/workbench.go b/internal/server/handler/workbench.go
new file mode 100644
index 0000000..0a09eb3
--- /dev/null
+++ b/internal/server/handler/workbench.go
@@ -0,0 +1,42 @@
+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 Workbench struct{ service *business.WorkbenchService }
+
+func NewWorkbench(service *business.WorkbenchService) *Workbench { return &Workbench{service: service} }
+func (h *Workbench) Summary(c *gin.Context) {
+ var req dto.WorkbenchQuery
+ if c.ShouldBindQuery(&req) != nil {
+ Fail(c, "工作台查询参数不正确")
+ return
+ }
+ data, err := h.service.Summary(c.Request.Context(), &req)
+ if err != nil {
+ Fail(c, errors.FromError(err).Message)
+ return
+ }
+ OKWithData(c, data)
+}
+func (h *Workbench) ranks(c *gin.Context, kind businessbiz.WorkbenchRankKind) {
+ var req dto.WorkbenchQuery
+ if c.ShouldBindQuery(&req) != nil {
+ Fail(c, "工作台查询参数不正确")
+ return
+ }
+ data, err := h.service.Ranks(c.Request.Context(), &req, kind)
+ if err != nil {
+ Fail(c, errors.FromError(err).Message)
+ return
+ }
+ OKWithData(c, data)
+}
+func (h *Workbench) RegisterTop(c *gin.Context) { h.ranks(c, businessbiz.WorkbenchRegister) }
+func (h *Workbench) PayRank(c *gin.Context) { h.ranks(c, businessbiz.WorkbenchPayHigh) }
+func (h *Workbench) UnloginTop(c *gin.Context) { h.ranks(c, businessbiz.WorkbenchUnlogin) }
diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go
index cbb1bf3..9f4fa46 100644
--- a/internal/server/router/routes.go
+++ b/internal/server/router/routes.go
@@ -20,6 +20,12 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
return
}
registrations := []func(){
+ func() {
+ private.GET("/sw/workbench/summary", r.handlers.Workbench.Summary)
+ private.GET("/sw/workbench/registerTop", r.handlers.Workbench.RegisterTop)
+ private.GET("/sw/workbench/payRank", r.handlers.Workbench.PayRank)
+ private.GET("/sw/workbench/unloginTop", r.handlers.Workbench.UnloginTop)
+ },
func() {
private.GET("/sw/serverAnalysis/servers", r.handlers.ServerAnalysis.Servers)
private.GET("/sw/serverAnalysis/list", r.handlers.ServerAnalysis.List)
diff --git a/internal/service/business/workbench.go b/internal/service/business/workbench.go
new file mode 100644
index 0000000..aa0bac6
--- /dev/null
+++ b/internal/service/business/workbench.go
@@ -0,0 +1,56 @@
+package business
+
+import (
+ "context"
+ "kra/internal/biz/business"
+ "kra/internal/service/dto"
+ "strings"
+)
+
+type WorkbenchUsecase interface {
+ Summary(context.Context, []string) (*business.WorkbenchSummary, error)
+ Ranks(context.Context, []string, string, business.WorkbenchRankKind) ([]*business.WorkbenchRankRow, error)
+}
+type WorkbenchService struct{ uc WorkbenchUsecase }
+
+func NewWorkbenchService(uc *business.WorkbenchUsecase) *WorkbenchService {
+ return &WorkbenchService{uc: uc}
+}
+func workbenchRequest(req *dto.WorkbenchQuery) ([]string, error) {
+ if req == nil {
+ return nil, business.ErrWorkbenchSource
+ }
+ return normalizePlatforms(req.Sources)
+}
+func workbenchCardResponse(card *business.WorkbenchCard) *dto.WorkbenchCard {
+ return &dto.WorkbenchCard{Value: card.Value, CompareValue: card.CompareValue, ChangeRate: card.ChangeRate, CompareLabel: card.CompareLabel}
+}
+func (s *WorkbenchService) Summary(ctx context.Context, req *dto.WorkbenchQuery) (*dto.WorkbenchSummary, error) {
+ sources, err := workbenchRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ data, err := s.uc.Summary(ctx, sources)
+ if err != nil {
+ return nil, err
+ }
+ return &dto.WorkbenchSummary{TodayRegister: workbenchCardResponse(data.TodayRegister), TodayActive: workbenchCardResponse(data.TodayActive), TodayPay: workbenchCardResponse(data.TodayPay), MonthPay: workbenchCardResponse(data.MonthPay), ActivePromote: workbenchCardResponse(data.ActivePromote), NewPromote: workbenchCardResponse(data.NewPromote), Available: data.Available}, nil
+}
+func (s *WorkbenchService) Ranks(ctx context.Context, req *dto.WorkbenchQuery, kind business.WorkbenchRankKind) ([]*dto.WorkbenchRankRow, error) {
+ sources, err := workbenchRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ if kind == business.WorkbenchPayHigh && strings.EqualFold(strings.TrimSpace(req.Order), "asc") {
+ kind = business.WorkbenchPayLow
+ }
+ rows, err := s.uc.Ranks(ctx, sources, req.Period, kind)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]*dto.WorkbenchRankRow, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, &dto.WorkbenchRankRow{Rank: row.Rank, SourceDB: row.SourceDB, SourceDBLabel: business.PlatformName(row.SourceDB), PromoteID: row.ID, PromoteName: row.Name, Value: row.Value})
+ }
+ return out, nil
+}
diff --git a/internal/service/dto/workbench.go b/internal/service/dto/workbench.go
new file mode 100644
index 0000000..98c6c53
--- /dev/null
+++ b/internal/service/dto/workbench.go
@@ -0,0 +1,31 @@
+package dto
+
+type WorkbenchQuery struct {
+ Sources []string `form:"sourceDbList"`
+ Period string `form:"period"`
+ Order string `form:"order"`
+}
+type WorkbenchCard struct {
+ Value *string `json:"value"`
+ CompareValue *string `json:"compareValue"`
+ ChangeRate *string `json:"changeRate"`
+ ChangeDiff *string `json:"changeDiff"`
+ CompareLabel string `json:"compareLabel"`
+}
+type WorkbenchSummary struct {
+ TodayRegister *WorkbenchCard `json:"todayRegister"`
+ TodayActive *WorkbenchCard `json:"todayActive"`
+ TodayPay *WorkbenchCard `json:"todayPay"`
+ MonthPay *WorkbenchCard `json:"monthPay"`
+ ActivePromote *WorkbenchCard `json:"activePromote"`
+ NewPromote *WorkbenchCard `json:"newPromote"`
+ Available bool `json:"available"`
+}
+type WorkbenchRankRow struct {
+ Rank int `json:"rank"`
+ SourceDB string `json:"sourceDb"`
+ SourceDBLabel string `json:"sourceDbLabel"`
+ PromoteID int64 `json:"promoteId"`
+ PromoteName string `json:"promoteName"`
+ Value string `json:"value"`
+}
diff --git a/internal/service/service.go b/internal/service/service.go
index 889b0da..3db23ac 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -26,6 +26,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewLedgerService,
businessservice.NewAnalyticsService,
businessservice.NewServerAnalysisService,
+ businessservice.NewWorkbenchService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
diff --git a/scripts/sw_workbench_http_smoke.py b/scripts/sw_workbench_http_smoke.py
new file mode 100644
index 0000000..9a4df6b
--- /dev/null
+++ b/scripts/sw_workbench_http_smoke.py
@@ -0,0 +1,41 @@
+"""真实会话只读验证工作台接口与缺表提示,不制造实际商务数据。"""
+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 endpoint in ['summary', 'registerTop', 'payRank', 'unloginTop']:
+ cases = [('', 7), ('sourceDbList=lx,hw,yq', 0 if endpoint == 'summary' else 7)]
+ if endpoint in ['registerTop', 'payRank']:
+ cases.append(('sourceDbList=lx&period=invalid', 7))
+ if endpoint == 'payRank':
+ cases.append(('sourceDbList=lx,hw,yq&order=asc&period=last30', 7))
+ for query, expected in cases:
+ path = '/sw/workbench/' + endpoint + '?' + 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 endpoint == 'summary' and expected == 0:
+ data = payload.get('data', {})
+ item['available'] = data.get('available')
+ item['passed'] &= data.get('available') is False and all(data.get(key, {}).get('value') is None for key in ['todayRegister', 'todayActive', 'todayPay', 'monthPay', 'activePromote', 'newPromote'])
+ 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/workbench.js b/web/src/api/workbench.js
new file mode 100644
index 0000000..1861a17
--- /dev/null
+++ b/web/src/api/workbench.js
@@ -0,0 +1,4 @@
+import service from '@/utils/request'
+
+export const getWorkbenchSummary = params => service({ url: '/sw/workbench/summary', method: 'get', params, donNotShowLoading: true })
+export const getWorkbenchRanks = (endpoint, params) => service({ url: `/sw/workbench/${endpoint}`, method: 'get', params, donNotShowLoading: true })
diff --git a/web/src/view/dashboard/components/BusinessDashboard.vue b/web/src/view/dashboard/components/BusinessDashboard.vue
new file mode 100644
index 0000000..2bb6de3
--- /dev/null
+++ b/web/src/view/dashboard/components/BusinessDashboard.vue
@@ -0,0 +1,125 @@
+
+ DASHBOARD {{ today }} · 当前展示后台实时数据 {{ today }} · 商务工作台欢迎回来,{{ userStore.userInfo.nickName }}
-