feat: 迁移渠道商务游戏排行榜及同期比较
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
businessbiz "kra/internal/biz/business"
|
||||||
|
"kra/internal/biz/system"
|
||||||
|
businessdata "kra/internal/data/business"
|
||||||
|
systemdata "kra/internal/data/system"
|
||||||
|
"kra/internal/server/handler"
|
||||||
|
businessservice "kra/internal/service/business"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRankingHTTP(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ranking.db")), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool, _ := db.DB()
|
||||||
|
t.Cleanup(func() { _ = pool.Close() })
|
||||||
|
for _, step := range append(systemdata.Migrations(), businessdata.Migrations()...) {
|
||||||
|
if err := step.Migrate(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sql := range []string{
|
||||||
|
"INSERT INTO sys_users(id,uuid,username,level,parent_id,id_path,enable) VALUES(1,'root','root',0,0,'0',1),(7,'owner','owner',1,0,'0',1),(8,'branch','branch',2,7,'0,7',1),(9,'leaf','leaf',3,8,'0,7,8',1),(10,'leaf2','leaf2',3,8,'0,7,8',1),(99,'other','other',1,0,'0',1)",
|
||||||
|
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(9,'lx',14,'0'),(10,'hw',14,'0'),(99,'yq',14,'0')",
|
||||||
|
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER)",
|
||||||
|
"INSERT INTO tab_promote VALUES(100,'lx','渠道甲',1,0,14),(101,'lx','渠道甲下级',2,100,14),(100,'hw','汇玩同号渠道',1,0,14),(100,'yq','其他商务渠道',1,0,14)",
|
||||||
|
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
|
||||||
|
"INSERT INTO tab_game VALUES(1,'lx','游戏甲'),(1,'hw','同号游戏'),(1,'yq','无权游戏')",
|
||||||
|
"CREATE TABLE tab_sw_report_day(source_db TEXT,promote_id INTEGER,game_id INTEGER,dedup_mode INTEGER,stat_date DATE,total_pay DECIMAL(20,2))",
|
||||||
|
"INSERT INTO tab_sw_report_day VALUES('lx',100,1,0,'2026-11-04',10),('lx',101,1,0,'2026-11-04',20),('hw',100,1,0,'2026-11-04',40),('yq',100,1,0,'2026-11-04',80),('lx',100,1,0,'2026-11-03',10),('lx',100,1,1,'2026-11-04',9999)",
|
||||||
|
"CREATE TABLE tab_sw_report_day_key(source_db TEXT,promote_id INTEGER,game_id INTEGER,dedup_mode INTEGER,stat_date DATE,metric INTEGER,identity_key TEXT)",
|
||||||
|
} {
|
||||||
|
if err := db.Exec(sql).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, row := range []struct {
|
||||||
|
source string
|
||||||
|
promote int
|
||||||
|
}{{"lx", 100}, {"lx", 101}, {"hw", 100}, {"yq", 100}} {
|
||||||
|
for _, metric := range []int{1, 2, 3} {
|
||||||
|
if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES(?,?,1,1,'2026-11-04',?,'A')", row.source, row.promote, metric).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Exec("UPDATE tab_sw_report_day_key SET identity_key='B' WHERE source_db='lx' AND promote_id=101 AND metric=2").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Exec("INSERT INTO tab_sw_report_day_key SELECT source_db,promote_id,game_id,dedup_mode,'2026-11-03',metric,identity_key FROM tab_sw_report_day_key WHERE source_db='lx' AND promote_id=100").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
provider := businessHTTPData{db}
|
||||||
|
uc := businessbiz.NewRankingUsecase(businessdata.NewRankingRepo(provider), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)))
|
||||||
|
h := handler.NewRanking(businessservice.NewRankingService(uc))
|
||||||
|
engine := gin.New()
|
||||||
|
engine.Use(func(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64)
|
||||||
|
aid := uint(901)
|
||||||
|
if id == 1 {
|
||||||
|
aid = 888
|
||||||
|
}
|
||||||
|
c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), system.Actor{UserID: uint(id), AuthorityID: aid}))
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
engine.GET("/sw/rank/channel", h.Channel)
|
||||||
|
engine.GET("/sw/rank/business", h.Business)
|
||||||
|
engine.GET("/sw/rank/game", h.Game)
|
||||||
|
srv := httptest.NewServer(engine)
|
||||||
|
defer srv.Close()
|
||||||
|
call := func(user int, kind, query string, code int) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", srv.URL+"/sw/rank/"+kind+"?"+query, nil)
|
||||||
|
req.Header.Set("X-Test-User", strconv.Itoa(user))
|
||||||
|
res, err := srv.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.StatusCode != 200 || body["code"] != float64(code) {
|
||||||
|
t.Fatalf("%s: %+v", kind, body)
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return body["data"].(map[string]any)
|
||||||
|
}
|
||||||
|
query := "beginTime=2026-11-04&endTime=2026-11-04&sourceDb=lx,hw,yq"
|
||||||
|
p := call(7, "channel", query, 0)
|
||||||
|
regs := p["registerRankList"].([]any)
|
||||||
|
if len(regs) != 2 {
|
||||||
|
t.Fatal("其他商务数据不应出现")
|
||||||
|
}
|
||||||
|
var lx map[string]any
|
||||||
|
for _, value := range p["payRateRankList"].([]any) {
|
||||||
|
row := value.(map[string]any)
|
||||||
|
if row["sourceDb"] == "lx" {
|
||||||
|
lx = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lx == nil || lx["metricValue"] != "50.00" || lx["momValue"] != "-50.00" || lx["momStatus"] != float64(-1) {
|
||||||
|
t.Fatalf("付费率应计算百分点差: %+v", lx)
|
||||||
|
}
|
||||||
|
p = call(7, "business", query, 0)
|
||||||
|
rows := p["registerRankList"].([]any)
|
||||||
|
row := rows[0].(map[string]any)
|
||||||
|
if len(rows) != 1 || row["objectId"] != "8" || row["objectName"] != "branch (L2)" || row["metricValue"] != "3" || row["sourceDb"] != nil {
|
||||||
|
t.Fatalf("商务范围或逐渠道加总错误: %+v", p)
|
||||||
|
}
|
||||||
|
row = p["rechargeRankList"].([]any)[0].(map[string]any)
|
||||||
|
if row["metricValue"] != "70.00" || row["momValue"] != "600.00" {
|
||||||
|
t.Fatal("商务金额环比错误")
|
||||||
|
}
|
||||||
|
p = call(8, "business", query, 0)
|
||||||
|
if len(p["registerRankList"].([]any)) != 2 {
|
||||||
|
t.Fatal("二级应看直属三级")
|
||||||
|
}
|
||||||
|
p = call(9, "business", query, 0)
|
||||||
|
if len(p["registerRankList"].([]any)) != 0 {
|
||||||
|
t.Fatal("三级不应展示商务排行")
|
||||||
|
}
|
||||||
|
p = call(1, "business", query, 0)
|
||||||
|
if len(p["registerRankList"].([]any)) != 5 {
|
||||||
|
t.Fatal("超管应展示所有有数据的商务层级")
|
||||||
|
}
|
||||||
|
p = call(7, "game", query+"&gameId=999", 0)
|
||||||
|
if len(p["registerRankList"].([]any)) != 2 {
|
||||||
|
t.Fatal("旧 gameId 参数未用于筛选,不能悄悄改变范围")
|
||||||
|
}
|
||||||
|
for _, v := range p["registerRankList"].([]any) {
|
||||||
|
row := v.(map[string]any)
|
||||||
|
if row["objectId"] != row["sourceDb"].(string)+"|1" {
|
||||||
|
t.Fatal("游戏键应包含平台")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call(7, "channel", "beginTime=bad&endTime=2026-11-04", 7)
|
||||||
|
call(7, "game", "beginTime=2026-11-05&endTime=2026-11-04", 7)
|
||||||
|
if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p = call(7, "channel", query, 0)
|
||||||
|
if p["available"] != false || len(p["registerRankList"].([]any)) != 0 {
|
||||||
|
t.Fatal("缺表不应返回假排行")
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6
-1
@@ -82,11 +82,15 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
|||||||
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
|
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
|
||||||
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
|
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
|
||||||
registry := taskRegistry()
|
registry := taskRegistry()
|
||||||
workbenchRepo := business.NewWorkbenchRepo(dataData)
|
rankingRepo := business.NewRankingRepo(dataData)
|
||||||
accountRepo := business.NewAccountRepo(dataData)
|
accountRepo := business.NewAccountRepo(dataData)
|
||||||
accountUsecase := business2.NewAccountUsecase(accountRepo)
|
accountUsecase := business2.NewAccountUsecase(accountRepo)
|
||||||
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
|
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
|
||||||
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
|
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
|
||||||
|
rankingUsecase := business2.NewRankingUsecase(rankingRepo, accountUsecase, userPreferenceUsecase)
|
||||||
|
rankingService := business3.NewRankingService(rankingUsecase)
|
||||||
|
ranking := handler.NewRanking(rankingService)
|
||||||
|
workbenchRepo := business.NewWorkbenchRepo(dataData)
|
||||||
workbenchUsecase := business2.NewWorkbenchUsecase(workbenchRepo, accountUsecase, userPreferenceUsecase)
|
workbenchUsecase := business2.NewWorkbenchUsecase(workbenchRepo, accountUsecase, userPreferenceUsecase)
|
||||||
workbenchService := business3.NewWorkbenchService(workbenchUsecase)
|
workbenchService := business3.NewWorkbenchService(workbenchUsecase)
|
||||||
workbench := handler.NewWorkbench(workbenchService)
|
workbench := handler.NewWorkbench(workbenchService)
|
||||||
@@ -243,6 +247,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
|||||||
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
|
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
|
||||||
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
||||||
set := &handler.Set{
|
set := &handler.Set{
|
||||||
|
Ranking: ranking,
|
||||||
Workbench: workbench,
|
Workbench: workbench,
|
||||||
ServerAnalysis: serverAnalysis,
|
ServerAnalysis: serverAnalysis,
|
||||||
Analytics: analytics,
|
Analytics: analytics,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 |
|
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 |
|
||||||
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 |
|
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 |
|
||||||
| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 功能已接入;包含渠道画像,完整业务数据和源平台联调仍待最终验收 |
|
| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 功能已接入;包含渠道画像,完整业务数据和源平台联调仍待最终验收 |
|
||||||
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出、渠道/游戏/区服统计、工作台仪表盘已接入,独立排行与任务待迁移 |
|
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出、渠道/游戏/区服统计、独立排行榜与工作台仪表盘已接入,汇总构建及预警任务待迁移 |
|
||||||
| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 |
|
| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 |
|
||||||
| 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 |
|
| 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 |
|
||||||
|
|
||||||
@@ -99,3 +99,5 @@
|
|||||||
- 工作台四个接口及 Kra 仪表盘业务区域已接入:六项指标、注册/充值 TOP10、未登录 TOP10、最低充值 TOP10 和已有预警的最新五条轮播。保留 Kra 欢迎区域、快捷入口与公告,平台和周期按旧页面设置;请求失败清空旧结果并提示,不能显示其他平台的缓存值。
|
- 工作台四个接口及 Kra 仪表盘业务区域已接入:六项指标、注册/充值 TOP10、未登录 TOP10、最低充值 TOP10 和已有预警的最新五条轮播。保留 Kra 欢迎区域、快捷入口与公告,平台和周期按旧页面设置;请求失败清空旧结果并提示,不能显示其他平台的缓存值。
|
||||||
- 工作台保留本月对上月同日(截断月末)、模式 0 跨平台 ID 隔离、渠道树注册去重、金额固定模式 0、活跃渠道按自身注册/正充值计数、最低榜含零一级渠道,以及未登录回退创建时间和 99999 哨兵值。详见 `workbench-source-notes.md`。
|
- 工作台保留本月对上月同日(截断月末)、模式 0 跨平台 ID 隔离、渠道树注册去重、金额固定模式 0、活跃渠道按自身注册/正充值计数、最低榜含零一级渠道,以及未登录回退创建时间和 99999 哨兵值。详见 `workbench-source-notes.md`。
|
||||||
- 工作台批次全量 Go、隔离 TCP HTTP、领域/仓储测试、前端 ESLint/构建通过;真实 sw7 请求记录见 `workbench-http-2026-11-04.json`。缺业务表时六卡返回 null、榜单明确报未接入。未执行页面交互检查或最终旧库复制。
|
- 工作台批次全量 Go、隔离 TCP HTTP、领域/仓储测试、前端 ESLint/构建通过;真实 sw7 请求记录见 `workbench-http-2026-11-04.json`。缺业务表时六卡返回 null、榜单明确报未接入。未执行页面交互检查或最终旧库复制。
|
||||||
|
- 独立排行榜渠道、商务、游戏三个接口和 Kra 页面已接入,各有注册/充值/付费率/活跃 TOP20,保留平台、维度独立日期记忆、近 7 天默认周期和等长上期。付费率用百分点差,其余用百分比环比。商务按直属下级展示、合并其整个下级绑定树,再累加各渠道人数;三级不展示商务排行。
|
||||||
|
- 排行批次全量 Go、隔离 TCP HTTP、领域/仓储测试、前端 ESLint/构建、真实 sw7 接口检查通过,见 `ranking-http-2026-11-04.json` 与 `ranking-source-notes.md`。当前 SQL 清单 63 个目标接口中 51 个已注册;其余 12 个均为 `SysUser` 历史系统别名,详见 `system-api-audit.md`,不能将接口注册数量当作全迁移完成证明。汇总和预警任务、最终旧库迁移验收仍待完成。
|
||||||
|
|||||||
@@ -192,3 +192,9 @@
|
|||||||
直接核对旧 `views/index.vue`,在原 Kra 仪表盘中加入六项商务指标、平台多选、注册/充值两组横向榜图、未登录/最低充值两组榜表,以及最新五条待处理预警的四秒轮播和查看全部入口。注册/最高充值默认今日,最低充值默认近 30 天;每个榜单单独切换周期。保留 Kra 欢迎、快捷入口和公告,使用 Kra 卡片、颜色与 Element Plus 控件。没有移植旧页面的自定义图表样式。
|
直接核对旧 `views/index.vue`,在原 Kra 仪表盘中加入六项商务指标、平台多选、注册/充值两组横向榜图、未登录/最低充值两组榜表,以及最新五条待处理预警的四秒轮播和查看全部入口。注册/最高充值默认今日,最低充值默认近 30 天;每个榜单单独切换周期。保留 Kra 欢迎、快捷入口和公告,使用 Kra 卡片、颜色与 Element Plus 控件。没有移植旧页面的自定义图表样式。
|
||||||
|
|
||||||
四个工作台接口和已有预警接口负责真实业务数据;接口缺表或错误明确提示,不将空选择、未接入和真实零混淆。按用户要求仅做接口实测、数据断言和构建,未做页面交互检查。
|
四个工作台接口和已有预警接口负责真实业务数据;接口缺表或错误明确提示,不将空选择、未接入和真实零混淆。按用户要求仅做接口实测、数据断言和构建,未做页面交互检查。
|
||||||
|
|
||||||
|
## 独立排行榜
|
||||||
|
|
||||||
|
按旧 `RankingAnalysis.vue` 保留渠道/商务/游戏三个页签、平台多选(空选表示全部)、各维度独立记忆的日期、默认近 7 天,以及注册/充值/付费率/活跃四组 TOP20。不足 20 条显示占位符,旧请求不覆盖新维度。界面采用 Kra 表格和配色;付费率的环比文案明确为百分点,修复旧页面将百分点也显示成百分比的歧义。没有额外增加分页、游戏选择或导出入口。
|
||||||
|
|
||||||
|
接口实测、隔离数据 HTTP、全量 Go 和前端构建通过。按用户要求未检查页面交互。详见 `ranking-source-notes.md`。
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"scope": "真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向排行和权限通过隔离 TCP HTTP 测试。",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"path": "/sw/rank/channel?sourceDb=lx,hw,yq",
|
||||||
|
"http": 200,
|
||||||
|
"code": 0,
|
||||||
|
"msg": "成功",
|
||||||
|
"passed": true,
|
||||||
|
"available": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sw/rank/channel?beginTime=bad&endTime=2026-11-04",
|
||||||
|
"http": 200,
|
||||||
|
"code": 7,
|
||||||
|
"msg": "统计周期格式错误,应为 yyyy-MM-dd",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sw/rank/channel?beginTime=2026-11-05&endTime=2026-11-04",
|
||||||
|
"http": 200,
|
||||||
|
"code": 7,
|
||||||
|
"msg": "统计开始日期不能晚于结束日期",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sw/rank/business?sourceDb=lx,hw,yq",
|
||||||
|
"http": 200,
|
||||||
|
"code": 0,
|
||||||
|
"msg": "成功",
|
||||||
|
"passed": true,
|
||||||
|
"available": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sw/rank/business?beginTime=bad&endTime=2026-11-04",
|
||||||
|
"http": 200,
|
||||||
|
"code": 7,
|
||||||
|
"msg": "统计周期格式错误,应为 yyyy-MM-dd",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sw/rank/business?beginTime=2026-11-05&endTime=2026-11-04",
|
||||||
|
"http": 200,
|
||||||
|
"code": 7,
|
||||||
|
"msg": "统计开始日期不能晚于结束日期",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sw/rank/game?sourceDb=lx,hw,yq",
|
||||||
|
"http": 200,
|
||||||
|
"code": 0,
|
||||||
|
"msg": "成功",
|
||||||
|
"passed": true,
|
||||||
|
"available": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sw/rank/game?beginTime=bad&endTime=2026-11-04",
|
||||||
|
"http": 200,
|
||||||
|
"code": 7,
|
||||||
|
"msg": "统计周期格式错误,应为 yyyy-MM-dd",
|
||||||
|
"passed": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/sw/rank/game?beginTime=2026-11-05&endTime=2026-11-04",
|
||||||
|
"http": 200,
|
||||||
|
"code": 7,
|
||||||
|
"msg": "统计开始日期不能晚于结束日期",
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"passed": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 独立排行榜迁移核对
|
||||||
|
|
||||||
|
来源为旧 main `RankServiceImpl`、`RankController`、日汇总和身份键 Mapper,以及 dev/dev1.0 `RankingAnalysis.vue`。
|
||||||
|
|
||||||
|
- `/sw/rank/channel`、`business`、`game` 各返回注册、充值、付费率、活跃四组 TOP20。日期缺一项时默认上海当天,前端默认近 7 天。上期为当前开始日之前的等长连续区间。
|
||||||
|
- 渠道榜金额和人数均使用旧 `COALESCE(NULLIF(top_promote_id,0),promote_id)` 分组,即使不包含下级也保留根算法。不能复用工作台的 CASE 根算法或注册榜 INNER JOIN;真实历史异常 top ID 已有测试覆盖。
|
||||||
|
- 游戏榜按平台+游戏 ID,展示当前游戏名,缺名为“游戏ID”。objectId 保留 `source|id`;渠道 objectId 为 ID 字符串。旧 query 的 gameId 实际没有用于筛选,新接口保留其无筛选行为,页面不添加旧模板不存在的游戏选择器。
|
||||||
|
- 商务榜超管列出一/二/三级商务,普通商务看直属下级,三级空榜。每个被展示商务的数据来自本人及全部下级的正常绑定渠道。一次关系查询展开目标、下级、绑定、渠道,代替旧每个目标单独查询下级的 N+1;查询条件保持各目标自己的祖先路径范围。
|
||||||
|
- 商务人数累加各渠道人数,不做跨渠道全局去重;金额累加同一组渠道。逐渠道保留 Java int 上限,商务总数采用 int64 避免旧 int 溢出。祖先与下级可以分别上榜;不是互斥分区。
|
||||||
|
- 注册、充值、活跃只展示正值;付费率只要求活跃大于 0,保留 0% 行。金额固定模式 0,人数取当前偏好。
|
||||||
|
- 注册/充值/活跃环比需要上期正值,否则为 null;付费率只要求上期存在行,上期活跃为零按 0% 比较,结果为百分点差。金额展示先保留两位,金额环比用未舍入汇总,与旧服务一致。
|
||||||
|
- 环比保留两位,绝对值小于 0.01 为持平。数值排序用精确有理数,同值追加平台/ID 稳定排序,截取后连续编号。
|
||||||
|
- 源码还核对了空平台含义:独立排行榜空选表示全部,区别于工作台必须选平台。非法/倒置日期明确拒绝。缺报表时 available=false 和空数组,不伪造零指标或空查询成功。
|
||||||
|
- 当前菜单名称、父级和路径已通过实际菜单接口核实,仍为 `sw_DataAnalysis` 下 `sw_RankingAnalysis` / `RankingAnalysis`。
|
||||||
|
|
||||||
|
已通过全量 Go、领域/仓储测试、独立 SQLite 的真实 TCP HTTP、ESLint/生产构建、真实 sw7 只读请求。没有页面交互检查,也没有进行最终旧库复制或同源比较。
|
||||||
+1
-1
@@ -11,4 +11,4 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ProviderSet is biz providers.
|
// ProviderSet is biz providers.
|
||||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase)
|
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase)
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RankingKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ChannelRanking RankingKind = "channel"
|
||||||
|
BusinessRanking RankingKind = "business"
|
||||||
|
GameRanking RankingKind = "game"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RankingRange struct {
|
||||||
|
AnalyticsFilter
|
||||||
|
PreviousBegin, PreviousEnd string
|
||||||
|
}
|
||||||
|
type RankingMetrics struct {
|
||||||
|
Counts PortraitCounts
|
||||||
|
Pay, Name string
|
||||||
|
}
|
||||||
|
type RankingBusiness struct {
|
||||||
|
ID uint
|
||||||
|
Name string
|
||||||
|
Level int
|
||||||
|
Channels []AnalyticsKey
|
||||||
|
}
|
||||||
|
type RankingData struct {
|
||||||
|
Current, Previous map[AnalyticsKey]*RankingMetrics
|
||||||
|
Businesses []*RankingBusiness
|
||||||
|
Available bool
|
||||||
|
}
|
||||||
|
type RankingItem struct {
|
||||||
|
AnalyticsKey
|
||||||
|
ObjectID, Name, Value string
|
||||||
|
Rank int
|
||||||
|
MoM *string
|
||||||
|
MoMStatus *int
|
||||||
|
}
|
||||||
|
type RankingPage struct {
|
||||||
|
Register, Recharge, PayRate, Active []*RankingItem
|
||||||
|
Begin, End string
|
||||||
|
Available bool
|
||||||
|
}
|
||||||
|
type RankingRepo interface {
|
||||||
|
Collect(context.Context, RankingKind, AnalyticsScope, RankingRange) (*RankingData, error)
|
||||||
|
}
|
||||||
|
type RankingUsecase struct {
|
||||||
|
repo RankingRepo
|
||||||
|
accounts *AccountUsecase
|
||||||
|
preferences *UserPreferenceUsecase
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRankingUsecase(repo RankingRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *RankingUsecase {
|
||||||
|
return &RankingUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
|
||||||
|
}
|
||||||
|
func (uc *RankingUsecase) List(ctx context.Context, kind RankingKind, f AnalyticsFilter) (*RankingPage, error) {
|
||||||
|
actor, account, err := uc.accounts.actor(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if kind != ChannelRanking && kind != BusinessRanking && kind != GameRanking {
|
||||||
|
return nil, ErrAnalyticsInput
|
||||||
|
}
|
||||||
|
if f.StartDate == nil || f.EndDate == nil {
|
||||||
|
day := workbenchWindow(uc.now()).Today
|
||||||
|
f.StartDate, f.EndDate = &day, &day
|
||||||
|
}
|
||||||
|
if f.EndDate.Before(*f.StartDate) {
|
||||||
|
return nil, ErrAnalyticsDateOrder
|
||||||
|
}
|
||||||
|
f.Begin, f.End = f.StartDate.Format("2006-01-02"), f.EndDate.Format("2006-01-02")
|
||||||
|
out := &RankingPage{Register: []*RankingItem{}, Recharge: []*RankingItem{}, PayRate: []*RankingItem{}, Active: []*RankingItem{}, Begin: f.Begin, End: f.End, Available: true}
|
||||||
|
if kind == BusinessRanking && actor.AuthorityID != 888 && account.Level >= 3 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
pref, err := uc.preferences.Current(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
viewer := portraitUserScope(account)
|
||||||
|
viewer.All = actor.AuthorityID == 888
|
||||||
|
days := int((f.EndDate.Unix()-f.StartDate.Unix())/86400) + 1
|
||||||
|
rangeFilter := RankingRange{AnalyticsFilter: f, PreviousBegin: f.StartDate.AddDate(0, 0, -days).Format("2006-01-02"), PreviousEnd: f.StartDate.AddDate(0, 0, -1).Format("2006-01-02")}
|
||||||
|
data, err := uc.repo.Collect(ctx, kind, AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, rangeFilter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Available = data.Available
|
||||||
|
if !data.Available {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
for _, period := range []map[AnalyticsKey]*RankingMetrics{data.Current, data.Previous} {
|
||||||
|
for _, row := range period {
|
||||||
|
row.Counts = analyticsCap(row.Counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == BusinessRanking {
|
||||||
|
data.Current, err = aggregateRankingBusinesses(data.Businesses, data.Current)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data.Previous, err = aggregateRankingBusinesses(data.Businesses, data.Previous)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, metric := range []struct {
|
||||||
|
name string
|
||||||
|
target *[]*RankingItem
|
||||||
|
}{{"register", &out.Register}, {"pay", &out.Recharge}, {"rate", &out.PayRate}, {"active", &out.Active}} {
|
||||||
|
rows, err := buildRankingItems(kind, metric.name, data.Current, data.Previous)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
*metric.target = rows
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func aggregateRankingBusinesses(businesses []*RankingBusiness, channels map[AnalyticsKey]*RankingMetrics) (map[AnalyticsKey]*RankingMetrics, error) {
|
||||||
|
out := map[AnalyticsKey]*RankingMetrics{}
|
||||||
|
for _, target := range businesses {
|
||||||
|
if len(target.Channels) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := &RankingMetrics{Name: fmt.Sprintf("%s (L%d)", target.Name, target.Level)}
|
||||||
|
pay := new(big.Rat)
|
||||||
|
for _, key := range target.Channels {
|
||||||
|
if c := channels[key]; c != nil {
|
||||||
|
row.Counts = analyticsAdd(row.Counts, c.Counts)
|
||||||
|
n, err := portraitNumber(c.Pay)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pay.Add(pay, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 商务人数沿用各渠道人数相加;使用 int64 避免原 Java int 溢出,不跨渠道重新去重。
|
||||||
|
row.Pay = pay.RatString()
|
||||||
|
out[AnalyticsKey{ID: int64(target.ID)}] = row
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func rankingValue(row *RankingMetrics, metric string) (*big.Rat, bool, error) {
|
||||||
|
if row == nil {
|
||||||
|
return new(big.Rat), false, nil
|
||||||
|
}
|
||||||
|
switch metric {
|
||||||
|
case "register":
|
||||||
|
return big.NewRat(row.Counts.Register, 1), row.Counts.Register > 0, nil
|
||||||
|
case "active":
|
||||||
|
return big.NewRat(row.Counts.Active, 1), row.Counts.Active > 0, nil
|
||||||
|
case "rate":
|
||||||
|
value := analyticsRatio(big.NewRat(row.Counts.Pay, 1), row.Counts.Active, true)
|
||||||
|
n, _ := portraitNumber(value)
|
||||||
|
return n, row.Counts.Active > 0, nil
|
||||||
|
default:
|
||||||
|
n, err := portraitNumber(row.Pay)
|
||||||
|
return n, err == nil && n.Sign() > 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func buildRankingItems(kind RankingKind, metric string, current, previous map[AnalyticsKey]*RankingMetrics) ([]*RankingItem, error) {
|
||||||
|
rows := []*RankingItem{}
|
||||||
|
values := map[*RankingItem]*big.Rat{}
|
||||||
|
for key, row := range current {
|
||||||
|
value, keep, err := rankingValue(row, metric)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !keep {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := &RankingItem{AnalyticsKey: key, ObjectID: strconv.FormatInt(key.ID, 10), Name: row.Name}
|
||||||
|
if kind == GameRanking {
|
||||||
|
item.ObjectID = key.SourceDB + "|" + item.ObjectID
|
||||||
|
if strings.TrimSpace(item.Name) == "" {
|
||||||
|
item.Name = "游戏" + strconv.FormatInt(key.ID, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == ChannelRanking {
|
||||||
|
item.Name = (&LedgerNames{PromoteID: &key.ID, PromoteAccount: row.Name}).DisplayPromote()
|
||||||
|
}
|
||||||
|
digits := 0
|
||||||
|
if metric == "pay" || metric == "rate" {
|
||||||
|
digits = 2
|
||||||
|
}
|
||||||
|
item.Value = portraitDecimal(value, digits)
|
||||||
|
values[item], _ = portraitNumber(item.Value)
|
||||||
|
prev := previous[key]
|
||||||
|
if prev != nil {
|
||||||
|
prior, valid, err := rankingValue(prev, metric)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if metric == "rate" || valid {
|
||||||
|
change := new(big.Rat).Sub(value, prior)
|
||||||
|
if metric != "rate" {
|
||||||
|
change.Mul(change, big.NewRat(100, 1))
|
||||||
|
change.Quo(change, prior)
|
||||||
|
}
|
||||||
|
item.MoM = stringPointer(portraitDecimal(change, 2))
|
||||||
|
rounded, _ := portraitNumber(*item.MoM)
|
||||||
|
status := 0
|
||||||
|
if new(big.Rat).Abs(rounded).Cmp(big.NewRat(1, 100)) >= 0 {
|
||||||
|
status = rounded.Sign()
|
||||||
|
}
|
||||||
|
item.MoMStatus = &status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, item)
|
||||||
|
}
|
||||||
|
sort.SliceStable(rows, func(i, j int) bool {
|
||||||
|
a, b := rows[i], rows[j]
|
||||||
|
if c := values[a].Cmp(values[b]); c != 0 {
|
||||||
|
return c > 0
|
||||||
|
}
|
||||||
|
if a.SourceDB != b.SourceDB {
|
||||||
|
return a.SourceDB < b.SourceDB
|
||||||
|
}
|
||||||
|
return a.ID < b.ID
|
||||||
|
})
|
||||||
|
rows = rows[:min(20, len(rows))]
|
||||||
|
for i, row := range rows {
|
||||||
|
row.Rank = i + 1
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rankingRepoFake struct {
|
||||||
|
data *RankingData
|
||||||
|
filter RankingRange
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rankingRepoFake) Collect(_ context.Context, _ RankingKind, _ AnalyticsScope, f RankingRange) (*RankingData, error) {
|
||||||
|
r.filter = f
|
||||||
|
r.calls++
|
||||||
|
out := &RankingData{Available: r.data.Available, Businesses: r.data.Businesses, Current: map[AnalyticsKey]*RankingMetrics{}, Previous: map[AnalyticsKey]*RankingMetrics{}}
|
||||||
|
for k, v := range r.data.Current {
|
||||||
|
c := *v
|
||||||
|
out.Current[k] = &c
|
||||||
|
}
|
||||||
|
for k, v := range r.data.Previous {
|
||||||
|
c := *v
|
||||||
|
out.Previous[k] = &c
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func TestRankingComparisonsAndBusinessCountSemantics(t *testing.T) {
|
||||||
|
k := AnalyticsKey{SourceDB: "lx", ID: 1}
|
||||||
|
zero := AnalyticsKey{SourceDB: "lx", ID: 2}
|
||||||
|
r := &rankingRepoFake{data: &RankingData{Available: true, Current: map[AnalyticsKey]*RankingMetrics{k: {Counts: PortraitCounts{Register: 3, Active: 4, Pay: 2}, Pay: "20.005"}, zero: {Counts: PortraitCounts{Active: 1}}}, Previous: map[AnalyticsKey]*RankingMetrics{k: {Counts: PortraitCounts{Register: 2, Active: 2, Pay: 1}, Pay: "10"}}}}
|
||||||
|
a := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Level: 1, IDPath: "0"}, 9: {ID: 9, Level: 3, IDPath: "0,7"}}}
|
||||||
|
uc := NewRankingUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}))
|
||||||
|
uc.now = func() time.Time { return time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation) }
|
||||||
|
begin, end := time.Date(2026, 10, 29, 0, 0, 0, 0, portraitLocation), time.Date(2026, 11, 4, 0, 0, 0, 0, portraitLocation)
|
||||||
|
p, err := uc.List(accountContext(7, 901), GameRanking, AnalyticsFilter{StartDate: &begin, EndDate: &end})
|
||||||
|
if err != nil || len(p.Register) != 1 || p.Register[0].ObjectID != "lx|1" || p.Register[0].Name != "游戏1" || *p.Register[0].MoM != "50.00" || p.Recharge[0].Value != "20.01" || *p.Recharge[0].MoM != "100.05" || len(p.PayRate) != 2 || p.PayRate[1].Value != "0.00" || p.PayRate[1].MoM != nil {
|
||||||
|
t.Fatalf("排行筛选、命名或环比错误: %+v %v", p, err)
|
||||||
|
}
|
||||||
|
if *p.PayRate[0].MoM != "0.00" || *p.PayRate[0].MoMStatus != 0 || r.filter.PreviousBegin != "2026-10-22" || r.filter.PreviousEnd != "2026-10-28" {
|
||||||
|
t.Fatal("等长上期或百分点差错误")
|
||||||
|
}
|
||||||
|
r.data.Businesses = []*RankingBusiness{{ID: 8, Name: "二级商务", Level: 2, Channels: []AnalyticsKey{k, zero}}}
|
||||||
|
p, err = uc.List(accountContext(7, 901), BusinessRanking, AnalyticsFilter{})
|
||||||
|
if err != nil || p.Active[0].Value != "5" || p.PayRate[0].Value != "40.00" || p.Register[0].Name != "二级商务 (L2)" || p.Register[0].SourceDB != "" {
|
||||||
|
t.Fatal("商务需加总各渠道人数,不全局去重")
|
||||||
|
}
|
||||||
|
calls := r.calls
|
||||||
|
p, err = uc.List(accountContext(9, 903), BusinessRanking, AnalyticsFilter{})
|
||||||
|
if err != nil || len(p.Register) != 0 || r.calls != calls {
|
||||||
|
t.Fatal("三级商务不得展开商务榜查询")
|
||||||
|
}
|
||||||
|
r.data.Available = false
|
||||||
|
p, err = uc.List(accountContext(7, 901), ChannelRanking, AnalyticsFilter{})
|
||||||
|
if err != nil || p.Available || len(p.Active) != 0 {
|
||||||
|
t.Fatal("缺数据不能产生伪造排行")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestRankingTopTwentyAndStatusThreshold(t *testing.T) {
|
||||||
|
current, previous := map[AnalyticsKey]*RankingMetrics{}, map[AnalyticsKey]*RankingMetrics{}
|
||||||
|
for i := int64(1); i <= 25; i++ {
|
||||||
|
k := AnalyticsKey{SourceDB: "lx", ID: i}
|
||||||
|
current[k] = &RankingMetrics{Counts: PortraitCounts{Active: i}, Pay: "9007199254740992.99"}
|
||||||
|
previous[k] = &RankingMetrics{Counts: PortraitCounts{Active: i}}
|
||||||
|
}
|
||||||
|
current[AnalyticsKey{SourceDB: "lx", ID: 25}].Pay = "9007199254740993.00"
|
||||||
|
rows, err := buildRankingItems(ChannelRanking, "pay", current, previous)
|
||||||
|
if err != nil || len(rows) != 20 || rows[0].ID != 25 || rows[19].Rank != 20 {
|
||||||
|
t.Fatal("TOP20 或精确金额排序错误")
|
||||||
|
}
|
||||||
|
rows, err = buildRankingItems(ChannelRanking, "active", current, previous)
|
||||||
|
if err != nil || *rows[0].MoMStatus != 0 || *rows[0].MoM != "0.00" {
|
||||||
|
t.Fatal("持平环比错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rankingRepo struct{ data Provider }
|
||||||
|
|
||||||
|
func NewRankingRepo(data Provider) business.RankingRepo { return &rankingRepo{data: data} }
|
||||||
|
|
||||||
|
// 通过一次关系查询展开各上榜商务的下级绑定,避免旧服务对每个商务查询下级。
|
||||||
|
func rankingBusinessBindings(db *gorm.DB, scope business.AnalyticsScope, sources []string) *gorm.DB {
|
||||||
|
q := db.Table("sys_users target").Where("target.deleted_at IS NULL")
|
||||||
|
if scope.Viewer.All {
|
||||||
|
q = q.Where("target.level IN (1,2,3)")
|
||||||
|
} else {
|
||||||
|
q = q.Where("target.parent_id=?", scope.Viewer.SelfID)
|
||||||
|
}
|
||||||
|
prefix := "(COALESCE(NULLIF(target.id_path,''),'0') || ',' || target.id)"
|
||||||
|
if db.Dialector.Name() == "mysql" {
|
||||||
|
prefix = "CONCAT(COALESCE(NULLIF(target.id_path,''),'0'),',',target.id)"
|
||||||
|
}
|
||||||
|
like := prefix + " || ',%'"
|
||||||
|
if db.Dialector.Name() == "mysql" {
|
||||||
|
like = "CONCAT(" + prefix + ",',%')"
|
||||||
|
}
|
||||||
|
q = q.Joins("JOIN sys_users child ON child.deleted_at IS NULL AND (child.id=target.id OR (target.level<3 AND (child.id_path=" + prefix + " OR child.id_path LIKE (" + like + "))))").Joins("JOIN sys_user_bind_platform b ON b.user_id=child.id AND b.status='0'")
|
||||||
|
if len(sources) > 0 {
|
||||||
|
q = q.Where("b.source_db IN ?", sources)
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
func rankingBusinessPromotes(db *gorm.DB, scope business.AnalyticsScope, sources []string) *gorm.DB {
|
||||||
|
q := rankingBusinessBindings(db, scope, sources).Joins("JOIN tab_promote p ON p.source_db=b.source_db AND p.busier_id=b.platform_busier_id")
|
||||||
|
if !scope.IncludeSub {
|
||||||
|
q = q.Where("p.promote_level=1")
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
func rankingReportQuery(db *gorm.DB, kind business.RankingKind, scope business.AnalyticsScope, f business.AnalyticsFilter, table string, mode int) *gorm.DB {
|
||||||
|
if kind != business.BusinessRanking {
|
||||||
|
return analyticsQuery(db, scope, f, table, mode)
|
||||||
|
}
|
||||||
|
all := business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true}
|
||||||
|
return analyticsQuery(db, all, f, table, mode).Where("EXISTS (?)", rankingBusinessPromotes(db, scope, f.Sources).Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
|
||||||
|
}
|
||||||
|
func (r *rankingRepo) Collect(ctx context.Context, kind business.RankingKind, scope business.AnalyticsScope, f business.RankingRange) (*business.RankingData, error) {
|
||||||
|
db := r.data.DB().WithContext(ctx)
|
||||||
|
out := &business.RankingData{Current: map[business.AnalyticsKey]*business.RankingMetrics{}, Previous: map[business.AnalyticsKey]*business.RankingMetrics{}, Available: true}
|
||||||
|
if kind == business.BusinessRanking {
|
||||||
|
var found []struct{ ID uint }
|
||||||
|
if err := rankingBusinessBindings(db, scope, f.Sources).Select("target.id").Limit(1).Scan(&found).Error; err != nil {
|
||||||
|
return nil, business.ErrStorage
|
||||||
|
}
|
||||||
|
if len(found) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
available, err := businessTableAvailable(db, "tab_promote")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
out.Available = false
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var pairs []struct {
|
||||||
|
UserID uint
|
||||||
|
Username string
|
||||||
|
Level int
|
||||||
|
SourceDB string `gorm:"column:source_db"`
|
||||||
|
PromoteID int64
|
||||||
|
}
|
||||||
|
if err := rankingBusinessPromotes(db, scope, f.Sources).Select("DISTINCT target.id AS user_id,target.username,target.level,p.source_db,p.id AS promote_id").Scan(&pairs).Error; err != nil {
|
||||||
|
return nil, business.ErrStorage
|
||||||
|
}
|
||||||
|
businesses := map[uint]*business.RankingBusiness{}
|
||||||
|
for _, po := range pairs {
|
||||||
|
if businesses[po.UserID] == nil {
|
||||||
|
businesses[po.UserID] = &business.RankingBusiness{ID: po.UserID, Name: po.Username, Level: po.Level}
|
||||||
|
}
|
||||||
|
target := businesses[po.UserID]
|
||||||
|
target.Channels = append(target.Channels, business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.PromoteID})
|
||||||
|
}
|
||||||
|
for _, target := range businesses {
|
||||||
|
out.Businesses = append(out.Businesses, target)
|
||||||
|
}
|
||||||
|
if len(out.Businesses) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
empty, available, err := prepareAnalytics(db, scope, f.AnalyticsFilter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Available = available
|
||||||
|
if empty || !available {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, table := range []string{"tab_sw_report_day", "tab_sw_report_day_key"} {
|
||||||
|
available, err := businessTableAvailable(db, table)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
out.Available = false
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, period := range []struct {
|
||||||
|
begin, end string
|
||||||
|
target map[business.AnalyticsKey]*business.RankingMetrics
|
||||||
|
}{{f.Begin, f.End, out.Current}, {f.PreviousBegin, f.PreviousEnd, out.Previous}} {
|
||||||
|
query := f.AnalyticsFilter
|
||||||
|
query.Begin, query.End = period.begin, period.end
|
||||||
|
rowFor := func(po analyticsAggPO) *business.RankingMetrics {
|
||||||
|
key := business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}
|
||||||
|
if period.target[key] == nil {
|
||||||
|
period.target[key] = &business.RankingMetrics{}
|
||||||
|
}
|
||||||
|
return period.target[key]
|
||||||
|
}
|
||||||
|
for _, pay := range []bool{true, false} {
|
||||||
|
table, mode := "tab_sw_report_day", 0
|
||||||
|
if !pay {
|
||||||
|
table = "tab_sw_report_day_key"
|
||||||
|
if scope.IDCardDedup {
|
||||||
|
mode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q := rankingReportQuery(db, kind, scope, query, table, mode)
|
||||||
|
id := "r.promote_id"
|
||||||
|
if kind == business.GameRanking {
|
||||||
|
id = "r.game_id"
|
||||||
|
}
|
||||||
|
if kind == business.ChannelRanking {
|
||||||
|
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
|
||||||
|
id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)"
|
||||||
|
}
|
||||||
|
selects := "r.source_db," + id + " AS entity_id"
|
||||||
|
group := "r.source_db," + id
|
||||||
|
if pay {
|
||||||
|
selects += ",COALESCE(SUM(r.total_pay),0) AS amount"
|
||||||
|
} else {
|
||||||
|
selects += ",r.metric,COUNT(DISTINCT r.identity_key) AS count"
|
||||||
|
group += ",r.metric"
|
||||||
|
}
|
||||||
|
var rows []analyticsAggPO
|
||||||
|
if err := q.Select(selects).Group(group).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, business.ErrStorage
|
||||||
|
}
|
||||||
|
for _, po := range rows {
|
||||||
|
if po.ID == nil || (pay && *po.ID < 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := rowFor(po)
|
||||||
|
if pay {
|
||||||
|
row.Pay = po.Amount
|
||||||
|
} else {
|
||||||
|
putAnalyticsCount(&row.Counts, po.Metric, po.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind != business.BusinessRanking && len(out.Current) > 0 {
|
||||||
|
table, column := "tab_promote", "account"
|
||||||
|
if kind == business.GameRanking {
|
||||||
|
table, column = "tab_game", "game_name"
|
||||||
|
}
|
||||||
|
available, err := businessTableAvailable(db, table)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
out.Available = false
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
keys := make([]business.AnalyticsKey, 0, len(out.Current))
|
||||||
|
for key := range out.Current {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
for start := 0; start < len(keys); start += 300 {
|
||||||
|
var rows []struct {
|
||||||
|
ID int64
|
||||||
|
SourceDB string `gorm:"column:source_db"`
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
if err := db.Table(table).Select("id,source_db,"+column+" AS name").Where("(source_db,id) IN ?", analyticsPairs(keys[start:min(start+300, len(keys))])).Scan(&rows).Error; err != nil {
|
||||||
|
return nil, business.ErrStorage
|
||||||
|
}
|
||||||
|
for _, po := range rows {
|
||||||
|
if row := out.Current[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}]; row != nil {
|
||||||
|
row.Name = po.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRankingStorageDifferentRootAndBusinessScopes(t *testing.T) {
|
||||||
|
db, _, scope, filter := prepareAnalyticsData(t)
|
||||||
|
r := NewRankingRepo(testData{db})
|
||||||
|
ctx := context.Background()
|
||||||
|
f := business.RankingRange{AnalyticsFilter: filter, PreviousBegin: "2026-10-01", PreviousEnd: "2026-10-03"}
|
||||||
|
data, err := r.Collect(ctx, business.GameRanking, scope, f)
|
||||||
|
if err != nil || len(data.Current) != 3 || data.Current[business.AnalyticsKey{SourceDB: "lx", ID: 1}].Name != "游戏甲" {
|
||||||
|
t.Fatalf("游戏排行或当前名称错误: %+v %v", data, err)
|
||||||
|
}
|
||||||
|
scope.Viewer = business.UserScope{SelfID: 7, IncludeSelf: true, DescendantPath: "0,7"}
|
||||||
|
data, err = r.Collect(ctx, business.BusinessRanking, scope, f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var child *business.RankingBusiness
|
||||||
|
for _, target := range data.Businesses {
|
||||||
|
if target.ID == 8 {
|
||||||
|
child = target
|
||||||
|
}
|
||||||
|
if target.ID != 8 && target.ID != 10 {
|
||||||
|
t.Fatal("一级只能排行直属下级")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if child == nil || len(child.Channels) != 5 {
|
||||||
|
t.Fatalf("二级本人的 hw 和下属三级的 lx 渠道应合并: %+v", child)
|
||||||
|
}
|
||||||
|
if _, ok := data.Current[business.AnalyticsKey{SourceDB: "lx", ID: 0}]; ok {
|
||||||
|
t.Fatal("商务榜渠道映射不应包含官方流水")
|
||||||
|
}
|
||||||
|
scope.Viewer = business.UserScope{SelfID: 8, IncludeSelf: true, DescendantPath: "0,7,8"}
|
||||||
|
data, err = r.Collect(ctx, business.BusinessRanking, scope, f)
|
||||||
|
if err != nil || len(data.Businesses) != 1 || data.Businesses[0].ID != 9 || len(data.Businesses[0].Channels) != 4 {
|
||||||
|
t.Fatal("二级商务榜只能展示直属三级及其渠道")
|
||||||
|
}
|
||||||
|
scope.Viewer = business.UserScope{All: true}
|
||||||
|
data, err = r.Collect(ctx, business.BusinessRanking, scope, f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen := map[uint]bool{}
|
||||||
|
for _, b := range data.Businesses {
|
||||||
|
seen[b.ID] = true
|
||||||
|
}
|
||||||
|
if !seen[7] || !seen[8] || !seen[9] || seen[1] || seen[99] {
|
||||||
|
t.Fatal("超管应展示所选平台有渠道的各级商务,不能包含普通框架管理员")
|
||||||
|
}
|
||||||
|
if err := db.Exec("UPDATE tab_promote SET top_promote_id=777 WHERE source_db='lx' AND id=100").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scope = business.AnalyticsScope{Viewer: channelTestScope(), IncludeSub: false, IDCardDedup: true}
|
||||||
|
data, err = r.Collect(ctx, business.ChannelRanking, scope, f)
|
||||||
|
root := data.Current[business.AnalyticsKey{SourceDB: "lx", ID: 777}]
|
||||||
|
if err != nil || root == nil || portraitTestMoney(root.Pay) != "10.10" || root.Counts.Register != 1 {
|
||||||
|
t.Fatalf("独立渠道榜使用 COALESCE(top,id),不应误用工作台 CASE 根算法: %+v %v", root, err)
|
||||||
|
}
|
||||||
|
if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err = r.Collect(ctx, business.ChannelRanking, scope, f)
|
||||||
|
if err != nil || data.Available {
|
||||||
|
t.Fatal("缺表应明确未接入")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
databusiness.NewAnalyticsRepo,
|
databusiness.NewAnalyticsRepo,
|
||||||
databusiness.NewServerAnalysisRepo,
|
databusiness.NewServerAnalysisRepo,
|
||||||
databusiness.NewWorkbenchRepo,
|
databusiness.NewWorkbenchRepo,
|
||||||
|
databusiness.NewRankingRepo,
|
||||||
NewIntegrationRuntime,
|
NewIntegrationRuntime,
|
||||||
wire.Bind(new(datasystem.Provider), new(*Data)),
|
wire.Bind(new(datasystem.Provider), new(*Data)),
|
||||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
func Definition() module.Definition {
|
func Definition() module.Definition {
|
||||||
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
|
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
|
||||||
Menus: []module.Menu{
|
Menus: []module.Menu{
|
||||||
|
{Name: "sw_RankingAnalysis", Path: "RankingAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/RankingAnalysis.vue", Title: "排行榜", Icon: "star", Sort: 6},
|
||||||
{Name: "sw_ServerAnalysis", Path: "ServerAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/ServerAnalysis.vue", Title: "区服分析", Icon: "data-analysis", Sort: 5},
|
{Name: "sw_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_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_GameAnalytics", Path: "GameAnalytics", ParentName: "sw_DataAnalysis", Component: "view/sw/GameAnalytics.vue", Title: "游戏数据", Icon: "data-analysis", Sort: 4},
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
|||||||
menus += len(definition.Surface.Menus)
|
menus += len(definition.Surface.Menus)
|
||||||
apis += len(definition.Surface.APIs)
|
apis += len(definition.Surface.APIs)
|
||||||
}
|
}
|
||||||
if menus != 17 || apis != 66 {
|
if menus != 18 || apis != 69 {
|
||||||
t.Fatalf("admin surface = %d menus/%d APIs, want 17/66", menus, apis)
|
t.Fatalf("admin surface = %d menus/%d APIs, want 18/69", menus, apis)
|
||||||
}
|
}
|
||||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ type routeValue struct {
|
|||||||
// Gin router files still bind handlers; tests keep those registrations aligned
|
// Gin router files still bind handlers; tests keep those registrations aligned
|
||||||
// with this catalog while public, audit and API metadata live only here.
|
// with this catalog while public, audit and API metadata live only here.
|
||||||
var routes = map[string]routeValue{
|
var routes = map[string]routeValue{
|
||||||
|
"GET /sw/rank/channel": {group: "排行榜", description: "查询渠道四项排行与环比", audit: true},
|
||||||
|
"GET /sw/rank/business": {group: "排行榜", description: "查询商务四项排行与环比", audit: true},
|
||||||
|
"GET /sw/rank/game": {group: "排行榜", description: "查询游戏四项排行与环比", audit: true},
|
||||||
"GET /sw/workbench/summary": {group: "工作台", description: "查询商务六项指标与同期对照"},
|
"GET /sw/workbench/summary": {group: "工作台", description: "查询商务六项指标与同期对照"},
|
||||||
"GET /sw/workbench/registerTop": {group: "工作台", description: "查询渠道注册排行"},
|
"GET /sw/workbench/registerTop": {group: "工作台", description: "查询渠道注册排行"},
|
||||||
"GET /sw/workbench/payRank": {group: "工作台", description: "查询渠道充值最高与最低排行"},
|
"GET /sw/workbench/payRank": {group: "工作台", description: "查询渠道充值最高与最低排行"},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func emptyHandlers() *handler.Set {
|
|||||||
Analytics: &handler.Analytics{},
|
Analytics: &handler.Analytics{},
|
||||||
ServerAnalysis: &handler.ServerAnalysis{},
|
ServerAnalysis: &handler.ServerAnalysis{},
|
||||||
Workbench: &handler.Workbench{},
|
Workbench: &handler.Workbench{},
|
||||||
|
Ranking: &handler.Ranking{},
|
||||||
Ledger: &handler.Ledger{},
|
Ledger: &handler.Ledger{},
|
||||||
ChannelPortrait: &handler.ChannelPortrait{},
|
ChannelPortrait: &handler.ChannelPortrait{},
|
||||||
BusinessPortrait: &handler.BusinessPortrait{},
|
BusinessPortrait: &handler.BusinessPortrait{},
|
||||||
@@ -109,7 +110,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
|||||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
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)
|
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||||
}
|
}
|
||||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":249`) {
|
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":252`) {
|
||||||
t.Fatalf("startup route summary is missing: %s", text)
|
t.Fatalf("startup route summary is missing: %s", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewAnalytics,
|
NewAnalytics,
|
||||||
NewServerAnalysis,
|
NewServerAnalysis,
|
||||||
NewWorkbench,
|
NewWorkbench,
|
||||||
|
NewRanking,
|
||||||
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
|
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
|
||||||
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
||||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-kratos/kratos/v3/errors"
|
||||||
|
businessbiz "kra/internal/biz/business"
|
||||||
|
"kra/internal/service/business"
|
||||||
|
"kra/internal/service/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Ranking struct{ service *business.RankingService }
|
||||||
|
|
||||||
|
func NewRanking(service *business.RankingService) *Ranking { return &Ranking{service: service} }
|
||||||
|
func (h *Ranking) list(c *gin.Context, kind businessbiz.RankingKind) {
|
||||||
|
var req dto.RankingQuery
|
||||||
|
if c.ShouldBindQuery(&req) != nil {
|
||||||
|
Fail(c, "排行榜查询参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.service.List(c.Request.Context(), kind, &req)
|
||||||
|
if err != nil {
|
||||||
|
Fail(c, errors.FromError(err).Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OKWithData(c, data)
|
||||||
|
}
|
||||||
|
func (h *Ranking) Channel(c *gin.Context) { h.list(c, businessbiz.ChannelRanking) }
|
||||||
|
func (h *Ranking) Business(c *gin.Context) { h.list(c, businessbiz.BusinessRanking) }
|
||||||
|
func (h *Ranking) Game(c *gin.Context) { h.list(c, businessbiz.GameRanking) }
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
type Set struct {
|
type Set struct {
|
||||||
|
Ranking *Ranking
|
||||||
Workbench *Workbench
|
Workbench *Workbench
|
||||||
ServerAnalysis *ServerAnalysis
|
ServerAnalysis *ServerAnalysis
|
||||||
Analytics *Analytics
|
Analytics *Analytics
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
registrations := []func(){
|
registrations := []func(){
|
||||||
|
func() {
|
||||||
|
private.GET("/sw/rank/channel", r.handlers.Ranking.Channel)
|
||||||
|
private.GET("/sw/rank/business", r.handlers.Ranking.Business)
|
||||||
|
private.GET("/sw/rank/game", r.handlers.Ranking.Game)
|
||||||
|
},
|
||||||
func() {
|
func() {
|
||||||
private.GET("/sw/workbench/summary", r.handlers.Workbench.Summary)
|
private.GET("/sw/workbench/summary", r.handlers.Workbench.Summary)
|
||||||
private.GET("/sw/workbench/registerTop", r.handlers.Workbench.RegisterTop)
|
private.GET("/sw/workbench/registerTop", r.handlers.Workbench.RegisterTop)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
"kra/internal/service/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RankingUsecase interface {
|
||||||
|
List(context.Context, business.RankingKind, business.AnalyticsFilter) (*business.RankingPage, error)
|
||||||
|
}
|
||||||
|
type RankingService struct{ uc RankingUsecase }
|
||||||
|
|
||||||
|
func NewRankingService(uc *business.RankingUsecase) *RankingService { return &RankingService{uc: uc} }
|
||||||
|
func rankingItems(rows []*business.RankingItem, kind business.RankingKind) []*dto.RankingItem {
|
||||||
|
out := make([]*dto.RankingItem, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
item := &dto.RankingItem{Rank: row.Rank, ObjectID: row.ObjectID, ObjectName: row.Name, MetricValue: row.Value, MoM: row.MoM, MoMStatus: row.MoMStatus}
|
||||||
|
if kind != business.BusinessRanking {
|
||||||
|
source, label := row.SourceDB, business.PlatformName(row.SourceDB)
|
||||||
|
item.SourceDB, item.SourceDBLabel = &source, &label
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
func (s *RankingService) List(ctx context.Context, kind business.RankingKind, req *dto.RankingQuery) (*dto.RankingPage, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, business.ErrAnalyticsInput
|
||||||
|
}
|
||||||
|
f, err := analyticsRequest(&dto.AnalyticsQuery{Sources: req.Sources, BeginTime: req.BeginTime, EndTime: req.EndTime})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
page, err := s.uc.List(ctx, kind, f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &dto.RankingPage{Register: rankingItems(page.Register, kind), Recharge: rankingItems(page.Recharge, kind), PayRate: rankingItems(page.PayRate, kind), Active: rankingItems(page.Active, kind), Begin: page.Begin, End: page.End, Available: page.Available}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
type RankingQuery struct {
|
||||||
|
Sources []string `form:"sourceDb"`
|
||||||
|
BeginTime string `form:"beginTime"`
|
||||||
|
EndTime string `form:"endTime"`
|
||||||
|
GameID *int64 `form:"gameId"`
|
||||||
|
}
|
||||||
|
type RankingItem struct {
|
||||||
|
Rank int `json:"rank"`
|
||||||
|
ObjectID string `json:"objectId"`
|
||||||
|
ObjectName string `json:"objectName"`
|
||||||
|
SourceDB *string `json:"sourceDb"`
|
||||||
|
SourceDBLabel *string `json:"sourceDbLabel"`
|
||||||
|
MetricValue string `json:"metricValue"`
|
||||||
|
MoM *string `json:"momValue"`
|
||||||
|
MoMStatus *int `json:"momStatus"`
|
||||||
|
}
|
||||||
|
type RankingPage struct {
|
||||||
|
Register []*RankingItem `json:"registerRankList"`
|
||||||
|
Recharge []*RankingItem `json:"rechargeRankList"`
|
||||||
|
PayRate []*RankingItem `json:"payRateRankList"`
|
||||||
|
Active []*RankingItem `json:"activeRankList"`
|
||||||
|
Begin string `json:"beginTime"`
|
||||||
|
End string `json:"endTime"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
businessservice.NewAnalyticsService,
|
businessservice.NewAnalyticsService,
|
||||||
businessservice.NewServerAnalysisService,
|
businessservice.NewServerAnalysisService,
|
||||||
businessservice.NewWorkbenchService,
|
businessservice.NewWorkbenchService,
|
||||||
|
businessservice.NewRankingService,
|
||||||
systemservice.ProviderSet,
|
systemservice.ProviderSet,
|
||||||
paymentservice.ProviderSet,
|
paymentservice.ProviderSet,
|
||||||
integrationservice.ProviderSet,
|
integrationservice.ProviderSet,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""真实会话只读验证独立排行榜接口,不创建业务数据。"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--base-url', default='http://127.0.0.1:18000')
|
||||||
|
parser.add_argument('--token-file', required=True, type=Path)
|
||||||
|
parser.add_argument('--output', required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
token = args.token_file.read_text(encoding='utf-8').strip()
|
||||||
|
checks = []
|
||||||
|
for kind in ['channel', 'business', 'game']:
|
||||||
|
for query, expected in [('sourceDb=lx,hw,yq', 0), ('beginTime=bad&endTime=2026-11-04', 7), ('beginTime=2026-11-05&endTime=2026-11-04', 7)]:
|
||||||
|
path = '/sw/rank/' + kind + '?' + query
|
||||||
|
req = urllib.request.Request(args.base_url + path, headers={'x-token': token})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as response:
|
||||||
|
payload = json.load(response)
|
||||||
|
item = {'path': path, 'http': response.status, 'code': payload.get('code'), 'msg': payload.get('msg'), 'passed': payload.get('code') == expected}
|
||||||
|
if expected == 0:
|
||||||
|
data = payload.get('data', {})
|
||||||
|
item['available'] = data.get('available')
|
||||||
|
item['passed'] &= data.get('available') is False and all(data.get(key) == [] for key in ['registerRankList', 'rechargeRankList', 'payRateRankList', 'activeRankList'])
|
||||||
|
checks.append(item)
|
||||||
|
report = {'scope': '真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向排行和权限通过隔离 TCP HTTP 测试。', 'checks': checks, 'passed': all(item['passed'] for item in checks)}
|
||||||
|
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||||
|
print(json.dumps(report, ensure_ascii=False))
|
||||||
|
if not report['passed']:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import service from '@/utils/request'
|
||||||
|
|
||||||
|
export const listRankings = (dimension, params) => service({ url: `/sw/rank/${dimension}`, method: 'get', params, timeout: 30000, donNotShowLoading: true })
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
"/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics",
|
"/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics",
|
||||||
"/src/view/sw/ChannelList.vue": "SwChannelList",
|
"/src/view/sw/ChannelList.vue": "SwChannelList",
|
||||||
"/src/view/sw/GameAnalytics.vue": "SwGameAnalytics",
|
"/src/view/sw/GameAnalytics.vue": "SwGameAnalytics",
|
||||||
|
"/src/view/sw/RankingAnalysis.vue": "RankingAnalysis",
|
||||||
"/src/view/sw/RechargeDetails.vue": "SwRechargeDetails",
|
"/src/view/sw/RechargeDetails.vue": "SwRechargeDetails",
|
||||||
"/src/view/sw/RegistrationDetails.vue": "SwRegistrationDetails",
|
"/src/view/sw/RegistrationDetails.vue": "SwRegistrationDetails",
|
||||||
"/src/view/sw/ServerAnalysis.vue": "ServerAnalysis",
|
"/src/view/sw/ServerAnalysis.vue": "ServerAnalysis",
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="kra-search-box">
|
||||||
|
<el-form :inline="true" @submit.prevent="load">
|
||||||
|
<el-form-item label="所属平台"><el-select v-model="sources" multiple collapse-tags clearable placeholder="全部平台" style="width: 240px" @change="load"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||||
|
<el-form-item><el-radio-group :model-value="dimension" @change="changeDimension"><el-radio-button v-for="(label, value) in dimensions" :key="value" :value="value">{{ label }}排行</el-radio-button></el-radio-group></el-form-item>
|
||||||
|
<el-form-item><el-radio-group v-model="memory[dimension].range" @change="changeRange"><el-radio-button v-for="item in ranges" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item>
|
||||||
|
<el-form-item label="日期"><el-date-picker v-model="memory[dimension].dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" :clearable="false" style="width: 300px" @change="customDate" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-text type="info">统计周期:{{ dates.join(' 至 ') }}</el-text>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="error" class="mb-4" type="error" :closable="false" :title="error" />
|
||||||
|
<el-alert v-else-if="!available" class="mb-4" type="warning" :closable="false" title="排行榜所需业务数据尚未接入。" />
|
||||||
|
<div v-loading="loading" class="ranking-panels">
|
||||||
|
<div v-for="panel in panels" :key="panel.key" class="kra-table-box ranking-panel">
|
||||||
|
<h3>{{ panel.title }}</h3>
|
||||||
|
<el-table :data="displayRows(panel)" row-key="rowKey" :row-class-name="({ row }) => row.rank <= 3 && !row.placeholder ? 'ranking-top' : ''">
|
||||||
|
<el-table-column label="所属平台" min-width="90"><template #default="{ row }">{{ row.sourceDbLabel || '—' }}</template></el-table-column>
|
||||||
|
<el-table-column label="#" width="45"><template #default="{ row }">{{ row.placeholder ? '—' : row.rank }}</template></el-table-column>
|
||||||
|
<el-table-column :label="`${dimensions[dimension]}名称`" min-width="130" show-overflow-tooltip><template #default="{ row }">{{ row.objectName || '—' }}</template></el-table-column>
|
||||||
|
<el-table-column :label="panel.label" min-width="125"><template #default="{ row }">{{ metric(panel, row.metricValue) }}</template></el-table-column>
|
||||||
|
<el-table-column label="上期环比" min-width="112"><template #default="{ row }"><el-text :type="row.momStatus > 0 ? 'success' : row.momStatus < 0 ? 'danger' : 'info'">{{ change(panel, row) }}</el-text></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { listRankings } from '@/api/ranking'
|
||||||
|
import { channelCount, channelMoney } from './channel-display'
|
||||||
|
defineOptions({ name: 'RankingAnalysis' })
|
||||||
|
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }, dimensions = { channel: '渠道', business: '商务', game: '游戏' }
|
||||||
|
const sources = ref(Object.keys(platforms)), dimension = ref('channel')
|
||||||
|
const ranges = [{ value: 'today', label: '今日' }, { value: 'yesterday', label: '昨日' }, { value: '7d', label: '近7天' }, { value: '30d', label: '近30天' }]
|
||||||
|
const ymd = value => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`
|
||||||
|
const dateRange = range => { const e = new Date(), b = new Date(); if (range === 'yesterday') { e.setDate(e.getDate() - 1); b.setDate(b.getDate() - 1) } else if (range === '7d') b.setDate(b.getDate() - 6); else if (range === '30d') b.setDate(b.getDate() - 29); return [ymd(b), ymd(e)] }
|
||||||
|
const memory = reactive(Object.fromEntries(Object.keys(dimensions).map(key => [key, { range: '7d', dates: dateRange('7d') }])))
|
||||||
|
const dates = computed(() => memory[dimension.value].dates)
|
||||||
|
const result = ref(null), available = ref(true), loading = ref(false), error = ref('')
|
||||||
|
let version = 0
|
||||||
|
const panels = computed(() => [
|
||||||
|
{ key: 'registerRankList', title: '玩家注册量', label: '注册量' },
|
||||||
|
{ key: 'rechargeRankList', title: '充值金额', label: '充值金额', money: true },
|
||||||
|
{ key: 'payRateRankList', title: '付费率', label: '付费率', rate: true },
|
||||||
|
{ key: 'activeRankList', title: `${ranges.find(item => item.value === memory[dimension.value].range)?.label || ''}活跃玩家数`, label: '活跃人数' }
|
||||||
|
])
|
||||||
|
const displayRows = panel => {
|
||||||
|
const seen = new Set(), rows = []
|
||||||
|
for (const item of result.value?.[panel.key] || []) { const key = `${item.sourceDb || ''}:${item.objectId}`; if (!seen.has(key)) { seen.add(key); rows.push({ ...item, rowKey: key }) } }
|
||||||
|
for (let i = rows.length; i < 20; i++) rows.push({ rowKey: `placeholder-${i}`, placeholder: true })
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
const metric = (panel, value) => value == null ? '—' : panel.money ? channelMoney(value) : panel.rate ? `${value}%` : channelCount(value)
|
||||||
|
const change = (panel, row) => row.momValue == null ? '—' : `${row.momStatus > 0 ? '+' : ''}${row.momValue}${panel.rate ? ' 个百分点' : '%'}`
|
||||||
|
const load = async () => {
|
||||||
|
const current = ++version; loading.value = true; result.value = null; error.value = ''; available.value = true
|
||||||
|
try {
|
||||||
|
// 旧排行榜空选平台表示全部,保留此语义;其他报表页面有各自的空选规则。
|
||||||
|
const res = await listRankings(dimension.value, { sourceDb: sources.value.length ? sources.value.join(',') : undefined, beginTime: dates.value[0], endTime: dates.value[1] })
|
||||||
|
if (current !== version) return
|
||||||
|
if (res.code === 0) { result.value = res.data; available.value = res.data.available !== false } else error.value = res.msg || '排行榜查询失败'
|
||||||
|
} catch { if (current === version) error.value = '排行榜查询失败,请重试' } finally { if (current === version) loading.value = false }
|
||||||
|
}
|
||||||
|
const changeDimension = value => { dimension.value = value; const m = memory[value]; if (m.range !== 'custom') m.dates = dateRange(m.range); return load() }
|
||||||
|
const changeRange = value => { memory[dimension.value].dates = dateRange(value); return load() }
|
||||||
|
const customDate = () => { memory[dimension.value].range = 'custom'; return load() }
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ranking-panels { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.ranking-panel { min-width: 0; }
|
||||||
|
.ranking-panel h3 { margin: 0 0 16px; font-weight: 600; }
|
||||||
|
:deep(.ranking-top) { --el-table-tr-bg-color: var(--el-color-primary-light-9); }
|
||||||
|
@media (max-width: 1100px) { .ranking-panels { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user