feat: 迁移渠道商务游戏排行榜及同期比较

This commit is contained in:
Yvan
2026-11-04 01:34:37 +08:00
parent cbf0ee9be3
commit ac4e13e610
27 changed files with 1079 additions and 6 deletions
+161
View File
@@ -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("缺表不应返回假排行")
}
}
+6 -1
View File
@@ -82,11 +82,15 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
registry := taskRegistry()
workbenchRepo := business.NewWorkbenchRepo(dataData)
rankingRepo := business.NewRankingRepo(dataData)
accountRepo := business.NewAccountRepo(dataData)
accountUsecase := business2.NewAccountUsecase(accountRepo)
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
rankingUsecase := business2.NewRankingUsecase(rankingRepo, accountUsecase, userPreferenceUsecase)
rankingService := business3.NewRankingService(rankingUsecase)
ranking := handler.NewRanking(rankingService)
workbenchRepo := business.NewWorkbenchRepo(dataData)
workbenchUsecase := business2.NewWorkbenchUsecase(workbenchRepo, accountUsecase, userPreferenceUsecase)
workbenchService := business3.NewWorkbenchService(workbenchUsecase)
workbench := handler.NewWorkbench(workbenchService)
@@ -243,6 +247,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
set := &handler.Set{
Ranking: ranking,
Workbench: workbench,
ServerAnalysis: serverAnalysis,
Analytics: analytics,
+3 -1
View File
@@ -27,7 +27,7 @@
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 |
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 |
| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 功能已接入;包含渠道画像,完整业务数据和源平台联调仍待最终验收 |
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出、渠道/游戏/区服统计、工作台仪表盘已接入,独立排行与任务待迁移 |
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、注册/充值明细及导出、渠道/游戏/区服统计、独立排行榜与工作台仪表盘已接入,汇总构建及预警任务待迁移 |
| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 |
| 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 |
@@ -99,3 +99,5 @@
- 工作台四个接口及 Kra 仪表盘业务区域已接入:六项指标、注册/充值 TOP10、未登录 TOP10、最低充值 TOP10 和已有预警的最新五条轮播。保留 Kra 欢迎区域、快捷入口与公告,平台和周期按旧页面设置;请求失败清空旧结果并提示,不能显示其他平台的缓存值。
- 工作台保留本月对上月同日(截断月末)、模式 0 跨平台 ID 隔离、渠道树注册去重、金额固定模式 0、活跃渠道按自身注册/正充值计数、最低榜含零一级渠道,以及未登录回退创建时间和 99999 哨兵值。详见 `workbench-source-notes.md`。
- 工作台批次全量 Go、隔离 TCP HTTP、领域/仓储测试、前端 ESLint/构建通过;真实 sw7 请求记录见 `workbench-http-2026-11-04.json`。缺业务表时六卡返回 null、榜单明确报未接入。未执行页面交互检查或最终旧库复制。
- 独立排行榜渠道、商务、游戏三个接口和 Kra 页面已接入,各有注册/充值/付费率/活跃 TOP20,保留平台、维度独立日期记忆、近 7 天默认周期和等长上期。付费率用百分点差,其余用百分比环比。商务按直属下级展示、合并其整个下级绑定树,再累加各渠道人数;三级不展示商务排行。
- 排行批次全量 Go、隔离 TCP HTTP、领域/仓储测试、前端 ESLint/构建、真实 sw7 接口检查通过,见 `ranking-http-2026-11-04.json` 与 `ranking-source-notes.md`。当前 SQL 清单 63 个目标接口中 51 个已注册;其余 12 个均为 `SysUser` 历史系统别名,详见 `system-api-audit.md`,不能将接口注册数量当作全迁移完成证明。汇总和预警任务、最终旧库迁移验收仍待完成。
+6
View File
@@ -192,3 +192,9 @@
直接核对旧 `views/index.vue`,在原 Kra 仪表盘中加入六项商务指标、平台多选、注册/充值两组横向榜图、未登录/最低充值两组榜表,以及最新五条待处理预警的四秒轮播和查看全部入口。注册/最高充值默认今日,最低充值默认近 30 天;每个榜单单独切换周期。保留 Kra 欢迎、快捷入口和公告,使用 Kra 卡片、颜色与 Element Plus 控件。没有移植旧页面的自定义图表样式。
四个工作台接口和已有预警接口负责真实业务数据;接口缺表或错误明确提示,不将空选择、未接入和真实零混淆。按用户要求仅做接口实测、数据断言和构建,未做页面交互检查。
## 独立排行榜
按旧 `RankingAnalysis.vue` 保留渠道/商务/游戏三个页签、平台多选(空选表示全部)、各维度独立记忆的日期、默认近 7 天,以及注册/充值/付费率/活跃四组 TOP20。不足 20 条显示占位符,旧请求不覆盖新维度。界面采用 Kra 表格和配色;付费率的环比文案明确为百分点,修复旧页面将百分点也显示成百分比的歧义。没有额外增加分页、游戏选择或导出入口。
接口实测、隔离数据 HTTP、全量 Go 和前端构建通过。按用户要求未检查页面交互。详见 `ranking-source-notes.md`。
@@ -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
}
+16
View File
@@ -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
View File
@@ -11,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase)
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase)
+235
View File
@@ -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
}
+76
View File
@@ -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("持平环比错误")
}
}
+201
View File
@@ -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
}
+71
View File
@@ -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("缺表应明确未接入")
}
}
+1
View File
@@ -45,6 +45,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewAnalyticsRepo,
databusiness.NewServerAnalysisRepo,
databusiness.NewWorkbenchRepo,
databusiness.NewRankingRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
+1
View File
@@ -9,6 +9,7 @@ import (
func Definition() module.Definition {
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
Menus: []module.Menu{
{Name: "sw_RankingAnalysis", Path: "RankingAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/RankingAnalysis.vue", Title: "排行榜", Icon: "star", Sort: 6},
{Name: "sw_ServerAnalysis", Path: "ServerAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/ServerAnalysis.vue", Title: "区服分析", Icon: "data-analysis", Sort: 5},
{Name: "sw_ChannelDataStatistics", Path: "ChannelDataStatistics", ParentName: "sw_DataAnalysis", Component: "view/sw/ChannelDataStatistics.vue", Title: "渠道数据统计", Icon: "data-analysis", Sort: 1},
{Name: "sw_GameAnalytics", Path: "GameAnalytics", ParentName: "sw_DataAnalysis", Component: "view/sw/GameAnalytics.vue", Title: "游戏数据", Icon: "data-analysis", Sort: 4},
+2 -2
View File
@@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs)
}
if menus != 17 || apis != 66 {
t.Fatalf("admin surface = %d menus/%d APIs, want 17/66", menus, apis)
if menus != 18 || apis != 69 {
t.Fatalf("admin surface = %d menus/%d APIs, want 18/69", menus, apis)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got))
+3
View File
@@ -41,6 +41,9 @@ type routeValue struct {
// Gin router files still bind handlers; tests keep those registrations aligned
// with this catalog while public, audit and API metadata live only here.
var routes = map[string]routeValue{
"GET /sw/rank/channel": {group: "排行榜", description: "查询渠道四项排行与环比", audit: true},
"GET /sw/rank/business": {group: "排行榜", description: "查询商务四项排行与环比", audit: true},
"GET /sw/rank/game": {group: "排行榜", description: "查询游戏四项排行与环比", audit: true},
"GET /sw/workbench/summary": {group: "工作台", description: "查询商务六项指标与同期对照"},
"GET /sw/workbench/registerTop": {group: "工作台", description: "查询渠道注册排行"},
"GET /sw/workbench/payRank": {group: "工作台", description: "查询渠道充值最高与最低排行"},
+2 -1
View File
@@ -29,6 +29,7 @@ func emptyHandlers() *handler.Set {
Analytics: &handler.Analytics{},
ServerAnalysis: &handler.ServerAnalysis{},
Workbench: &handler.Workbench{},
Ranking: &handler.Ranking{},
Ledger: &handler.Ledger{},
ChannelPortrait: &handler.ChannelPortrait{},
BusinessPortrait: &handler.BusinessPortrait{},
@@ -109,7 +110,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":249`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":252`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
+1
View File
@@ -18,6 +18,7 @@ var ProviderSet = wire.NewSet(
NewAnalytics,
NewServerAnalysis,
NewWorkbench,
NewRanking,
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
+29
View File
@@ -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
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
Ranking *Ranking
Workbench *Workbench
ServerAnalysis *ServerAnalysis
Analytics *Analytics
+5
View File
@@ -20,6 +20,11 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
return
}
registrations := []func(){
func() {
private.GET("/sw/rank/channel", r.handlers.Ranking.Channel)
private.GET("/sw/rank/business", r.handlers.Ranking.Business)
private.GET("/sw/rank/game", r.handlers.Ranking.Game)
},
func() {
private.GET("/sw/workbench/summary", r.handlers.Workbench.Summary)
private.GET("/sw/workbench/registerTop", r.handlers.Workbench.RegisterTop)
+40
View File
@@ -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
}
+27
View File
@@ -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"`
}
+1
View File
@@ -27,6 +27,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewAnalyticsService,
businessservice.NewServerAnalysisService,
businessservice.NewWorkbenchService,
businessservice.NewRankingService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
+36
View File
@@ -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()
+3
View File
@@ -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 })
+1
View File
@@ -59,6 +59,7 @@
"/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics",
"/src/view/sw/ChannelList.vue": "SwChannelList",
"/src/view/sw/GameAnalytics.vue": "SwGameAnalytics",
"/src/view/sw/RankingAnalysis.vue": "RankingAnalysis",
"/src/view/sw/RechargeDetails.vue": "SwRechargeDetails",
"/src/view/sw/RegistrationDetails.vue": "SwRegistrationDetails",
"/src/view/sw/ServerAnalysis.vue": "ServerAnalysis",
+78
View File
@@ -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>