feat: 按原型更新商务明细与游戏分析并清理临时资料

This commit is contained in:
Yvan
2026-11-04 18:13:03 +08:00
parent 19926d275c
commit f866cac768
41 changed files with 392 additions and 2955 deletions
+7
View File
@@ -48,3 +48,10 @@ __pycache__/
# Local reference sources and desktop-only tooling state
/gva/
/.claude/settings.local.json
# One-off migration notes, verification scripts, and generated reports
/docs/sw6-to-kra/
/docs/sw6tokra/
/scripts/
/test-results/
/playwright-report/
+4
View File
@@ -149,3 +149,7 @@ Regenerate via `make generate` or `make all`; never hand-edit `wire_gen.go`.
`docs:`, `test:`. Regenerated files belong in the same commit as
their source.
- Never commit real credentials in `configs/config.yaml`.
- Do not commit one-off scripts, migration scratch notes, test reports, or
screenshots. Keep temporary work outside the repository and delete it after
use. Do not recreate `docs/sw6-to-kra/`, `docs/sw6tokra/`, or `scripts/`.
Permanent regression tests belong beside the source they cover.
+2
View File
@@ -36,6 +36,8 @@ func TestChannelAndGameAnalyticsHTTP(t *testing.T) {
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(9,'lx',14,'SDK账号','0'),(99,'hw',20,'其他账号','0')",
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER,register_type INTEGER)",
"INSERT INTO tab_promote VALUES(100,'lx','root-channel',1,0,14,0),(101,'lx','child-channel',2,100,14,0),(200,'lx','guild-channel',1,0,14,1),(100,'hw','other-platform',1,0,20,0)",
"CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
"INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('hw',100,1,1),('lx',200,2,1)",
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
"INSERT INTO tab_game VALUES(1,'lx','当前游戏'),(2,'lx','公会游戏'),(1,'hw','外服游戏')",
"CREATE TABLE tab_sw_report_day(source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,stat_date DATE,total_pay DECIMAL(20,2))",
+6 -3
View File
@@ -43,6 +43,9 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) {
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(9,'lx',14,'sdk','0'),(99,'hw',20,'other','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','root-channel',1,0,14),(101,'lx','child-channel',2,100,14),(100,'hw','outside',1,0,20)",
"CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
"INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('hw',100,1,1)",
"ALTER TABLE tab_promote ADD COLUMN nickname TEXT",
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
"INSERT INTO tab_game VALUES(1,'lx','当前游戏'),(1,'hw','同号其他游戏')",
"CREATE TABLE tab_user(id INTEGER,source_db TEXT,register_time TEXT,fgame_id INTEGER,fgame_name TEXT,register_ip TEXT,device_name TEXT,promote_id INTEGER,puid INTEGER)",
@@ -107,7 +110,7 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) {
data := call(7, "GET", "/sw/register/list?sourceDb=lx&sortField=totalPayAmount", "", 0).(map[string]any)
rows := data["rows"].([]any)
first := rows[0].(map[string]any)
if data["total"] != float64(2) || first["userId"] != float64(1) || first["totalPayAmount"] != "1012.34" || first["fgameName"] != "历史游戏" || first["busierName"] != "sdk(leaf)" {
if data["total"] != float64(2) || first["userId"] != float64(1) || first["totalPayAmount"] != "1012.34" || first["fgameName"] != "历史游戏" || first["busierName"] != "owner→leaf" {
t.Fatalf("注册明细口径或字段错误: %+v", data)
}
games := call(7, "GET", "/sw/serverAnalysis/games?sourceDb=lx&limit=50", "", 0).([]any)
@@ -119,8 +122,8 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) {
t.Fatal("游戏筛选跨平台串号")
}
data = call(7, "GET", "/sw/spend/list?sourceDb=lx&payStatus=0", "", 0).(map[string]any)
if data["total"] != float64(1) || data["totalPayAmount"] != "15.34" {
t.Fatal("失败列表的合计仍须取对应成功订单")
if data["total"] != float64(1) || data["totalPayAmount"] != "0.00" {
t.Fatal("合计不得包含筛选条件外的成功订单")
}
data = call(1, "GET", "/sw/spend/list?sourceDb=lx", "", 0).(map[string]any)
if data["total"] != float64(4) || data["totalPayAmount"] != "20.34" {
+2
View File
@@ -37,6 +37,8 @@ func TestServerAnalysisHTTP(t *testing.T) {
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(9,'lx',14,'0'),(99,'hw',20,'0')",
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,promote_level INTEGER,busier_id INTEGER)",
"INSERT INTO tab_promote VALUES(100,'lx',1,14),(101,'lx',2,14),(100,'hw',1,20)",
"CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
"INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('hw',100,1,1)",
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
"INSERT INTO tab_game VALUES(1,'lx','游戏'),(1,'hw','其他游戏')",
"CREATE TABLE tab_game_server(id INTEGER,source_db TEXT,game_id INTEGER,server_num TEXT,server_name TEXT,start_time INTEGER)",
-55
View File
@@ -1,55 +0,0 @@
# 当前接口审查(2026-11-04)
## 范围
以 `internal/routecatalog/catalog.go` 与实际 Gin 注册为基线:共 **252** 个接口,其中商务 **55** 个(包括 4 个找回密码接口),原生框架 **197** 个。公开接口 15 个,其余 237 个均检查认证入口;分别在空前缀和 `/api` 前缀下执行未登录请求,共 **474** 项认证回归。
路由完整性与鉴权检查覆盖全部入口。按领域审查商务查询、权限范围、输入/排序/分页、写入事务、导出和定时任务,以及原生认证、权限、上传、支付、集成配置和审计的公共处理。真实外部支付、短信发送、OA 审批和所有原生管理写操作未在共享测试数据上逐一执行,不能将路由覆盖等同于这些外部流程全量验收。
## 本轮修复
| 问题 | 修改与回归 |
|---|---|
| 渠道激活只按商务账号名授权,跨平台同名可能串数据 | 列表和流水明细均按已通过绑定的“平台+账号”匹配;测试同名异平台、无绑定、管理员及状态范围 |
| 日期只传一端时被忽略,统计结果悄悄回到默认区间 | 商务列表、注册/充值、渠道/游戏统计、区服分析及排行榜统一拒绝不完整/非法/倒置范围;合法默认与单日保留 |
| 审计写入无 deadline,数据库异常可能拖住退出 | 每批写入限时 2 秒,Close 最多等 5 秒;关闭后拒绝入队,测试阻塞写入被取消 |
| 证书的 content/path/file 别名未脱敏 | 配置读回、掩码提交合并与审计共享识别;覆盖内联证书、密钥、PKCS12、嵌套字段和普通 content 字段 |
| 支付测试的结构化步骤消息透传适配器原始错误 | HTTP 返回安全错误和步骤信息,服务端保留诊断;避免 URL、凭据、SQL 经 data 字段泄露 |
| 已有错误弹窗时 401 回调被丢弃 | 会话失效立即清理并跳转,不依赖弹窗确认;旧请求不覆盖新令牌或注销新会话;真实 Chrome 验证 |
| Swagger 的环境判定与启动日志不同,空环境默认开放 | 配置统一判定,仅显式 dev/development/test/local 开放;启动地址和路由一致,包含缺省及 qa/uat 测试 |
| 媒体分类保存原名会把自身当重复记录 | 重名检查排除当前 ID,仍拒绝其他同名分类;媒体重命名缺失记录映射领域 NotFound |
| 偏好对比脚本在 HTTP 恢复失败时跳过数据库恢复 | 双层 finally 确保尝试恢复原行和更新时间 |
本次提交也包含此前完成但尚未提交的迁移任务、SQL 修正、查询索引、性能优化及页面修复。性能和统计口径见 [商务验收](sw6-to-kra/validation.md)。
## 验证结果与数据漂移
- `make generate`、`go test ./...`、`go vet ./...`、服务构建通过;Wire 由工具重新生成。
- 独立 MySQL 5.7 的 5 组测试通过:预警并发去重、渠道并发创建、注册 CAST 索引、区服时间索引、查询索引幂等及冲突拒绝。
- 前端完整 ESLint 和生产构建通过;构建仍提示大分块及第三方 BigInt 目标兼容告警,没有把告警视为旧浏览器兼容验证。Chrome 实测覆盖另一个错误弹窗已打开时的 401 清理和跳转。
- 新旧真实读取共 75 组:管理员 36,绑定三级 21,专用一级/二级/三级各 6。**73 组相等,2 组预警列表因数据快照不同而不等**,失败原始报告已保留。
- 漂移核实:sw8 的预警在 **2026-11-04 14:00** 被更新,当前 480 条;sw9 为原快照 478 条,其中 55 个同 ID 记录的时间已不同。本机旧服务配置仍关闭调度,未从其日志确认更新来源;没有覆盖、删除或回写 sw8 的新记录。
- 使用之前保存的同参数 sw8 快照重新核对当前 sw9 的完整预警列表,通过;报告明确 `source_capture_reused=true`。这证明当前列表符合该快照,不代表两库当前内容相等。
- 新版已启动在日常 8000;8 组工作台、排行榜、激活第二页、偏好和任务注册结果与验证实例一致。
本轮原始报告位于仓库外 `D:/Users/Yvan/backups/sw9/20261104_rehearsal/review_20261104/`。此前完整写入/导出/重建及恢复证据见 [历史综合索引](sw6-to-kra/sw8-sw9-recheck-summary-2026-11-04.json)。本机无可用 CGO 编译环境,未运行 `-race`,不宣称竞态检测通过。
## 保留的框架风险与验证边界
旧审查材料中尚未消除的内容移到这里,避免删除历史文档时丢失问题。它们不属于“已修复”清单:
| 范围 | 当前边界 |
|---|---|
| 媒体并发 | 删除只有进程内互斥,秒传复用创建与删除缺少统一的跨实例引用锁;长时间合并仍受陈旧会话清理宽限影响,需要独立并发/故障方案 |
| 配置保存 | storage、email 和配置文件分步持久化,中途失败可能形成部分成功;尚无跨存储的原子提交/补偿流程 |
| 历史支付订单 | 请求指纹严格匹配;缺少旧指纹样本的幂等重放验证,不直接放宽金额、业务身份的冲突检查 |
| 错误协议 | 框架仍有裸错误与字符串拼接、响应净化规则;此次堵住支付测试 data 泄露,但未统一全框架 typed error 协议 |
| 认证与前端路由 | token 仍由既有 localStorage 方案保存,登录与守卫仍各有动态路由注册;改成仅 HttpOnly cookie 或统一注册需要独立兼容设计 |
| 调度启动 | 永久非法 cron 会保持旧计划并继续启动重试;数据库读失败不清空既有计划,但固定重试间隔仍可改成有界退避 |
| 审计可读性 | 一些通用 key/序列号字段仍被旧脱敏策略掩盖;未为恢复可读性而放宽密钥保护 |
## 文档与脚本清理
首次清理将 104 份阶段报告、重复 JSON、旧目录审查和独立菜单 SQL,以及 26 个旧脚本移出项目。用户追加要求后,剩余 14 个验证工具、脚本说明和 Python 缓存也全部移出,`scripts` 目录已删除。所有移除文件均在仓库外归档并校验 SHA-256;统一迁移 SQL、业务口径与综合验收索引保留。
真实本地配置 `configs/config.yaml`、私有运行数据,以及其他会话的 V2 估算和 outputs 成果不纳入本次提交。未推送远端。
+1 -4
View File
@@ -1,9 +1,6 @@
# 文档入口
- [商务迁移](sw6-to-kra/README.md):统一 SQL、业务口径、接口与任务验收。
- [当前接口审查](API_REVIEW.md):此次修复、验证范围及保留的技术风险。
- [后台说明](ADMIN_BACKEND.md)、[支付接入](PAYMENT.md)、[短信接入](SMS_INTEGRATION.md)。
- [OA 迁移计划](OA_MIGRATION_PLAN.md):独立 OA 工作的设计材料,不是当前商务迁移的完成证明。
- [汇总任务说明](sw6-to-kra/report-build-source-notes.md):任务读写表及接口依赖;临时验证脚本已移出项目。
历史阶段记录已归档到仓库外;后续将最新结果更新到对应维护文档,不再每个接口生成一份日期报告。其他会话生成的商务 V2 估算及表格保持原状,本次清理和提交不包含这些文件。
此目录仅保留持续维护的文档。临时迁移笔记、验证报告和脚本用完即删,不纳入版本控制。
-28
View File
@@ -1,28 +0,0 @@
# 商务迁移维护入口
旧 Java 商务使用测试库 **sw8**,当前 Go 商务使用 **sw9**。两库来自相同线上副本;未执行正式库 sw6 迁移。
## 执行顺序
1. 准备完整业务副本及 SQL 要求的 26 张框架源表,保存数据库备份。
2. 在 [统一迁移 SQL](sw_to_kra_migrate.sql) 开头修改 `USE`,当前为 `USE sw9`。用支持 `DELIMITER` 的客户端在同一连接执行,遇错停止。
3. 启动当前 Go 版本,执行版本迁移,补齐业务锁、密码重置表、附件引用和查询索引。大表首次建索引需要等待完成。
4. 配置主库和 `lx`、`hw`、`yq`、`oa` 数据源。配置和凭据保存在仓库外;数据库连接信息不写入本目录。
5. 对照 [验收记录](validation.md) 与 [汇总任务说明](report-build-source-notes.md) 复核接口和任务。迁入计划默认停用,启用前应确认业务所需运行时段。
SQL 自动区分首次迁移、已完成迁移的菜单调整和异常中间状态;不会对完成迁移的库重新导入账号。重复执行及旧表已归档/清理的路径均有演练记录。
当前 SQL v5 按 LF 规范化 SHA-256:
`07e2c8395b6f43f064fe973407f374a962b39d07474bfba68aeb2b3c5ffe8bf2`。
## 维护材料
- [验收与性能记录](validation.md):数据、任务、已知契约差异和性能基线。
- [历史逐接口验收索引](sw8-sw9-recheck-summary-2026-11-04.json):63 项旧契约及 230 项检查;时间点为最近一次完整写入演练,不代表以后每轮都重跑写操作。
- [原生系统接口与旧别名](system-api-audit.md)、[附件引用转换](alert-attachment-migration.md)。
- 各域 `*-source-notes.md`:长期保留的查询、权限和统计口径。
- [当前接口审查](../API_REVIEW.md):此次修复、测试范围与需继续验证的框架边界。
SQL 生成器位于旧仓库 `script/sql/sw6_to_kra/build_sw7_migration.py`。修改生成器后同步本目录统一 SQL,并重新核对散列及迁移结果;不要恢复分阶段 SQL 补丁。
阶段报告、重复 JSON、全部临时验证脚本已归档到本机仓库外 `D:/Users/Yvan/backups/sw9/20261104_rehearsal/cleanup-archive/`,最后保留的工具位于其中的 `scripts-final/`。项目不再保留 `scripts` 目录,Go 任务运行不依赖这些脚本。归档及相邻的运行配置、原始响应、分区备份不纳入 Git。历史索引中 `reports` 项的文件名可在归档内查找。
@@ -1,15 +0,0 @@
# 预警日志附件的文件 ID 迁移补充
现有 `sw_to_kra_migrate.sql` 为 `sys_oss.oss_id` 建立 `entity='file'` 映射并导入 `media_file_upload_and_downloads`,同时只更新预警操作日志的操作人 ID,没有转换 `attachment_ids` 中的文件 ID。日志数字数组仍引用旧文件,不能直接当成 Kra 文件 ID 查询。
代码迁移步骤 `202611030007_business_alert_attachments` 在服务启动的迁移流程中执行,不修改用户提供的 SQL。最终数据库复制验收时,先完成原 SQL,再启动包含本步骤的新服务。
处理规则:
1. 新库没有旧日志备份和映射表时不执行转换。
2. 按日志 ID 与 `bak_sw2kra_20261103_sys_alert_operation_log` 关联,只处理当前附件与旧备份一致的纯数字数组,兼容 JSON 数字和数字字符串。
3. 根据文件映射写入新文件 ID,保留原数组顺序。没有旧文件映射的引用原本无法展示,转换后不指向任何同号新文件;原始引用仍在备份中保留。
4. URL 数组、混合 URL 数组、新日志、已改动的旧日志均不转换。分批查询和事务写入,重复执行不会二次映射。
5. 运行时附件解析支持数字 ID 与 URL;文件查询分批执行,已删除或不存在文件不展示。大数字 ID 在解析及返回过程中不经过浮点数。
隔离测试覆盖超出 JavaScript 精确整数范围的旧 ID、失效引用、新旧文件同号、URL、新日志、已转换日志及重复执行。最终实库验收应以旧、新附件解析出的 URL 对比,而不是要求转换后的 JSON 字节仍与旧 ID 数组一致。
-31
View File
@@ -1,31 +0,0 @@
# 渠道统计与游戏分析核对
核对基线至旧后端 main 最终代码中的 `ChannelDataServiceImpl`、`GameDataServiceImpl`、`BusinessBindScopeServiceImpl`、`ReportQueryScopes` 及日汇总/身份键 Mapper;页面直接核对旧 `ChannelDataStatistics.vue` 与 `GameAnalytics.vue`。
## 数据与范围
- 数据来自 `tab_sw_report_day` 和 `tab_sw_report_day_key`,金额固定 `dedup_mode=0`,人数按查看者的身份证偏好。金额用精确十进制处理,保留两位。
- 渠道查询保留本人/下级绑定范围和“包含下级渠道”开关;模糊查询匹配账号,不匹配昵称。开启下级时,命中的下级扩展到同根渠道树,再与原权限范围、类型筛选取交集。
- 金额根渠道:一级用自身,否则正 top ID,再否则自身。人数根渠道沿用旧 SQL 的 `COALESCE(NULLIF(top_promote_id,0),promote_id)`;两者在异常历史层级数据上可能不同,不能擅自统一。
- 超管全平台、包含下级且无渠道筛选时包含官方 0 号和已无渠道资料的历史行;普通商务只返回允许展示的根渠道。
- 游戏分组按平台+游戏 ID,过滤 `game_id > 0`。游戏名称筛选使用报表中的历史名,展示使用 `tab_game` 当前名。
- 渠道商务名称先用平台商务账号,否则 SW 用户名;此处不追加画像/明细页面使用的括号用户名。
## 汇总与计算
- 游戏 summary 累加全部筛选后游戏行,与当前页无关。相同身份出现在不同游戏时分别计数,不做全局去重。
- 普通商务渠道 summary 累加可见行。超管渠道 summary 使用筛选范围内全局去重;模式 0 按平台前缀区分身份,模式 1 跨平台去重。
- 付费率=充值人数÷活跃人数×100,ARPU=充值金额÷活跃人数,ARPPU=充值金额÷充值人数。分母为零时为零。
- 保留原逐行人数的 Java int 上限,游戏汇总改用 int64 避免相加溢出。相同排序值使用平台、ID 稳定排序。
- 后端不传日期时周期为上海当天,只传一端会拒绝;前端默认近 30 天含今天。后端页长缺失、非正或超过上限时为 500,页面显式传 10。
- 缺少必要数据表时 `available=false`、`summary=null`,顶部指标为 null,不把缺表当成零。
## 验证与优化
- 使用聚合查询和批量资料查询代替逐行查询,不改变业务口径。日期非法或倒置返回明确错误。
- 数字指标先按全部统计行计算汇总、排序和分页,再补当前页的渠道/游戏名称及商务资料;按渠道名称或渠道类型排序时仍先补全体资料。空页不读取资料,官方及未知渠道标签保持原规则。
- 需要渠道权限或渠道筛选时,报表通过平台+渠道 ID 的 IN 子查询匹配可见渠道,保留原绑定、层级、名称和类型条件,使 MySQL 5.7 能使用半连接处理。
- 隔离 TCP HTTP 和仓储测试覆盖全量汇总与分页、跨平台同号、官方/孤立渠道、根渠道树去重、类型交集、历史游戏名过滤、当前资料展示、偏好切换及空范围。
- 领域测试覆盖大金额精确排序、分母和人数上限。前端检查筛选回第一页、30 天默认值、汇总不随分页变化、缺数据提示、没有额外导出入口。
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
@@ -1,31 +0,0 @@
# 渠道画像迁移核对
## 数据范围与基本资料
- 先按当前商务本人/下级的正常绑定校验渠道可见性,平台与渠道 ID 组合定位;超管不限。
- 统计树与原渠道画像 helper 一致:关闭下级只看自身;二级看自身及直属三级;一级看自身及同平台 top/parent 对应的渠道。
- 基本信息包含昵称、账号、负责人、级别、审批状态、注册类型、注册/登录时间、归属商务、上级渠道与已通过推广游戏数。一级渠道不显示父级;其他渠道的父级账号从同平台记录重新读取。
- 画像状态取 status,与列表锁定状态不同:0 待审核、1 正常、-2 驳回、-3 商务新增。第四张指标卡是活跃人数,不能套用商务画像的人均充值。
## 时间与累计
- 支持 today、yesterday、last7、last30、total;累计起点取当前人数去重模式的最早日汇总,缺省上海当天。
- 累计模式无对照期,compareValue/changeRate/changeDiff/compareLabel 均为空;其他周期保持原对比规则。
- 游戏分布和付费分层始终按累计身份范围,与顶部指标周期独立。
- 今日/昨日趋势读取原始主账号和成功支付;其余按日汇总。金额固定模式 0,人数遵守全局去重偏好。
## 付费分层
- 参与者是累计注册身份键集合,重复键只计一次;无键时保留原注册人数回退规则。
- 分层金额来自统计渠道树内的所有历史成功支付,没有支付时间过滤。因此历史或未来时间戳的支付仍可能影响已注册身份的分层,不擅自改成顶部日期范围。
- 模式 1 按付款用户的原始 idcard 汇总,再按旧 trim/末尾 X 规则归一并合并;不擅自加主账号过滤或把子账号付款并到父账号。模式 0 按原始付款 user_id 汇总,并使用平台前缀与注册身份对应。
- 大R ≥10000,中R ≥1000 且 <10000,小R >0 且 <1000,其余免费。先按旧金额规则保留两位再归档,比例独立保留一位,不强制凑成 100%。未出现在注册集合中的付款人不增加分层人数。
## 修复的旧页面参数错误
- 旧“近7天/近30天”发送 7d/30d,而旧后台只识别 last7/last30,导致回退今日;新页面发送后台正式参数。
- 旧关联预警误将渠道 ID 传入 businessId;新页面使用 targetPromoteId 与 platformCodes,并继续由预警接口执行查看者数据范围隔离。
- 小R/免费区间文案按实际后端 >0 / ≤0 调整,避免把小于一元的付费玩家误解为免费。
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
-55
View File
@@ -1,55 +0,0 @@
# 渠道列表与新增渠道:源码核对记录
## 页面
来源 `sw/web/src/views/BusMgmt/ChannelList.vue`、`components/AddChannelDrawer.vue`、`components/ChannelGameDrawer.vue`。
- 筛选:平台默认全选、商务账号、关键词、渠道层级、注册类型、锁定状态。实际关键词后端只查询渠道账号;旧占位文案“渠道账号 / 负责人姓名”与实现不一致。
- 列:平台、ID、渠道账号、渠道层级、归属商务、上级渠道、负责人、渠道类型、推广游戏、玩家数量、累计充值、锁定状态、画像。没有编辑入口。
- 列表的注册类型选项只有“个人 0 / 公会 1”,其余选项被注释;新增抽屉则是“个人 0 / 企业 1”,不能擅自根据其中一处统一名称。
- 状态筛选及列使用 `lockStatus`:0 正常、-1 已锁定、-2 解除合作,与渠道审批字段 `status` 不同。
- “添加渠道”只按按钮权限判断,旧代码没有额外要求三级商务。游戏商务下拉随平台变化加载,选项取平台商务 ID 并去重。
- 新增字段:平台、游戏商务、推广类型、渠道账号、密码。账号为 `[\u4e00-\u9fa5a-zA-Z0-9]{4,30}`,提交前 trim;密码原值保留,按 Java UTF-16 长度 6–15 校验。
- 推广游戏与画像均是右侧抽屉,必须同时传 `sourceDb` 与渠道 ID。游戏抽屉调用 `/system/promote/game-distribution`,累计口径,列为游戏、玩家数量、充值金额。画像已迁移。
## 列表权限和查询
来源 `PromoteServiceImpl`、`BusinessBindScopeServiceImpl`、`BusierAccountFilters`。
- 非超管查询本人及全部下级的已通过平台绑定,三级仅本人。列表自身并不会因全局“包含下级渠道”关闭而只列一级;该偏好用于指标聚合。
- 超管默认不限绑定;一旦指定 `busierUserId` 或商务账号关键词,必须按匹配绑定收窄。
- `busierUserId` 是指定用户本人绑定,不能套用预警筛选“该商务及全部下级”的语义。
- 商务账号关键词在 Java 中按忽略大小写的文字子串匹配平台商务账号或 SW 用户名。`%`、`_` 在这里是普通字符,不能随意变成 SQL 通配符。
- 平台商务与渠道以 `(source_db, busier_id)` 关联,跨平台同号不得混用。用户名展示为 `平台账号(SW用户名)`;两者相同或其中为空时不重复拼接。
- 支持平台、层级、注册类型、审批状态、锁定状态、渠道账号关键词。默认按创建时间、ID 倒序;新增稳定的平台排序可用于跨平台同号并列。
## 列表指标
来源 `PromoteListMetricsSupport`、`PromotePortraitScopeHelper` 和三个 Mapper。
- `gameCount`:`tab_promote_apply.status=1`,按该行渠道自身去重 `game_id`;不随包含下级扩展。
- `playerCount`:`tab_user` 主账号(`puid=0 OR puid IS NULL`)。不去重时累计行数;身份证去重时在整棵统计树内去重,空证件不计入。
- 身份证规范化仅 trim,并将末尾大写 X 改为小写 x,不能做额外证件合法性校验或把无证件用户按 ID 计入。
- `totalPayAmount`:`tab_spend.pay_status=1` 累计 `pay_amount`,不按身份证去重,也没有日期上限。
- 关闭包含下级、层级为空或 >=3:只统计当前渠道。二级:本级与 `parent_id=当前ID AND promote_level=3`。一级:本级与同平台 `top_promote_id=当前ID OR parent_id=当前ID`。
- 注意一级旧加载函数还会加载第二跳子渠道,但最终 `PromotePortraitScopeHelper` 再按 top/parent 条件过滤;不能仅看加载函数就扩大最终统计集合。
## 源平台创建事务
来源 `PlatformPromoteWriteServiceImpl`、`PlatformTabPromote`、`TabPromoteSettlementTime`、`TabPromoteBusinessMapper`。
1. 先验证当前用户可用的已通过绑定与目标平台商务匹配,再进入对应 `lx/hw/yq` 源库写入事务。
2. 源库 `tab_promote` 账号不得重复。密码为 `XiguPasswordUtils.cmfPassword` 格式,需要复用已有环境盐适配器,不能用 Kra 登录密码算法替换。
3. 新渠道 `status=-3`、`promote_level=1`、`parent_id=0`、`top_promote_id=0`、`game_ids2=''`,注册类型默认 0;时间为 Unix 秒。
4. 同事务创建 `tab_promote_settlement_time`:`time_type=0`、`day_period=7`,下一次结算为当前秒数加 7 天。
5. 游戏商务必须存在,原子追加新渠道 ID 到 `tab_promote_business.promote_ids`,避免并发覆盖;任一步失败整体回滚。
6. 返回新 ID 与 `sourceDb`,原成功文案提示“添加成功,数据同步到列表可能有延迟”。不直接制造主库同步副本,不在真实平台库创建测试渠道。
## 游戏分布的已核对依赖
游戏分布依赖 `PromotePortraitServiceImpl.buildGameDistribution`、`SwReportDayQueryServiceImpl` 和日汇总/身份键 Mapper。当前实现只列根渠道已通过的游戏,用同渠道统计树读取日汇总:人数对 metric=1 的 identity_key 去重,dedup_mode 随偏好;金额固定 dedup_mode=0。累计起点从当前人数模式的最早日汇总取得,缺失则使用上海当天,即使模式 0 有更早数据也不改写该起点。截止上海当天,排除未来日期。先将金额保留两位再按金额降序、游戏 ID 升序排列。
完整渠道画像已接入,规则及验证见 `channel-portrait-source-notes.md`。报表汇总任务、预警生成任务和最终实库验收仍属于后续范围。
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
-36
View File
@@ -1,36 +0,0 @@
# 注册与充值明细迁移核对
已接入注册/充值列表、对应 XLSX 导出,以及页面依赖的游戏下拉接口。来源为旧 `RegisterServiceImpl`、`SpendServiceImpl`、`BusinessBindScopeServiceImpl`、`ReportQueryScopes` 和实际明细 Vue 页面。
## 查询口径
- 注册列表严格 `puid=0`,不包含 NULL,也不受身份证去重开关影响。注册时间使用 Unix 秒;未传完整日期时默认截至昨日的七天,页面主动传入的默认范围则是含今日的近30天。
- 注册累计充值按同平台+玩家 ID 汇总全部历史成功支付,不受本次注册日期和渠道过滤影响,也不归并子账号。
- 充值列表按选定状态过滤;成功支付合计强制 pay_status=1,忽略选定状态,保留其余过滤。因此筛选失败订单时合计仍可非零。
- 充值保留历史游戏名和渠道账号,注册保留首登游戏名快照。渠道资料缺失时依次保留历史账号、显示官方渠道(ID=0)或未知渠道及 ID。
- 游戏选项来自平台游戏表,列表按游戏名称传参;注册游戏筛选通过同平台游戏 ID 关联,充值直接筛订单的游戏名快照。
## 可见范围
- 普通商务只用本人和下级正常绑定;三级只用本人。指定商务账号按平台账号或 SW 登录名的文字子串收窄。
- 渠道账号为区分大小写的精确匹配。包含下级时,先解析命中渠道所属一级树,再与原可见渠道范围取交集;不包含下级时只保留一级。
- 超管未按商务/渠道收窄且包含下级时,可以看到官方和已无当前渠道资料的历史数据;其他情况必须命中真实可见渠道。
- 所有平台实体关联均带 source_db,不将同号玩家、渠道或游戏混为同一条。
## 修复与优化
- 修复旧注册游戏 EXISTS 内未限定外层 source_db 导致跨平台串号。
- 保留旧累计充值排序已修复的外层用户关联,并按数值注册时间排序。
- 移除“命中超过200个渠道后退回全平台”的筛选失效行为;用相关 EXISTS 表达渠道树,不需要把大量 ID 拼进请求。
- 只读取当前页/导出范围所需的渠道与绑定资料,累计充值按玩家组合键分批查询。
- 注册导出按钮使用正确的注册导出权限,不再误用充值导出权限;充值 ID 列不显示后台不支持的排序箭头。
## 导出与页面
- 超过10000条在读取完整结果和生成工作簿前拒绝;缺业务表不导出假空数据,确定无匹配绑定可导出只有表头的工作簿。
- 保留原 VO 导出字段顺序,包括没有中文 ExcelProperty 的辅助字段;时间保留 Unix 秒,状态/方式保留原代码。
- 数值直接写入数值 XML,不先转 float64;账号、订单号等强制文本,保留前导零并避免当作公式。
- 页面按旧模板提供筛选、游戏远程下拉、日期快捷项、排序、分页、导出;充值金额≥5000的行高亮。查询失败会清除旧行和旧合计。
- 游戏下拉的默认/批量 limit 行为沿用旧接口;区服分析的 servers/list 现已完成迁移。
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
-43
View File
@@ -1,43 +0,0 @@
# 商务与 SDK 商务画像源码核对
两种画像接口、三个页面入口及报表读取已实现。接口、平台数据源及账号状态验证统一见 `validation.md`。
## 范围与入口
- `GET /sw/business/:userId/portrait`:普通商务画像。旧 `BusinessPortraitQueryBo` 取 `sourceDbList`,必须选择平台;不是渠道接口的 `sourceDb` 参数。通过目标商务本人及下级的正常绑定解析渠道范围。
- `GET /sw/business/bindPlatform/portrait`:指定 `sourceDb` 与 `platformBusierId` 的正常绑定,校验当前登录人的可见范围。基本资料读取对应平台 `tab_promote_business`,不能用 SW 用户资料冒充。
- 两种画像共用旧 `BusinessProfileDrawer.vue`。商务列表“画像”、绑定正常行“画像”、只读 SDK 绑定详情的平台入口都需要接上;此前移除的“编辑”不能恢复。
- SDK 基本信息中 `userId` 实际是平台商务 ID。关联预警及旗下渠道的查询仍应使用打开抽屉时的 SW 用户 ID 和绑定参数,不能误用这个平台 ID。
## 基本信息
- 普通商务:账号、姓名、级别、父商务账号/姓名、创建时间、审核状态;渠道数只数所选范围内一级渠道,本月新增判断渠道创建 Unix 秒位于上海本月首日(含)至明天零点(不含)。
- SDK 商务:账号、姓名、级别、创建 Unix 秒。二级的父商务是 `commerce1`,三级是 `commerce2`,一级没有父级;父记录不存在时不填父资料。
- SDK 状态与 SW 不同:SDK 的 1 为正常、0 为待审核、其余为停用;不能把 SDK 字符串 `"0"` 套用成 SW 正常。
- 普通商务画像的渠道数包含目标商务的下级绑定;旧“旗下渠道”抽屉接口仍是直属口径,需要保留并记录两者差异。
## 时间、卡片与趋势
来源:`BusinessPortraitServiceImpl`、`BusinessPortraitSupport`。
- 画像服务支持 today、yesterday、last7、last30、thismonth,大小写归一,空/未知回退 today。公共 helper 另外支持 total,但普通商务画像服务不接受 total;渠道画像才使用累计模式。
- today 对比昨日;yesterday 对比前日;last7/last30 包含今天并对比前一完整同长周期;thismonth 对比上月首日至相同日号,上月不足该日时截到月末。
- 注册玩家来自日汇总身份键的 REGISTER=1;付费率为 PAY=3 人数 / ACTIVE=2 人数 ×100,保留 1 位;人均充值为金额 / 注册人数,保留 2 位。
- 当前活跃为零时付费率为 0.0;对照期活跃为零时 compareValue 为 null,百分点差也为 null。
- 注册、金额、ARPU 的相对变化率为 `(current-previous)*100/previous`,保留 1 位;对照为零/null 时 changeRate 为 null。付费率用 changeDiff 百分点差。
- today/yesterday 的趋势读取原始主账号注册与成功支付,补齐 24 个小时;小时注册趋势不做身份证去重。MySQL 与原 Mapper 一致使用 `HOUR(FROM_UNIXTIME(...))`,原服务与新服务的数据库会话时区在最终实库验收时核对;SQLite 测试按传入上海日边界计算小时。
- 其他周期趋势来自日汇总/身份键,按天补零。金额查询固定 dedup_mode=0;人数随当前登录人的偏好。
- 跨平台不去重人数需要给身份键加 source_db 前缀;身份证去重模式按身份键跨平台去重。具体 SQL 在 `TabSwReportDayKeyMapper.xml` 的 summary / summaryByDate 中。
## 排行和关联预警
- 注册 TOP 5 经 `ChannelTreeMetricsSupport.countRegisterByDisplayChannel` 汇总:含下级时按源平台和一级渠道树 COUNT DISTINCT;一级取自身 ID,其他渠道 top_promote_id>0 时取 top,否则取自身 ID。不能把子渠道人数简单求和。人数先按旧 int 上限归一,再排序和截取 TOP 5。
- 充值 TOP 5 先按渠道取金额,再按 `resolveRootPromoteId` 归根;同平台分组,过滤 <=0,只取前五。按值降序、渠道 ID 升序,返回 rank、平台、渠道 ID/账号、value(保留两位)。
- 关联预警不在画像 DTO 中,由前端独立请求 `/sw/alertRecord/list`,参数为 `platformCodes`、SW `businessId`、分页。
- 旧 SDK 画像的预警仍按 SW 商务及平台筛选,没有单独按 platformBusierId 收窄;需按实际模板核对,不从基本资料 ID 推断范围。
## 验证与实现要求
沿用 DTO → service → biz → data,金额用精确计算,页面采用 Kra 图表/表格风格。不要在 stats 缺表时伪造零;没有任何绑定是已知空范围,可返回真实零和零趋势,有绑定但渠道/汇总未接入时保留可读取基本信息并明确未知指标。
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
-16
View File
@@ -1,16 +0,0 @@
# 独立排行榜迁移核对
来源为旧 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,人数取当前偏好。
- 2026-11-04 按用户要求,四榜环比统一为 `(本期值 - 上期值) / 上期值 × 100%`。上期没有有效数据或为零时为 null;本期付费率降到零且上期为正时为 -100%。付费率原来的百分点差已改为相对增幅,这是迁移后明确采用的新口径。金额展示先保留两位,金额环比用未舍入汇总,与旧服务一致。
- 环比保留两位,绝对值小于 0.01 为持平。数值排序用精确有理数,同值追加平台/ID 稳定排序,截取后连续编号。
- 源码还核对了空平台含义:独立排行榜空选表示全部,区别于工作台必须选平台。非法/倒置日期明确拒绝。缺报表时 available=false 和空数组,不伪造零指标或空查询成功。
- 当前菜单名称、父级和路径已通过实际菜单接口核实,仍为 `sw_DataAnalysis` 下 `sw_RankingAnalysis` / `RankingAnalysis`。
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
@@ -1,64 +0,0 @@
# 日汇总和区服汇总任务迁移
核对旧 main 的两个 `SwReport*BuildServiceImpl`、两个 SnailJob 执行器、`ReportIdentityHelper` 和 `ReportBuildRangeSupport`。
## 原始数据口径
- 日汇总注册只取 `tab_user.puid=0`,注册时间按原 `CAST(register_time AS UNSIGNED)` 语义。按平台、渠道、首次游戏分组。
- 日汇总活跃来自 `tab_user_day_login`,必须能找到同平台主账号。记录无平台时,仅当全库同 ID 恰好一个主账号时才接受;跨平台存在多个主账号时跳过。缺渠道回退账号的渠道。
- 日汇总充值取当天成功支付,金额不限制主账号;付款用户不存在时模式 0 仍计账号,模式 1 因无身份证不计身份。没有付款用户 ID 的成功单仍计金额。
- 新增付费先取同一分组内“当日注册账号 ∩ 当日付费账号”,再转身份证;不能直接用身份证交集,否则会把同证件的其他账号误认成新增付费。
- 区服注册取 `tab_user_play_info.create_time` 的创角,活跃取 `play_time`,不套用日注册/登录的主账号限制。区服编号去首尾空白,保留前导零;创角/活跃缺游戏 ID 时忽略,充值缺游戏 ID 沿用 0。区服汇总没有新增付费指标。
- 两类报表均按日、平台、渠道、游戏(区服额外编号)生成模式 0/1 两行及对应身份键。模式 1 去首尾空格、仅将末尾 X 转 x,空身份证丢弃。金额两种模式相同,查询层仍固定读取模式 0。
- 游戏名作为当前游戏资料的快照写入。已知平台的用户按平台分组、ID 排序后分批加载;缺平台的事件仍读取全部同号用户以判断唯一主账号。平台作为字典键的一部分,不混淆跨平台同号。
## 原子替换与任务
- 所有原始数据读取、计算和两张报表替换在同一事务。按报表类型+日期的锁表串行同分区重建,避免不同任务同时回跑同一天。读不到必要源表/目标表时拒绝重建,不清空既有数据。
- 汇总、身份键按批写入,失败或取消回滚原有分区;其他日期不变。输出日期固定 `yyyy-MM-dd`,不采用驱动返回的时间格式作为写入日期。
- 任务方法保留 `swReportDayTodayTask` / `YesterdayTask` / `BackfillTask` 和对应 `swReportServerDay*` 六个名字。已有迁移 SQL 保留旧执行器名和 `_execution` 参数,可直接绑定 Kra 方法执行器。
- 回跑参数为 startDate/endDate;每个缺省日期独立取昨日,闭区间逐日重建、日间暂停一秒,最后一天不暂停。支持上下文取消,非法/倒置日期报错。
- 迁移本身不新建默认商务计划、不启用旧导入计划;实际运行状态由管理员单独配置。原始同步表和四张业务报表表沿用旧库结构,不在系统库中伪造完整业务数据源。
## 验证边界
领域与存储测试覆盖身份/账号规则、重复构建、自然日筛选和身份明细写失败后的原子回滚。真实 TCP HTTP 使用隔离数据库和 Kra 原生任务创建、手动触发、执行日志,核对两种报表实际落库且任务仍为禁用。
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
## 任务、表与接口的对应关系
这些任务是 Go 内部注册的方法,与已删除的临时验证脚本无关。它们从当前主库读取已同步的原始数据,再生成统计结果;不负责将 SDK 平台数据同步进主库。当前运行服务主库为 sw9。
| 任务方法 | 日期范围 | 写入的统计表 |
|---|---|---|
| swReportDayTodayTask | 上海时区今天 | tab_sw_report_day、tab_sw_report_day_key |
| swReportDayYesterdayTask | 上海时区昨天 | 同上 |
| swReportDayBackfillTask | startDate 至 endDate,含两端 | 同上 |
| swReportServerDayTodayTask | 上海时区今天 | tab_sw_report_server_day、tab_sw_report_server_day_key |
| swReportServerDayYesterdayTask | 上海时区昨天 | 同上 |
| swReportServerDayBackfillTask | startDate 至 endDate,含两端 | 同上 |
商务日报读取 `tab_user`(主账号注册及身份资料)、`tab_user_day_login`(登录)、`tab_spend`(成功充值)、`tab_game`(游戏名)。区服日报读取 `tab_user_play_info`(创角 create_time、活跃 play_time)、`tab_spend`、`tab_user`、`tab_game`。
`tab_sw_report_day` 按日期、平台、渠道、游戏、去重模式保存注册数、活跃人数、付费人数、新增付费人数和充值金额。`tab_sw_report_server_day` 在相同维度上增加区服,注册数代表创角玩家数,不包含新增付费人数。
两个 `_key` 表保存参与统计的身份键,供跨日期、渠道或游戏聚合时重新 `COUNT(DISTINCT identity_key)`。例如同一玩家连续两天活跃,查询这两天的独立活跃人数应为 1,不能直接将两个日活人数相加。`dedup_mode=0` 使用账号 ID,`dedup_mode=1` 使用规范化身份证;空身份证不计入模式 1。`metric` 为注册 1、活跃 2、付费 3,商务日报另有新增付费 4;金额查询只读模式 0,避免两种模式重复累加。
每次重建会在一个事务内锁定“报表类型+日期”,读取原始数据,删除统计表及对应 key 表该 `stat_date` 的旧行,批量写入新结果。读取或写入失败、取消时回滚;每个日期单独提交,回跑中途失败不会撤销此前已完成日期。除统计表外,会使用 `sys_business_report_build_locks` 保存互斥锁键,任务执行框架将结果记入 `sys_timed_task_logs`。原始用户、充值和登录记录不会被这些任务改写。
| 使用方 | 接口 | 依赖 |
|---|---|---|
| 渠道数据统计 | GET /sw/channelData/list | 商务日报两表 |
| 游戏数据 | GET /sw/gameData/list | 商务日报两表 |
| 三类排行榜 | GET /sw/rank/channel、/business、/game | 商务日报两表 |
| 仪表盘/工作台 | GET /sw/workbench/summary、/registerTop、/payRank | 商务日报两表;汇总中的新增渠道另查渠道表 |
| 商务画像 | GET /sw/business/:userId/portrait、/sw/business/bindPlatform/portrait | 商务日报两表 |
| 渠道画像及游戏分布 | GET /sw/promote/portrait、/sw/promote/game-distribution | 商务日报两表;付费分层还会读取原始充值 |
| 区服分析 | GET /sw/serverAnalysis/list | 区服日报两表 |
普通渠道列表、注册明细、充值明细、渠道激活、工作台未登录榜,以及游戏/区服下拉选项不直接使用这四张汇总表。预警检测 `alertTriggerTask` 另行读取原始注册/充值数据,不由这些汇总任务生成预警。
今天任务用于当天持续刷新,昨天任务用于补算昨日完整数据,backfill 用于补历史或纠正迟到数据。2026-11-04 按用户要求与旧计划同步:四条今天/昨天汇总计划和预警计划启用;两个 backfill 保持停用,需要时手动运行。周期、时区和超时/重试参数保持原配置,详见 [验收记录](validation.md)。
报表输入索引由版本 `202611040014_business_report_build_indexes` 增量添加:`tab_user.idx_sw_user_report_identity` 覆盖用户身份及渠道字段,`tab_user_play_info.idx_sw_play_report_active` 覆盖活跃时间查询的全部读取字段。迁移跳过未接入的业务表,重复执行不重建已有同列索引,同名异列索引报错。MySQL 使用 `ALGORITHM=INPLACE, LOCK=NONE`,不改写原始业务字段。
@@ -1,16 +0,0 @@
# 区服分析迁移核对
核对旧 main 的 `ServerAnalysisServiceImpl`、`SwReportServerDayQueryServiceImpl`、两个区服日汇总 Mapper,以及 dev/dev1.0 的 `ServerAnalysis.vue`。
- `/sw/serverAnalysis/games` 复用已迁移游戏选项;新增 `/servers`、`/list`。两者均要求游戏名称;来源支持逗号分隔或重复参数。
- 选项通过当前 `tab_game.game_name` 确定平台+游戏,匹配区服名称;保留区服编号的字符串及前导零。原接口不按商务绑定裁剪资料选项,统计查询独立按可见范围隔离。
- 选项默认 50,超过 100 时允许 100000,沿用旧批量选项请求。使用 `(source_db,game_id) IN (SELECT source_db,id FROM tab_game …)` 保持成对关联并避免重复行;MySQL 5.7 可先按游戏名索引定位,再使用区服的 game_id 索引,避免相关 EXISTS 扫描全部区服。
- 统计读取 `tab_sw_report_server_day` 和身份键表,按平台+游戏+区服编号分组;空白编号不展示。不额外加日游戏报表的 `game_id > 0` 限制。
- 人数在每个区服内 COUNT DISTINCT,金额固定模式 0;筛选历史报表游戏名。展示优先金额汇总的历史游戏名,无金额行时回退当前游戏名,保留旧行为。
- 区服名称/开服日期按三元组批量补充;重复资料固定取最小 ID,提高原不确定顺序的稳定性。相同主排序后保留区服编号顺序,再按平台、游戏 ID 稳定排列。
- 未指定区服时忽略新增/活跃/付费人数和四舍五入金额均为零的行;指定区服保留报表已有零行,不凭资料表制造新行。
- 汇总是全部筛选后展示行之和,不随分页变化;ARPU 的分母是活跃人数。逐行人数保留 Java int 上限,summary 改用 int64 避免原累加溢出;金额与比率精确处理。
- 缺任一必要业务表时明确未接入,summary 为 null;不能混用不存在区服和缺表错误。未知开服时间保持 null。
- 后端默认上海当天,前端默认近 30 天;非法日期/倒置周期明确拒绝。页长沿用最大 500。
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
@@ -1,558 +0,0 @@
{
"date": "2026-11-04",
"sourceDatabase": "sw8",
"targetDatabase": "sw9",
"ports": {
"legacy": 18080,
"newRecheck": 19002,
"newDevelopment": 8000
},
"businessContractsVerified": 51,
"legacyFrameworkAliasesReplaced": 12,
"readComparisonCases": 104,
"businessTasksMigrated": 7,
"businessTasksEnabled": 0,
"checks": 230,
"reports": [
{
"file": "sw8-sw9-recheck-account-create-2026-11-04.json",
"checks": 10,
"passed": true
},
{
"file": "sw8-sw9-recheck-account-write-2026-11-04.json",
"checks": 21,
"passed": true
},
{
"file": "sw8-sw9-recheck-admin-2026-11-04.json",
"checks": 36,
"passed": true
},
{
"file": "sw8-sw9-recheck-exports-2026-11-04.json",
"checks": 2,
"passed": true
},
{
"file": "sw8-sw9-recheck-level3-2026-11-04.json",
"checks": 15,
"passed": true
},
{
"file": "sw8-sw9-recheck-pagination-2026-11-04.json",
"checks": 19,
"passed": true
},
{
"file": "sw8-sw9-recheck-preference-matrix-2026-11-04-sub0-dedup0.json",
"checks": 4,
"passed": true
},
{
"file": "sw8-sw9-recheck-preference-matrix-2026-11-04-sub0-dedup1.json",
"checks": 4,
"passed": true
},
{
"file": "sw8-sw9-recheck-preference-matrix-2026-11-04-sub1-dedup0.json",
"checks": 4,
"passed": true
},
{
"file": "sw8-sw9-recheck-preference-matrix-2026-11-04-sub1-dedup1.json",
"checks": 4,
"passed": true
},
{
"file": "sw8-sw9-recheck-preference-matrix-2026-11-04.json",
"checks": 14,
"passed": true
},
{
"file": "sw8-sw9-recheck-sdk-write-2026-11-04.json",
"checks": 35,
"passed": true
},
{
"file": "sw8-sw9-recheck-task-validation-2026-11-04.json",
"checks": 8,
"passed": true
},
{
"file": "sw8-sw9-recheck-tasks-2026-11-04.json",
"checks": 7,
"passed": true
},
{
"file": "sw8-sw9-recheck-testlevel1-2026-11-04.json",
"checks": 6,
"passed": true
},
{
"file": "sw8-sw9-recheck-testlevel2-2026-11-04.json",
"checks": 6,
"passed": true
},
{
"file": "sw8-sw9-recheck-testlevel3-2026-11-04.json",
"checks": 6,
"passed": true
},
{
"file": "sw8-sw9-recheck-write-2026-11-04.json",
"checks": 29,
"passed": true
}
],
"coverage": [
{
"method": "GET",
"source_path": "/system/user/preference",
"target_path": "/sw/user/preference",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "PUT",
"source_path": "/system/user/preference",
"target_path": "/sw/user/preference",
"status": "verified",
"evidence": "sw8-sw9-recheck-preference-matrix-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/promote/list",
"target_path": "/sw/promote/list",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/promote/portrait",
"target_path": "/sw/promote/portrait",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/promote/game-distribution",
"target_path": "/sw/promote/game-distribution",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/promote/bindOptions",
"target_path": "/sw/promote/bindOptions",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/promote",
"target_path": "/sw/promote",
"status": "verified",
"evidence": "sw8-sw9-recheck-sdk-write-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/promoteActivePool/bindList",
"target_path": "/sw/promoteActivePool/bindList",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/promoteActivePool/bindSpendDetail",
"target_path": "/sw/promoteActivePool/bindSpendDetail",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/workbench/summary",
"target_path": "/sw/workbench/summary",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/workbench/registerTop",
"target_path": "/sw/workbench/registerTop",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/workbench/payRank",
"target_path": "/sw/workbench/payRank",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/workbench/unloginTop",
"target_path": "/sw/workbench/unloginTop",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/rank/channel",
"target_path": "/sw/rank/channel",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/rank/business",
"target_path": "/sw/rank/business",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/rank/game",
"target_path": "/sw/rank/game",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/gameData/list",
"target_path": "/sw/gameData/list",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/channelData/list",
"target_path": "/sw/channelData/list",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/register/list",
"target_path": "/sw/register/list",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/register/export",
"target_path": "/sw/register/export",
"status": "verified",
"evidence": "sw8-sw9-recheck-exports-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/spend/list",
"target_path": "/sw/spend/list",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/spend/export",
"target_path": "/sw/spend/export",
"status": "verified",
"evidence": "sw8-sw9-recheck-exports-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/serverAnalysis/games",
"target_path": "/sw/serverAnalysis/games",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/serverAnalysis/servers",
"target_path": "/sw/serverAnalysis/servers",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/serverAnalysis/list",
"target_path": "/sw/serverAnalysis/list",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/alertRule/list",
"target_path": "/sw/alertRule/list",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/alertRule/ruleTypes",
"target_path": "/sw/alertRule/ruleTypes",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/alertRule/{id}",
"target_path": "/sw/alertRule/:id",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/alertRule/add",
"target_path": "/sw/alertRule/add",
"status": "verified",
"evidence": "sw8-sw9-recheck-write-2026-11-04.json"
},
{
"method": "PUT",
"source_path": "/system/alertRule/edit",
"target_path": "/sw/alertRule/edit",
"status": "verified",
"evidence": "sw8-sw9-recheck-write-2026-11-04.json"
},
{
"method": "DELETE",
"source_path": "/system/alertRule/{id}",
"target_path": "/sw/alertRule/:id",
"status": "verified",
"evidence": "sw8-sw9-recheck-write-2026-11-04.json"
},
{
"method": "PUT",
"source_path": "/system/alertRule/toggle/{id}",
"target_path": "/sw/alertRule/toggle/:id",
"status": "verified",
"evidence": "sw8-sw9-recheck-write-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/alertRecord/list",
"target_path": "/sw/alertRecord/list",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/alertRecord/detail/{id}",
"target_path": "/sw/alertRecord/detail/:id",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/alertRecord/startProcess",
"target_path": "/sw/alertRecord/startProcess",
"status": "verified",
"evidence": "sw8-sw9-recheck-write-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/alertRecord/submitAudit",
"target_path": "/sw/alertRecord/submitAudit",
"status": "verified",
"evidence": "sw8-sw9-recheck-write-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/alertRecord/approve",
"target_path": "/sw/alertRecord/approve",
"status": "verified",
"evidence": "sw8-sw9-recheck-write-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/alertRecord/reject",
"target_path": "/sw/alertRecord/reject",
"status": "verified",
"evidence": "sw8-sw9-recheck-write-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/alertRecord/reProcess",
"target_path": "/sw/alertRecord/reProcess",
"status": "verified",
"evidence": "sw8-sw9-recheck-write-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/alertRecord/addNote",
"target_path": "/sw/alertRecord/addNote",
"status": "verified",
"evidence": "sw8-sw9-recheck-write-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/user/list",
"target_path": "/sw/business/list",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/user/{userId}/promotes",
"target_path": "/sw/business/:userId/promotes",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/user/{userId}/portrait",
"target_path": "/sw/business/:userId/portrait",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/user/{userId}/bindPlatform",
"target_path": "/sw/business/:userId/bindPlatform",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/user/bindPlatform",
"target_path": "/sw/business/bindPlatform",
"status": "verified",
"evidence": "sw8-sw9-recheck-sdk-write-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/user/bindPlatform",
"target_path": "/sw/business/bindPlatform",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "GET",
"source_path": "/system/user/bindPlatform/portrait",
"target_path": "/sw/business/bindPlatform/portrait",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "DELETE",
"source_path": "/system/user/bindPlatform",
"target_path": "/sw/business/bindPlatform",
"status": "verified",
"evidence": "sw8-sw9-recheck-sdk-write-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/user/export",
"target_path": "/sw/business/export",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
},
{
"method": "POST",
"source_path": "/system/user/importData",
"target_path": "/sw/business/importData",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
},
{
"method": "GET",
"source_path": "/system/user/",
"target_path": "/sw/business",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
},
{
"method": "GET",
"source_path": "/system/user/{userId}",
"target_path": "/sw/business/:userId",
"status": "verified",
"evidence": "sw8-sw9-recheck-admin-2026-11-04.json"
},
{
"method": "POST",
"source_path": "/system/user",
"target_path": "/sw/business",
"status": "verified",
"evidence": "sw8-sw9-recheck-account-create-2026-11-04.json"
},
{
"method": "PUT",
"source_path": "/system/user",
"target_path": "/sw/business",
"status": "verified",
"evidence": "sw8-sw9-recheck-account-write-2026-11-04.json"
},
{
"method": "DELETE",
"source_path": "/system/user/{userIds}",
"target_path": "/sw/business/:userIds",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
},
{
"method": "GET",
"source_path": "/system/user/optionselect",
"target_path": "/sw/business/optionselect",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
},
{
"method": "PUT",
"source_path": "/system/user/resetPwd",
"target_path": "/sw/business/resetPwd",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
},
{
"method": "PUT",
"source_path": "/system/user/changeStatus",
"target_path": "/sw/business/changeStatus",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
},
{
"method": "GET",
"source_path": "/system/user/unlock/{userId}",
"target_path": "/sw/business/unlock/:userId",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
},
{
"method": "GET",
"source_path": "/system/user/authRole/{userId}",
"target_path": "/sw/business/authRole/:userId",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
},
{
"method": "PUT",
"source_path": "/system/user/authRole",
"target_path": "/sw/business/authRole",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
},
{
"method": "GET",
"source_path": "/system/user/deptTree",
"target_path": "/sw/business/deptTree",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
},
{
"method": "GET",
"source_path": "/system/user/list/dept/{deptId}",
"target_path": "/sw/business/list/dept/:deptId",
"status": "native_framework_replacement",
"note": "not a legacy wire-protocol alias"
}
],
"explicitDifferences": [
"Pay-rate ranking uses user-requested relative percentage growth instead of percentage-point change.",
"Only the creation times of three explicitly identified HTTP-created test users are excluded; original migrated users remain strict.",
"Legacy MyBatis returns page one on an out-of-range page; new service returns an empty page. Valid pages match.",
"Disabled/pending/rejected users cannot use existing sessions in the new service; old service permits this."
],
"passed": true
}
File diff suppressed because it is too large Load Diff
-50
View File
@@ -1,50 +0,0 @@
# 系统接口承接核对
核对基线:旧后端 `d31082f4a7816d4c83544b8f54e6f42a3b2132b1` 至 `main`。原 SQL 的接口表按控制器收集接口,因此包含基线前已有的 RuoYi 用户管理接口;不能把所有记录都当成新商务新增功能,也不能只凭新路由存在就宣布功能完成。
## 商务新增与改动
`SysUserController` 的 Git 差异主要是商务列表语义、添加子商务,以及直属渠道、平台绑定和两种商务画像。用户列表查询新增商务树、姓名/等级过滤、直属渠道与正常绑定数量;添加子商务新增层级、归属与偏好初始化。当前列表、子商务、偏好、平台绑定及两种商务画像均已迁移;2026-11-04 已完成 sw8/sw9 同源接口核对。
原用户控制器中的以下方法在基线前已经存在,旧 `BusMgmt/BusinessList.vue` 没有为它们增加实际操作入口。新商务页面不据此增加编辑、删除、导入、导出、角色分配等按钮。
| 旧目标别名 | 当前 Kra 能力 / 核对结果 |
| --- | --- |
| `GET /sw/business`(新增表单初始化) | 原生用户表单通过用户、角色、部门接口加载;商务新增表单由后端决定商务层级,不开放角色选择 |
| `DELETE /sw/business/:userIds` | 原生 `DELETE /user/deleteUser`;旧商务列表没有删除入口 |
| `GET /sw/business/optionselect` | 原生用户列表/部门用户查询;商务与报表实际使用各自的可见范围接口 |
| `PUT /sw/business/resetPwd` | 原生 `POST /user/resetPassword`;商务自助找回密码使用已迁移的独立流程 |
| `PUT /sw/business/changeStatus` | 原生 `PUT /user/setUserInfo`;本批补齐 enable 与 business_status 联动 |
| `GET /sw/business/authRole/:userId` | 原生用户角色资料及角色列表 |
| `PUT /sw/business/authRole` | 原生 `POST /user/setUserAuthorities` |
| `GET /sw/business/deptTree` | 原生 `POST /department/getDepartmentList` |
| `GET /sw/business/list/dept/:deptId` | 原生 `GET /department/getDepartmentUsers` |
| `POST /sw/business/export` | RuoYi Excel 导出协议尚未增加别名;旧导出调用共享查询构造器,会继承商务过滤,不能把原生通用导出直接算成相同协议已实现 |
| `POST /sw/business/importData` | RuoYi Excel 导入协议尚未实现;当前商务模板没有导入入口 |
| `GET /sw/business/unlock/:userId` | 原先删除 RuoYi Redis 密码错误键;新系统使用 Kra 鉴权/限制机制,不读取旧 Redis 键。旧协议别名尚未注册 |
以上记录说明框架能力的对应关系,并非宣称旧别名已可调用。别名与原生接口的请求/响应并不相同;最终接口核对仍须保留这项差异,尤其不能把导入/导出列为已经通过兼容验收。当前不删除用户 SQL 中的这些历史接口元数据。
## 账号启停与商务状态
原 RuoYi 用 `status` 同时表示正常、停用、驳回和待审核。迁移后有 Kra 的 `enable` 和保留语义的 `business_status`。原生用户启停此前只改前者,可能导致商务列表仍显示正常,或启用后仍因旧状态无法登录。
本批处理:
- 新建原生账号时,默认启用与禁用状态同步到商务状态。
- 管理员明确启用时恢复正常;关闭启用时,普通状态变为停用,待审核/驳回继续保留,避免普通资料编辑丢失审核状态。
- 读取当前业务状态与写入两个字段在同一行锁事务内完成,规则由 biz 回调决定,data 只做持久化。
- 个人资料不传 enable 时仍按原有字段更新规则处理;传 enable 时不能越过管理员已经写入的停用、待审或驳回状态。
- 角色范围检查继续执行,不增加商务列表的编辑入口。
隔离 TCP HTTP 测试覆盖原生启停后商务接口返回状态、待审/驳回资料编辑、自行启用被拒绝和不存在用户;仓储测试覆盖失败回滚与越权。现有鉴权测试继续检查停用/待审/驳回会拒绝密码登录和已有会话。
sw8/sw9 的账号状态及原密码保留情况见 `validation.md`。
## 2026-11-04 实库复核
SQL 的 63 项契约已逐项归类:51 项商务接口完成真实 HTTP 核对,12 项本页所列旧框架别名由 Kra 原生能力承接,不宣称旧 Excel/Redis 解锁等协议兼容。详见 `sw8-sw9-recheck-summary-2026-11-04.json`。
真实状态写入验证确认:旧系统停用、待审、驳回后已签发会话仍可访问偏好;新系统会拒绝这些会话。该安全差异保留,恢复正常后旧令牌重新可用。
原库一级商务角色没有 `system:promote:add` 权限,新库保持同一授权限制。新增渠道的正向实测使用管理员,未擅自扩大角色权限。
-91
View File
@@ -1,91 +0,0 @@
# 商务迁移验收与性能记录
核验日期:2026-11-04。Java → sw8,Go → sw9。原始响应与备份位于仓库外 `D:/Users/Yvan/backups/sw9/20261104_rehearsal/`。
## 迁移与接口
- SQL v5 在独立 MySQL 5.7 完成 52 项备份检查、24 项迁移检查;首次执行、重复执行、异常账本拒绝、旧表归档后执行均通过。45 个原用户密码哈希逐字节保留。
- 全量旧契约 63 项:51 项商务接口已实测(33 读取、18 写入/导出);12 项旧框架别名由原生功能承接,不能视为旧协议兼容。
- 最近完整演练共 230 项检查:104 组读取、两个 Excel 逐单元格、账号创建/编辑/状态、规则增删改/启停、预警状态流转/备注、SDK 绑定/重提/解绑、创建渠道和任务配置。
- 日报与区服报表真实重建后,68,857 行逻辑数据一致,随后恢复原分区;预警实际任务两边生成 480 条后恢复原 478 条。
- 恢复后的两库均为用户 48、平台绑定 58、规则 5、预警 478、预警日志 313。增加的 3 个层级账号为获授权保留的测试账号。
- SDK 写入使用本机独立平台夹具,OA 审批使用受控夹具;没有宣称真实第三方审核或线上支付已验收。
详细旧契约清单保留在 [综合索引](sw8-sw9-recheck-summary-2026-11-04.json)。本轮新增审查及运行结果见 [接口审查](../API_REVIEW.md)。
## 明确保留的差异
1. 付费率榜单按用户要求统一 `%`,变化率为 `(本期-上期)/上期×100%`,上期为零返回无可比值;不是百分点差。
2. 仅三个分别创建的测试账号允许创建时刻不同,原迁移用户时间严格比较。
3. 超出末页时新接口返回空页;旧 MyBatis 会回到第一页。有效页码和总数保持相同。
4. 新服务立即拒绝停用、待审、驳回用户的已有会话;旧服务未做这项检查。
5. 本轮修正渠道激活的跨平台同名账号访问:本人已通过绑定的“平台+账号”才有权限。列表及流水明细使用同一规则。
6. 日期范围只传一端、倒置或非法时返回参数错误;不再静默忽略筛选。
## 定时任务
7 条实际商务计划及 7 个方法均迁移,cron、秒字段、上海时区、回跑参数与超时/重试配置已经逐条核对。
| 方法 | 周期 | 测试库状态 |
|---|---|---|
| swReportDayYesterdayTask | 每日 01:00 | 启用 |
| swReportServerDayYesterdayTask | 每日 01:05 | 启用 |
| swReportDayTodayTask | 每 5 分钟,第 40 秒 | 启用 |
| swReportServerDayTodayTask | 每 5 分钟 | 启用 |
| swReportDayBackfillTask | 每 60 秒,原配置停用 | 停用 |
| swReportServerDayBackfillTask | 每 60 秒,原配置停用 | 停用 |
| alertTriggerTask | 每 30 分钟 | 启用 |
2026-11-04 按用户确认与旧库启停状态同步,五条常规任务开启自动执行,两个历史回填保持停用;框架自带 ClearDB、CleanStaleUploads 继续停用。七条计划的周期、启停、上海时区、下次执行时间及超时/重试参数逐条核对通过,常规任务仍为 60 秒超时。旧工程的演示、分片、MapReduce 示例没有实际商务计划,不属于漏迁。方法超时后仍未退出时禁止重复执行,同分区报表和预警写入有事务锁及去重测试。
### 启用后的实际复测
中断续接时,昨日两类汇总均有 60 秒超时记录。用户查询改为按平台分组、ID 排序分批,避开 MySQL 5.7 多列 IN 部分批次的全表扫描;新增用户身份和区服活跃覆盖索引,减少大行回表。原始业务字段、统计规则和任务执行参数不变。
版本 `202611040014_business_report_build_indexes` 已在 sw9 应用,迁移版本总数为 28。两条索引分别为 `tab_user.idx_sw_user_report_identity(id,source_db,puid,promote_id,idcard)`、`tab_user_play_info.idx_sw_play_report_active(play_time,user_id,source_db,promote_id,game_id,server_id)`。
| 复测项 | 结果 |
|---|---|
| 昨日商务汇总,统计日 2026-11-03 | 最终版本实际执行成功,21.032 秒 |
| 昨日区服汇总,统计日 2026-11-03 | 最终版本实际执行成功,20.243 秒 |
| 今日两个汇总 | 已观察到多轮自动成功执行 |
| 预警任务 | 15:30 自动成功,28.864 秒;两库 480 条业务记录相等,用户 ID 按迁移表映射,排除生成 ID 和创建/更新时间 |
| 区服用户资料读取 | 同批 18,177 用户,71.619 秒降至 0.443 秒,38 批全部使用覆盖索引 |
| 区服活跃输入读取 | 同批 20,564 行,28.545 秒降至 0.074 秒,使用覆盖范围索引 |
以上为该测试库的单轮实测,包含当时缓存和数据库负载影响,不是固定延迟承诺。Go 全量测试、构建及独立 MySQL 5.7 的六组回归通过;新增回归验证覆盖索引、批量身份读取、索引幂等和同名异列冲突拒绝,既有事务回滚测试通过。
四张表的 2026-11-03 重建结果分别为 3,440、15,064、19,682、30,671 行,合计 **68,857 行**,逐行及 SHA-256 均与此前保存的旧程序同日重建结果一致。该比较明确复用旧重算基线,本轮没有重跑或覆盖 sw8 的昨日分区。
直接比较两库当前昨日分区仍有差异:sw8 保留此前恢复的旧汇总,日报 8 行指标变化、日报身份键少 10 行,区服汇总 15 行旧结果对应 29 行重建结果、区服身份键少 31 行。不能把重算基线一致表述为两库当前分区一致。2026-11-04 两库四表均为空,sw9 当日注册、登录、成功充值、创角及活跃原始记录均为 0;今日验证覆盖自动触发和空日处理,非空数据核对使用昨日。
原始对比、任务日志和查询计划位于仓库外 `D:/Users/Yvan/backups/sw9/20261104_rehearsal/tasks-enabled-20261104_145308/`,最终证据为 `resumed-verification.json`、`final-manual-task-logs.json`、`task-index-installation.json`。辅助脚本同样留在仓库外,未恢复项目内已删除的 scripts 目录。最终版本运行于日常 8000 服务,保持五条自动计划启用。
## 已完成的性能优化
同一 sw9 副本的多轮中位耗时,单位 ms;首次冷读与负载变化会有波动。数据统计近 30 天,排行榜近 7 天。
| 接口 | 优化前 | 优化后 |
|---|---:|---:|
| 渠道列表 | 175 | 108 |
| 渠道数据统计 | 3839 | 1110 |
| 充值明细 | 216 | 113 |
| 注册明细 | 139 | 88 |
| 游戏数据 | 859 | 453 |
| 区服分析 | 3012 | 42 |
| 渠道排行榜 | 863 | 483 |
| 商务排行榜 | 5048 | 325 |
| 游戏排行榜 | 631 | 229 |
| 工作台汇总 | 75 | 42 |
| 工作台最低充值榜 | 1509 | 171 |
| 工作台未登录榜 | 224 | 158 |
| 工作台近 7 天注册榜 | 92 | 74 |
| 工作台近 7 天充值榜 | 147 | 53 |
优化包括分页主键后回表、按原渠道预聚合、取消相关子查询、合并汇总查询、限制并发为 2、只加载 TOP10 名称,以及关闭 SQL 日志时避免参数展开。61 组业务列表/排行榜和 52 组工作台数据逐字段相等,另有日常 8080 接口调用检查。
新增版本 `202611040013_business_query_indexes` 已在 sw9 应用 7 个查询索引,累计 27 个迁移版本;注册转换列索引和区服创角时间索引亦已存在。查询优化不改变用户权限和统计口径。
## 页面
渠道激活页不再在发起分页请求时清零 total;排行榜与仪表盘四榜并排、付费率用百分比、头部两项偏好直接勾选;三级商务保留商务列表/绑定列表切换。页面检查脚本、运行截图和带业务数据的响应均已保存在仓库外。
-26
View File
@@ -1,26 +0,0 @@
# 工作台与仪表盘业务数据
核对旧 main `WorkbenchServiceImpl`、`ChannelTreeMetricsSupport`、日汇总 Mapper,以及 dev/dev1.0 的 `views/index.vue`,迁移 `/sw/workbench/summary`、`registerTop`、`payRank`、`unloginTop`。
## 六项指标
- `sourceDbList` 至少选择一个平台;商务范围为本人及下级正常绑定,超管不限;是否包含下级渠道与身份证去重读取当前偏好。
- 今日注册、活跃人数对比昨日,今日充值对比昨日,本月充值对比上月 1 日至同日(上月无该日期时取月末)。金额固定模式 0,人数模式 0 在全平台汇总时加平台前缀,模式 1 跨平台去重。
- 活跃渠道数按每个渠道自身当日注册人数大于 0 或汇总充值金额大于 0 计算,不合并一级;同一渠道同时命中只计一次。新增渠道数按可见实体的创建时间统计,包含下级开关同时影响两项。
- 本期/上期值先保留两位,再算一位小数环比;上期为零时 changeRate=null。changeDiff 保留字段但为 null,与旧服务实际输出一致。
## 四组榜单
- 注册榜 TOP10:包含下级时使用旧 `countByChannelRoot` 的 INNER JOIN+CASE,在渠道树内去重;不会将缺少实体的官方/历史孤立记录纳入注册榜。此处与渠道统计页的 LEFT JOIN 根口径不同。
- 充值最高/最低榜按金额固定模式 0,再归并到根渠道。最高榜只保留正值,最低榜额外包含可见一级渠道的零充值实体;负值沿用旧最低榜行为,不擅自清零。官方/缺账号名称按旧规则回退。
- 注册/最高充值默认今日,最低充值默认近 30 天,支持 today/yesterday/last7/last30;包括今天。最高/最低共用 payRank,通过 order 区分。
- 未登录榜仅取可见一级实体,先用最后登录时间,无有效登录时用创建时间,两者都无效为 99999;未来日期为 0,按上海自然日计算。
- 数值排序使用精确有理数;同值按 ID,跨平台同号追加平台稳定排序。充值显示两位小数,未登录天数为整数字符串;所有榜单排名连续 1 至 10。
## 实现与验证
- 仪表盘直接调用四个接口,并复用已有预警列表查询 status=1/pageSize=5,实现原最新五条轮播。Kra 快捷入口和公告保留。
- 聚合、资料查询批量执行,未对每个渠道发起独立 SQL。注册榜不依赖金额表,未登录榜不依赖报表,缺少必要表返回明确不可用。
- 六卡扩展 available=false 并返回 null 值;榜单保留数组成功协议,缺表返回业务错误,避免把未知榜单当作真实空榜。
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
+9
View File
@@ -234,6 +234,15 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter
return out, nil
}
func sortAnalytics(rows []*AnalyticsRow, kind AnalyticsKind, field string, ascending bool) error {
if kind == GameAnalytics && field == "" {
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].SourceDB != rows[j].SourceDB {
return rows[i].SourceDB < rows[j].SourceDB
}
return rows[i].ID < rows[j].ID
})
return nil
}
allowed := map[string]bool{"newRegisterCount": true, "activeCount": true, "payUserCount": true, "newPayUserCount": true, "totalPay": true, "payRate": true}
if kind == GameAnalytics {
allowed["arpu"] = true
+19
View File
@@ -91,6 +91,25 @@ func TestAnalyticsSortAndMissingData(t *testing.T) {
}
}
func TestGameAnalyticsCancelSortRestoresDefaultOrder(t *testing.T) {
uc, repo := analyticsFixture()
repo.data.Rows = []*AnalyticsRow{
{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 2}, Pay: "5"},
{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 1}, Pay: "10"},
{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 3}, Pay: "20"},
}
for _, sample := range []struct {
field string
ascending bool
first int64
}{{"totalPay", true, 2}, {"totalPay", false, 3}, {"", false, 1}} {
page, err := uc.List(accountContext(7, 901), GameAnalytics, AnalyticsFilter{SortField: sample.field, Ascending: sample.ascending}, 1, 1)
if err != nil || page.Rows[0].ID != sample.first || page.Summary.Pay != "35.00" {
t.Fatalf("切换排序不应改变汇总,取消时恢复默认顺序: %+v %v", page, err)
}
}
}
func TestAnalyticsLoadsMetadataOnlyForSelectedPage(t *testing.T) {
for _, kind := range []AnalyticsKind{ChannelAnalytics, GameAnalytics} {
t.Run(string(kind), func(t *testing.T) {
+10 -3
View File
@@ -37,6 +37,7 @@ type LedgerListOptions struct {
type LedgerNames struct {
PromoteID *int64
PromoteAccount, BindingAccount, BindingUserName string
BusinessPath []string
}
func (n *LedgerNames) DisplayPromote() string {
@@ -52,6 +53,9 @@ func (n *LedgerNames) DisplayPromote() string {
return "未知渠道(ID:" + strconv.FormatInt(*n.PromoteID, 10) + ")"
}
func (n *LedgerNames) DisplayBusiness() string {
if len(n.BusinessPath) > 0 {
return strings.Join(n.BusinessPath, "→")
}
return (&Channel{BindingAccount: n.BindingAccount, BindingUserName: n.BindingUserName}).BusinessDisplayName()
}
@@ -91,7 +95,7 @@ type GameOption struct {
type LedgerRepo interface {
Registrations(context.Context, LedgerScope, LedgerFilter, LedgerListOptions) (*RegistrationPage, error)
Spends(context.Context, LedgerScope, LedgerFilter, LedgerListOptions) (*SpendPage, error)
Games(context.Context, []string, string, int) ([]*GameOption, error)
Games(context.Context, UserScope, []string, string, int) ([]*GameOption, error)
}
type LedgerUsecase struct {
repo LedgerRepo
@@ -226,14 +230,17 @@ func (uc *LedgerUsecase) ExportSpends(ctx context.Context, filter LedgerFilter)
return page.Rows, nil
}
func (uc *LedgerUsecase) Games(ctx context.Context, sources []string, keyword string, limit int) ([]*GameOption, error) {
if _, _, err := uc.accounts.actor(ctx); err != nil {
actor, account, err := uc.accounts.actor(ctx)
if err != nil {
return nil, err
}
viewer := portraitUserScope(account)
viewer.All = actor.AuthorityID == 888
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 10000
}
return uc.repo.Games(ctx, sources, strings.TrimSpace(keyword), limit)
return uc.repo.Games(ctx, viewer, sources, strings.TrimSpace(keyword), limit)
}
+8 -1
View File
@@ -28,7 +28,8 @@ func (r *ledgerRepoFake) Spends(_ context.Context, s LedgerScope, f LedgerFilter
r.options = o
return &SpendPage{Rows: []*Spend{}, Total: r.total, Available: r.available, TotalPayAmount: stringPointer("0")}, nil
}
func (r *ledgerRepoFake) Games(_ context.Context, _ []string, _ string, limit int) ([]*GameOption, error) {
func (r *ledgerRepoFake) Games(_ context.Context, viewer UserScope, _ []string, _ string, limit int) ([]*GameOption, error) {
r.scope.Viewer = viewer
r.gameLimit = limit
return []*GameOption{}, nil
}
@@ -89,4 +90,10 @@ func TestLedgerExportLimitAndDisplayFallback(t *testing.T) {
if r.gameLimit != 10000 {
t.Fatal("原游戏选项的特殊批量上限丢失")
}
if !r.scope.Viewer.All {
t.Fatal("超管游戏选项范围未传递")
}
if _, err := uc.Games(accountContext(9, 903), nil, "", 20); err != nil || !r.scope.Viewer.OnlySelf || r.scope.Viewer.All {
t.Fatal("普通商务游戏选项必须限定本人范围")
}
}
+19 -3
View File
@@ -47,7 +47,7 @@ type ServerOption struct {
}
type ServerAnalysisRepo interface {
Aggregate(context.Context, AnalyticsScope, ServerAnalysisFilter) (*ServerAnalysisData, error)
Servers(context.Context, []string, []string, string, int) ([]*ServerOption, error)
Servers(context.Context, UserScope, []string, []string, string, int) ([]*ServerOption, error)
}
type ServerAnalysisUsecase struct {
repo ServerAnalysisRepo
@@ -60,9 +60,12 @@ func NewServerAnalysisUsecase(repo ServerAnalysisRepo, accounts *AccountUsecase,
return &ServerAnalysisUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
}
func (uc *ServerAnalysisUsecase) Servers(ctx context.Context, sources, games []string, keyword string, limit int) ([]*ServerOption, error) {
if _, _, err := uc.accounts.actor(ctx); err != nil {
actor, account, err := uc.accounts.actor(ctx)
if err != nil {
return nil, err
}
viewer := portraitUserScope(account)
viewer.All = actor.AuthorityID == 888
if len(games) == 0 {
return nil, ErrServerAnalysisGame
}
@@ -72,7 +75,7 @@ func (uc *ServerAnalysisUsecase) Servers(ctx context.Context, sources, games []s
if limit > 100 {
limit = 100000
}
return uc.repo.Servers(ctx, sources, games, strings.TrimSpace(keyword), limit)
return uc.repo.Servers(ctx, viewer, sources, games, strings.TrimSpace(keyword), limit)
}
func (uc *ServerAnalysisUsecase) List(ctx context.Context, f ServerAnalysisFilter, page, size int) (*ServerAnalysisPage, error) {
actor, account, err := uc.accounts.actor(ctx)
@@ -151,6 +154,19 @@ func (uc *ServerAnalysisUsecase) List(ctx context.Context, f ServerAnalysisFilte
return out, nil
}
func sortServerAnalysis(rows []*ServerAnalysisRow, field string, ascending bool) error {
if field == "" {
sort.SliceStable(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
if a.SourceDB != b.SourceDB {
return a.SourceDB < b.SourceDB
}
if a.GameID != b.GameID {
return a.GameID < b.GameID
}
return a.ServerNum < b.ServerNum
})
return nil
}
values := map[*ServerAnalysisRow]*big.Rat{}
for _, row := range rows {
var n *big.Rat
+19 -1
View File
@@ -22,7 +22,8 @@ func (r *serverAnalysisFake) Aggregate(_ context.Context, s AnalyticsScope, f Se
}
return out, nil
}
func (r *serverAnalysisFake) Servers(_ context.Context, _, _ []string, _ string, limit int) ([]*ServerOption, error) {
func (r *serverAnalysisFake) Servers(_ context.Context, viewer UserScope, _, _ []string, _ string, limit int) ([]*ServerOption, error) {
r.scope.Viewer = viewer
r.limit = limit
return []*ServerOption{}, nil
}
@@ -67,3 +68,20 @@ func TestServerAnalysisSummaryZerosSortingAndLimits(t *testing.T) {
}
}
}
func TestServerAnalysisCancelSortRestoresIdentityOrder(t *testing.T) {
rows := []*ServerAnalysisRow{
{ServerAnalysisKey: ServerAnalysisKey{SourceDB: "lx", GameID: 1, ServerNum: "002"}, Pay: "5"},
{ServerAnalysisKey: ServerAnalysisKey{SourceDB: "lx", GameID: 1, ServerNum: "001"}, Pay: "10"},
{ServerAnalysisKey: ServerAnalysisKey{SourceDB: "lx", GameID: 1, ServerNum: "003"}, Pay: "20"},
}
if err := sortServerAnalysis(rows, "totalPay", true); err != nil || rows[0].ServerNum != "002" {
t.Fatal("升序错误")
}
if err := sortServerAnalysis(rows, "totalPay", false); err != nil || rows[0].ServerNum != "003" {
t.Fatal("降序错误")
}
if err := sortServerAnalysis(rows, "", false); err != nil || rows[0].ServerNum != "001" {
t.Fatal("取消排序应恢复稳定的区服顺序")
}
}
+13
View File
@@ -79,6 +79,9 @@ type analyticsAggPO struct {
func analyticsRoot(db *gorm.DB, kind business.AnalyticsKind, scope business.AnalyticsScope, f business.AnalyticsFilter, table string, mode int, pay bool) (*gorm.DB, string) {
q := analyticsQuery(db, scope, f, table, mode)
if kind == business.GameAnalytics {
q = q.Where("(r.source_db,r.game_id) IN (?)", approvedGamesQuery(db, scope.Viewer, f.Sources).Select("g.source_db,g.id"))
}
id := "r.game_id"
if kind == business.ChannelAnalytics {
id = "r.promote_id"
@@ -124,6 +127,16 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
if empty || !available {
return result, nil
}
if kind == business.GameAnalytics {
available, err := approvedGamesAvailable(db)
if err != nil {
return nil, err
}
if !available {
result.Available = false
return result, nil
}
}
for _, table := range []string{"tab_sw_report_day", "tab_sw_report_day_key"} {
available, err := businessTableAvailable(db, table)
if err != nil {
+1
View File
@@ -12,6 +12,7 @@ func prepareAnalyticsData(t *testing.T) (*gorm.DB, business.AnalyticsRepo, busin
t.Helper()
db, _ := prepareChannels(t)
for _, sql := range []string{
"INSERT INTO tab_promote_apply VALUES('hw',100,1,1)",
"ALTER TABLE tab_sw_report_day ADD COLUMN game_name TEXT",
"ALTER TABLE tab_sw_report_day_key ADD COLUMN game_name TEXT",
"UPDATE tab_sw_report_day SET game_name=CASE WHEN game_id=1 THEN '历史游戏甲' ELSE '游戏乙' END",
+28
View File
@@ -0,0 +1,28 @@
package business
import (
"gorm.io/gorm"
"kra/internal/biz/business"
)
func approvedGamesAvailable(db *gorm.DB) (bool, error) {
for _, table := range []string{"tab_game", "tab_promote", "tab_promote_apply"} {
available, err := businessTableAvailable(db, table)
if err != nil || !available {
return available, err
}
}
return true, nil
}
// 游戏可选范围来自可见渠道的有效申请,不受统计时是否包含下级的开关影响。
// 用平台和 ID 配对,避免同号游戏或其他商务的申请扩大可选范围。
func approvedGamesQuery(db *gorm.DB, viewer business.UserScope, sources []string) *gorm.DB {
promotes := ledgerBasePromotes(db, business.LedgerScope{Viewer: viewer, IncludeSub: true, Unrestricted: viewer.All}, business.LedgerFilter{Sources: sources}, "approved_p").Select("approved_p.source_db,approved_p.id")
applications := db.Table("tab_promote_apply a").Select("a.source_db,a.game_id").Where("a.status=1").Where("(a.source_db,a.promote_id) IN (?)", promotes)
q := db.Table("tab_game g").Where("(g.source_db,g.id) IN (?)", applications)
if len(sources) > 0 {
q = q.Where("g.source_db IN ?", sources)
}
return q
}
@@ -0,0 +1,62 @@
package business
import (
"context"
"testing"
"kra/internal/biz/business"
)
func TestGameOptionsRequireVisibleApprovedApplications(t *testing.T) {
db, _ := prepareChannels(t)
for _, sql := range []string{
"INSERT INTO tab_game VALUES('lx',3,'子渠道游戏'),('lx',4,'未申请包服'),('lx',5,'其他商务游戏'),('lx',6,'待审核游戏')",
"INSERT INTO tab_promote_apply VALUES('lx',104,5,1),('lx',100,6,0)",
} {
if err := db.Exec(sql).Error; err != nil {
t.Fatal(err)
}
}
repo := NewLedgerRepo(testData{db})
ctx := context.Background()
rows, err := repo.Games(ctx, channelTestScope(), nil, "", 100)
if err != nil || len(rows) != 3 {
t.Fatalf("只能返回可见渠道已通过申请的游戏,重复申请不能重复: %+v %v", rows, err)
}
for _, row := range rows {
if row.SourceDB != "lx" || row.ID > 3 {
t.Fatalf("泄漏未申请或其他商务的游戏: %+v", row)
}
}
rows, err = repo.Games(ctx, channelTestScope(), []string{"hw"}, "", 100)
if err != nil || len(rows) != 0 {
t.Fatal("不能借用其他平台同号游戏的申请")
}
rows, err = repo.Games(ctx, business.UserScope{SelfID: 99, OnlySelf: true}, []string{"lx"}, "", 100)
if err != nil || len(rows) != 0 {
t.Fatal("无可见绑定不能退化为全部游戏")
}
rows, err = repo.Games(ctx, channelTestScope(), nil, "子渠道", 1)
if err != nil || len(rows) != 1 || rows[0].ID != 3 {
t.Fatal("应支持已申请游戏的关键词与数量筛选")
}
if err := db.Exec("DROP TABLE tab_promote_apply").Error; err != nil {
t.Fatal(err)
}
if _, err := repo.Games(ctx, channelTestScope(), nil, "", 100); err != business.ErrGameOptionsUnavailable {
t.Fatal("缺申请表不能开放所有游戏")
}
}
func TestGameReportsRejectUnapprovedGamesEvenWithVisibleHistory(t *testing.T) {
db, repo, scope, filter := prepareAnalyticsData(t)
if err := db.Exec("UPDATE tab_promote_apply SET status=0 WHERE source_db='lx' AND game_id=1").Error; err != nil {
t.Fatal(err)
}
filter.Sources = []string{"lx"}
filter.GameNames = []string{"历史游戏甲"}
rows, err := repo.Aggregate(context.Background(), business.GameAnalytics, scope, filter)
if err != nil || !rows.Available || len(rows.Rows) != 0 {
t.Fatalf("直接指定未申请游戏也不能返回统计: %+v %v", rows, err)
}
}
+45 -15
View File
@@ -32,11 +32,8 @@ func ledgerPromotes(db *gorm.DB, scope business.LedgerScope, filter business.Led
return q
}
hits := ledgerBasePromotes(db, scope, filter, "hit")
exact := "TRIM(hit.account) = ? COLLATE BINARY"
if db.Dialector.Name() == "mysql" {
exact = "BINARY TRIM(hit.account) = ?"
}
hits = hits.Where(exact, filter.PromoteAccount).Where("hit.source_db=" + alias + ".source_db")
keyword := strings.ToLower(strings.TrimSpace(filter.PromoteAccount))
hits = hits.Where("(INSTR(LOWER(hit.account),?) > 0 OR INSTR(LOWER(hit.nickname),?) > 0)", keyword, keyword).Where("hit.source_db=" + alias + ".source_db")
if scope.IncludeSub {
root := "CASE WHEN hit.promote_level=1 THEN hit.id WHEN hit.top_promote_id>0 THEN hit.top_promote_id ELSE hit.id END"
hits = hits.Where("(" + alias + ".id = (" + root + ") OR " + alias + ".top_promote_id = (" + root + "))")
@@ -232,7 +229,8 @@ func spendQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerF
q := ledgerScopeQuery(db, scope, filter, "tab_spend").Where("r.pay_time BETWEEN ? AND ?", filter.BeginUnix, filter.EndUnix)
if successOnly {
q = q.Where("r.pay_status = 1")
} else if filter.PayStatus != nil {
}
if filter.PayStatus != nil {
q = q.Where("r.pay_status = ?", *filter.PayStatus)
}
if len(filter.GameNames) > 0 {
@@ -353,11 +351,13 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
SourceDB string `gorm:"column:source_db"`
PlatformBusierID uint64
PlatformBusierAccount, Username string
UserID uint
IDPath string `gorm:"column:id_path"`
}
names := map[business.PlatformKey]bindName{}
for start := 0; start < len(pairs); start += 300 {
var rows []bindName
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,u.username").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,u.username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
if err := q.Scan(&rows).Error; err != nil {
return business.ErrStorage
}
@@ -368,6 +368,31 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
}
}
}
ancestorIDs := map[uint]bool{}
paths := map[business.PlatformKey][]uint{}
for key, binding := range names {
for _, part := range strings.Split(binding.IDPath, ",") {
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32)
if err == nil && id > 0 && uint(id) != binding.UserID {
paths[key] = append(paths[key], uint(id))
ancestorIDs[uint(id)] = true
}
}
}
ids := make([]uint, 0, len(ancestorIDs))
for id := range ancestorIDs {
ids = append(ids, id)
}
ancestors := map[uint]string{}
for start := 0; start < len(ids); start += 300 {
var rows []accountPO
if err := db.Select("id,username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil {
return business.ErrStorage
}
for _, row := range rows {
ancestors[row.ID] = row.Username
}
}
for _, target := range targets {
n := target.Names
if n.PromoteID == nil || *n.PromoteID < 0 {
@@ -380,33 +405,38 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
if binding, ok := names[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}]; ok {
n.BindingAccount = binding.PlatformBusierAccount
n.BindingUserName = binding.Username
for _, id := range paths[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}] {
if name := strings.TrimSpace(ancestors[id]); name != "" {
n.BusinessPath = append(n.BusinessPath, name)
}
}
if name := strings.TrimSpace(binding.Username); name != "" {
n.BusinessPath = append(n.BusinessPath, name)
}
}
}
}
return nil
}
func (r *ledgerRepo) Games(ctx context.Context, sources []string, keyword string, limit int) ([]*business.GameOption, error) {
func (r *ledgerRepo) Games(ctx context.Context, viewer business.UserScope, sources []string, keyword string, limit int) ([]*business.GameOption, error) {
db := r.data.DB().WithContext(ctx)
available, err := businessTableAvailable(db, "tab_game")
available, err := approvedGamesAvailable(db)
if err != nil {
return nil, err
}
if !available {
return nil, business.ErrGameOptionsUnavailable
}
q := db.Table("tab_game")
if len(sources) > 0 {
q = q.Where("source_db IN ?", sources)
}
q := approvedGamesQuery(db, viewer, sources)
if keyword != "" {
q = q.Where("game_name LIKE ?", "%"+keyword+"%")
q = q.Where("INSTR(LOWER(g.game_name),?) > 0", strings.ToLower(keyword))
}
var rows []struct {
ID uint64
SourceDB string `gorm:"column:source_db"`
GameName string
}
if err := q.Select("id,source_db,game_name").Order("game_name,id,source_db").Limit(limit).Scan(&rows).Error; err != nil {
if err := q.Select("g.id,g.source_db,g.game_name").Distinct().Order("g.game_name,g.id,g.source_db").Limit(limit).Scan(&rows).Error; err != nil {
return nil, business.ErrStorage
}
result := make([]*business.GameOption, 0, len(rows))
+29 -7
View File
@@ -40,7 +40,7 @@ func TestRegistrationScopePuidAndUnboundedPlayerPay(t *testing.T) {
first = row
}
}
if first == nil || first.TotalPayAmount == nil || portraitTestMoney(*first.TotalPayAmount) != "710.10" || first.FirstGameName != "历史游戏名" || first.DisplayBusiness() != "lx-business(leaf)" {
if first == nil || first.TotalPayAmount == nil || portraitTestMoney(*first.TotalPayAmount) != "710.10" || first.FirstGameName != "历史游戏名" || first.DisplayBusiness() != "parent→branch→leaf" {
t.Fatalf("累计充值不能受本次时间/渠道限制,且需保留游戏快照: %+v", first)
}
filter.SortField = "totalPayAmount"
@@ -59,19 +59,19 @@ func TestRegistrationScopePuidAndUnboundedPlayerPay(t *testing.T) {
if err != nil || page.Total != 2 {
t.Fatal("精确子渠道账号应扩到同一一级树内的可见渠道")
}
filter.PromoteAccount = "CHILD-channel"
filter.PromoteAccount = "CHILD-chan"
page, err = repo.Registrations(ctx, scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 0 {
t.Fatal("渠道账号应保持区分大小写的精确匹配")
if err != nil || page.Total != 2 {
t.Fatal("渠道应支持不区分大小写的模糊匹配")
}
}
func TestSpendSummaryIgnoresSelectedFailureStatusAndKeepsSnapshot(t *testing.T) {
func TestSpendSummaryRespectsSelectedStatusAndKeepsSnapshot(t *testing.T) {
_, repo, scope, filter := prepareLedgerData(t)
status := 0
filter.PayStatus = &status
page, err := repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 1 || page.Rows[0].ID != 904 || portraitTestMoney(*page.TotalPayAmount) != "80.80" {
t.Fatalf("失败列表与成功支付合计不能使用同一个状态条件: %+v %v", page, err)
if err != nil || page.Total != 1 || page.Rows[0].ID != 904 || portraitTestMoney(*page.TotalPayAmount) != "0.00" {
t.Fatalf("失败状态筛选下不能汇总筛选外成功订单: %+v %v", page, err)
}
status = 2
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
@@ -125,3 +125,25 @@ func TestLedgerLargeTreeFilterDoesNotFallBackToAll(t *testing.T) {
t.Fatal("缺渠道范围表不能把限制解释为全量")
}
}
func TestLedgerChannelNicknameAndLiteralFuzzyFilter(t *testing.T) {
db, repo, scope, filter := prepareLedgerData(t)
if err := db.Exec("UPDATE tab_promote SET nickname='推广公会' WHERE source_db='lx' AND id=100").Error; err != nil {
t.Fatal(err)
}
filter.PromoteAccount = "公会"
page, err := repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 4 {
t.Fatalf("昵称片段应命中渠道及可见下级: %+v %v", page, err)
}
filter.PromoteAccount = "%"
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 0 {
t.Fatal("模糊输入中的百分号应按文字匹配")
}
filter.PromoteAccount = "other"
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
if err != nil || page.Total != 0 {
t.Fatal("模糊搜索不得扩大商务范围")
}
}
+5 -4
View File
@@ -17,6 +17,7 @@ func NewServerAnalysisRepo(data Provider) business.ServerAnalysisRepo {
func serverReportQuery(db *gorm.DB, scope business.AnalyticsScope, f business.ServerAnalysisFilter, table string, mode int) *gorm.DB {
q := ledgerScopeQuery(db, business.LedgerScope{Viewer: scope.Viewer, IncludeSub: scope.IncludeSub, Unrestricted: scope.Viewer.All}, business.LedgerFilter{Sources: f.Sources}, table)
q = q.Where("r.stat_date >= ? AND r.stat_date <= ? AND r.dedup_mode=? AND r.server_num != ''", f.Begin, f.End, mode).Where("r.game_name IN ?", f.GameNames)
q = q.Where("(r.source_db,r.game_id) IN (?)", approvedGamesQuery(db, scope.Viewer, f.Sources).Select("g.source_db,g.id"))
if f.ServerNum != "" {
q = q.Where("r.server_num=?", f.ServerNum)
}
@@ -44,7 +45,7 @@ func (r *serverAnalysisRepo) Aggregate(ctx context.Context, scope business.Analy
if empty || !available {
return out, nil
}
for _, table := range []string{"tab_sw_report_server_day", "tab_sw_report_server_day_key", "tab_game_server", "tab_game"} {
for _, table := range []string{"tab_sw_report_server_day", "tab_sw_report_server_day_key", "tab_game_server", "tab_game", "tab_promote_apply"} {
available, err := businessTableAvailable(db, table)
if err != nil {
return nil, err
@@ -144,9 +145,9 @@ func fillServerAnalysisNames(db *gorm.DB, rows map[business.ServerAnalysisKey]*b
}
return nil
}
func (r *serverAnalysisRepo) Servers(ctx context.Context, sources, games []string, keyword string, limit int) ([]*business.ServerOption, error) {
func (r *serverAnalysisRepo) Servers(ctx context.Context, viewer business.UserScope, sources, games []string, keyword string, limit int) ([]*business.ServerOption, error) {
db := r.data.DB().WithContext(ctx)
for _, table := range []string{"tab_game", "tab_game_server"} {
for _, table := range []string{"tab_game", "tab_game_server", "tab_promote", "tab_promote_apply"} {
available, err := businessTableAvailable(db, table)
if err != nil {
return nil, err
@@ -158,7 +159,7 @@ func (r *serverAnalysisRepo) Servers(ctx context.Context, sources, games []strin
// MySQL 5.7 不会将相关 EXISTS 展开为半连接,导致扫描全部区服。
// 多列 IN 让优化器先按游戏名索引定位,再使用区服的 game_id 索引,
// 同时保留平台匹配和重复游戏资料不扩增结果的语义。
gamesQuery := db.Table("tab_game g").Select("g.source_db,g.id").Where("g.game_name IN ?", games)
gamesQuery := approvedGamesQuery(db, viewer, sources).Select("g.source_db,g.id").Where("g.game_name IN ?", games)
q := db.Table("tab_game_server s").Where("(s.source_db,s.game_id) IN (?)", gamesQuery)
if len(sources) > 0 {
q = q.Where("s.source_db IN ?", sources)
+21 -4
View File
@@ -9,6 +9,7 @@ import (
func TestServerAnalysisStorageScopeSnapshotAndPreference(t *testing.T) {
db, _ := prepareChannels(t)
for _, sql := range []string{
"INSERT INTO tab_promote_apply VALUES('hw',100,1,1)",
"CREATE TABLE tab_game_server(id INTEGER,source_db TEXT,game_id INTEGER,server_num TEXT,server_name TEXT,start_time INTEGER)",
"INSERT INTO tab_game_server VALUES(1,'lx',1,'001','龙翔一区',100),(2,'hw',1,'001','汇玩一区',200),(3,'lx',2,'001','游戏乙一区',300),(4,'yq',1,'001','无权区服',400)",
"CREATE TABLE tab_sw_report_server_day(source_db TEXT,game_id INTEGER,game_name TEXT,promote_id INTEGER,server_num TEXT,stat_date DATE,dedup_mode INTEGER,total_pay DECIMAL(20,2))",
@@ -68,7 +69,7 @@ func TestServerAnalysisStorageScopeSnapshotAndPreference(t *testing.T) {
if err != nil || len(p.Rows) != 1 {
t.Fatal("区服编号筛选错误")
}
options, err := r.Servers(ctx, []string{"lx"}, []string{"游戏甲"}, "一区", 50)
options, err := r.Servers(ctx, s.Viewer, []string{"lx"}, []string{"游戏甲"}, "一区", 50)
if err != nil || len(options) != 1 || options[0].Number != "001" || options[0].Name != "龙翔一区" {
t.Fatal("选项应按当前游戏名、同平台游戏关联及区服名称筛选")
}
@@ -77,6 +78,18 @@ func TestServerAnalysisStorageScopeSnapshotAndPreference(t *testing.T) {
if err != nil || len(p.Rows) != 0 {
t.Fatal("报表必须按快照游戏名筛选")
}
if err := db.Exec("UPDATE tab_promote_apply SET status=0 WHERE source_db='lx' AND game_id=1").Error; err != nil {
t.Fatal(err)
}
options, err = r.Servers(ctx, s.Viewer, []string{"lx"}, []string{"游戏甲"}, "", 50)
if err != nil || len(options) != 0 {
t.Fatal("没有有效申请的游戏不能获取区服选项")
}
f.GameNames = []string{"历史游戏"}
p, err = r.Aggregate(ctx, s, f)
if err != nil || len(p.Rows) != 0 {
t.Fatal("直接传入未申请游戏也不能获取区服数据")
}
if err := db.Exec("DROP TABLE tab_sw_report_server_day_key").Error; err != nil {
t.Fatal(err)
}
@@ -89,6 +102,10 @@ func TestServerAnalysisStorageScopeSnapshotAndPreference(t *testing.T) {
func TestServerOptionsPreservePlatformFilterAndDoNotDuplicateMatches(t *testing.T) {
db := openTestDB(t)
for _, sql := range []string{
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT)",
"INSERT INTO tab_promote VALUES(100,'lx'),(100,'hw')",
"CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
"INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('lx',100,2,1),('hw',100,1,1)",
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
"INSERT INTO tab_game VALUES(1,'lx','甲'),(1,'lx','甲'),(1,'hw','乙'),(2,'lx','乙')",
"CREATE TABLE tab_game_server(id INTEGER,source_db TEXT,game_id INTEGER,server_num TEXT,server_name TEXT,start_time INTEGER)",
@@ -99,15 +116,15 @@ func TestServerOptionsPreservePlatformFilterAndDoNotDuplicateMatches(t *testing.
}
}
repo := NewServerAnalysisRepo(testData{db})
rows, err := repo.Servers(context.Background(), nil, []string{"甲"}, "", 100)
rows, err := repo.Servers(context.Background(), business.UserScope{All: true}, nil, []string{"甲"}, "", 100)
if err != nil || len(rows) != 2 || rows[0].Name != "一区" || rows[1].Number != "002" {
t.Fatalf("相同游戏资料不能扩增区服,异平台同号不能匹配: %+v %v", rows, err)
}
rows, err = repo.Servers(context.Background(), []string{"lx"}, []string{"甲", "乙"}, "区", 1)
rows, err = repo.Servers(context.Background(), business.UserScope{All: true}, []string{"lx"}, []string{"甲", "乙"}, "区", 1)
if err != nil || len(rows) != 1 || rows[0].Name != "一区" {
t.Fatalf("名称筛选、排序或数量限制改变: %+v %v", rows, err)
}
rows, err = repo.Servers(context.Background(), []string{"hw"}, []string{"甲"}, "", 100)
rows, err = repo.Servers(context.Background(), business.UserScope{All: true}, []string{"hw"}, []string{"甲"}, "", 100)
if err != nil || len(rows) != 0 {
t.Fatalf("平台筛选被忽略: %+v %v", rows, err)
}
+12 -9
View File
@@ -3,7 +3,7 @@
<div class="kra-search-box">
<el-form :model="query" :inline="true" @submit.prevent="search">
<el-form-item label="所属平台"><el-select v-model="sources" multiple collapse-tags clearable placeholder="选择平台" style="width: 210px" @change="sourceChanged"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="游戏"><el-select v-model="games" multiple collapse-tags clearable filterable :reserve-keyword="false" :loading="gameLoading" :disabled="!sources.length" placeholder="请选择游戏" style="width: 200px" @change="gameChanged"><el-option v-for="name in gameOptions" :key="name" :label="name" :value="name" /></el-select></el-form-item>
<el-form-item label="游戏"><el-select v-model="selectedGame" filterable :reserve-keyword="false" :loading="gameLoading" :disabled="!sources.length" placeholder="请选择游戏" style="width: 200px" @change="gameChanged"><el-option v-for="name in gameOptions" :key="name" :label="name" :value="name" /></el-select></el-form-item>
<el-form-item label="区服"><el-select v-model="query.serverNum" clearable filterable :loading="serverLoading" :disabled="!sources.length || !games.length" placeholder="全部区服" style="width: 200px"><el-option label="全部区服" value="" /><el-option v-for="row in serverOptions" :key="row.serverNum" :label="row.serverName ? `${row.serverName} (${row.serverNum})` : row.serverNum" :value="row.serverNum" /></el-select></el-form-item>
<el-form-item label="日期"><el-date-picker v-model="dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" :clearable="false" :shortcuts="shortcuts" style="width: 300px" /></el-form-item>
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button></el-form-item>
@@ -13,12 +13,12 @@
<div class="server-heading"><strong>区服数据</strong><el-text type="info">统计周期:{{ period }}</el-text></div>
<el-alert v-if="error || optionError" class="mb-4" type="error" :closable="false" :title="error || optionError" />
<el-alert v-else-if="!available" class="mb-4" type="warning" :closable="false" title="区服统计所需业务汇总尚未接入。" />
<el-table v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.gameId}:${row.serverNum}`" :default-sort="{ prop: 'totalPay', order: 'descending' }" show-summary :summary-method="summaries" @sort-change="sortChanged">
<el-table v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.gameId}:${row.serverNum}`" show-summary :summary-method="summaries" @sort-change="sortChanged">
<el-table-column prop="sourceDbLabel" label="所属平台" min-width="100" />
<el-table-column prop="serverNum" label="区服编号" min-width="110" show-overflow-tooltip />
<el-table-column prop="serverName" label="区服名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="openTime" label="开服日期" min-width="120" sortable="custom"><template #default="{ row }">{{ openDate(row.openTime) }}</template></el-table-column>
<el-table-column v-for="column in columns" :key="column.key" :prop="column.key" :label="column.label" :min-width="column.money ? 140 : 110" sortable="custom"><template #default="{ row }">{{ format(column, row[column.key]) }}</template></el-table-column>
<el-table-column v-for="column in columns" :key="column.key" :prop="column.key" :label="column.label" :min-width="column.money ? 140 : 110" sortable="custom"><template #header><el-tooltip :content="metricTips[column.key]" placement="top"><span>{{ column.label }}</span></el-tooltip></template><template #default="{ row }">{{ format(column, row[column.key]) }}</template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
@@ -31,6 +31,8 @@ import { ElMessage } from 'element-plus'
import { listLedgerGames } from '@/api/ledger'
import { listServerAnalysis, listServerOptions } from '@/api/serverAnalysis'
import { channelCount, channelMoney } from './channel-display'
import { metricTips } from './table-display'
import { analysisGameSelection } from './analysis-game-selection'
defineOptions({ name: 'ServerAnalysis' })
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
@@ -45,7 +47,8 @@ const shortcuts = [
{ text: '本月', value: () => { const e = day(); return [new Date(e.getFullYear(), e.getMonth(), 1), e] } }
]
const sources = ref(Object.keys(platforms)), games = ref([]), gameOptions = ref([]), serverOptions = ref([])
const query = reactive({ pageNum: 1, pageSize: 10, serverNum: '', sortField: 'totalPay', sortOrder: 'desc' })
const selectedGame = computed({ get: () => games.value[0] || '', set: value => { games.value = value ? [value] : [] } })
const query = reactive({ pageNum: 1, pageSize: 10, serverNum: '', sortField: '', sortOrder: 'desc' })
const rows = ref([]), total = ref(0), summary = ref(null), result = ref(null), loading = ref(false), available = ref(true), error = ref(''), optionError = ref('')
const gameLoading = ref(false), serverLoading = ref(false)
let version = 0, gameVersion = 0, serverVersion = 0
@@ -60,7 +63,7 @@ const openDate = value => {
const summaries = ({ columns: tableColumns }) => tableColumns.map((column, index) => {
if (index === 0) return '合计'
const field = columns.find(item => item.key === column.property)
return field ? format(field, summary.value?.[column.property]) : '—'
return field ? format(field, summary.value?.[column.property]) : ''
})
const clear = () => { ++version; rows.value = []; total.value = 0; summary.value = null; result.value = null; loading.value = false; error.value = ''; available.value = true }
const params = () => ({ ...query, sourceDb: sources.value.join(','), gameName: games.value.join(','), beginTime: dates.value[0], endTime: dates.value[1] })
@@ -80,7 +83,7 @@ const search = () => {
else if (!games.value.length) ElMessage.warning('请选择游戏')
return load()
}
const sortChanged = ({ prop, order }) => { query.pageNum = 1; query.sortField = order ? prop : 'totalPay'; query.sortOrder = order === 'ascending' ? 'asc' : 'desc'; return load() }
const sortChanged = ({ prop, order }) => { query.pageNum = 1; query.sortField = order ? prop : ''; query.sortOrder = order === 'ascending' ? 'asc' : 'desc'; return load() }
const loadGames = async () => {
const current = ++gameVersion; gameLoading.value = true; optionError.value = ''
try {
@@ -104,13 +107,13 @@ const loadServers = async () => {
const sourceChanged = async () => {
++gameVersion; ++serverVersion; gameLoading.value = false; serverLoading.value = false
games.value = []; gameOptions.value = []; query.serverNum = ''; serverOptions.value = []; optionError.value = ''; clear()
if (sources.value.length) await loadGames()
if (sources.value.length) { await loadGames(); if (gameOptions.value.length) { selectedGame.value = gameOptions.value[0]; await gameChanged() } }
}
const gameChanged = () => { query.serverNum = ''; query.pageNum = 1; clear(); return loadServers() }
const gameChanged = () => { analysisGameSelection.value = selectedGame.value; query.serverNum = ''; query.pageNum = 1; clear(); return Promise.all([loadServers(), load()]) }
onMounted(async () => {
await loadGames()
if (!gameOptions.value.length) return
games.value = [gameOptions.value[0]]
selectedGame.value = gameOptions.value.includes(analysisGameSelection.value) ? analysisGameSelection.value : gameOptions.value[0]
await Promise.all([loadServers(), load()])
})
</script>
@@ -0,0 +1,5 @@
import { ref } from 'vue'
// Preserve the game while moving between analysis pages; validate it against
// the current account's available options before using it on a new page.
export const analysisGameSelection = ref(null)
+13 -8
View File
@@ -6,7 +6,7 @@
<el-form-item label="商务账号"><el-input v-model="query.busierAccount" clearable placeholder="商务账号" /></el-form-item>
<el-form-item label="玩家ID"><el-input v-model="playerID" clearable placeholder="玩家ID" /></el-form-item>
<el-form-item v-if="spend" label="角色名"><el-input v-model="query.gamePlayerName" clearable placeholder="角色名" /></el-form-item>
<el-form-item label="渠道账号"><el-input v-model="query.promoteAccount" clearable placeholder="渠道账号(精确匹配)" /></el-form-item>
<el-form-item label="渠道名称"><el-input v-model="query.promoteAccount" clearable placeholder="渠道名称或账号(模糊匹配)" /></el-form-item>
<el-form-item v-if="spend" label="订单号"><el-input v-model="query.payOrderNumber" clearable placeholder="订单号" /></el-form-item>
<el-form-item label="游戏"><el-select v-model="gameNames" multiple collapse-tags clearable filterable remote reserve-keyword :remote-method="loadGames" :loading="gamesLoading" :disabled="!sources.length" placeholder="全部游戏" style="width: 200px" @visible-change="open => open && loadGames('')"><el-option v-for="name in gameOptions" :key="name" :label="name" :value="name" /></el-select></el-form-item>
<el-form-item v-if="spend" label="方式"><el-select v-model="query.payWay" clearable placeholder="全部方式" style="width: 140px"><el-option v-for="(label, value) in payWays" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
@@ -19,17 +19,17 @@
<div class="kra-btn-list"><el-button v-if="canExport" type="warning" plain icon="download" :loading="exporting" @click="download">导出</el-button></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="明细所需业务数据尚未接入。" />
<el-table v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.id}`" :row-class-name="rowClass" :default-sort="{ prop: spend ? 'payTime' : 'registerTime', order: 'descending' }" @sort-change="sortChanged">
<el-table v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.id}`" :row-class-name="rowClass">
<el-table-column prop="sourceDbLabel" label="所属平台" min-width="100" />
<el-table-column v-if="spend" prop="id" label="ID" min-width="90" />
<el-table-column prop="userId" label="玩家ID" min-width="110" />
<el-table-column v-if="!spend" prop="registerTime" label="注册时间" min-width="145" sortable="custom"><template #default="{ row }">{{ unixTime(row.registerTime) }}</template></el-table-column>
<el-table-column v-if="!spend" prop="registerTime" label="注册时间" min-width="165" sortable :sort-method="(a, b) => Number(a.registerTime) - Number(b.registerTime)"><template #default="{ row }">{{ unixTime(row.registerTime) }}</template></el-table-column>
<el-table-column v-if="spend" prop="payOrderNumber" label="订单号" min-width="190" show-overflow-tooltip />
<el-table-column :prop="spend ? 'gameName' : 'fgameName'" label="游戏" min-width="150" show-overflow-tooltip />
<el-table-column v-if="spend" prop="serverName" label="区服" min-width="140" show-overflow-tooltip />
<el-table-column v-if="spend" prop="gamePlayerName" label="角色" min-width="130" show-overflow-tooltip />
<el-table-column :prop="spend ? 'payAmount' : 'totalPayAmount'" :label="spend ? '实付金额' : '累计充值'" min-width="150" sortable="custom"><template #default="{ row }">{{ channelMoney(spend ? row.payAmount ?? '0.00' : row.totalPayAmount) }}</template></el-table-column>
<el-table-column v-if="spend" prop="payTime" label="充值时间" min-width="145" sortable="custom"><template #default="{ row }">{{ unixTime(row.payTime) }}</template></el-table-column>
<el-table-column :prop="spend ? 'payAmount' : 'totalPayAmount'" :label="spend ? '实付金额' : '累计充值'" min-width="150" sortable :sort-method="(a, b) => compareAmounts(a[spend ? 'payAmount' : 'totalPayAmount'], b[spend ? 'payAmount' : 'totalPayAmount'])"><template #default="{ row }">{{ channelMoney(spend ? row.payAmount ?? '0.00' : row.totalPayAmount) }}</template></el-table-column>
<el-table-column v-if="spend" prop="payTime" label="充值时间" min-width="165" sortable :sort-method="(a, b) => Number(a.payTime) - Number(b.payTime)"><template #default="{ row }">{{ unixTime(row.payTime) }}</template></el-table-column>
<el-table-column v-if="spend" label="方式" min-width="100"><template #default="{ row }">{{ payWays[row.payWay] ?? row.payWay ?? '—' }}</template></el-table-column>
<el-table-column v-if="spend" label="状态" min-width="110"><template #default="{ row }"><el-tag :type="{ 1: 'success', 2: 'warning', 0: 'danger' }[row.payStatus] || 'info'">{{ statuses[row.payStatus] ?? row.payStatus ?? '—' }}</el-tag></template></el-table-column>
<el-table-column :label="spend ? 'IP' : '注册IP'" min-width="130"><template #default="{ row }">{{ (spend ? row.spendIp : row.registerIp) || '—' }}</template></el-table-column>
@@ -50,6 +50,7 @@ import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
import { listLedger, exportLedger, listLedgerGames } from '@/api/ledger'
import { channelMoney } from '../channel-display'
import { compareAmounts } from '../table-display'
const props = defineProps({ kind: { type: String, required: true } })
const spend = computed(() => props.kind === 'spend')
const user = useUserStore(), buttons = useBtnAuth()
@@ -94,18 +95,22 @@ const load = async () => {
} catch { if (version === listVersion) { rows.value = []; total.value = 0; totalPayAmount.value = null; error.value = '明细查询失败,请重试' } } finally { if (version === listVersion) loading.value = false }
}
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
const sortChanged = ({ prop, order }) => { query.sortField = prop || (spend.value ? 'payTime' : 'registerTime'); query.sortOrder = order === 'ascending' ? 'asc' : 'desc'; return search() }
const sourceChanged = () => { ++gameVersion; gameNames.value = []; gameOptions.value = []; gamesLoading.value = false }
const loadGames = async (keyword = '') => {
const version = ++gameVersion; if (!sources.value.length) return
gamesLoading.value = true
try { const res = await listLedgerGames({ sourceDb: sources.value.join(','), keyword, limit: 50 }); if (version === gameVersion && res.code === 0) gameOptions.value = [...new Set([...gameNames.value, ...res.data.map(row => row.gameName).filter(Boolean)])] } finally { if (version === gameVersion) gamesLoading.value = false }
try {
const res = await listLedgerGames({ sourceDb: sources.value.join(','), keyword, limit: 50 })
if (version !== gameVersion) return
if (res.code === 0) gameOptions.value = [...new Set([...gameNames.value, ...res.data.map(row => row.gameName).filter(Boolean)])]
else { gameOptions.value = []; ElMessage.error(res.msg || '游戏选项读取失败') }
} catch { if (version === gameVersion) { gameOptions.value = []; ElMessage.error('游戏选项读取失败,请重试') } } finally { if (version === gameVersion) gamesLoading.value = false }
}
const unixTime = value => {
if (value == null) return '—'
const date = new Date(Number(value) * 1000)
if (Number.isNaN(date.getTime())) return '—'
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
return `${ymd(date)} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`
}
const rowClass = ({ row }) => spend.value && Number(row.payAmount) >= 5000 ? 'large-recharge-row' : ''
const download = async () => {
+28 -10
View File
@@ -6,7 +6,10 @@
<div class="kra-search-box">
<el-form :model="query" :inline="true" @submit.prevent="search">
<el-form-item label="所属平台"><el-select v-model="sources" multiple collapse-tags clearable placeholder="选择平台" style="width: 210px" @change="sourceChanged"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="游戏"><el-select v-model="games" multiple collapse-tags clearable filterable remote reserve-keyword :remote-method="loadGames" :loading="gameLoading" :disabled="!sources.length" placeholder="全部游戏" style="width: 190px" @visible-change="open => open && loadGames('')"><el-option v-for="name in gameOptions" :key="name" :label="name" :value="name" /></el-select></el-form-item>
<el-form-item label="游戏">
<el-select v-if="isChannel" v-model="games" multiple collapse-tags clearable filterable remote reserve-keyword :remote-method="loadGames" :loading="gameLoading" :disabled="!sources.length" placeholder="全部游戏" style="width: 190px" @visible-change="open => open && loadGames('')"><el-option v-for="name in gameOptions" :key="name" :label="name" :value="name" /></el-select>
<el-select v-else v-model="selectedGame" clearable filterable :loading="gameLoading" :disabled="!sources.length" placeholder="全部游戏" style="width: 190px" @change="gameChanged"><el-option label="全部游戏" value="" /><el-option v-for="name in gameOptions" :key="name" :label="name" :value="name" /></el-select>
</el-form-item>
<el-form-item v-if="isChannel" label="渠道类型"><el-select v-model="query.promoteType" clearable placeholder="全部渠道类型" style="width: 150px"><el-option label="公会" :value="1" /><el-option label="个人" :value="0" /></el-select></el-form-item>
<el-form-item v-if="isChannel" label="渠道账号"><el-input v-model="query.promoteName" clearable placeholder="渠道账号(模糊匹配)" /></el-form-item>
<el-form-item label="日期"><el-date-picker v-model="dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" :clearable="false" :shortcuts="shortcuts" style="width: 300px" /></el-form-item>
@@ -17,13 +20,13 @@
<div v-if="!isChannel" class="report-heading"><strong>核心指标</strong><el-text type="info">统计周期:{{ period }}</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="统计所需业务汇总尚未接入。" />
<el-table v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${isChannel ? row.promoteId : row.gameId}`" :default-sort="{ prop: 'totalPay', order: 'descending' }" :show-summary="!isChannel" :summary-method="summaries" @sort-change="sortChanged">
<el-table v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${isChannel ? row.promoteId : row.gameId}`" :default-sort="isChannel ? { prop: 'totalPay', order: 'descending' } : {}" :show-summary="!isChannel" :summary-method="summaries" @sort-change="sortChanged">
<el-table-column prop="sourceDbLabel" label="所属平台" min-width="100" />
<el-table-column :prop="isChannel ? 'promoteName' : 'gameName'" :label="isChannel ? '渠道名称' : '游戏名称'" min-width="170" show-overflow-tooltip />
<el-table-column v-if="isChannel" prop="busierName" label="归属商务" min-width="140" show-overflow-tooltip />
<el-table-column v-if="isChannel" label="渠道类型" min-width="100"><template #default="{ row }">{{ { 0: '个人', 1: '公会' }[row.promoteType] ?? row.promoteType ?? '—' }}</template></el-table-column>
<el-table-column v-else prop="gameId" label="游戏ID" min-width="100" />
<el-table-column v-for="column in columns" :key="column.key" :prop="column.key" :label="column.label" :min-width="column.width || 110" sortable="custom"><template #default="{ row }">{{ format(column.type, row[column.key]) }}</template></el-table-column>
<el-table-column v-for="column in columns" :key="column.key" :prop="column.key" :label="column.label" :min-width="column.width || 110" sortable="custom"><template #header><el-tooltip :content="metricTips[column.key]" placement="top"><span>{{ column.label }}</span></el-tooltip></template><template #default="{ row }">{{ format(column.type, row[column.key]) }}</template></el-table-column>
</el-table>
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
@@ -36,12 +39,14 @@ import { ElMessage } from 'element-plus'
import { listAnalytics } from '@/api/analytics'
import { listLedgerGames } from '@/api/ledger'
import { channelCount, channelMoney } from '../channel-display'
import { metricTips } from '../table-display'
import { analysisGameSelection } from '../analysis-game-selection'
const props = defineProps({ kind: { type: String, required: true } })
const isChannel = computed(() => props.kind === 'channel')
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const day = () => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), n.getDate()) }
const ymd = value => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`
const end = day(), begin = day(); begin.setDate(begin.getDate() - 29)
const end = day(), begin = day(); if (isChannel.value) begin.setDate(begin.getDate() - 29)
const dates = ref([ymd(begin), ymd(end)])
const shortcuts = [
{ text: '今日', value: () => [day(), day()] }, { text: '昨日', value: () => { const d = day(); d.setDate(d.getDate() - 1); return [d, new Date(d)] } },
@@ -49,7 +54,8 @@ const shortcuts = [
{ text: '本月', value: () => { const e = day(); return [new Date(e.getFullYear(), e.getMonth(), 1), e] } }
]
const sources = ref(Object.keys(platforms)), games = ref([]), gameOptions = ref([]), gameLoading = ref(false)
const query = reactive({ pageNum: 1, pageSize: 10, promoteType: undefined, promoteName: '', sortField: 'totalPay', sortOrder: 'desc' })
const selectedGame = computed({ get: () => games.value[0] || '', set: value => { games.value = value ? [value] : [] } })
const query = reactive({ pageNum: 1, pageSize: 10, promoteType: undefined, promoteName: '', sortField: isChannel.value ? 'totalPay' : '', sortOrder: 'desc' })
const rows = ref([]), total = ref(0), summary = ref(null), result = ref(null), loading = ref(false), available = ref(true), error = ref('')
let version = 0, gameVersion = 0
const period = computed(() => result.value?.beginTime && result.value?.endTime ? `${result.value.beginTime} 至 ${result.value.endTime}` : dates.value.join(' 至 '))
@@ -57,7 +63,7 @@ const fields = { newRegisterCount: { key: 'newRegisterCount', label: '新增注
const columns = computed(() => (isChannel.value ? ['newRegisterCount', 'activeCount', 'totalPay', 'payUserCount', 'newPayUserCount', 'payRate'] : ['newRegisterCount', 'activeCount', 'payUserCount', 'totalPay', 'payRate', 'arpu', 'arppu', 'newPayUserCount']).map(key => fields[key]))
const format = (type, value) => value == null ? '未接入' : type === 'money' ? channelMoney(value) : type === 'percent' ? `${value}%` : channelCount(value)
const cards = computed(() => [{ label: '渠道总数', key: 'channelCount', type: 'count' }, { label: '渠道总注册玩家', key: 'totalRegisterCount', type: 'count' }, { label: '渠道总充值', key: 'totalPayAmount', type: 'money' }, { label: '平均付费率', key: 'avgPayRate', type: 'percent' }].map(card => ({ label: card.label, value: error.value ? '未获取' : format(card.type, result.value?.[card.key]) })))
const summaries = ({ columns: tableColumns }) => tableColumns.map((column, index) => { if (index === 0) return '合计'; if (index === 1) return summary.value?.gameName || ''; const field = fields[column.property]; return field ? format(field.type, summary.value?.[column.property]) : '' })
const summaries = ({ columns: tableColumns }) => tableColumns.map((column, index) => { if (index === 0) return '全部游戏'; if (index === 1) return ''; const field = fields[column.property]; return field ? format(field.type, summary.value?.[column.property]) : '' })
const clear = () => { rows.value = []; total.value = 0; summary.value = null; result.value = null }
const load = async () => {
const current = ++version
@@ -71,14 +77,26 @@ const load = async () => {
} catch { if (current === version) { clear(); error.value = '统计查询失败,请重试' } } finally { if (current === version) loading.value = false }
}
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
const sortChanged = ({ prop, order }) => { query.sortField = prop || 'totalPay'; query.sortOrder = order === 'ascending' ? 'asc' : 'desc'; return search() }
const sourceChanged = () => { ++gameVersion; games.value = []; gameOptions.value = []; gameLoading.value = false }
const sortChanged = ({ prop, order }) => { query.sortField = order ? prop : isChannel.value ? 'totalPay' : ''; query.sortOrder = order === 'ascending' ? 'asc' : 'desc'; return search() }
const gameChanged = () => { analysisGameSelection.value = selectedGame.value; return search() }
const sourceChanged = async () => {
++version; ++gameVersion; games.value = []; gameOptions.value = []; gameLoading.value = false; loading.value = false; error.value = ''; clear()
if (!isChannel.value) { analysisGameSelection.value = ''; if (sources.value.length) await loadGames(); await search() }
}
const loadGames = async (keyword = '') => {
const current = ++gameVersion; if (!sources.value.length) return
gameLoading.value = true
try { const res = await listLedgerGames({ sourceDb: sources.value.join(','), keyword, limit: 50 }); if (current === gameVersion && res.code === 0) gameOptions.value = [...new Set([...games.value, ...res.data.map(row => row.gameName).filter(Boolean)])] } finally { if (current === gameVersion) gameLoading.value = false }
try {
const res = await listLedgerGames({ sourceDb: sources.value.join(','), keyword, limit: isChannel.value ? 50 : 10000 })
if (current !== gameVersion) return
if (res.code === 0) gameOptions.value = [...new Set(res.data.map(row => row.gameName).filter(Boolean))]
else { gameOptions.value = []; ElMessage.error(res.msg || '游戏选项读取失败') }
} catch { if (current === gameVersion) { gameOptions.value = []; ElMessage.error('游戏选项读取失败,请重试') } } finally { if (current === gameVersion) gameLoading.value = false }
}
onMounted(load)
onMounted(async () => {
if (!isChannel.value) { await loadGames(); if (gameOptions.value.includes(analysisGameSelection.value)) selectedGame.value = analysisGameSelection.value }
await load()
})
</script>
<style scoped>
+24
View File
@@ -0,0 +1,24 @@
// Amounts arrive as fixed-point strings; preserve cents beyond Number's safe range.
export const compareAmounts = (a, b) => {
const cents = value => {
const match = String(value ?? '0').match(/^(-?)(\d+)(?:\.(\d{1,2}))?$/)
if (!match) return { negative: false, digits: '0' }
const digits = (match[2] + (match[3] || '').padEnd(2, '0')).replace(/^0+/, '') || '0'
return { negative: !!match[1] && digits !== '0', digits }
}
const left = cents(a), right = cents(b)
if (left.negative !== right.negative) return left.negative ? -1 : 1
const order = left.digits.length - right.digits.length || (left.digits < right.digits ? -1 : left.digits > right.digits ? 1 : 0)
return left.negative ? -order : order
}
export const metricTips = {
newRegisterCount: '所选周期内新增注册玩家数;开启身份证去重后按实名身份证去重,无实名信息不计入。',
activeCount: '所选周期内登录过游戏的去重玩家数;受身份证去重开关影响。',
payUserCount: '所选周期内产生成功充值的去重玩家数。',
totalPay: '所选周期内成功充值金额汇总,不计入失败或取消订单。',
payRate: '付费玩家 ÷ 活跃玩家 × 100%。',
arpu: '充值金额 ÷ 活跃玩家。',
arppu: '充值金额 ÷ 付费玩家。',
newPayUserCount: '所选周期内首次产生成功充值的玩家数,此前从未付费。'
}