fix: 完成商务迁移复核与接口优化并清理历史资料
This commit is contained in:
@@ -42,6 +42,8 @@ bin/
|
|||||||
web/dist/
|
web/dist/
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
# Local reference sources and desktop-only tooling state
|
# Local reference sources and desktop-only tooling state
|
||||||
/gva/
|
/gva/
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
businessbiz "kra/internal/biz/business"
|
||||||
|
taskbiz "kra/internal/biz/task"
|
||||||
|
businessdata "kra/internal/data/business"
|
||||||
|
taskdata "kra/internal/data/task"
|
||||||
|
"kra/internal/server/handler"
|
||||||
|
"kra/internal/server/router"
|
||||||
|
taskservice "kra/internal/service/task"
|
||||||
|
"kra/internal/worker"
|
||||||
|
platformtask "kra/pkg/task"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertTriggerThroughNativeTaskHTTP(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "alert-task.db")), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool, _ := db.DB()
|
||||||
|
t.Cleanup(func() { _ = pool.Close() })
|
||||||
|
for _, step := range append(taskdata.Migrations(), businessdata.Migrations()...) {
|
||||||
|
if err := step.Migrate(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sql := range []string{
|
||||||
|
"CREATE TABLE sys_users(id INTEGER,username TEXT,level INTEGER,parent_id INTEGER,id_path TEXT,authority_id INTEGER,deleted_at DATETIME)",
|
||||||
|
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,busier_id INTEGER,account TEXT,promote_level INTEGER,status INTEGER,last_login_time INTEGER,create_time INTEGER)",
|
||||||
|
"INSERT INTO sys_users VALUES(10,'一级',1,0,'0',901,NULL),(20,'二级',2,10,'0,10',902,NULL),(30,'三级',3,20,'0,10,20',903,NULL)",
|
||||||
|
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(30,'lx',7,'绑定','0')",
|
||||||
|
"INSERT INTO tab_promote VALUES(10,'lx',7,'测试渠道',1,1,0,1)",
|
||||||
|
"INSERT INTO sys_alert_rule(rule_name,rule_type,monitor_metric,creator_id,unlogin_days,warning_color,enabled,is_deleted) VALUES('未登录规则','未登录',3,10,1,'red',1,0)",
|
||||||
|
} {
|
||||||
|
if err := db.Exec(sql).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
provider := businessHTTPData{db}
|
||||||
|
registry := platformtask.NewRegistry()
|
||||||
|
tasks := taskbiz.NewTaskUsecaseWithRegistry(taskdata.NewTaskRepo(reportTaskHTTPData{provider}), registry)
|
||||||
|
alerts := businessbiz.NewAlertTriggerUsecase(businessdata.NewAlertTriggerRepo(provider))
|
||||||
|
worker.NewTaskMethods(tasks, nil, nil, nil, nil, alerts).RegisterTasks(registry)
|
||||||
|
scheduler := worker.NewTaskScheduler(tasks, nil, worker.NewTaskExecutorWithRegistry(tasks, registry), nil)
|
||||||
|
defer func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = scheduler.Stop(ctx)
|
||||||
|
}()
|
||||||
|
engine := gin.New()
|
||||||
|
router.RegisterTask(engine.Group(""), handler.NewTask(taskservice.NewTaskService(taskbiz.NewTaskApplicationUsecase(tasks, scheduler))))
|
||||||
|
server := httptest.NewServer(engine)
|
||||||
|
defer server.Close()
|
||||||
|
call := func(method, path string, body any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
raw, _ := json.Marshal(body)
|
||||||
|
req, _ := http.NewRequest(method, server.URL+"/timedTask/"+path, bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res, err := server.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result["code"] != float64(0) {
|
||||||
|
t.Fatalf("预警任务 HTTP 失败: %+v", result)
|
||||||
|
}
|
||||||
|
return result["data"].(map[string]any)
|
||||||
|
}
|
||||||
|
call("POST", "createTimedTask", map[string]any{"name": "预警 HTTP 演练", "spec": "@daily", "executorType": "method", "methodName": "alertTriggerTask", "enabled": false})
|
||||||
|
var id uint
|
||||||
|
if err := db.Table("sys_timed_tasks").Select("id").Where("name=?", "预警 HTTP 演练").Scan(&id).Error; err != nil || id == 0 {
|
||||||
|
t.Fatalf("任务未创建: %v", err)
|
||||||
|
}
|
||||||
|
for iteration := 0; iteration < 2; iteration++ {
|
||||||
|
call("POST", "triggerTimedTask", map[string]any{"id": id})
|
||||||
|
deadline := time.Now().Add(8 * time.Second)
|
||||||
|
finished := false
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
logs := call("GET", fmt.Sprintf("getTimedTaskLogList?taskId=%d&page=1&pageSize=10", id), nil)["list"].([]any)
|
||||||
|
if len(logs) > iteration {
|
||||||
|
if logs[0].(map[string]any)["status"] != "success" {
|
||||||
|
t.Fatalf("预警任务失败: %+v", logs)
|
||||||
|
}
|
||||||
|
finished = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !finished {
|
||||||
|
t.Fatal("没有收到预警执行完成日志")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
BusinessID, TargetPromoteID uint
|
||||||
|
Status int
|
||||||
|
PlatformCode string
|
||||||
|
}
|
||||||
|
if err := db.Table("sys_alert_record").Scan(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].BusinessID != 10 || rows[0].TargetPromoteID != 10 || rows[0].PlatformCode != "lx" || rows[0].Status != 1 {
|
||||||
|
t.Fatalf("重复触发或归属错误: %+v", rows)
|
||||||
|
}
|
||||||
|
var enabled int64
|
||||||
|
db.Table("sys_timed_tasks").Where("enabled=1").Count(&enabled)
|
||||||
|
if enabled != 0 {
|
||||||
|
t.Fatal("手动触发不应启用自动计划")
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-5
@@ -103,11 +103,8 @@ func httpAddress(server *config.Server) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func swaggerAddress(server *config.Server, admin *config.Admin) string {
|
func swaggerAddress(server *config.Server, admin *config.Admin) string {
|
||||||
if admin != nil && admin.App != nil {
|
if !config.SwaggerEnabled(admin) {
|
||||||
switch strings.ToLower(strings.TrimSpace(admin.App.Env)) {
|
return "disabled"
|
||||||
case "production", "prod", "live", "staging":
|
|
||||||
return "disabled"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
address := httpAddress(server)
|
address := httpAddress(server)
|
||||||
if strings.HasPrefix(address, ":") {
|
if strings.HasPrefix(address, ":") {
|
||||||
|
|||||||
+5
-3
@@ -13,9 +13,11 @@ func TestSwaggerAddress(t *testing.T) {
|
|||||||
admin *config.Admin
|
admin *config.Admin
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "default", want: "http://127.0.0.1:8000/swagger/index.html"},
|
{name: "missing environment", want: "disabled"},
|
||||||
{name: "wildcard with prefix", server: &config.Server{HTTP: &config.ServerHTTP{Addr: "0.0.0.0:9000"}}, admin: &config.Admin{RouterPrefix: "/admin/"}, want: "http://127.0.0.1:9000/admin/swagger/index.html"},
|
{name: "qa", admin: &config.Admin{App: &config.App{Env: "qa"}}, want: "disabled"},
|
||||||
{name: "host", server: &config.Server{HTTP: &config.ServerHTTP{Addr: "example.test:8080"}}, want: "http://example.test:8080/swagger/index.html"},
|
{name: "development default", admin: &config.Admin{App: &config.App{Env: "dev"}}, want: "http://127.0.0.1:8000/swagger/index.html"},
|
||||||
|
{name: "wildcard with prefix", server: &config.Server{HTTP: &config.ServerHTTP{Addr: "0.0.0.0:9000"}}, admin: &config.Admin{RouterPrefix: "/admin/", App: &config.App{Env: "test"}}, want: "http://127.0.0.1:9000/admin/swagger/index.html"},
|
||||||
|
{name: "host", server: &config.Server{HTTP: &config.ServerHTTP{Addr: "example.test:8080"}}, admin: &config.Admin{App: &config.App{Env: "local"}}, want: "http://example.test:8080/swagger/index.html"},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -115,7 +115,19 @@ func TestRankingHTTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if lx == nil || lx["metricValue"] != "50.00" || lx["momValue"] != "-50.00" || lx["momStatus"] != float64(-1) {
|
if lx == nil || lx["metricValue"] != "50.00" || lx["momValue"] != "-50.00" || lx["momStatus"] != float64(-1) {
|
||||||
t.Fatalf("付费率应计算百分点差: %+v", lx)
|
t.Fatalf("付费率应计算相对上期增幅: %+v", lx)
|
||||||
|
}
|
||||||
|
for _, identity := range []string{"B", "C", "D"} {
|
||||||
|
if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES('lx',100,1,1,'2026-11-03',2,?)", identity).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p = call(7, "channel", query, 0)
|
||||||
|
for _, value := range p["payRateRankList"].([]any) {
|
||||||
|
row := value.(map[string]any)
|
||||||
|
if row["sourceDb"] == "lx" && (row["metricValue"] != "50.00" || row["momValue"] != "100.00" || row["momStatus"] != float64(1)) {
|
||||||
|
t.Fatalf("25%% 升到 50%% 应为 +100%% 环比: %+v", row)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
p = call(7, "business", query, 0)
|
p = call(7, "business", query, 0)
|
||||||
rows := p["registerRankList"].([]any)
|
rows := p["registerRankList"].([]any)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func TestReportBuildThroughNativeTaskHTTP(t *testing.T) {
|
|||||||
registry := platformtask.NewRegistry()
|
registry := platformtask.NewRegistry()
|
||||||
tasks := taskbiz.NewTaskUsecaseWithRegistry(taskdata.NewTaskRepo(reportTaskHTTPData{provider}), registry)
|
tasks := taskbiz.NewTaskUsecaseWithRegistry(taskdata.NewTaskRepo(reportTaskHTTPData{provider}), registry)
|
||||||
reports := businessbiz.NewReportBuildUsecase(businessdata.NewReportBuildRepo(provider))
|
reports := businessbiz.NewReportBuildUsecase(businessdata.NewReportBuildRepo(provider))
|
||||||
worker.NewTaskMethods(tasks, nil, nil, nil, reports).RegisterTasks(registry)
|
worker.NewTaskMethods(tasks, nil, nil, nil, reports, nil).RegisterTasks(registry)
|
||||||
scheduler := worker.NewTaskScheduler(tasks, nil, worker.NewTaskExecutorWithRegistry(tasks, registry), nil)
|
scheduler := worker.NewTaskScheduler(tasks, nil, worker.NewTaskExecutorWithRegistry(tasks, registry), nil)
|
||||||
defer func() {
|
defer func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
@@ -102,7 +102,7 @@ func TestReportBuildThroughNativeTaskHTTP(t *testing.T) {
|
|||||||
return value["data"].(map[string]any)
|
return value["data"].(map[string]any)
|
||||||
}
|
}
|
||||||
methods := call("GET", "getRegisteredMethods", nil)["methods"].([]any)
|
methods := call("GET", "getRegisteredMethods", nil)["methods"].([]any)
|
||||||
if len(methods) != 8 {
|
if len(methods) != 9 {
|
||||||
t.Fatalf("六个商务汇总方法未注册: %d", len(methods))
|
t.Fatalf("六个商务汇总方法未注册: %d", len(methods))
|
||||||
}
|
}
|
||||||
for index, method := range []string{"swReportDayBackfillTask", "swReportServerDayBackfillTask"} {
|
for index, method := range []string{"swReportDayBackfillTask", "swReportServerDayBackfillTask"} {
|
||||||
|
|||||||
Generated
+3
-1
@@ -290,7 +290,9 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
|||||||
maintenanceUsecase := system2.NewMaintenanceUsecase(maintenanceRepo)
|
maintenanceUsecase := system2.NewMaintenanceUsecase(maintenanceRepo)
|
||||||
reportBuildRepo := business.NewReportBuildRepo(dataData)
|
reportBuildRepo := business.NewReportBuildRepo(dataData)
|
||||||
reportBuildUsecase := business2.NewReportBuildUsecase(reportBuildRepo)
|
reportBuildUsecase := business2.NewReportBuildUsecase(reportBuildRepo)
|
||||||
taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store, reportBuildUsecase)
|
alertTriggerRepo := business.NewAlertTriggerRepo(dataData)
|
||||||
|
alertTriggerUsecase := business2.NewAlertTriggerUsecase(alertTriggerRepo)
|
||||||
|
taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store, reportBuildUsecase, alertTriggerUsecase)
|
||||||
routeRegistrar := buildRuntime(registry, routes, taskMethods)
|
routeRegistrar := buildRuntime(registry, routes, taskMethods)
|
||||||
websocketServer, cleanup2, err := websocket.New(runtimeconfigStore)
|
websocketServer, cleanup2, err := websocket.New(runtimeconfigStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Kra 管理后台由 Kratos 应用承载生命周期,Gin 提供兼容管理端
|
|||||||
| 目录 | 职责 |
|
| 目录 | 职责 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `internal/server` | Gin 路由、中间件和 Kratos transport 生命周期 |
|
| `internal/server` | Gin 路由、中间件和 Kratos transport 生命周期 |
|
||||||
| `internal/service` | HTTP DTO 与领域对象转换、用例编排 |
|
| `internal/service` | HTTP 输入校验、DTO 与领域对象转换 |
|
||||||
| `internal/biz` | 领域对象、业务规则和仓储接口 |
|
| `internal/biz` | 领域对象、业务规则和仓储接口 |
|
||||||
| `internal/data` | GORM、MySQL、Redis 和本地文件存储实现 |
|
| `internal/data` | GORM、MySQL、Redis 和本地文件存储实现 |
|
||||||
| `web` | Vue 3 管理端 |
|
| `web` | Vue 3 管理端 |
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 当前接口审查(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 个旧脚本已逐文件复制到仓库外并校验 SHA-256 后移除。保留统一迁移 SQL、业务口径、综合验收索引和 14 个当前验证工具,补充维护入口与脚本说明。
|
||||||
|
|
||||||
|
真实本地配置 `configs/config.yaml`、私有运行数据,以及其他会话的 V2 估算和 outputs 成果不纳入本次提交。未推送远端。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 文档入口
|
||||||
|
|
||||||
|
- [商务迁移](sw6-to-kra/README.md):统一 SQL、业务口径、接口与任务验收。
|
||||||
|
- [当前接口审查](API_REVIEW.md):此次修复、验证范围及保留的技术风险。
|
||||||
|
- [后台说明](ADMIN_BACKEND.md)、[支付接入](PAYMENT.md)、[短信接入](SMS_INTEGRATION.md)。
|
||||||
|
- [OA 迁移计划](OA_MIGRATION_PLAN.md):独立 OA 工作的设计材料,不是当前商务迁移的完成证明。
|
||||||
|
- [验证脚本](../scripts/README.md):保留工具的用途和私有输入约定。
|
||||||
|
|
||||||
|
历史阶段记录已归档到仓库外;后续将最新结果更新到对应维护文档,不再每个接口生成一份日期报告。其他会话生成的商务 V2 估算及表格保持原状,本次清理和提交不包含这些文件。
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# 代码审查问题清单(internal + pkg)
|
|
||||||
|
|
||||||
- 审查日期:2026-08-27 ~ 2026-08-28,共十一轮全量审查。第十一轮(2026-08-28):核查第十轮划线项(22 项)+ 全新视角回归审查;`go build ./...` 编译验证通过
|
|
||||||
- 文档结构:只保留待修复问题,按**类型**归类;每条标注发现轮次与处置状态(专项待修/待修);已修复并经复查确认的、经评估决定保留的、用户决策不修的直接删除
|
|
||||||
- gva/ 目录是遗留参考库(独立 module 不参与 kra 编译),不在审查范围
|
|
||||||
- 依赖方向合规:pkg 无 import internal;integration 不 import data/service;data 不 import integration/payment;无循环依赖
|
|
||||||
- 第十一轮总评:核心分层契约、日志防循环、CleanupStale 顺序反转、调度器重试、微文件合并等历轮修复验收干净;当前风险集中在**边缘入口的防护不对称**——公开写接口无治理、TTL 语义错位、停机路径无超时、前端凭证存储薄弱
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、安全与正确性缺陷
|
|
||||||
|
|
||||||
### 专项设计待修(用户已确认需专项处理)
|
|
||||||
|
|
||||||
| # | 问题 | 位置 | 状态 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| ~~V-39~~ | ~~公开无限流写接口 `/sysError/createSysError`~~:已移入鉴权路由组、纳入安全限流,并限制 `Info` 最大长度 | ~~server/router/audit.go;middleware/rate_limit.go;service/dto/audit.go~~ | ~~十一轮~~ |
|
|
||||||
| V-33 | 支付指纹严格相等校验对存量订单幂等重放的冲突风险(initialized/pending 存量订单重放报参数冲突而非幂等短路)——修复需兼容旧指纹、区分订单状态、覆盖并发重放,直接放宽会削弱防篡改 | biz/payment/payment.go:645-657 | 十轮发现,专项 |
|
|
||||||
| V-24 | Delete↔Create 竞态窗口仍开(锁仅覆盖 Delete,秒传/上传/Complete 的 CreateMedia 路径不持锁);多副本需数据库/分布式锁 | biz/system/media.go:50,111-143;media_upload.go:100-107,262 | 八轮发现,专项 |
|
|
||||||
| V-13b | swagger:空 env fail-open(gin.go:68 仍注册);gin 白名单 vs main.go 黑名单双清单不一致(qa/uat 时日志谎报);env 门禁零测试——需统一单一判定函数+环境矩阵测试 | server/gin.go:64-70;cmd/main.go:105-126,171 | 七轮发现,专项 |
|
|
||||||
| V-27 | redis_list 仍按下标回退(configuration.go:439-441,database_list 已修✓);别名匹配失败时哨兵字面量落盘无拒绝校验——需重新定义配置项稳定标识与密钥继承规则 | internal/initialize/configuration.go:427-446 | 九轮发现,专项 |
|
|
||||||
| V-20 | 前端 token 拼入 URL(完整 JWT 拼二维码 URL)——需后端一次性短时效票据+扫码协议迁移 | web/src/components/upload/QR-code.vue:55 | 六轮发现,专项 |
|
|
||||||
| V-15 | **envelope 净化剩余三洞**(全角冒号已修✓——response.go:53 分隔符已扩 ["失败:","失败:"]):① 17 处无冒号/紧贴拼接调用截断不可命中(api.go:137,156、authority.go 9 处、menu.go:170,178,198、user.go:150),非黑名单错误原样透出;② **CodeError data 通道完全无净化**——payment TestProvider Stages[].Message 原始 err 随 data 到前端;③ 净化静默无日志("不泄露也不可排障")。另有误伤:宽泛词 marker+约 45 处截断吞 biz 可读提示——需统一错误模型+结构化 Data 脱敏+日志策略 | server/httpx/response.go:32-59;data/payment/payment.go:79-139 | 六轮发现,专项 |
|
|
||||||
| V-19 | **证书别名缺口仍在(三层同源✓但键表未补别名——"已纳入键表"声称不实)**:app_cert_content/root_cert_content/pkcs12_content/cert_content/key_content/cert_path 等仍不命中 IsIntegrationSecretKey(后缀表无 *_content/*_path 项,integration_config.go:402-413)→ API 直连提交明文落库并回显,日志 resp_data 同泄;修法:剥后缀匹配词干或显式枚举(勿用裸 content/path 后缀防误伤) | biz/integration/integration_config.go:402-413,329-332,427-435 | 六轮发现,十轮部分+十一轮复核未闭合 |
|
|
||||||
| ~~V-30~~ | ~~douyin `platform_serial_no` required 标记与别名校验不一致~~:字段已标记 required,支付 required 校验同时识别 `platform_cert_serial` 别名 | ~~biz/integration/integration_config_definition.go;integration_config.go~~ | ~~十一轮复核~~ |
|
|
||||||
| V-38 | 媒体域裸错误(ErrMediaTooLarge/ErrUploadSessionNotFound)vs AGENTS typed errors 契约——需连同错误码表与 HTTP 映射一起迁移 | biz/system/media.go:52、media_upload.go:21 | 十轮发现,专项 |
|
|
||||||
|
|
||||||
### 待修(正确性缺陷)
|
|
||||||
|
|
||||||
| # | 问题 | 位置 | 状态 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| ~~V-40~~ | ~~上传会话 TTL 未按最后活动时间刷新~~:每次分片写入后同步更新上传会话 `updated_at` | ~~data/system/media_upload.go~~ | ~~十一轮~~ |
|
|
||||||
| V-41 | (新发现,中低)merging 仅 1h 宽限(data:129),远程存储 Compose 超 1h 的合法合并被清理并整体失败(有回滚无泄漏,功能误杀) | data/system/media_upload.go:129 | 十一轮 |
|
|
||||||
| V-42 | **(新发现,中)dataScopeAuditWriter.Close 无超时且 MySQL DSN 无网络超时**:Close 无限期 `<-w.done`(:67-73)、flush 用 context.Background 无 deadline(:88)、DSN 无 readTimeout/writeTimeout(config.yaml:9,15)——DB 故障时 cleanup 链(data.go:191-198 先 auditLog.Close 后关 DB)被挂起 flush 永久阻塞,进程无法优雅退出 | data/data_scope_audit.go:67-73,88;configs/config.yaml:9,15 | 十一轮 |
|
|
||||||
| V-43 | **(新发现,中)前端 token 存 localStorage(7 天长期凭证 XSS 可窃)+ 401 登出回调可被并发弹窗吞掉**(application/index.vue:16 已有弹窗时 show-error 被忽略、handleClose 置 null cb 不执行 → 401 后 token 残留循环) | web/src/pinia/modules/user.js:23;components/application/index.vue:16;utils/request.js:231-241 | 十一轮 |
|
|
||||||
| ~~V-44~~ | ~~V-36 词表收窄导致“导出token无效或已过期”不再命中~~:已补充 `token无效` 匹配 | ~~middleware/error_audit.go~~ | ~~十一轮~~ |
|
|
||||||
| ~~V-45~~ | ~~sysError 写路径失败无日志~~:创建失败时已记录结构化错误日志 | ~~server/handler/audit.go~~ | ~~十一轮~~ |
|
|
||||||
| V-46 | (新发现,低)调度器启动重试无上限无退避:永久性错误(cron 解析失败整批 return err,task_scheduler.go:204-207)会每 5s 重试永不成功+刷 Warn 日志(约 720 条/小时)——需区分永久/暂时性错误 | worker/task_scheduler.go:93-112,204-207 | 十一轮(V-35 修复残留) |
|
|
||||||
| V-47 | (新发现,低)PersistRuntimeConfig 三段写非原子(先 storage 再 email 再 config,任一失败返回)——前面已写的集成配置与未替换的 runtime 出现不一致窗口 | data/initialization_backend.go:74-83 | 十一轮 |
|
|
||||||
| V-31 残留 | UpdateMediaName 仍透传 gorm 原文(404 语义丢失) | data/system/media.go:125-127 | 九轮,部分修复 |
|
|
||||||
|
|
||||||
## 二、死代码与碎屑
|
|
||||||
|
|
||||||
| # | 问题 | 位置 | 状态 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| Z-15 | 软删字段死重量(4 个 PO 的 DeletedAt+索引在删除路径全 Unscoped 后成死配置)——存量表结构清理需迁移与回滚策略,待数据库迁移窗口 | data/system/media.go:16,34;media_upload.go:17,35,104 | 九轮,专项 |
|
|
||||||
| Z-9 | audit.go:76/83 ctxRespTextKey/ctxRespTruncatedKey 消费分支生产链路不可达(后置键);access_log 先对截断体完整脱敏再覆盖(白做功)——需连同响应捕获链路整体重构 | server/middleware/audit.go:76-85;access_log.go:110-121 | 八轮,专项 |
|
|
||||||
| Z-13 残留 | cors.go:13-14 逗号空格、audit.go:104 单行 13 字段、audit.go:46-47 空 else-if——纯格式问题(用户评估:格式化工具统一处理) | server/middleware/ | 八轮,评估暂缓 |
|
|
||||||
| Z-17 | scanUpload.vue 空 catch 吞路由就绪异常——需统一前端错误上报 | web/src/view/media/scanUpload.vue:115-116 | 九轮,专项 |
|
|
||||||
| Z-22 | (新发现)Z-19 清理后前端死代码残留:image.js:32-35 空 if 块+未使用 newImgSize;global.js:51,54 未使用 allKeys+悬空比较表达式;另 3 处已注释 console 代码(cropper.vue:198、upload.vue:363、menus.vue:302) | web/src/utils/image.js:32-35;core/global.js:51,54 | 十一轮 |
|
|
||||||
| ~~Z-23~~ | ~~douyin 别名分支放错校验器~~:已移除通信校验器中的不可达分支,别名逻辑归入支付校验器 | ~~biz/integration/integration_config.go~~ | ~~十一轮~~ |
|
|
||||||
| Z-24 | (新发现)秒传分支变量名 `copy` 遮蔽内置函数(可读性) | biz/system/media_upload.go:100 | 十一轮 |
|
|
||||||
|
|
||||||
## 三、重复实现 / 双轨残留
|
|
||||||
|
|
||||||
| # | 问题 | 位置 | 状态 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| Y-7 | 掩码三套实现+键表多层漂移(data Secret∪IsIntegrationSecretKey / service 仅后者 / redact.go 第四张表);哨兵双轨(****** vs ***)——需先定义跨层协议(专项) | data/integration:225-245;service/integration:69-96;redact.go:18-27 | 九轮,专项 |
|
|
||||||
| Y-12 | **(新发现,中低)redact 键表误伤非机密标识符,审计记录失去回放价值**:redact.go:22 精确项 "key"、:24-25 platformserialno/webhookid 是序列号/ID 非秘密——实证误伤 dto/menu.go:26,90(菜单路由 key)、dto/media.go:15,76(存储 key)、settings.go:45,56,117、version.go:39,这些写接口的操作审计响应中 key 全为 `***`,菜单/媒体/设置类审计基本无法回放排障(安全无泄漏,可用性受损) | server/middleware/redact.go:22,24-25;dto/menu.go:26 等 | 十一轮 |
|
|
||||||
| Y-5 | 集成配置校验双重执行(biz 层对掩码值总通过半失效)——用户评估:分层防线保留 | biz/integration:122-129 | 八轮,评估保留 |
|
|
||||||
| Y-2 剩余 | capture.go JSON 成功路径先 marshal 后判超限(超长白做)——用户评估:安全优先保留现状 | middleware/capture.go:49-76 | 六轮,评估保留 |
|
|
||||||
| Y-10 | 前端两套动态路由注册逻辑(登录整树 vs 守卫扁平化+redirect)——需先补路由矩阵测试 | web/src/pinia/modules/user.js:85-87;permission.js:78-146 | 十轮,专项 |
|
|
||||||
|
|
||||||
## 四、文档漂移
|
|
||||||
|
|
||||||
| # | 问题 | 位置 | 状态 |
|
|
||||||
|---|------|------|------|
|
|
||||||
| F-37 | **CLAUDE.md:19 integration 描述仍为 "(cache, email, payment, storage)" 4 项("已同步"声称不实——实际目录 8 个模块,AGENTS.md 版本已含全部)** | CLAUDE.md:19 | 十轮发现,声称不实 |
|
|
||||||
| F-39 | (新,微)根 README.md:7-18 目录树缺 internal/{integration,modules,paymentkit,routecatalog,logging,utils} 与 pkg/(AGENTS.md 为权威无错,README 属简化版) | README.md:7-18 | 十一轮 |
|
|
||||||
|
|
||||||
## 处置建议(按优先级)
|
|
||||||
|
|
||||||
1. **V-39 公开写接口治理**(限流覆盖/长度 binding/或移入鉴权组——安全优先)+ **V-44/V-45 ErrorAudit 噪音与盲区**(词表补导出 token 文案或改 4xx;CreateError 失败补日志)
|
|
||||||
2. **V-40 TTL 改活动时间**(UpsertChunk touch 会话 updated_at——一行修复)+ **V-42 Close/DSN 超时** + **V-46 重试上限/退避**
|
|
||||||
3. **V-19 键表补别名**(剥后缀匹配词干——一处三层受益;连带 Z-23 死分支归位)+ **V-30 douyin required**(通用循环感知别名后置 true)
|
|
||||||
4. **V-43 前端凭证**(401 回调修复+token 存储评估)+ **F-37 一行同步**(本轮声称不实项)
|
|
||||||
5. **V-41/V-47/V-31 残留 + Z-22/23/24 碎屑 + Y-12 redact 误伤面收敛**(与 Y-7 专项一并设计)
|
|
||||||
6. **专项批次**:V-15 错误模型统一、V-33 指纹兼容窗口、V-24 分布式锁、V-13b 单一判定函数、V-27 稳定标识、V-20 短时票据、V-38 错误码迁移、Z-15 迁移窗口、Z-9 响应链重构、Y-7 跨层协议、Y-10 路由矩阵
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 商务迁移维护入口
|
||||||
|
|
||||||
|
旧 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) 与 [验证脚本说明](../../scripts/README.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、旧 sw7 冒烟脚本已归档到本机仓库外 `D:/Users/Yvan/backups/sw9/20261104_rehearsal/cleanup-archive/`。该目录以及相邻的运行配置、原始响应、分区备份不纳入 Git。历史索引中 `reports` 项的文件名可在归档内查找。
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "当前 sw7 运行实例,只读请求;OA 未接入场景明确返回 available=false。完整 OA 与流水数据通过两个隔离数据库的 TCP HTTP 测试。",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promoteActivePool/bindList?pageNum=1&pageSize=10",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promoteActivePool/bindList?platformNames=lx,hw&statuses=reached",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promoteActivePool/bindList?statuses=%E5%B7%B2%E8%BE%BE%E6%A0%87&busierAccount=does-not-exist",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promoteActivePool/bindSpendDetail?id=0",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "绑定记录不存在",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "真实 Chrome,受控 OA 和流水响应,无写入",
|
|
||||||
"passed": true,
|
|
||||||
"rejectedHidesFlow": true,
|
|
||||||
"pendingShowsReasonWithoutFakeTotal": true,
|
|
||||||
"reachedCutoffDisplayed": true,
|
|
||||||
"exactLargeAmount": true,
|
|
||||||
"filtersAndReset": true,
|
|
||||||
"unavailableSourceNotice": true,
|
|
||||||
"pageErrors": []
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "既有 sw7,只读查询及必然拒绝的无效写请求;正向流转另用隔离 SQLite 和 TCP HTTP 测试",
|
|
||||||
"base_url": "http://127.0.0.1:18000",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/alertRecord/list?pageNum=1&pageSize=10",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": true,
|
|
||||||
"status_count": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/alertRecord/list?platformCodes=lx,hw&status=5",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": true,
|
|
||||||
"status_count": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/alertRule/ruleTypes",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/business/list?pageNum=1&pageSize=500",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/alertRecord/list?businessId=10007",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": false,
|
|
||||||
"status_count": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/alertRecord/list?businessId=10001",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": true,
|
|
||||||
"status_count": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/alertRecord/detail/0",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "预警记录参数不正确",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/alertRecord/startProcess",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "预警记录参数不正确",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/alertRecord/submitAudit",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "预警记录参数不正确",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/alertRecord/approve",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "预警记录参数不正确",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/alertRecord/reject",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "预警记录参数不正确",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/alertRecord/reProcess",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "预警记录参数不正确",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/alertRecord/addNote",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "预警记录参数不正确",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "真实 Chrome 渲染;受控身份、预警数据、写接口和图片上传;未修改真实业务数据",
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"level": 0,
|
|
||||||
"stateButtons": [
|
|
||||||
{
|
|
||||||
"status": 1,
|
|
||||||
"label": "处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 2,
|
|
||||||
"label": "继续处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 3,
|
|
||||||
"label": "查看"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 4,
|
|
||||||
"label": "重新处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 5,
|
|
||||||
"label": "查看"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true,
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 1,
|
|
||||||
"stateButtons": [
|
|
||||||
{
|
|
||||||
"status": 1,
|
|
||||||
"label": "处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 2,
|
|
||||||
"label": "继续处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 3,
|
|
||||||
"label": "审核"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 4,
|
|
||||||
"label": "重新处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 5,
|
|
||||||
"label": "查看"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true,
|
|
||||||
"workflowAndAttachments": true,
|
|
||||||
"pasteDropAndRemove": true,
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 2,
|
|
||||||
"stateButtons": [
|
|
||||||
{
|
|
||||||
"status": 1,
|
|
||||||
"label": "处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 2,
|
|
||||||
"label": "继续处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 3,
|
|
||||||
"label": "查看"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 4,
|
|
||||||
"label": "重新处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 5,
|
|
||||||
"label": "查看"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true,
|
|
||||||
"statusCardsAndUnavailable": true,
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 3,
|
|
||||||
"stateButtons": [
|
|
||||||
{
|
|
||||||
"status": 1,
|
|
||||||
"label": "处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 2,
|
|
||||||
"label": "继续处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 3,
|
|
||||||
"label": "查看"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 4,
|
|
||||||
"label": "重新处理"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"status": 5,
|
|
||||||
"label": "查看"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true,
|
|
||||||
"pageErrors": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "既有 sw7;查询和拒绝无效写请求,未创建真实规则",
|
|
||||||
"schema_before": {
|
|
||||||
"column_type": "decimal(7,2)",
|
|
||||||
"rule_rows": 0
|
|
||||||
},
|
|
||||||
"schema_after": "decimal(7,2)",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/alertRule/list?pageNum=1&pageSize=10",
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/alertRule/ruleTypes",
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/alertRule/add",
|
|
||||||
"code": 7,
|
|
||||||
"msg": "预警规则参数不正确",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "PUT",
|
|
||||||
"path": "/sw/alertRule/toggle/0?enabled=1",
|
|
||||||
"code": 7,
|
|
||||||
"msg": "预警规则参数不正确",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "真实 Chrome 渲染,受控角色及规则数据;不写真实规则",
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"level": 0,
|
|
||||||
"createButton": 1,
|
|
||||||
"editButton": 1,
|
|
||||||
"passed": true,
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 1,
|
|
||||||
"createButton": 1,
|
|
||||||
"editButton": 1,
|
|
||||||
"passed": true,
|
|
||||||
"metricFields": true,
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 2,
|
|
||||||
"createButton": 0,
|
|
||||||
"editButton": 0,
|
|
||||||
"passed": true,
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 3,
|
|
||||||
"createButton": 0,
|
|
||||||
"editButton": 0,
|
|
||||||
"passed": true,
|
|
||||||
"pageErrors": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "真实 sw7 会话,只读请求;完整统计及权限口径另由隔离 TCP HTTP 测试验证。",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"path": "/sw/channelData/list?sourceDb=lx,hw,yq&pageNum=1&pageSize=10",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"available": false,
|
|
||||||
"total": 0,
|
|
||||||
"begin": "2026-11-04",
|
|
||||||
"end": "2026-11-04"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/channelData/list?beginTime=bad&endTime=2026-11-04",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计周期格式错误,应为 yyyy-MM-dd",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/channelData/list?beginTime=2026-11-05&endTime=2026-11-04",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计开始日期不能晚于结束日期",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/gameData/list?sourceDb=lx,hw,yq&pageNum=1&pageSize=10",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"available": false,
|
|
||||||
"total": 0,
|
|
||||||
"begin": "2026-11-04",
|
|
||||||
"end": "2026-11-04"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/gameData/list?beginTime=bad&endTime=2026-11-04",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计周期格式错误,应为 yyyy-MM-dd",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/gameData/list?beginTime=2026-11-05&endTime=2026-11-04",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计开始日期不能晚于结束日期",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "真实 Chrome 渲染,受控业务数据;未写入真实数据",
|
|
||||||
"reports": [
|
|
||||||
{
|
|
||||||
"kind": "channel",
|
|
||||||
"passed": true,
|
|
||||||
"fullSummaryAcrossPages": true,
|
|
||||||
"filtersResetPage": true,
|
|
||||||
"platformAndGameFilters": true,
|
|
||||||
"noInventedExport": true,
|
|
||||||
"missingDataNotZero": true,
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "game",
|
|
||||||
"passed": true,
|
|
||||||
"fullSummaryAcrossPages": true,
|
|
||||||
"filtersResetPage": true,
|
|
||||||
"platformAndGameFilters": true,
|
|
||||||
"noInventedExport": true,
|
|
||||||
"missingDataNotZero": true,
|
|
||||||
"pageErrors": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -25,4 +25,5 @@
|
|||||||
- 使用聚合查询和批量资料查询代替逐行查询,不改变业务口径。日期非法或倒置返回明确错误。
|
- 使用聚合查询和批量资料查询代替逐行查询,不改变业务口径。日期非法或倒置返回明确错误。
|
||||||
- 隔离 TCP HTTP 和仓储测试覆盖全量汇总与分页、跨平台同号、官方/孤立渠道、根渠道树去重、类型交集、历史游戏名过滤、当前资料展示、偏好切换及空范围。
|
- 隔离 TCP HTTP 和仓储测试覆盖全量汇总与分页、跨平台同号、官方/孤立渠道、根渠道树去重、类型交集、历史游戏名过滤、当前资料展示、偏好切换及空范围。
|
||||||
- 领域测试覆盖大金额精确排序、分母和人数上限。前端检查筛选回第一页、30 天默认值、汇总不随分页变化、缺数据提示、没有额外导出入口。
|
- 领域测试覆盖大金额精确排序、分母和人数上限。前端检查筛选回第一页、30 天默认值、汇总不随分页变化、缺数据提示、没有额外导出入口。
|
||||||
- 真实 sw7 库只做只读请求;当前缺少完整业务报表,最终旧库复制与同源数据比较仍未开始。
|
|
||||||
|
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "真实 Chrome 渲染;受控身份和业务数据,权限页签与状态操作核对",
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"level": 0,
|
|
||||||
"bindingTab": false,
|
|
||||||
"editButtons": 0,
|
|
||||||
"passed": true,
|
|
||||||
"bindingCalls": 0,
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 1,
|
|
||||||
"bindingTab": false,
|
|
||||||
"editButtons": 0,
|
|
||||||
"passed": true,
|
|
||||||
"sdkDetailReadonly": true,
|
|
||||||
"bindingCalls": 0,
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 2,
|
|
||||||
"bindingTab": false,
|
|
||||||
"editButtons": 0,
|
|
||||||
"passed": true,
|
|
||||||
"sdkDetailReadonly": true,
|
|
||||||
"bindingCalls": 0,
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 3,
|
|
||||||
"bindingTab": true,
|
|
||||||
"editButtons": 0,
|
|
||||||
"passed": true,
|
|
||||||
"normalUnbind": 1,
|
|
||||||
"rejectedUnbind": 0,
|
|
||||||
"pendingUnbind": 0,
|
|
||||||
"rejectedDetail": 1,
|
|
||||||
"rejectedChannelsDisabled": true,
|
|
||||||
"pendingChannelsDisabled": true,
|
|
||||||
"channelFilters": true,
|
|
||||||
"rejectDrawerInViewport": true,
|
|
||||||
"bindingCalls": 1,
|
|
||||||
"pageErrors": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "当前 sw7 实际登录会话;只读查询和无效创建。完整查询、偏好、游戏分布及源库创建已在隔离数据库通过 TCP HTTP 验证。",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promote/list?pageNum=1&pageSize=10",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promote/list?sourceDb=lx,hw&lockStatus=-1&promoteLevel=1",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promote/list?busierAccount=does-not-exist",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promote/bindOptions?sourceDb=lx",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"rows": 17
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promote/bindOptions?sourceDb=hw",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"rows": 20
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promote/bindOptions?sourceDb=yq",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"rows": 20
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promote/game-distribution?sourceDb=lx&promoteId=0",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "渠道查询参数不正确",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/promote/game-distribution?sourceDb=lx&promoteId=1",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "渠道数据尚未接入",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/promote",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "请选择有效的游戏商务绑定",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "真实 Chrome 渲染;受控页面数据、身份及平台创建响应,不创建真实平台渠道",
|
|
||||||
"passed": true,
|
|
||||||
"level": 1,
|
|
||||||
"noEditEntry": true,
|
|
||||||
"createAllowedForUpperBusiness": true,
|
|
||||||
"platformSwitchClearsBinding": true,
|
|
||||||
"crossPlatformSameID": true,
|
|
||||||
"zeroGameCountClickable": true,
|
|
||||||
"missingMetricsVisible": true,
|
|
||||||
"sourceDatabaseCreatePayload": true,
|
|
||||||
"portraitPending": false,
|
|
||||||
"pageErrors": []
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "当前 sw7 登录会话,只读检查;完整累计、分层、日期与权限在隔离 TCP HTTP 中验证。",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"path": "/sw/promote/list?pageNum=1&pageSize=1",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/promote/portrait?sourceDb=lx&promoteId=1&period=total",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "渠道数据尚未接入",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/promote/portrait?sourceDb=lx&promoteId=0",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "渠道查询参数不正确",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/promote/portrait?sourceDb=invalid&promoteId=1",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "所属平台不正确",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "真实 Chrome,画像、游戏与预警数据为受控响应,不改业务数据",
|
|
||||||
"passed": true,
|
|
||||||
"activeMetric": true,
|
|
||||||
"totalNoComparison": true,
|
|
||||||
"canonicalLast7": true,
|
|
||||||
"fourPayTiers": true,
|
|
||||||
"gameDrawer": true,
|
|
||||||
"platformAndChannelAlertFilter": true,
|
|
||||||
"crossPlatformSameID": true,
|
|
||||||
"unavailableIsNotZero": true,
|
|
||||||
"pageErrors": []
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
# 渠道画像迁移核对
|
# 渠道画像迁移核对
|
||||||
|
|
||||||
已实现 `GET /sw/promote/portrait` 及渠道列表画像入口,来源为旧 `PromotePortraitServiceImpl`、`ChannelProfileDrawer.vue`、日汇总/身份键和支付 Mapper。完整业务库数据仍待最终复制验收。
|
|
||||||
|
|
||||||
## 数据范围与基本资料
|
## 数据范围与基本资料
|
||||||
|
|
||||||
@@ -29,4 +28,4 @@
|
|||||||
- 旧关联预警误将渠道 ID 传入 businessId;新页面使用 targetPromoteId 与 platformCodes,并继续由预警接口执行查看者数据范围隔离。
|
- 旧关联预警误将渠道 ID 传入 businessId;新页面使用 targetPromoteId 与 platformCodes,并继续由预警接口执行查看者数据范围隔离。
|
||||||
- 小R/免费区间文案按实际后端 >0 / ≤0 调整,避免把小于一元的付费玩家误解为免费。
|
- 小R/免费区间文案按实际后端 >0 / ≤0 调整,避免把小于一元的付费玩家误解为免费。
|
||||||
|
|
||||||
验证覆盖领域规则、真实仓储查询、隔离数据库 TCP HTTP、浏览器完整交互及当前 sw7 只读接口。详见 `channel-portrait-http-2026-11-03.json` 与 `channel-portrait-page-2026-11-03.json`;全量 Go 测试与前端构建通过。
|
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# 渠道列表与新增渠道:源码核对记录
|
# 渠道列表与新增渠道:源码核对记录
|
||||||
|
|
||||||
以下是实现核对契约,源码基线与 `implementation.md` 一致。列表、新增、绑定选项、游戏分布及渠道画像已接入并验证;报表构建任务及完整数据验收仍未完成。
|
|
||||||
|
|
||||||
## 页面
|
## 页面
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@
|
|||||||
- 状态筛选及列使用 `lockStatus`:0 正常、-1 已锁定、-2 解除合作,与渠道审批字段 `status` 不同。
|
- 状态筛选及列使用 `lockStatus`:0 正常、-1 已锁定、-2 解除合作,与渠道审批字段 `status` 不同。
|
||||||
- “添加渠道”只按按钮权限判断,旧代码没有额外要求三级商务。游戏商务下拉随平台变化加载,选项取平台商务 ID 并去重。
|
- “添加渠道”只按按钮权限判断,旧代码没有额外要求三级商务。游戏商务下拉随平台变化加载,选项取平台商务 ID 并去重。
|
||||||
- 新增字段:平台、游戏商务、推广类型、渠道账号、密码。账号为 `[\u4e00-\u9fa5a-zA-Z0-9]{4,30}`,提交前 trim;密码原值保留,按 Java UTF-16 长度 6–15 校验。
|
- 新增字段:平台、游戏商务、推广类型、渠道账号、密码。账号为 `[\u4e00-\u9fa5a-zA-Z0-9]{4,30}`,提交前 trim;密码原值保留,按 Java UTF-16 长度 6–15 校验。
|
||||||
- 推广游戏与画像均是右侧抽屉,必须同时传 `sourceDb` 与渠道 ID。游戏抽屉调用 `/system/promote/game-distribution`,累计口径,列为游戏、玩家数量、充值金额。画像尚待迁移,不能宣称渠道整个菜单完成。
|
- 推广游戏与画像均是右侧抽屉,必须同时传 `sourceDb` 与渠道 ID。游戏抽屉调用 `/system/promote/game-distribution`,累计口径,列为游戏、玩家数量、充值金额。画像已迁移。
|
||||||
|
|
||||||
## 列表权限和查询
|
## 列表权限和查询
|
||||||
|
|
||||||
@@ -35,7 +34,6 @@
|
|||||||
- `totalPayAmount`:`tab_spend.pay_status=1` 累计 `pay_amount`,不按身份证去重,也没有日期上限。
|
- `totalPayAmount`:`tab_spend.pay_status=1` 累计 `pay_amount`,不按身份证去重,也没有日期上限。
|
||||||
- 关闭包含下级、层级为空或 >=3:只统计当前渠道。二级:本级与 `parent_id=当前ID AND promote_level=3`。一级:本级与同平台 `top_promote_id=当前ID OR parent_id=当前ID`。
|
- 关闭包含下级、层级为空或 >=3:只统计当前渠道。二级:本级与 `parent_id=当前ID AND promote_level=3`。一级:本级与同平台 `top_promote_id=当前ID OR parent_id=当前ID`。
|
||||||
- 注意一级旧加载函数还会加载第二跳子渠道,但最终 `PromotePortraitScopeHelper` 再按 top/parent 条件过滤;不能仅看加载函数就扩大最终统计集合。
|
- 注意一级旧加载函数还会加载第二跳子渠道,但最终 `PromotePortraitScopeHelper` 再按 top/parent 条件过滤;不能仅看加载函数就扩大最终统计集合。
|
||||||
- 当前 sw7 缺业务表,列表本体及各指标要明确表示未接入,不能伪造真实零。
|
|
||||||
|
|
||||||
## 源平台创建事务
|
## 源平台创建事务
|
||||||
|
|
||||||
@@ -53,3 +51,5 @@
|
|||||||
游戏分布依赖 `PromotePortraitServiceImpl.buildGameDistribution`、`SwReportDayQueryServiceImpl` 和日汇总/身份键 Mapper。当前实现只列根渠道已通过的游戏,用同渠道统计树读取日汇总:人数对 metric=1 的 identity_key 去重,dedup_mode 随偏好;金额固定 dedup_mode=0。累计起点从当前人数模式的最早日汇总取得,缺失则使用上海当天,即使模式 0 有更早数据也不改写该起点。截止上海当天,排除未来日期。先将金额保留两位再按金额降序、游戏 ID 升序排列。
|
游戏分布依赖 `PromotePortraitServiceImpl.buildGameDistribution`、`SwReportDayQueryServiceImpl` 和日汇总/身份键 Mapper。当前实现只列根渠道已通过的游戏,用同渠道统计树读取日汇总:人数对 metric=1 的 identity_key 去重,dedup_mode 随偏好;金额固定 dedup_mode=0。累计起点从当前人数模式的最早日汇总取得,缺失则使用上海当天,即使模式 0 有更早数据也不改写该起点。截止上海当天,排除未来日期。先将金额保留两位再按金额降序、游戏 ID 升序排列。
|
||||||
|
|
||||||
完整渠道画像已接入,规则及验证见 `channel-portrait-source-notes.md`。报表汇总任务、预警生成任务和最终实库验收仍属于后续范围。
|
完整渠道画像已接入,规则及验证见 `channel-portrait-source-notes.md`。报表汇总任务、预警生成任务和最终实库验收仍属于后续范围。
|
||||||
|
|
||||||
|
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "既有 sw7 系统库;渠道表尚未接入。完整数据与跨平台范围通过隔离数据库 HTTP/仓储测试。",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"path": "/sw/business/10001/promotes",
|
|
||||||
"code": 0,
|
|
||||||
"available": true,
|
|
||||||
"total": 0,
|
|
||||||
"rows": 0,
|
|
||||||
"elapsed_ms": 14
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/business/10007/promotes",
|
|
||||||
"code": 0,
|
|
||||||
"available": false,
|
|
||||||
"total": 0,
|
|
||||||
"rows": 0,
|
|
||||||
"elapsed_ms": 15
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/business/10007/promotes?sourceDb=hw&platformBusierId=14",
|
|
||||||
"code": 0,
|
|
||||||
"available": false,
|
|
||||||
"total": 0,
|
|
||||||
"rows": 0,
|
|
||||||
"elapsed_ms": 33
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
{
|
|
||||||
"base_url": "http://127.0.0.1:18000",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/business/list?pageNum=1&pageSize=500",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"elapsed_ms": 184,
|
|
||||||
"passed": true,
|
|
||||||
"total": 45,
|
|
||||||
"rows": 45
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/business/list?pageNum=2&pageSize=10",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"elapsed_ms": 43,
|
|
||||||
"passed": true,
|
|
||||||
"total": 45,
|
|
||||||
"rows": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/business/10001",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"elapsed_ms": 19,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/business/list?userName=admin",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"elapsed_ms": 22,
|
|
||||||
"passed": true,
|
|
||||||
"total": 1,
|
|
||||||
"rows": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/business/10007/bindPlatform",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"elapsed_ms": 25,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/user/preference",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"elapsed_ms": 94,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/business/bindPlatform?pageNum=1&pageSize=10",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"elapsed_ms": 22,
|
|
||||||
"passed": true,
|
|
||||||
"total": 58,
|
|
||||||
"rows": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/business/bindPlatform?sourceDb=lx,hw&status=0&pageSize=2",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"elapsed_ms": 23,
|
|
||||||
"passed": true,
|
|
||||||
"total": 37,
|
|
||||||
"rows": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "PUT",
|
|
||||||
"path": "/sw/user/preference",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "偏好参数不能为空",
|
|
||||||
"elapsed_ms": 424,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/business",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "商务账号参数不正确",
|
|
||||||
"elapsed_ms": 471,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "PUT",
|
|
||||||
"path": "/sw/business",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "商务账号参数不正确",
|
|
||||||
"elapsed_ms": 335,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/business/bindPlatform",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "商务账号参数不正确",
|
|
||||||
"elapsed_ms": 521,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "DELETE",
|
|
||||||
"path": "/sw/business/bindPlatform",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "商务账号参数不正确",
|
|
||||||
"elapsed_ms": 272,
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
{
|
|
||||||
"date": "2026-11-03",
|
|
||||||
"timezone": "Asia/Shanghai",
|
|
||||||
"base_url": "http://127.0.0.1:18000",
|
|
||||||
"database_scope": "沿用已配置的 sw7 系统迁移测试库;未复制或迁移旧正式数据库",
|
|
||||||
"authentication": "通过本地服务验证码及账号密码正常登录后,携带 JWT 请求;凭据和令牌不记录在报告中",
|
|
||||||
"observed_failure": "原 8000 实例日志:Error 1146,sw7.tab_promote 不存在导致商务列表返回商务数据访问失败",
|
|
||||||
"fix": "基础商务资料和已通过绑定数继续返回;有绑定但渠道表未接入时 promoteCount 为 null;完整业务库仍按原口径计算真实渠道数",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"path": "/health",
|
|
||||||
"http": 200,
|
|
||||||
"elapsed_ms": 64
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/business/list?pageNum=1&pageSize=10",
|
|
||||||
"http": 200,
|
|
||||||
"elapsed_ms": 36,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"total": 45,
|
|
||||||
"rows": 10,
|
|
||||||
"unavailable_promote_counts": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/business/list?pageNum=2&pageSize=10",
|
|
||||||
"http": 200,
|
|
||||||
"elapsed_ms": 46,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"total": 45,
|
|
||||||
"rows": 10,
|
|
||||||
"unavailable_promote_counts": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/business/list?userName=admin&pageNum=1&pageSize=10",
|
|
||||||
"http": 200,
|
|
||||||
"elapsed_ms": 32,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"total": 1,
|
|
||||||
"rows": 1,
|
|
||||||
"unavailable_promote_counts": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/business/10001",
|
|
||||||
"http": 200,
|
|
||||||
"elapsed_ms": 33,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/user/preference",
|
|
||||||
"http": 200,
|
|
||||||
"elapsed_ms": 103,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"limits": [
|
|
||||||
"验证实例与 GoLand 的 8000 进程独立;GoLand 必须重新运行才能使用新代码",
|
|
||||||
"本次未验证完整渠道数据,完整旧库复制和最终一致性核对仍待迁移结束后确认"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# 商务功能迁移执行记录
|
|
||||||
|
|
||||||
## 基线与方法
|
|
||||||
|
|
||||||
- 旧后端:`D:/Users/Yvan/projects/yzyz/sw`,`main`,`f9a5e3a90`。
|
|
||||||
- 框架基线:`d31082f4a7816d4c83544b8f54e6f42a3b2132b1`。
|
|
||||||
- 旧前端:`D:/Users/Yvan/projects/yzyz/sw/web`,`dev/dev1.0`,`ba284011c87fd3d5730e0e3dceffb14a7389730c`。
|
|
||||||
- 新实现分支:`codex/sw-business-migration`。
|
|
||||||
|
|
||||||
按基线至当前版本的最终差异提取功能,以当前 Java 实现、实际调用的前端接口和 SQL 为核对依据。提交记录用于解释修复和撤销,不逐提交搬运。旧前端中未接入的 mock、废弃接口不能作为已实现业务迁移。
|
|
||||||
|
|
||||||
沿用现有迁移 SQL 的 `/sw/` 路由与 `view/sw/` 菜单组件路径。认证、响应信封、权限、表格及图表采用 Kra;业务字段、统计口径、状态流转、可见范围保留旧实现。登录页、系统名称和品牌资源来自旧商务。新增代码注释与提交信息使用中文。
|
|
||||||
|
|
||||||
用户补充:允许直接优化旧接口、SQL 和逻辑。优化时记录行为差异并验证业务结果;各批实现必须实际请求 HTTP 接口,不能只以单元测试和构建作为完成依据。
|
|
||||||
|
|
||||||
2026-11-04 用户调整验证要求:后续不需要检查页面交互,只验证接口并继续迁移,包括工作台/仪表盘。后续保留旧页面源码核对、接口实测、数据断言和构建检查,不再执行 Chrome 交互验收。
|
|
||||||
|
|
||||||
用户再次指出页面偏差后,明确按旧前端实际页面核对入口,不能从后端接口推断 UI 功能。详见 `page-parity.md`。商务列表已移除错误增加的编辑入口,恢复“绑定列表”名称及仅三级显示规则,并对齐按审核状态显示行操作。
|
|
||||||
|
|
||||||
用户已有的 `configs/config.yaml` 修改与迁移 SQL 保留。未经最后阶段的数据库确认,不连接、复制或修改用户的旧数据库。
|
|
||||||
|
|
||||||
## 依赖顺序与完成标准
|
|
||||||
|
|
||||||
| 阶段 | 范围 | 状态 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 1 | 商务用户字段、层级与角色、账号资料和审核、偏好、登录及找回密码 | 进行中 |
|
|
||||||
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 |
|
|
||||||
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 |
|
|
||||||
| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 功能已接入;包含渠道画像,完整业务数据和源平台联调仍待最终验收 |
|
|
||||||
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 进行中;画像、明细及导出、各报表/排行榜、工作台和两类汇总构建已接入,预警检测任务与最终实库验收待完成 |
|
|
||||||
| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 |
|
|
||||||
| 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 |
|
|
||||||
|
|
||||||
每项完成需覆盖 DTO → service → biz → data、路由目录、权限、菜单页面和有意义的行为测试。只有真实实现并通过验证的项才标为完成。整项目标在实库验证之前保持未完成。
|
|
||||||
|
|
||||||
## 已核实的业务约束
|
|
||||||
|
|
||||||
- 超管创建一级商务,一级创建二级,二级创建三级;三级不能创建下级。层级与祖先链由服务器生成。
|
|
||||||
- 新增子商务最终版本默认正常,不采用残留注释中的“默认待审核”。
|
|
||||||
- 一/二级商务列表只显示下级树,不显示自己;三级列表为空。报表使用独立渠道可见范围,不能套用 Kra 的部门过滤替代。
|
|
||||||
- `includeSubPromote` 与 `idcardDedup` 无记录或数据库值非零时均为开启,新增账号同时初始化开启。
|
|
||||||
- SDK 商务绑定数量仅计算已通过记录;渠道计数依照当前直属渠道算法,跨平台 ID 不能混用。
|
|
||||||
- 用户审核状态有正常、停用、驳回、待审核四种,须保留 `business_status`,不能压成 `enable` 后丢失含义。
|
|
||||||
- 金额查询固定不做身份证去重;人数查询遵守全局偏好。
|
|
||||||
- SQL 已建立用户、角色、文件等 ID 映射;平台实体及业务主键保留。迁移验收应按映射比较,不能直接比较所有原始 ID。
|
|
||||||
|
|
||||||
## 验证记录
|
|
||||||
|
|
||||||
- 已落地商务用户七个扩展字段的存取及增量补列;登录和既有会话同时检查商务审核状态。
|
|
||||||
- 已落地全局偏好 GET/PUT、当前用户隔离、缺省开启、显式关闭及幂等写入;Kra 顶栏增加统计口径入口。
|
|
||||||
- 已落地商务列表、查询资料、新增子商务、部分编辑和审核状态,以及 Kra 风格 `BusinessList` 页面。层级及角色由服务端按原角色 ID 映射分配;账号、角色、偏好在同一事务创建。列表按平台与商务组合键统计直属一级渠道和已通过绑定。
|
|
||||||
- 商务、SDK 商务和渠道画像已接入;其余报表及任务继续迁移。后端导出等接口另按源码核对,不能据此给旧页面增加不存在的按钮;最终仍需完整数据源验收。
|
|
||||||
- 已接入绑定列表、指定商务已通过绑定详情、提交绑定及解绑四个接口,页面恢复仅三级可见的“绑定列表”页签及只读详情抽屉。绑定流程保持三级限制、平台验密、OA 待审核及驳回重提规则;新增键锁表 `sys_business_binding_locks`,串行处理同一平台商务的并发绑定。
|
|
||||||
- 已恢复旧登录页的图片、文案、布局及记住账号功能,系统名称统一为“遨游龙翔游戏平台”。找回密码四个匿名接口位于 `/sw/auth/forgot-password/`,保留账号/手机号检查、60 秒发码限制、两分钟验证码、十分钟凭证、6–15 位密码及失败五次吊销等规则。
|
|
||||||
- 找回密码采用同库事务保存验证码摘要和一次性凭证,并原子执行改密/消费凭证;新增 `sys_business_password_codes`、`sys_business_password_resets`。更新密码版本使旧 JWT 失效。短信复用 Kra 集成配置,`SW_PASSWORD_RESET_SMS_PROVIDER` 默认 `tencent`,也支持 `aliyun`;模板和通道凭据在对应集成配置中维护。
|
|
||||||
- 旗下渠道接口 `/sw/business/:userId/promotes` 和两处抽屉入口已接入:先校验商务可见范围,再按已通过绑定的 `(source_db, platform_busier_id)` 查询直属一级渠道;按渠道 ID、平台稳定分页。实际 sw7 请求与隔离完整数据 HTTP/仓储测试均通过,浏览器确认绑定入口同时携带平台和平台商务 ID,未通过绑定禁用该入口。
|
|
||||||
- 平台数据源通过 Kra 的 `data.database_list` 复用连接,别名为 `lx` / `hw` / `yq`。旧密码算法支持三种格式,兼容盐通过环境变量 `SW_SDK_AUTH_CODE`、`SW_SDK_MIGRATE_AUTH_CODE` 提供,取值应与旧 `XiguPasswordUtils` 对应常量一致;`SW_SDK_TABLE_PREFIX` 缺省为 `sys_`。真实连接凭据和盐不写进新仓库。完整平台库验密仍需部署配置后联调。
|
|
||||||
- 已生成 `source-inventory.json`:现有 SQL 的 63 个接口契约、279 个变更源文件及额外找回密码控制器。此清单是范围索引,不代表已完成实现。
|
|
||||||
- 已执行 `make generate`、相关 Go 测试、变更前端 ESLint 和生产构建。构建存在原 `arcdash` 依赖的 BigInt 浏览器目标警告。
|
|
||||||
- 第一批提交 `ce4b54a` 后 `go test ./...` 全量通过;第二批账号功能的 biz/data/service/modules/server/cmd 检查及前端生产构建通过。
|
|
||||||
- 用户反馈实际商务列表失败后,定位为 sw7 缺少 `tab_promote`。已解耦基础列表与渠道统计:有绑定且渠道表缺失时返回 `promoteCount: null`,界面显示“未接入”,无绑定账号仍为真实零。新增通过真实 TCP 请求的 HTTP 回归测试,覆盖系统数据库及完整渠道表两种场景。
|
|
||||||
- 已通过正常验证码登录取得测试会话,并在修复后的独立 18000 实例连接同一 sw7 执行 HTTP 请求。列表总数 45,分页、账号筛选、资料和偏好查询全部成功。详见 `http-verification-2026-11-03.json`。8000 为用户 GoLand 旧二进制,需重新运行才加载源码修复。
|
|
||||||
- 绑定批次通过 13 项真实 HTTP 检查:商务 45 条、绑定 58 条、平台与审核筛选、详情、偏好查询和写请求校验,结果见 `http-business-bindings-2026-11-03.json`;`scripts/sw_http_smoke.py` 可复用且不记录令牌。真实库不制造测试商务或发起实际平台绑定。新增、修改、偏好关闭、提交绑定及解绑的正向流程已用独立 SQLite 数据库通过实际 TCP HTTP 测试;平台验密外部边界使用测试替身,密码算法另用固定向量验证。
|
|
||||||
- 登录/找回密码批次 `go test ./...` 全量通过,前端 ESLint 和生产构建通过。Chrome 已检查桌面/移动登录页及三步找回流程(短信接口受控替身);完整找回密码链路已通过匿名 TCP HTTP + SQLite 测试,覆盖错误验证码后重试、原子消费、改密版本更新和凭证重放拒绝。实际运行实例四个匿名接口校验与既有 13 项商务 HTTP 回归全部通过,未发送真实短信或修改真实用户密码。
|
|
||||||
- 预警规则七个接口及 Kra 页面已接入。一级商务管理自己的规则,二/三级读取所属一级的规则,超管可管理全部;启停、软删除、监控指标与原校验一致。分类选项来自历史预警记录,删除规则不会丢失历史分类。编辑不覆盖独立的启停/删除状态,省略的可选指标字段沿用旧值。
|
|
||||||
- 新增预警表缺表建表迁移,保留既有表;阈值列支持原页面 9999% 上限。当前 sw7 启动前后均为 `decimal(7,2)`,没有改动既有规则数据。触发检测任务和记录去重写入尚未迁移,不作为规则页面完成范围。
|
|
||||||
- 规则批次通过 biz/data/service/cmd/server/modules 检查、前端 ESLint 与生产构建;隔离 SQLite + TCP HTTP 测试覆盖新增、编辑、启停、软删除、历史分类和跨商务权限。`alert-rules-http-2026-11-03.json` 记录真实 sw7 查询与无效写请求,`alert-rules-page-2026-11-03.json` 记录四级角色入口与指标表单联动;可复用脚本为 `scripts/sw_alert_rules_smoke.cjs`。
|
|
||||||
- 预警记录八个接口与列表、处理抽屉已接入。列表、详情和写入同时按所属一级商务及本人/下级的已通过绑定隔离,渠道键包含平台,保留停用一级渠道的历史预警。`businessId` 筛选取指定商务渠道范围与查看范围的交集,不误用预警归属一级字段。状态汇总仅随范围、平台和指定渠道变化,不受名称、分类、指标及列表状态筛选影响。
|
|
||||||
- 完整工作流为未处理→处理中→待审核→已处理,或待审核→审核驳回→处理中后重新提交。审核只允许一级商务,超管等级为零时也不能审核。状态条件在带权限条件的行锁事务中检查,状态日志及可选备注共同提交;独立备注不改状态,保留原接口允许已关闭记录追加备注的行为(旧页面只读态仍不展示备注编辑区)。
|
|
||||||
- 备注图片复用 Kra 上传接口,支持选取、拖拽、粘贴、暂存预览和移除;添加备注时才上传。修复旧前后端矛盾:先要求非空备注再上传,部分上传或备注失败重试时复用已上传结果。历史数字附件批量查询新文件表,URL 附件保持原值,异常 JSON 视为空附件;记录/规则/日志大 ID 使用字符串响应,避免浏览器精度丢失。
|
|
||||||
- 补充 `202611030007_business_alert_attachments`,针对原 SQL 遗留的日志数字附件做文件 ID 映射。只转换与 `bak_sw2kra_20261103_sys_alert_operation_log` 备份相符的旧日志,新日志、URL 或已变化引用不改动;缺失旧文件不解释为同号新文件,原引用保留在备份。详见 `alert-attachment-migration.md`。
|
|
||||||
- 预警记录批次全量 `go test ./...`、前端 ESLint 与生产构建通过。隔离 SQLite + 实际 TCP HTTP 覆盖全部处理动作、驳回重提、跨商务/平台拒绝、大 ID、附件读取与缺表场景;仓储测试覆盖日志失败回滚与重复请求。MySQL 行锁的真实并发验证留待最终隔离旧库验收。`alert-records-http-2026-11-03.json` 记录当前 sw7 的真实会话请求;当前预警零条,未创建真实预警或上传文件。原商务 45 条、绑定 58 条的 HTTP 回归继续通过。
|
|
||||||
- `alert-records-page-2026-11-03.json` 记录真实 Chrome 下四个等级、五种状态的入口检查,以及完整抽屉流程、附件延迟上传、状态卡片筛选、缺渠道数据提示。浏览器业务数据、处理写入和上传使用受控替身;正向服务端行为另由前述 TCP HTTP 测试覆盖。
|
|
||||||
- 渠道激活 `/sw/promoteActivePool/bindList` 和 `/sw/promoteActivePool/bindSpendDetail` 及旧同名页面已接入。OA 连接复用 `data.database_list` 中别名 `oa` 的共享客户端;只读 `sys_promote_active_pool_bind`,不创建或修改 OA 表。普通商务按本人已通过绑定的账号集合匹配 OA 认领,不包含下级,超管不限;保持旧实现按账号匹配的规则。
|
|
||||||
- 激活流水从主库 `tab_spend` 按游戏汇总,成功支付、同平台、绑定开始(含)到达标/解绑截止(含);渠道树包含本级、直属子级及 `top_promote_id` 匹配项,不依赖全局下级/身份证偏好。达标时间优先于解绑时间,进行中没有结束上限。使用 EXISTS 避免重复渠道行导致金额翻倍,金额及大 ID 以字符串返回,计算和页面格式化避免浮点精度丢失。
|
|
||||||
- 激活批次通过全量 `go test ./...`、ESLint 和生产构建,以及主库/OA 两个隔离数据库的实际 TCP HTTP 测试。`activation-http-2026-11-03.json` 记录当前真实会话请求(OA 未接入,明确 available=false);`activation-page-2026-11-03.json` 核对旧页面列、五种状态入口、筛选重置、达标截止与大金额。待审核可以打开流水入口但显示原接口“审核通过后可查看”,已驳回不显示入口;明细读取失败不展示伪造零流水。
|
|
||||||
- 已对框架基线至 main 的 Git 差异核实:`SysMessageController` 与 `SysMessageServiceImpl` 未变更,旧导航栏消息入口也被注释,因此不新增商务消息 UI。SW 主库写入待审绑定后由外部 OA 审核,并没有需要照搬的 SW 内部 OA 同步任务;最终联调仍须核对 OA 连接目标及商务用户 ID 映射,不能把隔离替身测试当成外部审核验收。
|
|
||||||
- 渠道列表、可用绑定、新增渠道、推广游戏分布四个接口及对应 Kra 页面已接入。列表读取本人及全部下级的正常绑定,超管默认不限;指定商务用户只取该用户本人的绑定。商务账号关键词同时匹配平台账号和 SW 登录名,保留 Java 文字子串语义,不把 `%` 变成通配符。渠道状态筛选使用锁定状态字段。
|
|
||||||
- 渠道列表游戏数仅统计当前渠道已通过申请的去重游戏;玩家数和成功充值额按原渠道树及全局偏好汇总。身份证去重跨整棵统计树进行,空证件不计入,末尾 X/x 归一。指标按平台/渠道组合批量查询;缺失指标单独返回 null,已有指标继续显示。真实 TCP HTTP 回归发现并修复 `idcard` 列映射错误,增加存储字段与实际返回人数断言。
|
|
||||||
- 游戏分布只列当前渠道已通过的推广游戏,读取 `tab_sw_report_day` / `tab_sw_report_day_key`,人数按当前去重模式去重,金额固定模式 0。累计起点保留旧实现从当前人数口径的最早日汇总取得、缺省当天的规则,截止上海当天;不能与列表原始注册/充值口径混用。金额先保留两位再排序,同金额按游戏 ID。
|
|
||||||
- 新增渠道由 biz 决定一级、待审核 `-3`、默认注册类型和七天结算周期;data 在对应源平台库同事务写渠道、结算记录、商务渠道关系。密码复用旧 cmf 格式和 `SW_SDK_AUTH_CODE`。MySQL 创建使用固定连接持有账号命名锁直到事务提交,商务关系在行锁内追加;命名锁的真实并发验证留待最终隔离平台库联调。没有创建真实平台测试渠道,也没有伪造主库同步副本。
|
|
||||||
- 渠道批次通过全量 Go 检查、前端 ESLint/生产构建、独立主库和源平台 SQLite 的 TCP HTTP 验证。`channel-http-2026-11-03.json` 记录实际 sw7 会话请求,当前主库渠道数据未接入,lx/hw/yq 正常绑定选项分别为 17/20/20 条。`channel-page-2026-11-03.json` 核对一级商务新增入口、平台切换清空绑定、跨平台同号、零游戏数入口、缺失指标提示及新增提交参数。画像入口继续随画像用例迁移,当前不将整个渠道菜单标为完成。
|
|
||||||
- 核对剩余用户接口的基线差异及 Kra 原生能力,记录于 `system-api-audit.md`;保留历史别名未注册、Excel 协议未兼容等差异,不把能力对应表当成接口兼容完成证明。商务页面仍不增加原页面没有的编辑/删除/导入/导出入口。
|
|
||||||
- 修复原生用户启停只更新 enable、商务状态不同步的问题:由 biz 决定状态联动,在 data 行锁事务中一起写入;未启用时保留待审/驳回,个人资料请求不能自行解除管理员限制。当前 45 个真实用户只读检查未发现已有冲突,没有批量更新真实账号;正常启停、审核状态保留、失败回滚和越权通过隔离 HTTP/仓储/领域测试。全量 Go 测试通过,实际服务检查记录为 `user-status-http-2026-11-03.json`。
|
|
||||||
- 新增 `/sw/business/:userId/portrait` 与 `/sw/business/bindPlatform/portrait`。补齐商务列表画像、正常绑定画像和只读 SDK 详情平台入口,共用 Kra 风格抽屉,包含基本资料、五个时间周期、四项指标、可交互趋势、注册/充值 TOP 5 及独立分页的关联预警。SVG 图表使用 Kra 主题变量,无新增图表依赖,支持键盘数据点和负金额。
|
|
||||||
- 普通商务基本资料来自新系统用户,SDK 资料来自对应平台库,父级分别按 SW parent_id 和平台 commerce1/commerce2 解析。SDK 状态 0 仍是待审核,不能当作 SW 正常;关联预警/旗下渠道使用打开时的 SW 用户 ID,平台商务 ID 只作为绑定筛选。保留旧 SDK 画像关联预警按商务及平台的实际范围。
|
|
||||||
- 画像日指标与日趋势读取汇总/身份键表,模式 0 人数带平台前缀、模式 1 跨平台身份证去重,金额固定模式 0。付费率分母为活跃人数,人均充值分母为注册人数;上期活跃为零时付费率对照和百分点差为 null。今日/昨日趋势读原始主账号与成功支付、补齐 24 小时;MySQL 保留原 HOUR(FROM_UNIXTIME) 会话时区语义。注册 TOP 5 在渠道树内 COUNT DISTINCT;人数上限在排序截取前应用。
|
|
||||||
- 画像批次通过领域/存储/双库实际 TCP HTTP 测试、全量 Go 检查和前端 ESLint/构建;验证月末/闰月对比、四舍五入与负零、去重切换、金额稳定、原始小时口径、父级状态、跨商务拒绝及缺表。`portrait-http-2026-11-03.json` 为真实会话检查:无绑定返回零指标及完整趋势,有绑定缺表保留基本资料并明确未知。`portrait-page-2026-11-03.json` 核对三个入口、周期切换、交互图表、两个 ID 范畴和缺数据提示。
|
|
||||||
- 当前没有执行最终旧数据库复制与迁移验收。
|
|
||||||
- 渠道画像 `/sw/promote/portrait` 与列表入口已接入,包含基本资料、累计/当期指标、趋势、四档付费分层、关联预警及推广游戏抽屉。复用画像统计并增加经过校验的单渠道树范围、无对照期分支;渠道第四项保持活跃玩家。分层成员取累计注册身份,金额按历史成功支付及原始付款账号/身份证口径,保持无日期过滤,不能误复用指标周期过滤。
|
|
||||||
- 修复旧渠道页面两处参数错配:近7/30天改发 last7/last30,关联预警改发 targetPromoteId 而不是 businessId。领域、存储和隔离 TCP HTTP 覆盖分层边界、子账号与跨平台、模式切换、累计无环比、原始小时趋势和缺数据;浏览器验证入口、正确参数、四档分层及游戏抽屉。全量 Go、ESLint/构建通过;当前真实会话请求记录于 `channel-portrait-http-2026-11-03.json`,仍未复制实际旧业务库。
|
|
||||||
- 注册/充值明细、两个 XLSX 导出及游戏下拉共五个接口已接入。注册保留 puid=0、同平台玩家全部历史成功充值;充值合计只算成功单,不随列表状态改变。渠道精确匹配、商务文字筛选、官方/未知渠道和跨平台同号均按旧口径处理。
|
|
||||||
- 修复注册游戏关联跨平台、超过200个渠道后放宽筛选的问题,批量补充当前页资料。导出超过10000条提前拒绝,保留数字/文本类型及原字段顺序。页面使用 Kra 表格、筛选、日期控件与下载,保留近30天默认显示和大额行高亮;注册导出权限与接口一致。
|
|
||||||
- 明细批次通过全量 Go、前端 ESLint/构建、隔离 TCP HTTP、工作簿内容校验和浏览器筛选/下载。真实 sw7 请求及空查询工作簿验证记录见 `ledger-http-2026-11-04.json`、`ledger-page-2026-11-04.json`,没有导出或修改真实业务行;详细边界见 `ledger-source-notes.md`。
|
|
||||||
- 渠道/游戏统计两个接口与 Kra 页面已接入,保留游戏筛选、渠道账号/类型筛选、平台、日期、排序、分页及全量汇总。金额固定模式 0,人数按当前身份证偏好;游戏汇总累加各游戏,渠道超管汇总全局去重、普通商务累加可见渠道,不互相替代。渠道模糊匹配扩到可见同树渠道,并保持类型交集;超管全平台范围包含官方和历史孤立渠道。
|
|
||||||
- 统计批次通过全量 Go、隔离数据库真实 TCP HTTP、领域/仓储测试、ESLint/生产构建;真实 sw7 请求和 Chrome 检查见 `analytics-http-2026-11-04.json`、`analytics-page-2026-11-04.json`。分页不改变汇总,缺表明确未接入;非法日期/倒置周期明确报错。没有添加旧统计页面不存在的导出按钮。详细口径与优化见 `analytics-source-notes.md`。
|
|
||||||
- 区服列表与区服选项接口、Kra 区服分析页面已接入。统计按平台+游戏+区服编号分组,人数区服内去重、金额固定模式 0;页尾汇总全部筛选行。游戏必选,选项按当前游戏名,报表按历史游戏名,区服编号保留前导零。
|
|
||||||
- 区服批次通过全量 Go、隔离数据库真实 TCP HTTP、前端 ESLint/构建和真实 sw7 只读接口请求,结果见 `server-analysis-http-2026-11-04.json`。覆盖跨平台同号、全量汇总、零行处理、偏好切换、资料补全及缺表;按用户新要求未执行页面交互检查。最终旧库验收仍未开始。
|
|
||||||
- 工作台四个接口及 Kra 仪表盘业务区域已接入:六项指标、注册/充值 TOP10、未登录 TOP10、最低充值 TOP10 和已有预警的最新五条轮播。保留 Kra 欢迎区域、快捷入口与公告,平台和周期按旧页面设置;请求失败清空旧结果并提示,不能显示其他平台的缓存值。
|
|
||||||
- 工作台保留本月对上月同日(截断月末)、模式 0 跨平台 ID 隔离、渠道树注册去重、金额固定模式 0、活跃渠道按自身注册/正充值计数、最低榜含零一级渠道,以及未登录回退创建时间和 99999 哨兵值。详见 `workbench-source-notes.md`。
|
|
||||||
- 工作台批次全量 Go、隔离 TCP HTTP、领域/仓储测试、前端 ESLint/构建通过;真实 sw7 请求记录见 `workbench-http-2026-11-04.json`。缺业务表时六卡返回 null、榜单明确报未接入。未执行页面交互检查或最终旧库复制。
|
|
||||||
- 独立排行榜渠道、商务、游戏三个接口和 Kra 页面已接入,各有注册/充值/付费率/活跃 TOP20,保留平台、维度独立日期记忆、近 7 天默认周期和等长上期。付费率用百分点差,其余用百分比环比。商务按直属下级展示、合并其整个下级绑定树,再累加各渠道人数;三级不展示商务排行。
|
|
||||||
- 排行批次全量 Go、隔离 TCP HTTP、领域/仓储测试、前端 ESLint/构建、真实 sw7 接口检查通过,见 `ranking-http-2026-11-04.json` 与 `ranking-source-notes.md`。当前 SQL 清单 63 个目标接口中 51 个已注册;其余 12 个均为 `SysUser` 历史系统别名,详见 `system-api-audit.md`,不能将接口注册数量当作全迁移完成证明。汇总和预警任务、最终旧库迁移验收仍待完成。
|
|
||||||
- 已迁移日汇总/区服汇总的今日、昨日、按日期回跑六个任务方法,复用 Kra 原生任务创建、手动触发、执行日志和取消机制。汇总和身份键同事务替换,按报表类型与日期持有数据库行锁;增加 `202611040008_business_report_build_locks`。只注册方法,不自动新建或启用计划。当前真实 sw7 的 7 个导入任务均保持禁用。
|
|
||||||
- 汇总领域/仓储测试和原生任务实际 TCP HTTP 测试通过,覆盖账号/身份证双口径、主/子账号差异、无平台登录歧义、原始账号新增付费交集、自然日格式、幂等回跑及写入失败回滚。全量 Go 通过;真实服务方法和禁用状态验证见 `report-build-http-2026-11-04.json`。未触发实际 sw7 的数据重建;完整源库重跑比较仍留待最终阶段。
|
|
||||||
- 用户反馈仅排行榜图标可显示,定位为原 SQL 直接复制 RuoYi SVG 名称。已修正原 SQL 首次迁移和重复执行分支,模块菜单改用统一的 14 项有效 Kra 图标;新增 `202611040009_business_menu_icons` 修复已迁移库,单独补丁为 `menu-icon-repair.sql`。仅替换旧迁移值,保留其他自定义图标。
|
|
||||||
- 14 个图标都已通过本地已安装组件的 SVG 输出验证;实际 8000 和 18000 菜单接口全部返回正确图标,见 `menu-icons-components-2026-11-04.json`、`menu-icons-live-http-2026-11-04.json`、`menu-icons-http-2026-11-04.json`。这次按用户明确要求修改原迁移 SQL,原有暂存内容继续保留;未执行整个旧库迁移脚本。
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "真实 sw7 会话,只读查询;导出使用确定为空的商务筛选,仅校验表头工作簿,不导出真实业务行。",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/register/list?sourceDb=lx,hw,yq",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/register/list?busierAccount=__kra_empty_export_probe_20261104__",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/register/export",
|
|
||||||
"http": 200,
|
|
||||||
"xlsx": true,
|
|
||||||
"worksheet_rows": 1,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/register/export",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "明细数据尚未接入,无法导出",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/spend/list?sourceDb=lx,hw,yq",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/spend/list?busierAccount=__kra_empty_export_probe_20261104__",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"total": 0,
|
|
||||||
"available": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/spend/export",
|
|
||||||
"http": 200,
|
|
||||||
"xlsx": true,
|
|
||||||
"worksheet_rows": 1,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/sw/spend/export",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "明细数据尚未接入,无法导出",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/register/list?beginRegisterTime=bad&endRegisterTime=2026-11-04",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "明细查询参数不正确",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "真实 Chrome,列表数据受控;导出使用真实服务的空筛选生成 XLSX,不读取业务数据。",
|
|
||||||
"reports": [
|
|
||||||
{
|
|
||||||
"kind": "register",
|
|
||||||
"passed": true,
|
|
||||||
"defaultThirtyDays": true,
|
|
||||||
"gameNameSelection": true,
|
|
||||||
"exactAmountOrSuccessSummary": true,
|
|
||||||
"xlsxDownload": true,
|
|
||||||
"exportKeepsFilters": true,
|
|
||||||
"failedQueryClearsOldData": true,
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "spend",
|
|
||||||
"passed": true,
|
|
||||||
"defaultThirtyDays": true,
|
|
||||||
"gameNameSelection": true,
|
|
||||||
"exactAmountOrSuccessSummary": true,
|
|
||||||
"xlsxDownload": true,
|
|
||||||
"exportKeepsFilters": true,
|
|
||||||
"failedQueryClearsOldData": true,
|
|
||||||
"pageErrors": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -31,6 +31,6 @@
|
|||||||
- 保留原 VO 导出字段顺序,包括没有中文 ExcelProperty 的辅助字段;时间保留 Unix 秒,状态/方式保留原代码。
|
- 保留原 VO 导出字段顺序,包括没有中文 ExcelProperty 的辅助字段;时间保留 Unix 秒,状态/方式保留原代码。
|
||||||
- 数值直接写入数值 XML,不先转 float64;账号、订单号等强制文本,保留前导零并避免当作公式。
|
- 数值直接写入数值 XML,不先转 float64;账号、订单号等强制文本,保留前导零并避免当作公式。
|
||||||
- 页面按旧模板提供筛选、游戏远程下拉、日期快捷项、排序、分页、导出;充值金额≥5000的行高亮。查询失败会清除旧行和旧合计。
|
- 页面按旧模板提供筛选、游戏远程下拉、日期快捷项、排序、分页、导出;充值金额≥5000的行高亮。查询失败会清除旧行和旧合计。
|
||||||
- 游戏下拉的默认/批量 limit 行为沿用旧接口;区服分析的 servers/list 尚待后续实现,不能因 games 可用而标记整个区服页面完成。
|
- 游戏下拉的默认/批量 limit 行为沿用旧接口;区服分析的 servers/list 现已完成迁移。
|
||||||
|
|
||||||
验证:领域/存储测试,实际 TCP HTTP 与 XLSX 内容校验,真实 sw7 的只读查询及确定空查询导出,Chrome 筛选/下载回归。见 `ledger-http-2026-11-04.json`、`ledger-page-2026-11-04.json`。未修改真实业务数据,最终旧库复制和数据比对尚未开始。
|
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "真实 Chrome 渲染;找回密码接口使用受控响应,不发送真实短信",
|
|
||||||
"title": "遨游龙翔游戏平台",
|
|
||||||
"actions": [
|
|
||||||
"check",
|
|
||||||
"send-code",
|
|
||||||
"verify-code",
|
|
||||||
"reset"
|
|
||||||
],
|
|
||||||
"pageErrors": [],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- 已完成迁移的 Kra 数据库可单独执行;仅更新商务菜单的旧图标值。
|
|
||||||
START TRANSACTION;
|
|
||||||
-- RuoYi SVG 名称转换为 Kra 已注册图标;只修复旧值,保留后续自定义选择。
|
|
||||||
UPDATE sys_base_menus SET icon=CASE name
|
|
||||||
WHEN 'sw_BusMgmt' THEN 'Briefcase'
|
|
||||||
WHEN 'sw_BusinessList' THEN 'UserFilled'
|
|
||||||
WHEN 'sw_ChannelList' THEN 'Connection'
|
|
||||||
WHEN 'sw_ChannelActivationList' THEN 'CircleCheck'
|
|
||||||
WHEN 'sw_DataAnalysis' THEN 'DataAnalysis'
|
|
||||||
WHEN 'sw_ChannelDataStatistics' THEN 'Histogram'
|
|
||||||
WHEN 'sw_RechargeDetails' THEN 'Money'
|
|
||||||
WHEN 'sw_RegistrationDetails' THEN 'User'
|
|
||||||
WHEN 'sw_GameAnalytics' THEN 'TrendCharts'
|
|
||||||
WHEN 'sw_ServerAnalysis' THEN 'Monitor'
|
|
||||||
WHEN 'sw_RankingAnalysis' THEN 'Star'
|
|
||||||
WHEN 'sw_AlertCenter' THEN 'Bell'
|
|
||||||
WHEN 'sw_AlertList' THEN 'Warning'
|
|
||||||
WHEN 'sw_AlertRules' THEN 'Setting'
|
|
||||||
ELSE icon END
|
|
||||||
WHERE name IN ('sw_BusMgmt','sw_BusinessList','sw_ChannelList','sw_ChannelActivationList','sw_DataAnalysis','sw_ChannelDataStatistics','sw_RechargeDetails','sw_RegistrationDetails','sw_GameAnalytics','sw_ServerAnalysis','sw_RankingAnalysis','sw_AlertCenter','sw_AlertList','sw_AlertRules')
|
|
||||||
AND (icon IS NULL OR icon IN ('', 'category', 'email', 'chart', 'company', 'international', 'color', 'dict', 'cascader', 'education', 'excel', 'star', 'process-definition', 'documentation', 'example', 'data-analysis', 'user', 'money', 'warn'));
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "本地已安装组件的 SSR SVG 验证,包含首次/重复迁移 SQL 与模块定义一致性检查",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"name": "sw_BusMgmt",
|
|
||||||
"icon": "Briefcase",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_BusinessList",
|
|
||||||
"icon": "UserFilled",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ChannelList",
|
|
||||||
"icon": "Connection",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ChannelActivationList",
|
|
||||||
"icon": "CircleCheck",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_DataAnalysis",
|
|
||||||
"icon": "DataAnalysis",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ChannelDataStatistics",
|
|
||||||
"icon": "Histogram",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_RechargeDetails",
|
|
||||||
"icon": "Money",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_RegistrationDetails",
|
|
||||||
"icon": "User",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_GameAnalytics",
|
|
||||||
"icon": "TrendCharts",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ServerAnalysis",
|
|
||||||
"icon": "Monitor",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_RankingAnalysis",
|
|
||||||
"icon": "Star",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_AlertCenter",
|
|
||||||
"icon": "Bell",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_AlertList",
|
|
||||||
"icon": "Warning",
|
|
||||||
"svgRendered": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_AlertRules",
|
|
||||||
"icon": "Setting",
|
|
||||||
"svgRendered": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
{
|
|
||||||
"baseURL": "http://127.0.0.1:18000",
|
|
||||||
"menuCount": 14,
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"name": "sw_BusMgmt",
|
|
||||||
"icon": "Briefcase",
|
|
||||||
"expected": "Briefcase",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_BusinessList",
|
|
||||||
"icon": "UserFilled",
|
|
||||||
"expected": "UserFilled",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ChannelList",
|
|
||||||
"icon": "Connection",
|
|
||||||
"expected": "Connection",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ChannelActivationList",
|
|
||||||
"icon": "CircleCheck",
|
|
||||||
"expected": "CircleCheck",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_DataAnalysis",
|
|
||||||
"icon": "DataAnalysis",
|
|
||||||
"expected": "DataAnalysis",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ChannelDataStatistics",
|
|
||||||
"icon": "Histogram",
|
|
||||||
"expected": "Histogram",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_RechargeDetails",
|
|
||||||
"icon": "Money",
|
|
||||||
"expected": "Money",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_RegistrationDetails",
|
|
||||||
"icon": "User",
|
|
||||||
"expected": "User",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_GameAnalytics",
|
|
||||||
"icon": "TrendCharts",
|
|
||||||
"expected": "TrendCharts",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ServerAnalysis",
|
|
||||||
"icon": "Monitor",
|
|
||||||
"expected": "Monitor",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_RankingAnalysis",
|
|
||||||
"icon": "Star",
|
|
||||||
"expected": "Star",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_AlertCenter",
|
|
||||||
"icon": "Bell",
|
|
||||||
"expected": "Bell",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_AlertList",
|
|
||||||
"icon": "Warning",
|
|
||||||
"expected": "Warning",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_AlertRules",
|
|
||||||
"icon": "Setting",
|
|
||||||
"expected": "Setting",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
{
|
|
||||||
"baseURL": "http://127.0.0.1:8000",
|
|
||||||
"menuCount": 14,
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"name": "sw_BusMgmt",
|
|
||||||
"icon": "Briefcase",
|
|
||||||
"expected": "Briefcase",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_BusinessList",
|
|
||||||
"icon": "UserFilled",
|
|
||||||
"expected": "UserFilled",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ChannelList",
|
|
||||||
"icon": "Connection",
|
|
||||||
"expected": "Connection",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ChannelActivationList",
|
|
||||||
"icon": "CircleCheck",
|
|
||||||
"expected": "CircleCheck",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_DataAnalysis",
|
|
||||||
"icon": "DataAnalysis",
|
|
||||||
"expected": "DataAnalysis",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ChannelDataStatistics",
|
|
||||||
"icon": "Histogram",
|
|
||||||
"expected": "Histogram",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_RechargeDetails",
|
|
||||||
"icon": "Money",
|
|
||||||
"expected": "Money",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_RegistrationDetails",
|
|
||||||
"icon": "User",
|
|
||||||
"expected": "User",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_GameAnalytics",
|
|
||||||
"icon": "TrendCharts",
|
|
||||||
"expected": "TrendCharts",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_ServerAnalysis",
|
|
||||||
"icon": "Monitor",
|
|
||||||
"expected": "Monitor",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_RankingAnalysis",
|
|
||||||
"icon": "Star",
|
|
||||||
"expected": "Star",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_AlertCenter",
|
|
||||||
"icon": "Bell",
|
|
||||||
"expected": "Bell",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_AlertList",
|
|
||||||
"icon": "Warning",
|
|
||||||
"expected": "Warning",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sw_AlertRules",
|
|
||||||
"icon": "Setting",
|
|
||||||
"expected": "Setting",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# 商务菜单图标修复
|
|
||||||
|
|
||||||
旧 SQL 使用 `COALESCE(n.icon,'')` 原样迁移 RuoYi 图标名。实际菜单接口返回 category/email/chart/international 等,Kra 未注册这些图标;star 恰好对应 Element Plus 的 Star。
|
|
||||||
|
|
||||||
Kra 的 `web/src/core/global.js` 注册全部 `@element-plus/icons-vue` 导出组件,以及仓库本地 SVG。现在明确使用组件导出名,避免依赖旧 SVG 文件或大小写猜测。
|
|
||||||
|
|
||||||
| 菜单 | Kra 图标 |
|
|
||||||
| --- | --- |
|
|
||||||
| 商务管理 / 商务列表 | Briefcase / UserFilled |
|
|
||||||
| 渠道列表 / 渠道激活 | Connection / CircleCheck |
|
|
||||||
| 数据分析 / 渠道数据统计 | DataAnalysis / Histogram |
|
|
||||||
| 充值明细 / 注册明细 | Money / User |
|
|
||||||
| 游戏数据 / 区服分析 | TrendCharts / Monitor |
|
|
||||||
| 排行榜 | Star |
|
|
||||||
| 预警中心 / 预警列表 / 预警规则 | Bell / Warning / Setting |
|
|
||||||
|
|
||||||
首次迁移按旧菜单 path 映射;重复执行分支和 `202611040009_business_menu_icons` 修复已迁移库。仅替换原迁移、早期模块默认图标或空值,其他自定义图标保留。无需完整重跑数据库迁移,独立 SQL 补丁见 `menu-icon-repair.sql`。
|
|
||||||
|
|
||||||
`scripts/sw_menu_icons_verify.cjs` 检查当前安装的真实组件、SVG 输出、模块定义和 SQL 两条分支的一致性。实际 8000/18000 菜单接口均已通过 14 项检查;没有进行浏览器交互检查。刷新页面重新加载菜单后生效。
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# 旧商务页面功能核对
|
|
||||||
|
|
||||||
以旧前端 `dev/dev1.0` 的实际模板、事件处理和接口调用为准,不因为后端存在某个接口就向页面增加入口。页面样式采用 Kra;名称、业务可见条件和交互沿用旧商务。只有完成页面、接口和实际验证后才标为整个菜单完成。
|
|
||||||
|
|
||||||
## 商务列表与绑定列表
|
|
||||||
|
|
||||||
来源:`sw/web/src/views/BusMgmt/BusinessList.vue`,以及该目录下实际引用的抽屉组件。前端基线 `ba284011c87fd3d5730e0e3dceffb14a7389730c`。
|
|
||||||
|
|
||||||
| 旧页面行为 | 新页面状态 |
|
|
||||||
| --- | --- |
|
|
||||||
| 商务列表没有编辑按钮;操作列为画像 | 已对齐,商务画像及对应抽屉已接入 |
|
|
||||||
| 第二个页签名为“绑定列表”,仅 `level=3` 显示 | 已对齐;超管、一级、二级均隐藏且不会加载绑定列表 |
|
|
||||||
| 商务列表筛选:商务名称、商务账号、等级、状态 | 已实现 |
|
|
||||||
| 添加子商务:账号、密码、姓名、手机号、QQ;展示等级/所属商务 | 已实现;已移除原页面没有的邮箱和编辑审核表单 |
|
|
||||||
| SDK商务数量可点击,零数量也能打开详情 | 已对齐 |
|
|
||||||
| SDK商务绑定详情是只读抽屉,上级可以查看;列为平台、ID、游戏商务账号、审核状态、绑定日期 | 已对齐,平台点击可进入游戏商务画像 |
|
|
||||||
| 商务列表列名“旗下渠道数”,点击进入旗下渠道抽屉 | 已对齐,抽屉显示序号、渠道名称、所属平台及分页 |
|
|
||||||
| 注册日期、绑定日期显示年月日 | 已对齐 |
|
|
||||||
| 绑定状态正常:画像、解绑;驳回:查看驳回;待审核:无操作 | 已对齐,画像只出现在正常绑定行 |
|
|
||||||
| 查看驳回打开审核详情,展示申请商务、平台、账号、状态、申请/审核时间、审核人和审核意见 | 已实现 |
|
|
||||||
| 添加绑定、解除绑定确认使用右侧抽屉 | 已对齐 |
|
|
||||||
| 绑定列表点击旗下渠道数时,仅正常状态可查看并携带平台/平台商务筛选 | 已实现并通过浏览器参数核对 |
|
|
||||||
| 全局偏好变化刷新商务/绑定数据 | 已接入 Kra 页面刷新机制 |
|
|
||||||
|
|
||||||
“仅三级可见”是旧页面页签规则。旧 Java 绑定查询接口仍支持超管全量、一级/二级查询下级,SDK 详情接口允许上级查看下级;这些接口的数据范围不能因页签隐藏而一并误删。
|
|
||||||
|
|
||||||
目前 sw7 是系统数据演练库,缺少 `tab_promote`。有绑定但渠道表未接入的数量明确返回空值并展示“未接入”,不当作零;完整数据环境继续计算真实数量。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- `business-page-parity-2026-11-03.json`:真实 Chrome 渲染、受控身份/业务数据,检查四个等级的页签和按钮,以及三种审核状态的行操作;无页面运行时错误。
|
|
||||||
- `http-business-bindings-2026-11-03.json`:真实运行实例及已有 sw7 测试数据的 HTTP 验证。
|
|
||||||
- 旗下渠道已通过仓储与实际 HTTP 回归,覆盖已通过绑定、直属一级、跨平台同号和稳定分页;sw7 缺渠道数据时明确返回 `available=false`。
|
|
||||||
- 商务、SDK 商务和渠道画像已通过接口及浏览器验证;真实业务库数值仍待最终验收。
|
|
||||||
|
|
||||||
## 商务与游戏商务画像
|
|
||||||
|
|
||||||
来源:旧 `BusinessProfileDrawer.vue` 及 `BusinessList.vue` 的实际入口、`handleProfileOpenChannels` 参数传递。
|
|
||||||
|
|
||||||
| 旧页面行为 | 新页面状态 |
|
|
||||||
| --- | --- |
|
|
||||||
| 商务列表画像、正常绑定行画像、只读 SDK 详情平台入口 | 三个入口已接入,并分别验证一级与三级身份 |
|
|
||||||
| 基本资料、上级商务、注册时间、状态、一级渠道数、本月新增 | 已实现;SDK 父级来自 commerce1/commerce2,SDK 状态 0 展示待审核 |
|
|
||||||
| 今日、昨日、近7天、近30天、本月 | 已实现,周期和对照区间由后端统一计算 |
|
|
||||||
| 注册玩家、充值金额、付费率、人均充值及环比 | 已实现,付费率使用百分点差并保留无对照期时的 null |
|
|
||||||
| 注册、充值趋势及 TOP 5 | 已实现;使用 Kra 主题样式,图表支持鼠标与键盘查看数据点 |
|
|
||||||
| 关联预警独立分页,不随指标周期改变查询范围 | 已实现,按 SW businessId 与平台过滤 |
|
|
||||||
| 画像“旗下渠道”继续打开直属渠道抽屉 | 已对照父组件处理函数;传入 SW 用户 ID,SDK 场景附带平台和平台商务 ID |
|
|
||||||
| 缺业务数据与没有业务数据 | 分别显示未接入与真实零,避免未知指标显示为零 |
|
|
||||||
|
|
||||||
验证:`portrait-http-2026-11-03.json`、`portrait-page-2026-11-03.json` 及隔离双库 TCP HTTP 测试。完整平台资料与真实业务数值仍待最终实库联调。
|
|
||||||
|
|
||||||
## 其他页面
|
|
||||||
|
|
||||||
渠道统计、游戏数据、区服分析和排行榜,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。
|
|
||||||
|
|
||||||
## 注册与充值明细
|
|
||||||
|
|
||||||
来源:旧 `RegistrationDetails.vue`、`RechargeDetails.vue`,查询及导出边界见 `ledger-source-notes.md`。
|
|
||||||
|
|
||||||
| 旧页面行为 | 新页面状态 |
|
|
||||||
| --- | --- |
|
|
||||||
| 平台、商务账号、玩家ID、精确渠道账号、游戏名称与日期筛选 | 已实现;游戏选项按平台查询并按名称选择 |
|
|
||||||
| 页面默认含今日近30天,后台不传日期默认截至昨日7天 | 分别保留;日期快捷为今日/昨日/本周/本月 |
|
|
||||||
| 注册列没有独立 ID 与角色名称,包含累计充值 | 已对齐;累计充值按同平台玩家全部历史成功支付 |
|
|
||||||
| 充值包含订单、区服、角色、方式、状态及成功支付合计 | 已实现;合计不跟随失败/处理中状态筛选改变口径 |
|
|
||||||
| 时间、金额服务端排序 | 已实现;不提供后台不支持的充值 ID 排序 |
|
|
||||||
| 充值≥5000的行高亮 | 已保留 |
|
|
||||||
| 导出当前筛选数据,最多10000条 | 已实现 XLSX;注册按钮改用正确的注册导出权限 |
|
|
||||||
| 缺业务数据或查询失败 | 明确提示,清除旧行与旧合计,不导出假空业务数据 |
|
|
||||||
|
|
||||||
验证:`ledger-http-2026-11-04.json`、`ledger-page-2026-11-04.json`,实际 TCP HTTP、工作簿内容检查及全量 Go/前端构建。真实服务导出验证仅使用确定无匹配数据的筛选。
|
|
||||||
|
|
||||||
## 预警规则
|
|
||||||
|
|
||||||
来源:`sw/web/src/views/AlertCenter/AlertRules.vue` 及其规则编辑组件;规则触发提示同时核对最终 `AlertRecordCreationService`,旧页面“每天一次”提示已失效。
|
|
||||||
|
|
||||||
| 旧页面行为 | 新页面状态 |
|
|
||||||
| --- | --- |
|
|
||||||
| 按监控指标、规则名称筛选 | 已实现 |
|
|
||||||
| 列为启用状态、规则名称、规则类型、描述、创建人、作用范围、操作 | 已实现;类型显示预警颜色,范围固定一级渠道 |
|
|
||||||
| 一级商务和超管可新建、编辑、删除、启停;二/三级只读 | 已实现,后端同时校验归属,真实 Chrome 检查四级角色 |
|
|
||||||
| 新建/编辑右侧抽屉,类型为自由文本 | 已实现 |
|
|
||||||
| 注册/充值展示周期、方向、阈值;登录展示未登录天数 | 已实现,浏览器验证切换字段及提交参数 |
|
|
||||||
| 删除需要确认 | 已实现,后端软删除 |
|
|
||||||
| 预警触发去重说明 | 按最终后端描述平台、渠道、类型的未关闭记录去重;生成任务仍待迁移 |
|
|
||||||
|
|
||||||
验证:`alert-rules-http-2026-11-03.json`、`alert-rules-page-2026-11-03.json`,以及隔离数据的真实 TCP HTTP 生命周期测试。预警检测与生成任务仍待迁移。
|
|
||||||
|
|
||||||
## 预警列表与处理抽屉
|
|
||||||
|
|
||||||
来源:`sw/web/src/views/AlertCenter/AlertList.vue`、`components/AlertHandleDialog.vue`,并核对最终 `SysAlertRecordServiceImpl` 和 `SysAlertOperationLogServiceImpl`。
|
|
||||||
|
|
||||||
| 旧页面行为 | 新页面状态 |
|
|
||||||
| --- | --- |
|
|
||||||
| 顶部五种状态卡片,点击筛选、再次点击取消 | 已实现;状态卡片统计保留原后端口径 |
|
|
||||||
| 平台默认全选;规则名称、类型、状态、归属商务筛选 | 已实现;平台取消全选时清空列表,不误查询全部 |
|
|
||||||
| 所属平台、触发时间、规则名称、类型、预警对象、归属商务、内容、状态、操作 | 已实现,触发时间按月日时分显示 |
|
|
||||||
| 行操作按五种状态显示处理、继续处理、审核/查看、重新处理、查看 | 已实现;待审核仅一级显示审核,等级为零的超管显示查看 |
|
|
||||||
| 右侧处理/详情抽屉展示摘要与不可删除的操作记录 | 已实现,日志按时间、ID 倒序,动作编码映射中文 |
|
|
||||||
| 只读态隐藏备注与写入按钮 | 已实现;详情最新状态与服务端 canAudit 同时决定可操作性 |
|
|
||||||
| 开始、提交、驳回、重新处理需要确认;审核通过直接提交 | 已对齐 |
|
|
||||||
| 操作成功刷新详情与列表,抽屉保持打开 | 已实现,状态变化后按钮和只读态同步更新 |
|
|
||||||
| 图片选取、拖拽、粘贴、预览、移除,添加备注时才上传 | 已实现;失败重试复用已上传结果,暂存图片阻止直接流转 |
|
|
||||||
| 旧页面允许只选图片,后端要求非空备注 | 按原后端契约提前校验备注,避免先上传后失败 |
|
|
||||||
| 缺渠道表无法判断可见范围 | 返回 available=false,卡片显示未接入,不把未知范围当成真实零 |
|
|
||||||
|
|
||||||
验证:`alert-records-http-2026-11-03.json`、`alert-records-page-2026-11-03.json`;后端完整状态流转通过独立 SQLite 和真实 TCP HTTP 测试,浏览器核对四个等级和五种状态的入口。预警检测/生成任务仍未接入,不因列表和工作流完成而宣称整个预警链路完成。
|
|
||||||
|
|
||||||
## 登录页
|
|
||||||
|
|
||||||
已从旧 `login.vue` 复用登录模板和样式,复制原品牌图片,适配 Kra 登录接口;保持记住账号而不存密码、验证码、三步找回密码和原文案。系统标题、侧栏标识及页脚已同步旧商务名称。`login-page-verification-2026-11-03.json` 记录 Chrome 的流程验证,短信接口为测试替身,不向真实手机发码。
|
|
||||||
|
|
||||||
## 渠道激活
|
|
||||||
|
|
||||||
来源:`sw/web/src/views/BusMgmt/ChannelActivationList.vue`,最终 `PromoteActivePoolBindServiceImpl` 与独立 OA 查询适配器。
|
|
||||||
|
|
||||||
| 旧页面行为 | 新页面状态 |
|
|
||||||
| --- | --- |
|
|
||||||
| 平台默认全选,渠道账号与绑定商务筛选,查询/重置 | 已实现;没有额外增加旧模板不存在的状态筛选 |
|
|
||||||
| 列为平台、渠道名称、绑定商务、绑定日期、解绑时间、累计流水金额、状态、操作 | 已实现,日期按年月日显示,金额保留两位小数 |
|
|
||||||
| 已驳回无流水明细按钮,其余状态均显示 | 已实现;待审核按原接口提示“审核通过后可查看” |
|
|
||||||
| 流水使用居中弹窗,显示平台、渠道、绑定期间、游戏汇总和总额 | 已实现 |
|
|
||||||
| 普通商务仅看本人认领,超管看全部 | 已实现,和预警的下级树范围分开;在隔离双库 HTTP 测试中验证 |
|
|
||||||
| 达标/解绑后的流水截止时间 | 保持最终后端计算,并修正旧弹窗达标状态仍显示“起”的文案,显示实际截止日期 |
|
|
||||||
| 明细加载失败 | 明确展示原因,不保留预填金额冒充成功读取的明细 |
|
|
||||||
| OA 未接入 | 明确显示未接入;不回退到主库同名表 |
|
|
||||||
|
|
||||||
验证:`activation-http-2026-11-03.json`、`activation-page-2026-11-03.json`,以及独立主库/OA 两个 SQLite 数据库的真实 TCP HTTP 测试。未修改实际 OA 数据。
|
|
||||||
|
|
||||||
## 渠道列表、新增与推广游戏
|
|
||||||
|
|
||||||
来源及统计规则详见 `channel-source-notes.md`。页面保持旧 `ChannelList.vue`、`AddChannelDrawer.vue`、`ChannelGameDrawer.vue` 的实际功能。
|
|
||||||
|
|
||||||
| 旧页面行为 | 新页面状态 |
|
|
||||||
| --- | --- |
|
|
||||||
| 平台、商务账号、渠道关键词、层级、类型、锁定状态筛选 | 已实现;修正旧“账号/负责人姓名”占位文案,按实际后端只查询账号 |
|
|
||||||
| 列表类型个人/公会;新增类型个人/企业 | 按两处实际模板分别保留 |
|
|
||||||
| 渠道信息、归属商务、上级/负责人、推广游戏、玩家、累计充值、锁定状态 | 已实现;缺失指标明确显示未接入 |
|
|
||||||
| 没有编辑入口,行操作为画像 | 已对齐,渠道画像入口及完整抽屉已接入 |
|
|
||||||
| 添加渠道按按钮权限,不额外限制三级 | 已实现并用一级浏览器身份核对,上级可选择下级正常绑定 |
|
|
||||||
| 切换平台清空游戏商务并重新加载、按平台商务 ID 去重 | 已实现,防止旧请求覆盖新平台选项 |
|
|
||||||
| 添加成功后关闭抽屉、刷新第一页,允许同步延迟 | 已实现,保持源平台创建及同步延迟提示 |
|
|
||||||
| 游戏数量零也可打开抽屉;必须携带平台和渠道 ID | 已实现并用跨平台同号与零游戏行验证 |
|
|
||||||
| 游戏分布显示累计玩家数、充值金额 | 已实现,实际取日汇总/身份键数据,独立于列表原始累计指标 |
|
|
||||||
| 全局偏好变化刷新页面 | 复用 Kra 全局 reload,后端每次请求读取当前偏好 |
|
|
||||||
|
|
||||||
验证:`channel-http-2026-11-03.json`、`channel-page-2026-11-03.json`,以及实际 TCP HTTP + 隔离主库/平台库。画像新增验证见下表,完整真实数据仍待最终验收。
|
|
||||||
|
|
||||||
## 渠道画像
|
|
||||||
|
|
||||||
来源及计算规则见 `channel-portrait-source-notes.md`。
|
|
||||||
|
|
||||||
| 旧页面行为 | 新页面状态 |
|
|
||||||
| --- | --- |
|
|
||||||
| 基本资料、上级渠道、注册与最后登录时间、推广游戏数 | 已实现,一级不显示上级,其余读取同平台当前上级账号 |
|
|
||||||
| 今日、昨日、近7天、近30天、累计 | 已实现;修正旧 7d/30d 参数错配,发送 last7/last30 |
|
|
||||||
| 注册玩家、充值金额、付费率、活跃玩家 | 已实现;累计模式无环比值 |
|
|
||||||
| 两个趋势图与四档付费分层 | 已实现,复用 Kra 主题图表和进度条,分层按累计身份与历史成功支付 |
|
|
||||||
| 推广游戏数点击打开游戏分布 | 已实现,携带同一平台和渠道 ID,始终累计口径 |
|
|
||||||
| 关联预警分页 | 已实现;修复旧页面误传 businessId,改为平台+targetPromoteId |
|
|
||||||
| 缺业务表 | 保留能读取的基本资料,明确提示未接入,不生成假零分层 |
|
|
||||||
|
|
||||||
验证:`channel-portrait-http-2026-11-03.json`、`channel-portrait-page-2026-11-03.json`、隔离数据真实 TCP HTTP 和完整 Go/前端构建检查。
|
|
||||||
|
|
||||||
## 渠道统计与游戏分析
|
|
||||||
|
|
||||||
来源及统计细节见 `analytics-source-notes.md`。
|
|
||||||
|
|
||||||
| 旧页面行为 | 新页面状态 |
|
|
||||||
| --- | --- |
|
|
||||||
| 平台默认全选,游戏多选、日期近 30 天,搜索/查询 | 已实现,查询和筛选回到第一页 |
|
|
||||||
| 渠道另有账号模糊、个人/公会类型筛选 | 已实现,文案明确按账号匹配 |
|
|
||||||
| 渠道顶部渠道数、注册、金额、付费率四卡片 | 已实现,使用全量后端汇总,与当前分页无关 |
|
|
||||||
| 渠道表平台、渠道、归属商务、注册/活跃/充值人数、新付费、金额和付费率 | 已实现,沿用 Kra 表格样式 |
|
|
||||||
| 游戏表平台、游戏、注册/活跃/付费人数、新付费、金额、付费率、ARPU、ARPPU | 已实现,游戏 ID 无无效排序箭头 |
|
|
||||||
| 游戏表尾汇总来自全部筛选结果 | 已实现,跨页保留全量 summary;渠道页使用顶部卡片 |
|
|
||||||
| 列排序和分页 | 已实现,服务器排序,同值增加平台/ID 保证稳定 |
|
|
||||||
| 旧页面无导出接口/按钮 | 不增加导出入口 |
|
|
||||||
| 缺报表数据 | 明确显示未接入,清空过期汇总,指标不伪造零 |
|
|
||||||
|
|
||||||
验证:`analytics-http-2026-11-04.json`、`analytics-page-2026-11-04.json`;Chrome 使用受控业务数据,完整计算和权限使用隔离数据库的真实 TCP HTTP 验证。
|
|
||||||
|
|
||||||
## 区服分析
|
|
||||||
|
|
||||||
按旧 `ServerAnalysis.vue` 源码迁移平台多选、游戏多选、区服单选、近 30 天默认日期、首个游戏自动查询、列排序/分页及全量汇总;沿用 Kra 控件,没有新增导出入口。切换平台清空游戏/区服,切换游戏清空区服,过期异步请求不覆盖新选项。统计字段保持所属平台、区服编号/名称、开服日期、新增注册、活跃玩家、充值金额、付费玩家和 ARPU。
|
|
||||||
|
|
||||||
接口通过真实 sw7 请求及隔离数据库 TCP HTTP 验证,前端 ESLint/生产构建通过。按用户 2026-11-04 新要求,此后只检查接口,不再检查页面交互。详见 `server-analysis-source-notes.md`。
|
|
||||||
|
|
||||||
## 工作台 / Kra 仪表盘
|
|
||||||
|
|
||||||
直接核对旧 `views/index.vue`,在原 Kra 仪表盘中加入六项商务指标、平台多选、注册/充值两组横向榜图、未登录/最低充值两组榜表,以及最新五条待处理预警的四秒轮播和查看全部入口。注册/最高充值默认今日,最低充值默认近 30 天;每个榜单单独切换周期。保留 Kra 欢迎、快捷入口和公告,使用 Kra 卡片、颜色与 Element Plus 控件。没有移植旧页面的自定义图表样式。
|
|
||||||
|
|
||||||
四个工作台接口和已有预警接口负责真实业务数据;接口缺表或错误明确提示,不将空选择、未接入和真实零混淆。按用户要求仅做接口实测、数据断言和构建,未做页面交互检查。
|
|
||||||
|
|
||||||
## 独立排行榜
|
|
||||||
|
|
||||||
按旧 `RankingAnalysis.vue` 保留渠道/商务/游戏三个页签、平台多选(空选表示全部)、各维度独立记忆的日期、默认近 7 天,以及注册/充值/付费率/活跃四组 TOP20。不足 20 条显示占位符,旧请求不覆盖新维度。界面采用 Kra 表格和配色;付费率的环比文案明确为百分点,修复旧页面将百分点也显示成百分比的歧义。没有额外增加分页、游戏选择或导出入口。
|
|
||||||
|
|
||||||
接口实测、隔离数据 HTTP、全量 Go 和前端构建通过。按用户要求未检查页面交互。详见 `ranking-source-notes.md`。
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "运行实例匿名接口校验;不发送短信、不修改真实用户密码",
|
|
||||||
"base_url": "http://127.0.0.1:18000",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"action": "check",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "账号不存在或未注册",
|
|
||||||
"elapsed_ms": 3097,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "send-code",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "商务账号参数不正确",
|
|
||||||
"elapsed_ms": 314,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "verify-code",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "商务账号参数不正确",
|
|
||||||
"elapsed_ms": 85,
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "reset",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "商务账号参数不正确",
|
|
||||||
"elapsed_ms": 224,
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "真实 sw7 登录会话,只读请求;有绑定但业务表未接入时不伪造零,无绑定时验证真实空画像。完整指标和 SDK 基本信息通过隔离双库 TCP HTTP 测试。",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"path": "/sw/business/list?pageNum=1&pageSize=500",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/business/10001/portrait?sourceDbList=lx,hw,yq&period=today",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"available": true,
|
|
||||||
"promote_count": 0,
|
|
||||||
"register_points": 24,
|
|
||||||
"pay_points": 24
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/business/10001/portrait?sourceDbList=lx&period=thisMonth",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"available": true,
|
|
||||||
"promote_count": 0,
|
|
||||||
"register_points": 3,
|
|
||||||
"pay_points": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/business/10004/portrait?sourceDbList=lx,hw,yq&period=last7",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"available": false,
|
|
||||||
"promote_count": null,
|
|
||||||
"register_points": 0,
|
|
||||||
"pay_points": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/business/10001/portrait",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "请选择所属平台后查看数据",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/business/bindPlatform/portrait?sourceDb=lx&platformBusierId=0",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "商务账号参数不正确",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"method": "真实 Chrome 渲染;业务接口使用受控数据。验证三个入口、时间切换、交互图表、SDK 状态、SW 用户 ID 与平台 ID 分离、缺数据提示。",
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"level": 1,
|
|
||||||
"passed": true,
|
|
||||||
"entry": "商务列表与只读 SDK 详情",
|
|
||||||
"pageErrors": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": 3,
|
|
||||||
"passed": true,
|
|
||||||
"entry": "三级绑定列表正常行",
|
|
||||||
"pageErrors": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 商务与 SDK 商务画像源码核对
|
# 商务与 SDK 商务画像源码核对
|
||||||
|
|
||||||
状态:两种画像接口、三个页面入口及报表读取已实现并通过隔离 HTTP、真实会话和浏览器验证。完整平台数据源与最终旧库数据验收仍未完成。账号状态联动已在 `fc17b38` 完成。
|
两种画像接口、三个页面入口及报表读取已实现。接口、平台数据源及账号状态验证统一见 `validation.md`。
|
||||||
|
|
||||||
## 范围与入口
|
## 范围与入口
|
||||||
|
|
||||||
@@ -40,4 +40,4 @@
|
|||||||
|
|
||||||
沿用 DTO → service → biz → data,金额用精确计算,页面采用 Kra 图表/表格风格。不要在 stats 缺表时伪造零;没有任何绑定是已知空范围,可返回真实零和零趋势,有绑定但渠道/汇总未接入时保留可读取基本信息并明确未知指标。
|
沿用 DTO → service → biz → data,金额用精确计算,页面采用 Kra 图表/表格风格。不要在 stats 缺表时伪造零;没有任何绑定是已知空范围,可返回真实零和零趋势,有绑定但渠道/汇总未接入时保留可读取基本信息并明确未知指标。
|
||||||
|
|
||||||
当前真实测试库仍缺完整业务表、平台/OA 别名尚未配齐。正向指标和权限测试继续用隔离数据与真实 TCP HTTP,真实 18000 实例用现有会话验证查询和缺数据场景。最终仍需用户确认旧库后复制验收。
|
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向排行和权限通过隔离 TCP HTTP 测试。",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"path": "/sw/rank/channel?sourceDb=lx,hw,yq",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/rank/channel?beginTime=bad&endTime=2026-11-04",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计周期格式错误,应为 yyyy-MM-dd",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/rank/channel?beginTime=2026-11-05&endTime=2026-11-04",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计开始日期不能晚于结束日期",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/rank/business?sourceDb=lx,hw,yq",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/rank/business?beginTime=bad&endTime=2026-11-04",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计周期格式错误,应为 yyyy-MM-dd",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/rank/business?beginTime=2026-11-05&endTime=2026-11-04",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计开始日期不能晚于结束日期",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/rank/game?sourceDb=lx,hw,yq",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/rank/game?beginTime=bad&endTime=2026-11-04",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计周期格式错误,应为 yyyy-MM-dd",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/rank/game?beginTime=2026-11-05&endTime=2026-11-04",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计开始日期不能晚于结束日期",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
- 商务榜超管列出一/二/三级商务,普通商务看直属下级,三级空榜。每个被展示商务的数据来自本人及全部下级的正常绑定渠道。一次关系查询展开目标、下级、绑定、渠道,代替旧每个目标单独查询下级的 N+1;查询条件保持各目标自己的祖先路径范围。
|
- 商务榜超管列出一/二/三级商务,普通商务看直属下级,三级空榜。每个被展示商务的数据来自本人及全部下级的正常绑定渠道。一次关系查询展开目标、下级、绑定、渠道,代替旧每个目标单独查询下级的 N+1;查询条件保持各目标自己的祖先路径范围。
|
||||||
- 商务人数累加各渠道人数,不做跨渠道全局去重;金额累加同一组渠道。逐渠道保留 Java int 上限,商务总数采用 int64 避免旧 int 溢出。祖先与下级可以分别上榜;不是互斥分区。
|
- 商务人数累加各渠道人数,不做跨渠道全局去重;金额累加同一组渠道。逐渠道保留 Java int 上限,商务总数采用 int64 避免旧 int 溢出。祖先与下级可以分别上榜;不是互斥分区。
|
||||||
- 注册、充值、活跃只展示正值;付费率只要求活跃大于 0,保留 0% 行。金额固定模式 0,人数取当前偏好。
|
- 注册、充值、活跃只展示正值;付费率只要求活跃大于 0,保留 0% 行。金额固定模式 0,人数取当前偏好。
|
||||||
- 注册/充值/活跃环比需要上期正值,否则为 null;付费率只要求上期存在行,上期活跃为零按 0% 比较,结果为百分点差。金额展示先保留两位,金额环比用未舍入汇总,与旧服务一致。
|
- 2026-11-04 按用户要求,四榜环比统一为 `(本期值 - 上期值) / 上期值 × 100%`。上期没有有效数据或为零时为 null;本期付费率降到零且上期为正时为 -100%。付费率原来的百分点差已改为相对增幅,这是迁移后明确采用的新口径。金额展示先保留两位,金额环比用未舍入汇总,与旧服务一致。
|
||||||
- 环比保留两位,绝对值小于 0.01 为持平。数值排序用精确有理数,同值追加平台/ID 稳定排序,截取后连续编号。
|
- 环比保留两位,绝对值小于 0.01 为持平。数值排序用精确有理数,同值追加平台/ID 稳定排序,截取后连续编号。
|
||||||
- 源码还核对了空平台含义:独立排行榜空选表示全部,区别于工作台必须选平台。非法/倒置日期明确拒绝。缺报表时 available=false 和空数组,不伪造零指标或空查询成功。
|
- 源码还核对了空平台含义:独立排行榜空选表示全部,区别于工作台必须选平台。非法/倒置日期明确拒绝。缺报表时 available=false 和空数组,不伪造零指标或空查询成功。
|
||||||
- 当前菜单名称、父级和路径已通过实际菜单接口核实,仍为 `sw_DataAnalysis` 下 `sw_RankingAnalysis` / `RankingAnalysis`。
|
- 当前菜单名称、父级和路径已通过实际菜单接口核实,仍为 `sw_DataAnalysis` 下 `sw_RankingAnalysis` / `RankingAnalysis`。
|
||||||
|
|
||||||
已通过全量 Go、领域/仓储测试、独立 SQLite 的真实 TCP HTTP、ESLint/生产构建、真实 sw7 只读请求。没有页面交互检查,也没有进行最终旧库复制或同源比较。
|
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "真实 sw7 会话,只读任务接口;正向创建和触发通过隔离数据库原生任务 HTTP 测试。",
|
|
||||||
"registeredMethods": [
|
|
||||||
"swReportDayBackfillTask",
|
|
||||||
"swReportDayTodayTask",
|
|
||||||
"swReportDayYesterdayTask",
|
|
||||||
"swReportServerDayBackfillTask",
|
|
||||||
"swReportServerDayTodayTask",
|
|
||||||
"swReportServerDayYesterdayTask"
|
|
||||||
],
|
|
||||||
"importedTasks": 7,
|
|
||||||
"enabledImportedTasks": 0,
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -24,4 +24,4 @@
|
|||||||
|
|
||||||
领域与存储测试覆盖身份/账号规则、重复构建、自然日筛选和身份明细写失败后的原子回滚。真实 TCP HTTP 使用隔离数据库和 Kra 原生任务创建、手动触发、执行日志,核对两种报表实际落库且任务仍为禁用。
|
领域与存储测试覆盖身份/账号规则、重复构建、自然日筛选和身份明细写失败后的原子回滚。真实 TCP HTTP 使用隔离数据库和 Kra 原生任务创建、手动触发、执行日志,核对两种报表实际落库且任务仍为禁用。
|
||||||
|
|
||||||
全量 Go 测试通过。当前真实 sw7 仅查询注册方法和计划状态,7 个导入任务均禁用。没有在真实 sw7 重建报表,也没有开始最终旧数据库复制与同源结果比较;MySQL 并发锁效果须在最终隔离旧库联调中验证。
|
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "真实 sw7 会话只读检查,当前缺少业务报表和游戏区服表;未创建实际业务数据。",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"endpoint": "list",
|
|
||||||
"query": {},
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "请选择游戏",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"endpoint": "servers",
|
|
||||||
"query": {},
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "请选择游戏",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"endpoint": "list",
|
|
||||||
"query": {
|
|
||||||
"gameName": "隔离核验不存在游戏",
|
|
||||||
"sourceDb": "lx,hw,yq"
|
|
||||||
},
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"available": false,
|
|
||||||
"total": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"endpoint": "list",
|
|
||||||
"query": {
|
|
||||||
"gameName": "x",
|
|
||||||
"beginTime": "bad",
|
|
||||||
"endTime": "2026-11-04"
|
|
||||||
},
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计周期格式错误,应为 yyyy-MM-dd",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"endpoint": "list",
|
|
||||||
"query": {
|
|
||||||
"gameName": "x",
|
|
||||||
"beginTime": "2026-11-05",
|
|
||||||
"endTime": "2026-11-04"
|
|
||||||
},
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "统计开始日期不能晚于结束日期",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"endpoint": "servers",
|
|
||||||
"query": {
|
|
||||||
"gameName": "隔离核验不存在游戏",
|
|
||||||
"sourceDb": "lx"
|
|
||||||
},
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "游戏或区服数据尚未接入",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -13,4 +13,4 @@
|
|||||||
- 缺任一必要业务表时明确未接入,summary 为 null;不能混用不存在区服和缺表错误。未知开服时间保持 null。
|
- 缺任一必要业务表时明确未接入,summary 为 null;不能混用不存在区服和缺表错误。未知开服时间保持 null。
|
||||||
- 后端默认上海当天,前端默认近 30 天;非法日期/倒置周期明确拒绝。页长沿用最大 500。
|
- 后端默认上海当天,前端默认近 30 天;非法日期/倒置周期明确拒绝。页长沿用最大 500。
|
||||||
|
|
||||||
已通过领域/仓储测试、独立数据库真实 TCP HTTP、全量 Go、ESLint/生产构建与真实 sw7 接口只读请求。用户已明确不需要页面交互检查;没有执行旧数据库复制或最终同源比对。
|
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,558 @@
|
|||||||
|
{
|
||||||
|
"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
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 商务新增与改动
|
## 商务新增与改动
|
||||||
|
|
||||||
`SysUserController` 的 Git 差异主要是商务列表语义、添加子商务,以及直属渠道、平台绑定和两种商务画像。用户列表查询新增商务树、姓名/等级过滤、直属渠道与正常绑定数量;添加子商务新增层级、归属与偏好初始化。当前列表、子商务、偏好和平台绑定已迁移;商务/平台商务画像仍未完成。
|
`SysUserController` 的 Git 差异主要是商务列表语义、添加子商务,以及直属渠道、平台绑定和两种商务画像。用户列表查询新增商务树、姓名/等级过滤、直属渠道与正常绑定数量;添加子商务新增层级、归属与偏好初始化。当前列表、子商务、偏好、平台绑定及两种商务画像均已迁移;2026-11-04 已完成 sw8/sw9 同源接口核对。
|
||||||
|
|
||||||
原用户控制器中的以下方法在基线前已经存在,旧 `BusMgmt/BusinessList.vue` 没有为它们增加实际操作入口。新商务页面不据此增加编辑、删除、导入、导出、角色分配等按钮。
|
原用户控制器中的以下方法在基线前已经存在,旧 `BusMgmt/BusinessList.vue` 没有为它们增加实际操作入口。新商务页面不据此增加编辑、删除、导入、导出、角色分配等按钮。
|
||||||
|
|
||||||
@@ -39,4 +39,12 @@
|
|||||||
|
|
||||||
隔离 TCP HTTP 测试覆盖原生启停后商务接口返回状态、待审/驳回资料编辑、自行启用被拒绝和不存在用户;仓储测试覆盖失败回滚与越权。现有鉴权测试继续检查停用/待审/驳回会拒绝密码登录和已有会话。
|
隔离 TCP HTTP 测试覆盖原生启停后商务接口返回状态、待审/驳回资料编辑、自行启用被拒绝和不存在用户;仓储测试覆盖失败回滚与越权。现有鉴权测试继续检查停用/待审/驳回会拒绝密码登录和已有会话。
|
||||||
|
|
||||||
当前 sw7 的 45 个用户已做只读一致性检查,未发现 enable 与 business_status 冲突,因此没有批量改写真实账号。
|
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` 权限,新库保持同一授权限制。新增渠道的正向实测使用管理员,未擅自扩大角色权限。
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "真实 sw7 登录会话,只读检查;未启停任何真实账号。正向启停和审核状态保留由隔离 TCP HTTP 测试覆盖。",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"path": "/user/getUserList",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "获取成功",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"path": "/sw/business/list?pageNum=1&pageSize=500",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "PUT",
|
|
||||||
"path": "/user/setUserInfo",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "ID值不能为空",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"native_total": 45,
|
|
||||||
"business_total": 45,
|
|
||||||
"checked_users": 45,
|
|
||||||
"inconsistent_states": 0,
|
|
||||||
"response_mismatches": 0,
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 商务迁移验收与性能记录
|
||||||
|
|
||||||
|
核验日期: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 分钟 | 停用 |
|
||||||
|
|
||||||
|
新库未开启自动执行;框架自带 ClearDB、CleanStaleUploads 另行保留。旧工程的演示、分片、MapReduce 示例没有实际商务计划,不属于漏迁。方法超时后仍未退出时禁止重复执行,同分区报表和预警写入有事务锁及去重测试。
|
||||||
|
|
||||||
|
## 已完成的性能优化
|
||||||
|
|
||||||
|
同一 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;排行榜与仪表盘四榜并排、付费率用百分比、头部两项偏好直接勾选;三级商务保留商务列表/绑定列表切换。完整页面检查脚本仍保留,运行截图和带业务数据的响应仅存仓库外。
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
{
|
|
||||||
"scope": "真实 sw7 会话;当前缺业务表,只读验证输入与未接入提示。正向六卡、榜单与权限通过隔离 TCP HTTP 测试。",
|
|
||||||
"checks": [
|
|
||||||
{
|
|
||||||
"path": "/sw/workbench/summary?",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "请选择所属平台后查看数据",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/workbench/summary?sourceDbList=lx,hw,yq",
|
|
||||||
"http": 200,
|
|
||||||
"code": 0,
|
|
||||||
"msg": "成功",
|
|
||||||
"passed": true,
|
|
||||||
"available": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/workbench/registerTop?",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "请选择所属平台后查看数据",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/workbench/registerTop?sourceDbList=lx,hw,yq",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "工作台所需业务数据尚未接入",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/workbench/registerTop?sourceDbList=lx&period=invalid",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "时间维度无效,应为 today/yesterday/last7/last30",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/workbench/payRank?",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "请选择所属平台后查看数据",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/workbench/payRank?sourceDbList=lx,hw,yq",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "工作台所需业务数据尚未接入",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/workbench/payRank?sourceDbList=lx&period=invalid",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "时间维度无效,应为 today/yesterday/last7/last30",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/workbench/payRank?sourceDbList=lx,hw,yq&order=asc&period=last30",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "工作台所需业务数据尚未接入",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/workbench/unloginTop?",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "请选择所属平台后查看数据",
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "/sw/workbench/unloginTop?sourceDbList=lx,hw,yq",
|
|
||||||
"http": 200,
|
|
||||||
"code": 7,
|
|
||||||
"msg": "工作台所需业务数据尚未接入",
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"passed": true
|
|
||||||
}
|
|
||||||
@@ -22,5 +22,5 @@
|
|||||||
- 仪表盘直接调用四个接口,并复用已有预警列表查询 status=1/pageSize=5,实现原最新五条轮播。Kra 快捷入口和公告保留。
|
- 仪表盘直接调用四个接口,并复用已有预警列表查询 status=1/pageSize=5,实现原最新五条轮播。Kra 快捷入口和公告保留。
|
||||||
- 聚合、资料查询批量执行,未对每个渠道发起独立 SQL。注册榜不依赖金额表,未登录榜不依赖报表,缺少必要表返回明确不可用。
|
- 聚合、资料查询批量执行,未对每个渠道发起独立 SQL。注册榜不依赖金额表,未登录榜不依赖报表,缺少必要表返回明确不可用。
|
||||||
- 六卡扩展 available=false 并返回 null 值;榜单保留数组成功协议,缺表返回业务错误,避免把未知榜单当作真实空榜。
|
- 六卡扩展 available=false 并返回 null 值;榜单保留数组成功协议,缺表返回业务错误,避免把未知榜单当作真实空榜。
|
||||||
- 全量 Go、隔离数据库真实 TCP HTTP、前端 ESLint/构建通过;测试覆盖月末对照、零分母、大金额、TOP10、身份去重、下级偏好、负金额/零渠道、未登录回退和跨商务拒绝。实际 sw7 只读结果见 `workbench-http-2026-11-04.json`。
|
|
||||||
- 按用户要求不检查页面交互。当前没有复制旧业务库,最终同源验收尚未开始。
|
当前验证状态统一见 [验收与性能记录](validation.md),不再维护阶段性运行报告。
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
# `` 到 `pkg` 复用性审查
|
|
||||||
|
|
||||||
审查原则:公共包只提供跨模块稳定的机制、协议或无状态纯函数;不能依赖
|
|
||||||
`app/*/internal`,也不承载 system 的业务表、用例、provider 客户端或运行时
|
|
||||||
配置。
|
|
||||||
|
|
||||||
## 已抽取到 `pkg`
|
|
||||||
|
|
||||||
| 能力 | 公共位置 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| HTTP JSON 响应契约 | `pkg/httpx` | `Response`、`PageResult`、状态码和 Gin 响应助手;`server/httpx/response.go` 仅保留 system 适配。 |
|
|
||||||
| protobuf JSON 局部合并 | `pkg/protoutil` | 与业务无关的字段归一化和局部反序列化;初始化直接使用公共包。 |
|
|
||||||
| 支付 provider/mode 标识 | `pkg/paymentkit` | provider 常量、支持列表、金额/签名/JSON 等跨模块协议;`biz/payment` 与 `biz/integration` 直接复用。 |
|
|
||||||
| 支付回调 ACK | `pkg/paymentkit` | 回调应答、失败包装和默认 provider 应答;具体渠道 SDK 留在 `internal/integration/payment`。 |
|
|
||||||
| WebSocket 通用收发 | `pkg/websocket` | Melody 的连接、事件、点对点发送、广播和会话查询封装;`internal/integration` 管理配置与生命周期。 |
|
|
||||||
| 消息队列 | `pkg/mq` | Broker 无关的发布、订阅、JSON 和 QoS 接口;EMQX/Paho 与 RabbitMQ/AMQP 客户端由 `internal/integration` 管理。 |
|
|
||||||
| 模块、任务和迁移协议 | `pkg/module`、`pkg/task`、`pkg/database/migration` | 供不同业务模块注册贡献,不带 system 业务语义。 |
|
|
||||||
|
|
||||||
## system 内部保留边界
|
|
||||||
|
|
||||||
- `app`:运行时组合根,汇总依赖注入后的路由和任务贡献。
|
|
||||||
- `modules`:静态模块 catalog,汇总各模块迁移、菜单、API 和默认任务。
|
|
||||||
- `modules/system`:system 模块的 Definition,声明系统表迁移。
|
|
||||||
- `modules/integration`:integration 配置迁移和管理面贡献。
|
|
||||||
- `modules/task`:定时任务迁移和默认任务贡献。
|
|
||||||
- `modules/payment`:payment 模块的 Definition,声明支付迁移和支付管理面。
|
|
||||||
- `biz/system`:用户、权限、菜单、审计、媒体和系统配置等系统领域模型与用例。
|
|
||||||
- `biz/payment`:支付订单、支付流程、支付接口和支付日志。
|
|
||||||
- `biz/integration`:支付/消息队列/WebSocket 集成配置定义与校验。
|
|
||||||
- `biz/task`:定时任务模型、任务用例和任务注册协议。
|
|
||||||
- `conf`:system 配置 proto、运行时快照和生成代码。
|
|
||||||
- `data`:共享数据库生命周期与配置 watcher;PO/仓储按 `system`、`integration`、`task`、`payment` 子包隔离;后台 JWT claims 与签发/解析位于 `data/system/token.go`。
|
|
||||||
- `initialize`:首次安装、配置迁移、种子编排和运行时重载。
|
|
||||||
- `integration`:Redis、邮件、存储、支付、WebSocket、EMQX 和 RabbitMQ 的 provider 生命周期。
|
|
||||||
- `routecatalog`:HTTP 公开性、操作审计、请求体策略和 API 分组/说明的统一目录。
|
|
||||||
- `service`:HTTP DTO(`service/dto`)、DTO 与 DO 转换和应用服务。
|
|
||||||
- `server`:Gin 生命周期;handler、middleware、router、HTTP 适配按子包维护。
|
|
||||||
- `worker`:任务调度、执行器、SSE 订阅及其并发状态。
|
|
||||||
|
|
||||||
这些代码都带有 system 的 API、配置、数据表或生命周期语义,不应为了减少
|
|
||||||
文件数量搬到 `pkg`。
|
|
||||||
|
|
||||||
## 目录分类约定
|
|
||||||
|
|
||||||
```text
|
|
||||||
internal/
|
|
||||||
app/ # 应用组合根
|
|
||||||
modules/ # 静态 catalog、业务模块定义及其模块级贡献
|
|
||||||
biz/
|
|
||||||
system/ # 系统领域
|
|
||||||
payment/ # 支付领域
|
|
||||||
integration/# 集成配置领域
|
|
||||||
task/ # 定时任务领域
|
|
||||||
config/ # Viper 配置、快照和热更新
|
|
||||||
global/ # 进程级共享资源入口
|
|
||||||
data/
|
|
||||||
system/ # 系统表与系统仓储,含后台 JWT token.go
|
|
||||||
integration/# 集成配置表与仓储
|
|
||||||
task/ # 定时任务表与仓储
|
|
||||||
payment/ # 支付表与仓储
|
|
||||||
initialize/ # 首次安装和配置编排
|
|
||||||
integration/ # 外部 I/O provider
|
|
||||||
server/ # Gin 生命周期,内部按 handler/middleware/router/httpx 分类
|
|
||||||
service/ # 应用服务;DTO 集中在 dto 子包
|
|
||||||
worker/ # 任务运行时
|
|
||||||
```
|
|
||||||
|
|
||||||
技术角色使用子目录表达,子目录内部使用资源名,例如 `handler/payment.go`、
|
|
||||||
`router/payment.go`、`dto/payment.go`。只有少量代码且没有独立边界时不建立
|
|
||||||
新包;同一角色文件较多时也不应全部堆在父目录。
|
|
||||||
|
|
||||||
## 暂不抽取的候选
|
|
||||||
|
|
||||||
1. token cookie 名称、SameSite 和反向代理策略:目前是 system 认证策略。
|
|
||||||
2. operation-audit 脱敏和请求采集:包含 system context key 与审计字段。
|
|
||||||
3. biz 查询选项和错误:当前绑定 system 的领域接口及 API reason。
|
|
||||||
|
|
||||||
只有当其他模块出现相同、稳定且不带 system 语义的契约时,才新增公共包;不要
|
|
||||||
直接把单个 system 类型搬到 `pkg`。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
```text
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
当前全仓测试通过,且未发现对已删除旧路径的 Go import。
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# `internal` 目录结构优化结论
|
|
||||||
|
|
||||||
参考 Go Kratos 的分层方式,顶层保留 `app`、`modules`、`biz`、`config`、`global`、
|
|
||||||
`data`、`initialize`、`integration`、`security`、`server`、`service`、`worker` 等
|
|
||||||
稳定职责。目录不是越少越好:同一技术角色文件较多时,应在所属层下分组,避免
|
|
||||||
一个目录堆积几十个文件。
|
|
||||||
|
|
||||||
## 当前结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
internal/
|
|
||||||
app/ # 应用组合根
|
|
||||||
modules/ # 静态 catalog 和模块定义
|
|
||||||
modules/payment/ # payment 模块定义
|
|
||||||
biz/ # DO、usecase、repo interface
|
|
||||||
config/ # Viper 配置、快照和热更新
|
|
||||||
global/ # 进程级共享资源
|
|
||||||
data/ # PO、repo、数据库和迁移
|
|
||||||
initialize/ # 首次安装和配置编排
|
|
||||||
integration/ # 外部 I/O provider
|
|
||||||
security/ # JWT 和安全实现
|
|
||||||
server/
|
|
||||||
handler/ # Gin handler,按资源命名
|
|
||||||
middleware/ # 认证、审计、限流、恢复等中间件
|
|
||||||
router/ # 各资源路由及 Routes 聚合
|
|
||||||
httpx/ # system HTTP 响应和 cookie 适配
|
|
||||||
service/
|
|
||||||
dto/ # HTTP 请求、响应和查询 DTO,按模块命名
|
|
||||||
worker/ # 任务运行时
|
|
||||||
```
|
|
||||||
|
|
||||||
## 本次调整
|
|
||||||
|
|
||||||
- DTO 从 `service` 根目录归档到 `service/dto`。
|
|
||||||
- handler、middleware、router、HTTP helper 分别归档到 `server` 子包。
|
|
||||||
- 子目录内文件直接使用资源名,例如 `handler/payment.go`、
|
|
||||||
`router/payment.go`、`dto/payment.go`,不保留重复角色前缀。
|
|
||||||
- Wire 直接装配 `handler.ProviderSet` 和 `router.ProviderSet`,server 根目录只
|
|
||||||
负责 Gin/Swagger 生命周期。
|
|
||||||
- `security/adminauth/token.go` 合并为 `security/token.go`;单文件子包没有
|
|
||||||
独立边界时不继续拆分。
|
|
||||||
- 删除只转发 `pkg/protoutil` 的 `utils/configutil`。
|
|
||||||
|
|
||||||
## `internal/app` 为什么只保留应用组合
|
|
||||||
|
|
||||||
`modules/catalog.go` 是静态模块注册点,负责按依赖顺序汇总各模块
|
|
||||||
`Definition()`;`app/runtime.go` 只负责任务注册和依赖注入后的路由组合。
|
|
||||||
这样模块定义不再和应用组合逻辑混在一起,也不能误并入 `biz`、`service` 或
|
|
||||||
`data`。
|
|
||||||
|
|
||||||
Catalog 只能自动汇总静态模块贡献;新增模块若提供运行时路由或依赖型任务,仍需
|
|
||||||
在 cmd/Wire 中显式注册。
|
|
||||||
|
|
||||||
## 其他目录审查
|
|
||||||
|
|
||||||
- `data` 与 `integration` 已按仓储或 provider 分类,边界和生命周期明确。
|
|
||||||
- `biz`、`service` 根目录采用一资源一文件;进一步拆成资源子包会改变 Go 包
|
|
||||||
边界并容易引入循环依赖,本次不做纯视觉拆分。
|
|
||||||
- `security` 当前只有一个文件,但安全实现是明确的依赖边界,后续认证机制也会
|
|
||||||
在此扩展,因此保留顶层包。
|
|
||||||
|
|
||||||
新增目录应至少满足独立依赖方向、状态生命周期或稳定技术角色之一。不要回到
|
|
||||||
一文件一目录,也不要为了减少目录数量把大量不同角色重新铺平。
|
|
||||||
+1
-1
@@ -11,4 +11,4 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ProviderSet is biz providers.
|
// ProviderSet is biz providers.
|
||||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase)
|
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
|
||||||
|
|||||||
@@ -32,9 +32,13 @@ func (a *Activation) EndAt() *time.Time {
|
|||||||
return a.UnbindAt
|
return a.UnbindAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ActivationAccount struct {
|
||||||
|
SourceDB, Account string
|
||||||
|
}
|
||||||
|
|
||||||
type ActivationScope struct {
|
type ActivationScope struct {
|
||||||
All bool
|
All bool
|
||||||
Accounts []string
|
Accounts []ActivationAccount
|
||||||
}
|
}
|
||||||
type ActivationFilter struct {
|
type ActivationFilter struct {
|
||||||
PlatformNames, Statuses []string
|
PlatformNames, Statuses []string
|
||||||
@@ -59,7 +63,7 @@ type ActivationSpendDetail struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ActivationRepo interface {
|
type ActivationRepo interface {
|
||||||
OwnAccounts(context.Context, uint) ([]string, error)
|
OwnAccounts(context.Context, uint) ([]ActivationAccount, error)
|
||||||
List(context.Context, ActivationScope, ActivationFilter, ...ListOption) (*ActivationPage, error)
|
List(context.Context, ActivationScope, ActivationFilter, ...ListOption) (*ActivationPage, error)
|
||||||
Find(context.Context, uint64) (*Activation, error)
|
Find(context.Context, uint64) (*Activation, error)
|
||||||
Spend(context.Context, ActivationSpendQuery) ([]*ActivationSpendItem, error)
|
Spend(context.Context, ActivationSpendQuery) ([]*ActivationSpendItem, error)
|
||||||
@@ -87,11 +91,12 @@ func (uc *ActivationUsecase) scope(ctx context.Context) (ActivationScope, error)
|
|||||||
return ActivationScope{}, err
|
return ActivationScope{}, err
|
||||||
}
|
}
|
||||||
scope := ActivationScope{}
|
scope := ActivationScope{}
|
||||||
seen := map[string]bool{}
|
seen := map[ActivationAccount]bool{}
|
||||||
for _, account := range accounts {
|
for _, account := range accounts {
|
||||||
account = strings.TrimSpace(account)
|
account.Account = strings.TrimSpace(account.Account)
|
||||||
key := strings.ToLower(account)
|
account.SourceDB, err = NormalizePlatform(account.SourceDB)
|
||||||
if key != "" && !seen[key] {
|
key := ActivationAccount{SourceDB: account.SourceDB, Account: strings.ToLower(account.Account)}
|
||||||
|
if err == nil && key.Account != "" && !seen[key] {
|
||||||
scope.Accounts = append(scope.Accounts, account)
|
scope.Accounts = append(scope.Accounts, account)
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
}
|
}
|
||||||
@@ -105,10 +110,10 @@ func (uc *ActivationUsecase) List(ctx context.Context, filter ActivationFilter,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !scope.All && filter.BusierAccount != "" {
|
if !scope.All && filter.BusierAccount != "" {
|
||||||
matched := []string{}
|
matched := []ActivationAccount{}
|
||||||
keyword := strings.ToLower(strings.TrimSpace(filter.BusierAccount))
|
keyword := strings.ToLower(strings.TrimSpace(filter.BusierAccount))
|
||||||
for _, account := range scope.Accounts {
|
for _, account := range scope.Accounts {
|
||||||
if strings.Contains(strings.ToLower(account), keyword) {
|
if strings.Contains(strings.ToLower(account.Account), keyword) {
|
||||||
matched = append(matched, account)
|
matched = append(matched, account)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,7 +144,7 @@ func (uc *ActivationUsecase) SpendDetail(ctx context.Context, id uint64) (*Activ
|
|||||||
if !scope.All {
|
if !scope.All {
|
||||||
found := false
|
found := false
|
||||||
for _, account := range scope.Accounts {
|
for _, account := range scope.Accounts {
|
||||||
if strings.EqualFold(account, strings.TrimSpace(activation.BusierAccount)) {
|
if strings.EqualFold(account.SourceDB, strings.TrimSpace(activation.PlatformName)) && strings.EqualFold(account.Account, strings.TrimSpace(activation.BusierAccount)) {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
type activationRepoFake struct {
|
type activationRepoFake struct {
|
||||||
ActivationRepo
|
ActivationRepo
|
||||||
accounts []string
|
accounts []ActivationAccount
|
||||||
requested uint
|
requested uint
|
||||||
scope ActivationScope
|
scope ActivationScope
|
||||||
filter ActivationFilter
|
filter ActivationFilter
|
||||||
@@ -18,7 +18,7 @@ type activationRepoFake struct {
|
|||||||
listCalls int
|
listCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *activationRepoFake) OwnAccounts(_ context.Context, id uint) ([]string, error) {
|
func (r *activationRepoFake) OwnAccounts(_ context.Context, id uint) ([]ActivationAccount, error) {
|
||||||
r.requested = id
|
r.requested = id
|
||||||
return r.accounts, nil
|
return r.accounts, nil
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ func (r *activationRepoFake) Spend(_ context.Context, q ActivationSpendQuery) ([
|
|||||||
}
|
}
|
||||||
func activationFixture() (*ActivationUsecase, *activationRepoFake) {
|
func activationFixture() (*ActivationUsecase, *activationRepoFake) {
|
||||||
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1, Level: 0}, 7: {ID: 7, Level: 1}, 8: {ID: 8, Level: 2, IDPath: "0,7"}}}
|
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1, Level: 0}, 7: {ID: 7, Level: 1}, 8: {ID: 8, Level: 2, IDPath: "0,7"}}}
|
||||||
r := &activationRepoFake{accounts: []string{" Own ", "own", "Other", ""}}
|
r := &activationRepoFake{accounts: []ActivationAccount{{"lx", " Own "}, {"LX", "own"}, {"hw", "Other"}, {"lx", ""}, {"unknown", "Own"}}}
|
||||||
return NewActivationUsecase(r, NewAccountUsecase(a)), r
|
return NewActivationUsecase(r, NewAccountUsecase(a)), r
|
||||||
}
|
}
|
||||||
func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) {
|
func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) {
|
||||||
@@ -43,7 +43,7 @@ func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) {
|
|||||||
if _, err := uc.List(accountContext(8, 902), ActivationFilter{BusierAccount: "oWn", Statuses: []string{"已达标"}}); err != nil {
|
if _, err := uc.List(accountContext(8, 902), ActivationFilter{BusierAccount: "oWn", Statuses: []string{"已达标"}}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if r.requested != 8 || r.scope.All || len(r.scope.Accounts) != 1 || r.scope.Accounts[0] != "Own" || r.filter.BusierAccount != "" || r.filter.Statuses[0] != "reached" {
|
if r.requested != 8 || r.scope.All || len(r.scope.Accounts) != 1 || r.scope.Accounts[0] != (ActivationAccount{"lx", "Own"}) || r.filter.BusierAccount != "" || r.filter.Statuses[0] != "reached" {
|
||||||
t.Fatalf("本人账号或中文状态筛选错误: %+v", r)
|
t.Fatalf("本人账号或中文状态筛选错误: %+v", r)
|
||||||
}
|
}
|
||||||
page, err := uc.List(accountContext(7, 901), ActivationFilter{BusierAccount: "下级账号"})
|
page, err := uc.List(accountContext(7, 901), ActivationFilter{BusierAccount: "下级账号"})
|
||||||
@@ -69,6 +69,11 @@ func TestActivationSpendScopeCutoffAndExactMoney(t *testing.T) {
|
|||||||
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != nil || *r.query.End != unbind.Unix() {
|
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != nil || *r.query.End != unbind.Unix() {
|
||||||
t.Fatal("解绑截止错误")
|
t.Fatal("解绑截止错误")
|
||||||
}
|
}
|
||||||
|
r.record.PlatformName = "hw"
|
||||||
|
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != ErrActivationScope {
|
||||||
|
t.Fatal("同名商务不能读取另一平台的激活流水")
|
||||||
|
}
|
||||||
|
r.record.PlatformName = "lx"
|
||||||
r.record.BusierAccount = "not-owned"
|
r.record.BusierAccount = "not-owned"
|
||||||
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != ErrActivationScope {
|
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != ErrActivationScope {
|
||||||
t.Fatal("非本人绑定可读取流水")
|
t.Fatal("非本人绑定可读取流水")
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ type AlertRecordRepo interface {
|
|||||||
|
|
||||||
type AlertAction string
|
type AlertAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertUnprocessed = 1
|
||||||
|
AlertProcessing = 2
|
||||||
|
AlertPendingAudit = 3
|
||||||
|
AlertAuditRejected = 4
|
||||||
|
AlertProcessed = 5
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AlertStart AlertAction = "start"
|
AlertStart AlertAction = "start"
|
||||||
AlertSubmit AlertAction = "submit"
|
AlertSubmit AlertAction = "submit"
|
||||||
@@ -148,15 +156,15 @@ func alertTransition(status int, action AlertAction) (int, error) {
|
|||||||
var message string
|
var message string
|
||||||
switch action {
|
switch action {
|
||||||
case AlertStart:
|
case AlertStart:
|
||||||
from, to, message = 1, 2, "仅未处理状态的预警可开始处理"
|
from, to, message = AlertUnprocessed, AlertProcessing, "仅未处理状态的预警可开始处理"
|
||||||
case AlertSubmit:
|
case AlertSubmit:
|
||||||
from, to, message = 2, 3, "仅处理中状态的预警可提交审核"
|
from, to, message = AlertProcessing, AlertPendingAudit, "仅处理中状态的预警可提交审核"
|
||||||
case AlertApprove:
|
case AlertApprove:
|
||||||
from, to, message = 3, 5, "仅待审核状态的预警可审核通过"
|
from, to, message = AlertPendingAudit, AlertProcessed, "仅待审核状态的预警可审核通过"
|
||||||
case AlertReject:
|
case AlertReject:
|
||||||
from, to, message = 3, 4, "仅待审核状态的预警可审核驳回"
|
from, to, message = AlertPendingAudit, AlertAuditRejected, "仅待审核状态的预警可审核驳回"
|
||||||
case AlertReprocess:
|
case AlertReprocess:
|
||||||
from, to, message = 4, 2, "仅审核驳回状态的预警可重新处理"
|
from, to, message = AlertAuditRejected, AlertProcessing, "仅审核驳回状态的预警可重新处理"
|
||||||
default:
|
default:
|
||||||
return status, ErrAlertRecordInput
|
return status, ErrAlertRecordInput
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AlertTriggerChannel struct {
|
||||||
|
ChannelKey
|
||||||
|
Account string
|
||||||
|
BusierID uint64
|
||||||
|
LastLoginUnix, CreatedUnix int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertTriggerInput struct {
|
||||||
|
Rules []*AlertRule
|
||||||
|
Accounts []*Account
|
||||||
|
Bindings []*Binding
|
||||||
|
Channels []*AlertTriggerChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertComparison struct {
|
||||||
|
FirstUnix int64
|
||||||
|
Current, Previous string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertTriggerRepo interface {
|
||||||
|
Input(context.Context) (*AlertTriggerInput, error)
|
||||||
|
Comparison(context.Context, int, []ChannelKey, time.Time, int) (map[ChannelKey]AlertComparison, error)
|
||||||
|
// 仓储在渠道/分类键锁及事务内读取未关闭记录,由领域决定是否刷新。
|
||||||
|
StoreHit(context.Context, *AlertRecord, []int, func(*AlertRecord) bool) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertTriggerResult struct{ Rules, Written, Skipped int }
|
||||||
|
|
||||||
|
type AlertTriggerUsecase struct{ repo AlertTriggerRepo }
|
||||||
|
|
||||||
|
func NewAlertTriggerUsecase(repo AlertTriggerRepo) *AlertTriggerUsecase {
|
||||||
|
return &AlertTriggerUsecase{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *AlertTriggerUsecase) Run(ctx context.Context) error {
|
||||||
|
_, err := uc.Detect(ctx, time.Now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTriggerRule(rule *AlertRule) bool {
|
||||||
|
if rule == nil || rule.CreatorID == 0 || strings.TrimSpace(rule.Type) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rule.Metric == 3 {
|
||||||
|
return rule.UnloginDays != nil && *rule.UnloginDays > 0
|
||||||
|
}
|
||||||
|
if rule.Metric != 1 && rule.Metric != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rule.StatPeriodDays == nil || *rule.StatPeriodDays <= 0 || rule.MomDirection == nil || (*rule.MomDirection != 1 && *rule.MomDirection != 2) || rule.MomThreshold == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
threshold, ok := new(big.Rat).SetString(*rule.MomThreshold)
|
||||||
|
// 历史规则检测允许零阈值,保持与旧任务一致;新增规则另有边界校验。
|
||||||
|
return ok && threshold.Sign() >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerOwners(input *AlertTriggerInput) (map[ChannelKey]*Account, map[uint]*Account) {
|
||||||
|
accounts := map[uint]*Account{}
|
||||||
|
for _, account := range input.Accounts {
|
||||||
|
if account != nil {
|
||||||
|
accounts[account.ID] = account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bindings := map[PlatformKey]uint{}
|
||||||
|
conflicts := map[PlatformKey]bool{}
|
||||||
|
for _, binding := range input.Bindings {
|
||||||
|
if binding == nil || binding.Status != "0" || binding.UserID == 0 || binding.BusierID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source, err := NormalizePlatform(binding.SourceDB)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := PlatformKey{SourceDB: source, BusierID: binding.BusierID}
|
||||||
|
if existing, ok := bindings[key]; ok && existing != binding.UserID {
|
||||||
|
conflicts[key] = true
|
||||||
|
}
|
||||||
|
bindings[key] = binding.UserID
|
||||||
|
}
|
||||||
|
owners := map[ChannelKey]*Account{}
|
||||||
|
for _, channel := range input.Channels {
|
||||||
|
if channel == nil || channel.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source, err := NormalizePlatform(channel.SourceDB)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
channel.SourceDB = source
|
||||||
|
key := PlatformKey{SourceDB: source, BusierID: channel.BusierID}
|
||||||
|
account := accounts[bindings[key]]
|
||||||
|
if conflicts[key] || account == nil || account.Level != 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := strings.Split(account.IDPath, ",")
|
||||||
|
if len(path) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(path[1], 10, 64)
|
||||||
|
owner := accounts[uint(id)]
|
||||||
|
if err != nil || owner == nil || owner.Level != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
owners[channel.ChannelKey] = owner
|
||||||
|
}
|
||||||
|
return owners, accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一次执行固定上海自然日,避免跨午夜时同一任务的各规则使用不同的比较窗口。
|
||||||
|
func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*AlertTriggerResult, error) {
|
||||||
|
result := &AlertTriggerResult{}
|
||||||
|
input, err := uc.repo.Input(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if input == nil {
|
||||||
|
return result, ErrStorage
|
||||||
|
}
|
||||||
|
zone := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
|
now = now.In(zone)
|
||||||
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, zone)
|
||||||
|
owners, accounts := triggerOwners(input)
|
||||||
|
type snapshotKey struct {
|
||||||
|
creator uint
|
||||||
|
metric, days int
|
||||||
|
}
|
||||||
|
snapshots := map[snapshotKey]map[ChannelKey]AlertComparison{}
|
||||||
|
var failures []string
|
||||||
|
for _, rule := range input.Rules {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if !validTriggerRule(rule) {
|
||||||
|
result.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
creator := accounts[rule.CreatorID]
|
||||||
|
if creator == nil || (creator.AuthorityID != 888 && creator.Level != 1) {
|
||||||
|
result.Skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
channels := []*AlertTriggerChannel{}
|
||||||
|
keys := []ChannelKey{}
|
||||||
|
for _, channel := range input.Channels {
|
||||||
|
if channel == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
owner := owners[channel.ChannelKey]
|
||||||
|
if owner != nil && (creator.AuthorityID == 888 || owner.ID == creator.ID) {
|
||||||
|
channels = append(channels, channel)
|
||||||
|
keys = append(keys, channel.ChannelKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(channels) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Rules++
|
||||||
|
var snapshot map[ChannelKey]AlertComparison
|
||||||
|
if rule.Metric != 3 {
|
||||||
|
key := snapshotKey{creator.ID, rule.Metric, *rule.StatPeriodDays}
|
||||||
|
var exists bool
|
||||||
|
snapshot, exists = snapshots[key]
|
||||||
|
if !exists {
|
||||||
|
snapshot, err = uc.repo.Comparison(ctx, rule.Metric, keys, today, key.days)
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, fmt.Sprintf("规则 %d 读取指标失败: %v", rule.ID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snapshots[key] = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, channel := range channels {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
content, hit, err := triggerContent(rule, channel, snapshot[channel.ChannelKey], today)
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, fmt.Sprintf("规则 %d 渠道 %s/%d: %v", rule.ID, channel.SourceDB, channel.ID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !hit {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
owner := owners[channel.ChannelKey]
|
||||||
|
record := &AlertRecord{RuleID: rule.ID, RuleName: rule.Name, RuleType: strings.TrimSpace(rule.Type), MonitorMetric: &rule.Metric,
|
||||||
|
PlatformCode: channel.SourceDB, PlatformName: PlatformName(channel.SourceDB), TargetPromoteID: channel.ID, TargetPromoteName: channel.Account,
|
||||||
|
BusinessID: owner.ID, BusinessName: owner.Username, AlertContent: content, Status: AlertUnprocessed, CreatedAt: now, UpdatedAt: now}
|
||||||
|
written, err := uc.repo.StoreHit(ctx, record, []int{AlertUnprocessed, AlertProcessing, AlertPendingAudit, AlertAuditRejected}, func(current *AlertRecord) bool { return current == nil || current.Status == AlertUnprocessed })
|
||||||
|
if err != nil {
|
||||||
|
failures = append(failures, fmt.Sprintf("规则 %d 渠道 %s/%d 写入失败: %v", rule.ID, channel.SourceDB, channel.ID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if written {
|
||||||
|
result.Written++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(failures) > 0 {
|
||||||
|
for _, failure := range failures {
|
||||||
|
slog.ErrorContext(ctx, "预警检测失败", "detail", failure)
|
||||||
|
}
|
||||||
|
return result, fmt.Errorf("预警规则检测存在 %d 项失败:%s", len(failures), strings.Join(failures[:min(5, len(failures))], ";"))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerContent(rule *AlertRule, channel *AlertTriggerChannel, sample AlertComparison, today time.Time) (string, bool, error) {
|
||||||
|
if rule.Metric == 3 {
|
||||||
|
stamp := channel.LastLoginUnix
|
||||||
|
if stamp <= 0 {
|
||||||
|
stamp = channel.CreatedUnix
|
||||||
|
}
|
||||||
|
if stamp <= 0 {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
day := time.Unix(stamp, 0).In(today.Location())
|
||||||
|
day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, today.Location())
|
||||||
|
days := int(today.Sub(day) / (24 * time.Hour))
|
||||||
|
if days < *rule.UnloginDays {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("连续%d天未登录", days), true, nil
|
||||||
|
}
|
||||||
|
days := *rule.StatPeriodDays
|
||||||
|
if sample.FirstUnix <= 0 || time.Unix(sample.FirstUnix, 0).In(today.Location()).Format("2006-01-02") > today.AddDate(0, 0, -2*days).Format("2006-01-02") {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
previous, err := portraitNumber(sample.Previous)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if previous.Sign() <= 0 {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
current, err := portraitNumber(sample.Current)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
rate := new(big.Rat).Quo(new(big.Rat).Sub(current, previous), previous)
|
||||||
|
// 先对比值保留四位再转百分比,与旧 BigDecimal HALF_UP 的阈值边界一致。
|
||||||
|
rate, _ = new(big.Rat).SetString(portraitDecimal(rate, 4))
|
||||||
|
rate.Mul(rate, big.NewRat(100, 1))
|
||||||
|
threshold, _ := new(big.Rat).SetString(*rule.MomThreshold)
|
||||||
|
if (*rule.MomDirection == 1 && rate.Cmp(threshold) < 0) || (*rule.MomDirection == 2 && rate.Cmp(new(big.Rat).Neg(threshold)) > 0) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
direction := "上升"
|
||||||
|
if rate.Sign() < 0 {
|
||||||
|
direction = "下降"
|
||||||
|
}
|
||||||
|
percent := portraitDecimal(new(big.Rat).Abs(rate), 2)
|
||||||
|
if rule.Metric == 1 {
|
||||||
|
return fmt.Sprintf("截至昨日%d个完整自然日注册%s人,较此前%d个完整自然日(%s人)环比%s%s%%", days, portraitDecimal(current, 0), days, portraitDecimal(previous, 0), direction, percent), true, nil
|
||||||
|
}
|
||||||
|
yuan := big.NewRat(10000, 1)
|
||||||
|
return fmt.Sprintf("截至昨日%d个完整自然日充值¥%s万,较此前%d个完整自然日(¥%s万)环比%s%s%%", days, portraitDecimal(new(big.Rat).Quo(current, yuan), 2), days, portraitDecimal(new(big.Rat).Quo(previous, yuan), 2), direction, percent), true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type triggerRepoFake struct {
|
||||||
|
input *AlertTriggerInput
|
||||||
|
metrics map[ChannelKey]AlertComparison
|
||||||
|
hits []*AlertRecord
|
||||||
|
metricCalls int
|
||||||
|
current *AlertRecord
|
||||||
|
failure error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *triggerRepoFake) Input(context.Context) (*AlertTriggerInput, error) { return r.input, nil }
|
||||||
|
func (r *triggerRepoFake) Comparison(context.Context, int, []ChannelKey, time.Time, int) (map[ChannelKey]AlertComparison, error) {
|
||||||
|
r.metricCalls++
|
||||||
|
return r.metrics, r.failure
|
||||||
|
}
|
||||||
|
func (r *triggerRepoFake) StoreHit(_ context.Context, hit *AlertRecord, _ []int, decide func(*AlertRecord) bool) (bool, error) {
|
||||||
|
if !decide(r.current) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
r.hits = append(r.hits, hit)
|
||||||
|
return true, r.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerFixture() (*triggerRepoFake, time.Time) {
|
||||||
|
now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||||
|
r := &triggerRepoFake{input: &AlertTriggerInput{Accounts: []*Account{
|
||||||
|
{ID: 1, AuthorityID: 888}, {ID: 10, Level: 1, Username: "一级甲"}, {ID: 11, Level: 1, Username: "一级乙"},
|
||||||
|
{ID: 20, Level: 2, IDPath: "0,10"}, {ID: 30, Level: 3, IDPath: "0,10,20"}, {ID: 31, Level: 3, IDPath: "0,11,21"},
|
||||||
|
}, Bindings: []*Binding{
|
||||||
|
{UserID: 30, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 7}, Status: "0"},
|
||||||
|
{UserID: 31, PlatformKey: PlatformKey{SourceDB: "hw", BusierID: 7}, Status: "0"},
|
||||||
|
{UserID: 30, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 8}, Status: "0"},
|
||||||
|
{UserID: 31, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 8}, Status: "0"},
|
||||||
|
}, Channels: []*AlertTriggerChannel{
|
||||||
|
{ChannelKey: ChannelKey{"lx", 10}, BusierID: 7, Account: "甲渠道", CreatedUnix: now.AddDate(0, 0, -3).Unix()},
|
||||||
|
{ChannelKey: ChannelKey{"hw", 10}, BusierID: 7, Account: "乙渠道", LastLoginUnix: now.AddDate(0, 0, -10).Unix()},
|
||||||
|
{ChannelKey: ChannelKey{"lx", 11}, BusierID: 8, Account: "冲突渠道", CreatedUnix: now.AddDate(0, 0, -10).Unix()},
|
||||||
|
}}}
|
||||||
|
return r, now
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertTriggerSeparatesOwnerPlatformAndConflictingBindings(t *testing.T) {
|
||||||
|
r, now := triggerFixture()
|
||||||
|
days := 3
|
||||||
|
r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "未登录", Metric: 3, UnloginDays: &days}, {ID: 2, CreatorID: 20, Type: "越级", Metric: 3, UnloginDays: &days}}
|
||||||
|
result, err := NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||||
|
if err != nil || result.Written != 1 || result.Skipped != 1 || len(r.hits) != 1 {
|
||||||
|
t.Fatalf("检测结果 %+v %v", result, err)
|
||||||
|
}
|
||||||
|
hit := r.hits[0]
|
||||||
|
if hit.BusinessID != 10 || hit.PlatformCode != "lx" || hit.TargetPromoteID != 10 || hit.AlertContent != "连续3天未登录" {
|
||||||
|
t.Fatalf("归属或自然日错误: %+v", hit)
|
||||||
|
}
|
||||||
|
r.hits = nil
|
||||||
|
r.current = &AlertRecord{Status: 2}
|
||||||
|
result, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||||
|
if err != nil || result.Written != 0 || len(r.hits) != 0 {
|
||||||
|
t.Fatalf("在途记录不应刷新: %+v %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertTriggerMoMBoundariesAndSnapshots(t *testing.T) {
|
||||||
|
r, now := triggerFixture()
|
||||||
|
days, direction := 2, 1
|
||||||
|
threshold := "10"
|
||||||
|
r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "充值", Metric: 2, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold},
|
||||||
|
{ID: 2, CreatorID: 10, Type: "另一个充值规则", Metric: 2, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold}}
|
||||||
|
r.metrics = map[ChannelKey]AlertComparison{{"lx", 10}: {FirstUnix: now.AddDate(0, 0, -4).Unix(), Current: "110000", Previous: "100000"}}
|
||||||
|
result, err := NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||||
|
if err != nil || result.Written != 2 || r.metricCalls != 1 {
|
||||||
|
t.Fatalf("同窗口应共享指标: %+v calls=%d %v", result, r.metricCalls, err)
|
||||||
|
}
|
||||||
|
if r.hits[0].AlertContent != "截至昨日2个完整自然日充值¥11.00万,较此前2个完整自然日(¥10.00万)环比上升10.00%" {
|
||||||
|
t.Fatal(r.hits[0].AlertContent)
|
||||||
|
}
|
||||||
|
r.metrics[ChannelKey{"lx", 10}] = AlertComparison{FirstUnix: now.AddDate(0, 0, -3).Unix(), Current: "110000", Previous: "100000"}
|
||||||
|
r.hits = nil
|
||||||
|
result, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||||
|
if err != nil || result.Written != 0 {
|
||||||
|
t.Fatalf("不足双周期不应触发: %+v %v", result, err)
|
||||||
|
}
|
||||||
|
r.failure = errors.New("指标不可用")
|
||||||
|
_, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "2 项失败") {
|
||||||
|
t.Fatalf("读取失败不能报告成功: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertTriggerRoundedThresholdAndNoBaseline(t *testing.T) {
|
||||||
|
days, direction := 1, 1
|
||||||
|
threshold := "10"
|
||||||
|
rule := &AlertRule{Metric: 1, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold}
|
||||||
|
_, now := triggerFixture()
|
||||||
|
for _, test := range []struct {
|
||||||
|
current, previous string
|
||||||
|
hit bool
|
||||||
|
}{{"109.995", "100", true}, {"109.994", "100", false}, {"1", "0", false}, {"1", "-1", false}} {
|
||||||
|
_, hit, err := triggerContent(rule, &AlertTriggerChannel{}, AlertComparison{FirstUnix: now.AddDate(0, 0, -2).Unix(), Current: test.current, Previous: test.previous}, now)
|
||||||
|
if err != nil || hit != test.hit {
|
||||||
|
t.Fatalf("%+v: hit=%v err=%v", test, hit, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,12 +200,12 @@ func buildRankingItems(kind RankingKind, metric string, current, previous map[An
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if metric == "rate" || valid {
|
// 四个榜单统一使用相对上期的增长率。上期为零或无有效
|
||||||
|
// 活跃基数时不能计算环比,保留 nil 供页面显示无可比数据。
|
||||||
|
if valid && prior.Sign() > 0 {
|
||||||
change := new(big.Rat).Sub(value, prior)
|
change := new(big.Rat).Sub(value, prior)
|
||||||
if metric != "rate" {
|
change.Mul(change, big.NewRat(100, 1))
|
||||||
change.Mul(change, big.NewRat(100, 1))
|
change.Quo(change, prior)
|
||||||
change.Quo(change, prior)
|
|
||||||
}
|
|
||||||
item.MoM = stringPointer(portraitDecimal(change, 2))
|
item.MoM = stringPointer(portraitDecimal(change, 2))
|
||||||
rounded, _ := portraitNumber(*item.MoM)
|
rounded, _ := portraitNumber(*item.MoM)
|
||||||
status := 0
|
status := 0
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestRankingComparisonsAndBusinessCountSemantics(t *testing.T) {
|
|||||||
t.Fatalf("排行筛选、命名或环比错误: %+v %v", p, err)
|
t.Fatalf("排行筛选、命名或环比错误: %+v %v", p, err)
|
||||||
}
|
}
|
||||||
if *p.PayRate[0].MoM != "0.00" || *p.PayRate[0].MoMStatus != 0 || r.filter.PreviousBegin != "2026-10-22" || r.filter.PreviousEnd != "2026-10-28" {
|
if *p.PayRate[0].MoM != "0.00" || *p.PayRate[0].MoMStatus != 0 || r.filter.PreviousBegin != "2026-10-22" || r.filter.PreviousEnd != "2026-10-28" {
|
||||||
t.Fatal("等长上期或百分点差错误")
|
t.Fatal("等长上期或付费率环比错误")
|
||||||
}
|
}
|
||||||
r.data.Businesses = []*RankingBusiness{{ID: 8, Name: "二级商务", Level: 2, Channels: []AnalyticsKey{k, zero}}}
|
r.data.Businesses = []*RankingBusiness{{ID: 8, Name: "二级商务", Level: 2, Channels: []AnalyticsKey{k, zero}}}
|
||||||
p, err = uc.List(accountContext(7, 901), BusinessRanking, AnalyticsFilter{})
|
p, err = uc.List(accountContext(7, 901), BusinessRanking, AnalyticsFilter{})
|
||||||
@@ -74,3 +74,37 @@ func TestRankingTopTwentyAndStatusThreshold(t *testing.T) {
|
|||||||
t.Fatal("持平环比错误")
|
t.Fatal("持平环比错误")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRankingPayRateRelativeGrowth(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
current PortraitCounts
|
||||||
|
previous *RankingMetrics
|
||||||
|
want string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"increase", PortraitCounts{Pay: 12, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 10, Active: 100}}, "20.00", 1},
|
||||||
|
{"decrease", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 20, Active: 100}}, "-50.00", -1},
|
||||||
|
{"unchanged", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 10, Active: 100}}, "0.00", 0},
|
||||||
|
{"no current payers", PortraitCounts{Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 10, Active: 100}}, "-100.00", -1},
|
||||||
|
{"zero prior rate", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Active: 100}}, "", 0},
|
||||||
|
{"no prior active players", PortraitCounts{Pay: 10, Active: 100}, &RankingMetrics{Counts: PortraitCounts{Pay: 1}}, "", 0},
|
||||||
|
{"no prior row", PortraitCounts{Pay: 10, Active: 100}, nil, "", 0},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
key := AnalyticsKey{SourceDB: "lx", ID: 1}
|
||||||
|
rows, err := buildRankingItems(ChannelRanking, "rate", map[AnalyticsKey]*RankingMetrics{key: {Counts: tc.current}}, map[AnalyticsKey]*RankingMetrics{key: tc.previous})
|
||||||
|
if err != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("ranking: %v, %v", rows, err)
|
||||||
|
}
|
||||||
|
row := rows[0]
|
||||||
|
if tc.want == "" {
|
||||||
|
if row.MoM != nil || row.MoMStatus != nil {
|
||||||
|
t.Fatal("missing or zero baseline must not produce a growth percentage")
|
||||||
|
}
|
||||||
|
} else if row.MoM == nil || *row.MoM != tc.want || row.MoMStatus == nil || *row.MoMStatus != tc.status {
|
||||||
|
t.Fatalf("expected %s%% with status %d, got %+v", tc.want, tc.status, row)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package business
|
package business
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"container/heap"
|
||||||
"context"
|
"context"
|
||||||
"math/big"
|
"math/big"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -57,6 +58,7 @@ type WorkbenchRankData struct {
|
|||||||
type WorkbenchRepo interface {
|
type WorkbenchRepo interface {
|
||||||
Summary(context.Context, AnalyticsScope, []string, WorkbenchWindow) (*WorkbenchTotals, error)
|
Summary(context.Context, AnalyticsScope, []string, WorkbenchWindow) (*WorkbenchTotals, error)
|
||||||
Ranks(context.Context, AnalyticsScope, AnalyticsFilter, WorkbenchRankKind) (*WorkbenchRankData, error)
|
Ranks(context.Context, AnalyticsScope, AnalyticsFilter, WorkbenchRankKind) (*WorkbenchRankData, error)
|
||||||
|
FillRankNames(context.Context, []*WorkbenchRankRow) error
|
||||||
}
|
}
|
||||||
type WorkbenchUsecase struct {
|
type WorkbenchUsecase struct {
|
||||||
repo WorkbenchRepo
|
repo WorkbenchRepo
|
||||||
@@ -185,9 +187,8 @@ func (uc *WorkbenchUsecase) Ranks(ctx context.Context, sources []string, period
|
|||||||
if !data.Available {
|
if !data.Available {
|
||||||
return nil, ErrWorkbenchUnavailable
|
return nil, ErrWorkbenchUnavailable
|
||||||
}
|
}
|
||||||
rows := make([]*WorkbenchRankRow, 0, len(data.Rows))
|
top := &workbenchRankHeap{ascending: kind == WorkbenchPayLow}
|
||||||
values := map[*WorkbenchRankRow]*big.Rat{}
|
for index, row := range data.Rows {
|
||||||
for _, row := range data.Rows {
|
|
||||||
if kind == WorkbenchUnlogin {
|
if kind == WorkbenchUnlogin {
|
||||||
row.Value = big.NewInt(workbenchUnloginDays(row, w.Today)).String()
|
row.Value = big.NewInt(workbenchUnloginDays(row, w.Today)).String()
|
||||||
}
|
}
|
||||||
@@ -201,32 +202,68 @@ func (uc *WorkbenchUsecase) Ranks(ctx context.Context, sources []string, period
|
|||||||
if (kind == WorkbenchRegister || kind == WorkbenchPayHigh) && n.Sign() <= 0 {
|
if (kind == WorkbenchRegister || kind == WorkbenchPayHigh) && n.Sign() <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
values[row] = n
|
candidate := workbenchRankCandidate{row: row, value: n, index: index}
|
||||||
rows = append(rows, row)
|
if top.Len() < 10 {
|
||||||
|
heap.Push(top, candidate)
|
||||||
|
} else if top.better(candidate, top.rows[0]) {
|
||||||
|
top.rows[0] = candidate
|
||||||
|
heap.Fix(top, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(top.rows, func(i, j int) bool { return top.better(top.rows[i], top.rows[j]) })
|
||||||
|
rows := make([]*WorkbenchRankRow, len(top.rows))
|
||||||
|
for i, candidate := range top.rows {
|
||||||
|
rows[i] = candidate.row
|
||||||
|
}
|
||||||
|
if err := uc.repo.FillRankNames(ctx, rows); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
sort.SliceStable(rows, func(i, j int) bool {
|
|
||||||
a, b := rows[i], rows[j]
|
|
||||||
if c := values[a].Cmp(values[b]); c != 0 {
|
|
||||||
if kind == WorkbenchPayLow {
|
|
||||||
return c < 0
|
|
||||||
}
|
|
||||||
return c > 0
|
|
||||||
}
|
|
||||||
if a.ID != b.ID {
|
|
||||||
return a.ID < b.ID
|
|
||||||
}
|
|
||||||
return a.SourceDB < b.SourceDB
|
|
||||||
})
|
|
||||||
rows = rows[:min(10, len(rows))]
|
|
||||||
for i, row := range rows {
|
for i, row := range rows {
|
||||||
row.Rank = i + 1
|
row.Rank = i + 1
|
||||||
row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote()
|
row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote()
|
||||||
if kind != WorkbenchUnlogin {
|
if kind != WorkbenchUnlogin {
|
||||||
row.Value = portraitDecimal(values[row], 2)
|
row.Value = portraitDecimal(top.rows[i].value, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type workbenchRankCandidate struct {
|
||||||
|
row *WorkbenchRankRow
|
||||||
|
value *big.Rat
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
|
// 堆顶保留最差的入选行,只维护 TOP10;比较规则与完整稳定排序一致。
|
||||||
|
type workbenchRankHeap struct {
|
||||||
|
rows []workbenchRankCandidate
|
||||||
|
ascending bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h workbenchRankHeap) better(a, b workbenchRankCandidate) bool {
|
||||||
|
if c := a.value.Cmp(b.value); c != 0 {
|
||||||
|
if h.ascending {
|
||||||
|
return c < 0
|
||||||
|
}
|
||||||
|
return c > 0
|
||||||
|
}
|
||||||
|
if a.row.ID != b.row.ID {
|
||||||
|
return a.row.ID < b.row.ID
|
||||||
|
}
|
||||||
|
if a.row.SourceDB != b.row.SourceDB {
|
||||||
|
return a.row.SourceDB < b.row.SourceDB
|
||||||
|
}
|
||||||
|
return a.index < b.index
|
||||||
|
}
|
||||||
|
func (h workbenchRankHeap) Len() int { return len(h.rows) }
|
||||||
|
func (h workbenchRankHeap) Less(i, j int) bool { return h.better(h.rows[j], h.rows[i]) }
|
||||||
|
func (h workbenchRankHeap) Swap(i, j int) { h.rows[i], h.rows[j] = h.rows[j], h.rows[i] }
|
||||||
|
func (h *workbenchRankHeap) Push(v any) { h.rows = append(h.rows, v.(workbenchRankCandidate)) }
|
||||||
|
func (h *workbenchRankHeap) Pop() any {
|
||||||
|
last := h.rows[len(h.rows)-1]
|
||||||
|
h.rows = h.rows[:len(h.rows)-1]
|
||||||
|
return last
|
||||||
|
}
|
||||||
func workbenchUnloginDays(row *WorkbenchRankRow, today time.Time) int64 {
|
func workbenchUnloginDays(row *WorkbenchRankRow, today time.Time) int64 {
|
||||||
stamp := row.LastLoginUnix
|
stamp := row.LastLoginUnix
|
||||||
if stamp == nil || *stamp <= 0 {
|
if stamp == nil || *stamp <= 0 {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package business
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -13,6 +15,52 @@ type workbenchRepoFake struct {
|
|||||||
window WorkbenchWindow
|
window WorkbenchWindow
|
||||||
filter AnalyticsFilter
|
filter AnalyticsFilter
|
||||||
scope AnalyticsScope
|
scope AnalyticsScope
|
||||||
|
named []*WorkbenchRankRow
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkbenchTopTenMatchesFullStableSort(t *testing.T) {
|
||||||
|
for _, kind := range []WorkbenchRankKind{WorkbenchPayHigh, WorkbenchPayLow} {
|
||||||
|
uc, repo := workbenchFixture()
|
||||||
|
for i := 0; i < 3000; i++ {
|
||||||
|
repo.data.Rows = append(repo.data.Rows, &WorkbenchRankRow{AnalyticsKey: AnalyticsKey{ID: int64(i%11 + 1), SourceDB: []string{"lx", "hw", "yq"}[i%3]}, Name: fmt.Sprintf("candidate-%04d", i), Value: big.NewRat(int64(i%7-2), 100).FloatString(2)})
|
||||||
|
}
|
||||||
|
expected := make([]*WorkbenchRankRow, 0, len(repo.data.Rows))
|
||||||
|
for _, row := range repo.data.Rows {
|
||||||
|
n, _ := new(big.Rat).SetString(row.Value)
|
||||||
|
if kind == WorkbenchPayLow || n.Sign() > 0 {
|
||||||
|
expected = append(expected, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(expected, func(i, j int) bool {
|
||||||
|
a, b := expected[i], expected[j]
|
||||||
|
av, _ := new(big.Rat).SetString(a.Value)
|
||||||
|
bv, _ := new(big.Rat).SetString(b.Value)
|
||||||
|
if order := av.Cmp(bv); order != 0 {
|
||||||
|
if kind == WorkbenchPayLow {
|
||||||
|
return order < 0
|
||||||
|
}
|
||||||
|
return order > 0
|
||||||
|
}
|
||||||
|
if a.ID != b.ID {
|
||||||
|
return a.ID < b.ID
|
||||||
|
}
|
||||||
|
return a.SourceDB < b.SourceDB
|
||||||
|
})
|
||||||
|
actual, err := uc.Ranks(accountContext(7, 901), []string{"lx", "hw", "yq"}, "last30", kind)
|
||||||
|
if err != nil || len(actual) != 10 {
|
||||||
|
t.Fatalf("rank failed: %v", err)
|
||||||
|
}
|
||||||
|
for i, row := range actual {
|
||||||
|
if row.AnalyticsKey != expected[i].AnalyticsKey || row.Value != expected[i].Value || row.Name != expected[i].Name || row.Rank != i+1 {
|
||||||
|
t.Fatalf("%s rank %d differs from full stable sort: %+v vs %+v", kind, i, row, expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *workbenchRepoFake) FillRankNames(_ context.Context, rows []*WorkbenchRankRow) error {
|
||||||
|
r.named = rows
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *workbenchRepoFake) Summary(_ context.Context, s AnalyticsScope, _ []string, w WorkbenchWindow) (*WorkbenchTotals, error) {
|
func (r *workbenchRepoFake) Summary(_ context.Context, s AnalyticsScope, _ []string, w WorkbenchWindow) (*WorkbenchTotals, error) {
|
||||||
@@ -64,6 +112,9 @@ func TestWorkbenchRankLimitsExactSortDefaultsAndUnlogin(t *testing.T) {
|
|||||||
if err != nil || len(rows) != 10 || rows[0].ID != 13 || rows[9].Rank != 10 {
|
if err != nil || len(rows) != 10 || rows[0].ID != 13 || rows[9].Rank != 10 {
|
||||||
t.Fatal("榜单排序或 TOP10 截断错误")
|
t.Fatal("榜单排序或 TOP10 截断错误")
|
||||||
}
|
}
|
||||||
|
if len(r.named) != 10 {
|
||||||
|
t.Fatal("仅应补充已入选 TOP10 的渠道名称")
|
||||||
|
}
|
||||||
rows, err = uc.Ranks(ctx, []string{"lx"}, "", WorkbenchPayLow)
|
rows, err = uc.Ranks(ctx, []string{"lx"}, "", WorkbenchPayLow)
|
||||||
if err != nil || rows[0].ID != 0 || rows[0].Value != "0.00" || rows[0].Name != "官方渠道" || r.filter.Begin != "2026-03-02" {
|
if err != nil || rows[0].ID != 0 || rows[0].Value != "0.00" || rows[0].Name != "官方渠道" || r.filter.Begin != "2026-03-02" {
|
||||||
t.Fatal("最低榜应包含零值,默认近30天")
|
t.Fatal("最低榜应包含零值,默认近30天")
|
||||||
|
|||||||
@@ -501,7 +501,12 @@ func IsIntegrationSecretKey(key string) bool {
|
|||||||
if strings.Contains(normalized, "secret") || strings.Contains(normalized, "password") || strings.Contains(normalized, "private") || strings.Contains(normalized, "credential") || strings.Contains(normalized, "token") {
|
if strings.Contains(normalized, "secret") || strings.Contains(normalized, "password") || strings.Contains(normalized, "private") || strings.Contains(normalized, "credential") || strings.Contains(normalized, "token") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, item := range []string{"key_pem", "api_key", "mch_key", "client_key", "certificate", "cert", "cert_pem", "p12", "pkcs12", "public_key", "platform_cert", "root_cert"} {
|
// Providers accept inline PEM, file and path aliases of the same secret.
|
||||||
|
// Strip only these transport suffixes; ordinary content/path fields stay public.
|
||||||
|
for _, suffix := range []string{"_content", "_path", "_file", "_pem"} {
|
||||||
|
normalized = strings.TrimSuffix(normalized, suffix)
|
||||||
|
}
|
||||||
|
for _, item := range []string{"key", "api_key", "mch_key", "client_key", "certificate", "cert", "p12", "pkcs12", "public_key", "platform_cert", "root_cert"} {
|
||||||
if normalized == item || strings.HasSuffix(normalized, "_"+item) {
|
if normalized == item || strings.HasSuffix(normalized, "_"+item) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestIntegrationCertificateAliases(t *testing.T) {
|
||||||
|
for _, key := range []string{"app_cert_content", "root_cert_path", "alipay_public_cert_content", "pkcs12_content", "p12_file", "cert_content", "key_content", "cert_path", "key_file", "client_key", "public_key_pem"} {
|
||||||
|
if !IsIntegrationSecretKey(key) {
|
||||||
|
t.Errorf("secret alias was not recognized: %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"content", "path", "notify_url", "template_content", "content_type", "platform_serial_no", "public_key_id"} {
|
||||||
|
if IsIntegrationSecretKey(key) {
|
||||||
|
t.Errorf("public field was masked: %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import "github.com/go-kratos/kratos/v3/errors"
|
||||||
|
|
||||||
|
var ErrPaymentTestFailed = errors.BadRequest("PAYMENT_TEST_FAILED", "支付渠道测试失败,请检查配置和服务端日志")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-kratos/kratos/v3/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrTaskExecutionOptions = errors.BadRequest("TASK_EXECUTION_OPTIONS_INVALID", "执行参数不正确:超时须为 1–86400 秒,重试次数为 0–10,重试间隔为 0–3600 秒")
|
||||||
|
|
||||||
|
type ExecutionOptions struct {
|
||||||
|
Timeout time.Duration
|
||||||
|
MaxRetryTimes int
|
||||||
|
RetryInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// MethodExecutionOptions 承接迁移 SQL 保留的 _execution 元数据;普通方法维持原默认行为。
|
||||||
|
func MethodExecutionOptions(params []byte) (ExecutionOptions, error) {
|
||||||
|
options := ExecutionOptions{Timeout: 5 * time.Minute, RetryInterval: time.Second}
|
||||||
|
params = bytes.TrimSpace(params)
|
||||||
|
if len(params) == 0 || params[0] != '{' {
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
Execution *struct {
|
||||||
|
TimeoutSeconds *int64 `json:"timeoutSeconds"`
|
||||||
|
MaxRetryTimes *int `json:"maxRetryTimes"`
|
||||||
|
RetryIntervalSeconds *int64 `json:"retryIntervalSeconds"`
|
||||||
|
} `json:"_execution"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(params, &envelope); err != nil {
|
||||||
|
return options, ErrTaskExecutionOptions
|
||||||
|
}
|
||||||
|
if envelope.Execution == nil {
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
value := envelope.Execution
|
||||||
|
if value.TimeoutSeconds != nil {
|
||||||
|
if *value.TimeoutSeconds < 1 || *value.TimeoutSeconds > 86400 {
|
||||||
|
return options, ErrTaskExecutionOptions
|
||||||
|
}
|
||||||
|
options.Timeout = time.Duration(*value.TimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
if value.MaxRetryTimes != nil {
|
||||||
|
if *value.MaxRetryTimes < 0 || *value.MaxRetryTimes > 10 {
|
||||||
|
return options, ErrTaskExecutionOptions
|
||||||
|
}
|
||||||
|
options.MaxRetryTimes = *value.MaxRetryTimes
|
||||||
|
}
|
||||||
|
if value.RetryIntervalSeconds != nil {
|
||||||
|
if *value.RetryIntervalSeconds < 0 || *value.RetryIntervalSeconds > 3600 {
|
||||||
|
return options, ErrTaskExecutionOptions
|
||||||
|
}
|
||||||
|
options.RetryInterval = time.Duration(*value.RetryIntervalSeconds) * time.Second
|
||||||
|
}
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMethodExecutionOptionsPreservesImportedPolicy(t *testing.T) {
|
||||||
|
options, err := MethodExecutionOptions([]byte(`{"startDate":"2026-11-03","_execution":{"timeoutSeconds":1600,"maxRetryTimes":3,"retryIntervalSeconds":1}}`))
|
||||||
|
if err != nil || options.Timeout != 1600*time.Second || options.MaxRetryTimes != 3 || options.RetryInterval != time.Second {
|
||||||
|
t.Fatalf("迁移任务策略丢失: %+v %v", options, err)
|
||||||
|
}
|
||||||
|
for _, params := range []string{"", `{}`, `[]`, `"value"`} {
|
||||||
|
options, err := MethodExecutionOptions([]byte(params))
|
||||||
|
if err != nil || options.Timeout != 5*time.Minute || options.MaxRetryTimes != 0 {
|
||||||
|
t.Fatalf("普通方法默认行为变化: %q %+v %v", params, options, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, params := range []string{`{"_execution":{"timeoutSeconds":0}}`, `{"_execution":{"timeoutSeconds":86401}}`, `{"_execution":{"maxRetryTimes":11}}`, `{"_execution":{"retryIntervalSeconds":-1}}`, `{"_execution":"invalid"}`} {
|
||||||
|
if _, err := MethodExecutionOptions([]byte(params)); err == nil {
|
||||||
|
t.Fatalf("无效执行参数未拒绝: %s", params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,9 @@ func (uc *TaskUsecase) Validate(value *TimedTask) error {
|
|||||||
if len(value.Params) > 0 && !json.Valid(value.Params) {
|
if len(value.Params) > 0 && !json.Valid(value.Params) {
|
||||||
return errors.New("params 必须是合法 JSON")
|
return errors.New("params 必须是合法 JSON")
|
||||||
}
|
}
|
||||||
|
if _, err := MethodExecutionOptions(value.Params); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
case TaskExecutorHTTP:
|
case TaskExecutorHTTP:
|
||||||
parsed, parseErr := url.Parse(value.HTTPURL)
|
parsed, parseErr := url.Parse(value.HTTPURL)
|
||||||
if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
if parseErr != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// SwaggerEnabled exposes API documentation only in explicitly selected local/test environments.
|
||||||
|
func SwaggerEnabled(admin *Admin) bool {
|
||||||
|
if admin == nil || admin.App == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(admin.App.Env)) {
|
||||||
|
case "development", "dev", "test", "local":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSwaggerRequiresExplicitDevelopmentEnvironment(t *testing.T) {
|
||||||
|
if SwaggerEnabled(nil) || SwaggerEnabled(&Admin{}) {
|
||||||
|
t.Fatal("missing environment exposed Swagger")
|
||||||
|
}
|
||||||
|
for _, env := range []string{"", "production", "prod", "staging", "qa", "uat", "unknown"} {
|
||||||
|
if SwaggerEnabled(&Admin{App: &App{Env: env}}) {
|
||||||
|
t.Errorf("Swagger enabled in %q", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, env := range []string{"development", "dev", "test", "local", " DEV "} {
|
||||||
|
if !SwaggerEnabled(&Admin{App: &App{Env: env}}) {
|
||||||
|
t.Errorf("Swagger disabled in %q", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,9 +37,13 @@ func NewActivationRepo(data Provider, databases PlatformProvider) business.Activ
|
|||||||
return &activationRepo{data: data, databases: databases}
|
return &activationRepo{data: data, databases: databases}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *activationRepo) OwnAccounts(ctx context.Context, id uint) ([]string, error) {
|
func (r *activationRepo) OwnAccounts(ctx context.Context, id uint) ([]business.ActivationAccount, error) {
|
||||||
accounts := []string{}
|
var rows []struct{ SourceDB, Account string }
|
||||||
err := r.data.DB().WithContext(ctx).Table("sys_user_bind_platform").Where("user_id = ? AND status = ? AND platform_busier_account IS NOT NULL", id, "0").Distinct("platform_busier_account").Pluck("platform_busier_account", &accounts).Error
|
err := r.data.DB().WithContext(ctx).Table("sys_user_bind_platform").Where("user_id = ? AND status = ? AND platform_busier_account IS NOT NULL", id, "0").Distinct("source_db,platform_busier_account AS account").Scan(&rows).Error
|
||||||
|
accounts := make([]business.ActivationAccount, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
accounts = append(accounts, business.ActivationAccount{SourceDB: row.SourceDB, Account: row.Account})
|
||||||
|
}
|
||||||
return accounts, storageError(err)
|
return accounts, storageError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +72,11 @@ func (r *activationRepo) List(ctx context.Context, scope business.ActivationScop
|
|||||||
}
|
}
|
||||||
q := db.Model(&activationPO{}).Where("deleted_at IS NULL")
|
q := db.Model(&activationPO{}).Where("deleted_at IS NULL")
|
||||||
if !scope.All {
|
if !scope.All {
|
||||||
accounts := make([]string, 0, len(scope.Accounts))
|
accounts := make([][]any, 0, len(scope.Accounts))
|
||||||
for _, account := range scope.Accounts {
|
for _, account := range scope.Accounts {
|
||||||
accounts = append(accounts, strings.ToLower(account))
|
accounts = append(accounts, []any{strings.ToLower(account.SourceDB), strings.ToLower(account.Account)})
|
||||||
}
|
}
|
||||||
q = q.Where("LOWER(busier_account) IN ?", accounts)
|
q = q.Where("(LOWER(platform_name),LOWER(busier_account)) IN ?", accounts)
|
||||||
}
|
}
|
||||||
if len(filter.PlatformNames) > 0 {
|
if len(filter.PlatformNames) > 0 {
|
||||||
q = q.Where("platform_name IN ?", filter.PlatformNames)
|
q = q.Where("platform_name IN ?", filter.PlatformNames)
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) {
|
|||||||
db, _, repo := prepareActivations(t)
|
db, _, repo := prepareActivations(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
accounts, err := repo.OwnAccounts(ctx, 7)
|
accounts, err := repo.OwnAccounts(ctx, 7)
|
||||||
if err != nil || len(accounts) != 1 || accounts[0] != "Own" {
|
if err != nil || len(accounts) != 1 || accounts[0] != (business.ActivationAccount{SourceDB: "lx", Account: "Own"}) {
|
||||||
t.Fatalf("本人已通过账号范围错误: %+v %v", accounts, err)
|
t.Fatalf("本人已通过账号范围错误: %+v %v", accounts, err)
|
||||||
}
|
}
|
||||||
page, err := repo.List(ctx, business.ActivationScope{Accounts: []string{"OWN"}}, business.ActivationFilter{})
|
page, err := repo.List(ctx, business.ActivationScope{Accounts: []business.ActivationAccount{{SourceDB: "lx", Account: "OWN"}}}, business.ActivationFilter{})
|
||||||
if err != nil || page.Total != 2 || page.Rows[0].ID != 2 {
|
if err != nil || page.Total != 1 || page.Rows[0].ID != 1 {
|
||||||
t.Fatalf("OA 列表或排序错误: %+v %v", page, err)
|
t.Fatalf("OA 列表或排序错误: %+v %v", page, err)
|
||||||
}
|
}
|
||||||
page, err = repo.List(ctx, business.ActivationScope{All: true}, business.ActivationFilter{PlatformNames: []string{"lx"}, Statuses: []string{"reached"}})
|
page, err = repo.List(ctx, business.ActivationScope{All: true}, business.ActivationFilter{PlatformNames: []string{"lx"}, Statuses: []string{"reached"}})
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
)
|
||||||
|
|
||||||
|
type alertTriggerRepo struct{ data Provider }
|
||||||
|
|
||||||
|
func NewAlertTriggerRepo(data Provider) business.AlertTriggerRepo {
|
||||||
|
return &alertTriggerRepo{data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
type alertTriggerLockPO struct {
|
||||||
|
SourceDB string `gorm:"column:source_db;primaryKey;size:50"`
|
||||||
|
PromoteID uint64 `gorm:"primaryKey;autoIncrement:false"`
|
||||||
|
RuleType string `gorm:"primaryKey;size:50"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (alertTriggerLockPO) TableName() string { return "sys_business_alert_locks" }
|
||||||
|
|
||||||
|
func (r *alertTriggerRepo) Input(ctx context.Context) (*business.AlertTriggerInput, error) {
|
||||||
|
db := r.data.DB().WithContext(ctx)
|
||||||
|
out := &business.AlertTriggerInput{}
|
||||||
|
var rules []alertRulePO
|
||||||
|
if err := db.Where("enabled=1 AND is_deleted=0").Order("id").Find(&rules).Error; err != nil {
|
||||||
|
return nil, business.ErrStorage
|
||||||
|
}
|
||||||
|
for _, row := range rules {
|
||||||
|
out.Rules = append(out.Rules, toBizAlertRule(row))
|
||||||
|
}
|
||||||
|
if len(rules) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
var accounts []accountPO
|
||||||
|
if err := db.Select("id,username,level,parent_id,id_path,authority_id").Find(&accounts).Error; err != nil {
|
||||||
|
return nil, business.ErrStorage
|
||||||
|
}
|
||||||
|
for _, row := range accounts {
|
||||||
|
out.Accounts = append(out.Accounts, toBizAccount(row))
|
||||||
|
}
|
||||||
|
var bindings []bindingPO
|
||||||
|
if err := db.Where("status=?", "0").Find(&bindings).Error; err != nil {
|
||||||
|
return nil, business.ErrStorage
|
||||||
|
}
|
||||||
|
for _, row := range bindings {
|
||||||
|
out.Bindings = append(out.Bindings, toBizBinding(row))
|
||||||
|
}
|
||||||
|
if len(bindings) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
available, err := businessTableAvailable(db, "tab_promote")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
return nil, business.ErrAlertChannelsUnavailable
|
||||||
|
}
|
||||||
|
var channels []struct {
|
||||||
|
ID, BusierID uint64
|
||||||
|
SourceDB string `gorm:"column:source_db"`
|
||||||
|
Account string
|
||||||
|
LastLoginTime, CreateTime *int64
|
||||||
|
}
|
||||||
|
bound := db.Table("sys_user_bind_platform b").Select("1").Where("b.status=? AND b.source_db=p.source_db AND b.platform_busier_id=p.busier_id", "0")
|
||||||
|
if err := db.Table("tab_promote p").Select("p.id,p.source_db,p.account,p.busier_id,p.last_login_time,p.create_time").Where("p.promote_level=1 AND p.status=1").Where("EXISTS (?)", bound).Order("p.source_db,p.id").Scan(&channels).Error; err != nil {
|
||||||
|
return nil, business.ErrStorage
|
||||||
|
}
|
||||||
|
for _, row := range channels {
|
||||||
|
channel := &business.AlertTriggerChannel{ChannelKey: business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID}, Account: row.Account, BusierID: row.BusierID}
|
||||||
|
if row.LastLoginTime != nil {
|
||||||
|
channel.LastLoginUnix = *row.LastLoginTime
|
||||||
|
}
|
||||||
|
if row.CreateTime != nil {
|
||||||
|
channel.CreatedUnix = *row.CreateTime
|
||||||
|
}
|
||||||
|
out.Channels = append(out.Channels, channel)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertTriggerRepo) Comparison(ctx context.Context, metric int, channels []business.ChannelKey, today time.Time, days int) (map[business.ChannelKey]business.AlertComparison, error) {
|
||||||
|
db := r.data.DB().WithContext(ctx)
|
||||||
|
table, stamp, amount, condition := "tab_user", "register_time", "1", "(puid=0 OR puid IS NULL)"
|
||||||
|
if metric == 2 {
|
||||||
|
table, stamp, amount, condition = "tab_spend", "pay_time", "pay_amount", "pay_status=1"
|
||||||
|
} else if metric != 1 {
|
||||||
|
return nil, business.ErrAlertRuleInput
|
||||||
|
}
|
||||||
|
available, err := businessTableAvailable(db, table)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
return nil, business.ErrReportBuildUnavailable
|
||||||
|
}
|
||||||
|
result := map[business.ChannelKey]business.AlertComparison{}
|
||||||
|
currentStart, previousStart := today.AddDate(0, 0, -days).Unix(), today.AddDate(0, 0, -2*days).Unix()
|
||||||
|
for start := 0; start < len(channels); start += 500 {
|
||||||
|
var rows []struct {
|
||||||
|
SourceDB string `gorm:"column:source_db"`
|
||||||
|
PromoteID uint64
|
||||||
|
FirstUnix *int64
|
||||||
|
CurrentValue, PreviousValue string
|
||||||
|
}
|
||||||
|
// 原始主账号与成功支付口径,不扩展下级渠道,也不使用身份证偏好。
|
||||||
|
selection := "source_db,promote_id,MIN(" + stamp + ") AS first_unix," +
|
||||||
|
"COALESCE(SUM(CASE WHEN " + stamp + ">=? AND " + stamp + "<? THEN " + amount + " ELSE 0 END),0) AS current_value," +
|
||||||
|
"COALESCE(SUM(CASE WHEN " + stamp + ">=? AND " + stamp + "<? THEN " + amount + " ELSE 0 END),0) AS previous_value"
|
||||||
|
if err := db.Table(table).Select(selection, currentStart, today.Unix(), previousStart, currentStart).Where(condition).Where("(source_db,promote_id) IN ?", channelPairs(channels[start:min(start+500, len(channels))])).Group("source_db,promote_id").Scan(&rows).Error; err != nil {
|
||||||
|
return nil, business.ErrStorage
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
value := business.AlertComparison{Current: row.CurrentValue, Previous: row.PreviousValue}
|
||||||
|
if row.FirstUnix != nil {
|
||||||
|
value.FirstUnix = *row.FirstUnix
|
||||||
|
}
|
||||||
|
result[business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *alertTriggerRepo) StoreHit(ctx context.Context, hit *business.AlertRecord, open []int, shouldWrite func(*business.AlertRecord) bool) (bool, error) {
|
||||||
|
written := false
|
||||||
|
err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
key := alertTriggerLockPO{SourceDB: hit.PlatformCode, PromoteID: hit.TargetPromoteID, RuleType: hit.RuleType}
|
||||||
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&key).Error; err != nil {
|
||||||
|
return business.ErrStorage
|
||||||
|
}
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("source_db=? AND promote_id=? AND rule_type=?", key.SourceDB, key.PromoteID, key.RuleType).Take(&key).Error; err != nil {
|
||||||
|
return business.ErrStorage
|
||||||
|
}
|
||||||
|
var previous alertRecordPO
|
||||||
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("platform_code=? AND target_promote_id=? AND rule_type=? AND status IN ?", key.SourceDB, key.PromoteID, key.RuleType, open).Order("id").Take(&previous).Error
|
||||||
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return business.ErrStorage
|
||||||
|
}
|
||||||
|
var current *business.AlertRecord
|
||||||
|
if err == nil {
|
||||||
|
current = toBizAlertRecord(previous)
|
||||||
|
}
|
||||||
|
if !shouldWrite(current) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
day := time.Date(hit.CreatedAt.Year(), hit.CreatedAt.Month(), hit.CreatedAt.Day(), 0, 0, 0, 0, hit.CreatedAt.Location())
|
||||||
|
row := alertRecordPO{PlatformCode: hit.PlatformCode, PlatformName: hit.PlatformName, RuleID: hit.RuleID, RuleName: hit.RuleName, RuleType: hit.RuleType,
|
||||||
|
MonitorMetric: hit.MonitorMetric, TargetPromoteID: hit.TargetPromoteID, TargetPromoteName: hit.TargetPromoteName, BusinessID: hit.BusinessID, BusinessName: hit.BusinessName,
|
||||||
|
AlertContent: hit.AlertContent, Status: hit.Status, AlertDate: &day, CreatedAt: hit.CreatedAt, UpdatedAt: hit.UpdatedAt}
|
||||||
|
if current == nil {
|
||||||
|
err = tx.Create(&row).Error
|
||||||
|
} else {
|
||||||
|
row.ID = previous.ID
|
||||||
|
err = tx.Model(&alertRecordPO{}).Where("id=?", previous.ID).Select("platform_name", "rule_id", "rule_name", "rule_type", "monitor_metric", "target_promote_name", "business_id", "business_name", "alert_content", "status", "alert_date", "created_at", "updated_at").Updates(&row).Error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return business.ErrStorage
|
||||||
|
}
|
||||||
|
written = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return written, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertTriggerStorageUsesRawMetricsAndPreservesWorkflow(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
if err := db.AutoMigrate(&accountPO{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||||
|
for _, sql := range []string{
|
||||||
|
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,busier_id INTEGER,account TEXT,promote_level INTEGER,status INTEGER,last_login_time INTEGER,create_time INTEGER)",
|
||||||
|
"CREATE TABLE tab_user(id INTEGER,source_db TEXT,promote_id INTEGER,puid INTEGER,register_time TEXT)",
|
||||||
|
"CREATE TABLE tab_spend(source_db TEXT,promote_id INTEGER,pay_time INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))",
|
||||||
|
"INSERT INTO sys_users(id,username,level,parent_id,id_path,authority_id) VALUES(10,'一级',1,0,'0',901),(20,'二级',2,10,'0,10',902),(30,'三级',3,20,'0,10,20',903)",
|
||||||
|
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(30,'lx',7,'绑定','0')",
|
||||||
|
"INSERT INTO tab_promote VALUES(10,'lx',7,'正常渠道',1,1,0,1),(11,'lx',7,'停用渠道',1,0,0,1),(12,'lx',7,'下级渠道',2,1,0,1)",
|
||||||
|
} {
|
||||||
|
if err := db.Exec(sql).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
today := time.Date(2026, 11, 4, 0, 0, 0, 0, now.Location())
|
||||||
|
if err := db.Exec("INSERT INTO tab_user VALUES(1,'lx',10,0,?),(2,'lx',10,0,?),(3,'lx',10,NULL,?),(4,'lx',10,1,?),(5,'hw',10,0,?)", today.AddDate(0, 0, -2).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Exec("INSERT INTO tab_spend VALUES('lx',10,?,1,100),('lx',10,?,1,110),('lx',10,?,0,999),('hw',10,?,1,999)", today.AddDate(0, 0, -2).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
day, direction := 1, 1
|
||||||
|
threshold := "10"
|
||||||
|
rule := alertRulePO{ID: 1, RuleName: "充值规则", RuleType: "充值", MonitorMetric: 2, CreatorID: 10, StatPeriodDays: &day, MomDirection: &direction, MomThreshold: &threshold, Enabled: 1}
|
||||||
|
if err := db.Create(&rule).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := NewAlertTriggerRepo(testData{db})
|
||||||
|
input, err := r.Input(context.Background())
|
||||||
|
if err != nil || len(input.Channels) != 1 {
|
||||||
|
t.Fatalf("渠道查询错误: %+v %v", input, err)
|
||||||
|
}
|
||||||
|
metrics, err := r.Comparison(context.Background(), 1, []business.ChannelKey{{SourceDB: "lx", ID: 10}}, today, 1)
|
||||||
|
if err != nil || metrics[business.ChannelKey{SourceDB: "lx", ID: 10}].Current != "2" {
|
||||||
|
t.Fatalf("主账号、NULL父账号或跨平台口径错误: %+v %v", metrics, err)
|
||||||
|
}
|
||||||
|
uc := business.NewAlertTriggerUsecase(r)
|
||||||
|
result, err := uc.Detect(context.Background(), now)
|
||||||
|
if err != nil || result.Written != 1 {
|
||||||
|
t.Fatalf("首次命中: %+v %v", result, err)
|
||||||
|
}
|
||||||
|
var first alertRecordPO
|
||||||
|
if err := db.First(&first).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.BusinessID != 10 || first.TargetPromoteID != 10 || first.PlatformCode != "lx" {
|
||||||
|
t.Fatalf("归属错误: %+v", first)
|
||||||
|
}
|
||||||
|
result, err = uc.Detect(context.Background(), now.Add(time.Hour))
|
||||||
|
var count int64
|
||||||
|
db.Model(&alertRecordPO{}).Count(&count)
|
||||||
|
if err != nil || result.Written != 1 || count != 1 {
|
||||||
|
t.Fatalf("重复命中必须刷新同一未处理记录: %+v count=%d %v", result, count, err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&alertRecordPO{}).Where("id=?", first.ID).Update("status", 2).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err = uc.Detect(context.Background(), now.Add(2*time.Hour))
|
||||||
|
if err != nil || result.Written != 0 {
|
||||||
|
t.Fatalf("处理中预警被覆盖: %+v %v", result, err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&alertRecordPO{}).Where("id=?", first.ID).Update("status", 4).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err = uc.Detect(context.Background(), now.Add(3*time.Hour))
|
||||||
|
db.Model(&alertRecordPO{}).Count(&count)
|
||||||
|
if err != nil || result.Written != 0 || count != 1 {
|
||||||
|
t.Fatalf("驳回仍是未关闭记录,不应重复生成: %+v count=%d %v", result, count, err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&alertRecordPO{}).Where("id=?", first.ID).Update("status", 5).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err = uc.Detect(context.Background(), now.Add(3*time.Hour))
|
||||||
|
db.Model(&alertRecordPO{}).Count(&count)
|
||||||
|
if err != nil || result.Written != 1 || count != 2 {
|
||||||
|
t.Fatalf("已关闭后应允许新一轮预警: %+v count=%d %v", result, count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertTriggerWriteFailureRollsBack(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
if err := db.Exec("CREATE TRIGGER reject_alert BEFORE INSERT ON sys_alert_record BEGIN SELECT RAISE(ABORT,'test write failure'); END").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := NewAlertTriggerRepo(testData{db})
|
||||||
|
hit := &business.AlertRecord{PlatformCode: "lx", TargetPromoteID: 1, RuleType: "注册", Status: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||||
|
_, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, func(*business.AlertRecord) bool { return true })
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("写入失败应返回错误")
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Model(&alertTriggerLockPO{}).Count(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("事务未回滚键锁: %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"kra/internal/biz/business"
|
"kra/internal/biz/business"
|
||||||
)
|
)
|
||||||
@@ -80,6 +81,9 @@ func analyticsRoot(db *gorm.DB, kind business.AnalyticsKind, scope business.Anal
|
|||||||
if kind == business.ChannelAnalytics {
|
if kind == business.ChannelAnalytics {
|
||||||
id = "r.promote_id"
|
id = "r.promote_id"
|
||||||
if scope.IncludeSub {
|
if scope.IncludeSub {
|
||||||
|
if pay {
|
||||||
|
q = analyticsPayLeaves(db, q)
|
||||||
|
}
|
||||||
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
|
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
|
||||||
id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)"
|
id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)"
|
||||||
if pay {
|
if pay {
|
||||||
@@ -89,6 +93,12 @@ func analyticsRoot(db *gorm.DB, kind business.AnalyticsKind, scope business.Anal
|
|||||||
}
|
}
|
||||||
return q, id
|
return q, id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func analyticsPayLeaves(db, q *gorm.DB) *gorm.DB {
|
||||||
|
// 金额可先按原渠道加总再归并树,减少按每天/游戏重复关联渠道资料。
|
||||||
|
leaves := q.Select("r.source_db,r.promote_id,COALESCE(SUM(r.total_pay),0) AS total_pay").Group("r.source_db,r.promote_id")
|
||||||
|
return db.Table("(?) r", leaves)
|
||||||
|
}
|
||||||
func putAnalyticsCount(counts *business.PortraitCounts, metric int, count int64) {
|
func putAnalyticsCount(counts *business.PortraitCounts, metric int, count int64) {
|
||||||
switch metric {
|
switch metric {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -134,44 +144,61 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
|
|||||||
}
|
}
|
||||||
return rowMap[key]
|
return rowMap[key]
|
||||||
}
|
}
|
||||||
var pays []analyticsAggPO
|
var pays, counts []analyticsAggPO
|
||||||
q, id := analyticsRoot(db, kind, scope, f, "tab_sw_report_day", 0, true)
|
var global []portraitCountPO
|
||||||
if err := q.Select("r.source_db," + id + " AS entity_id,COALESCE(SUM(r.total_pay),0) AS amount").Group("r.source_db," + id).Scan(&pays).Error; err != nil {
|
globalPay := "0"
|
||||||
return nil, business.ErrStorage
|
withGlobal := kind == business.ChannelAnalytics && scope.Viewer.All
|
||||||
|
group, queryContext := errgroup.WithContext(ctx)
|
||||||
|
// 每个请求最多两个并行查询,复用同一存储客户端;结果读取完后再组装 DO。
|
||||||
|
group.SetLimit(2)
|
||||||
|
queryDB := db.WithContext(queryContext)
|
||||||
|
group.Go(func() error {
|
||||||
|
q, id := analyticsRoot(queryDB, kind, scope, f, "tab_sw_report_day", 0, true)
|
||||||
|
if err := q.Select("r.source_db," + id + " AS entity_id,COALESCE(SUM(r.total_pay),0) AS amount").Group("r.source_db," + id).Scan(&pays).Error; err != nil {
|
||||||
|
return business.ErrStorage
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
group.Go(func() error {
|
||||||
|
q, id := analyticsRoot(queryDB, kind, scope, f, "tab_sw_report_day_key", mode, false)
|
||||||
|
if err := q.Select("r.source_db," + id + " AS entity_id,r.metric,COUNT(DISTINCT r.identity_key) AS count").Group("r.source_db," + id + ",r.metric").Scan(&counts).Error; err != nil {
|
||||||
|
return business.ErrStorage
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if withGlobal {
|
||||||
|
group.Go(func() error {
|
||||||
|
q := analyticsQuery(queryDB, scope, f, "tab_sw_report_day_key", mode)
|
||||||
|
if err := q.Select("r.metric,COUNT(DISTINCT " + portraitIdentity(queryDB, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&global).Error; err != nil {
|
||||||
|
return business.ErrStorage
|
||||||
|
}
|
||||||
|
if err := analyticsQuery(queryDB, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(&globalPay).Error; err != nil {
|
||||||
|
return business.ErrStorage
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := group.Wait(); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, po := range pays {
|
for _, po := range pays {
|
||||||
if po.ID == nil {
|
if po.ID != nil {
|
||||||
continue
|
rowFor(po).Pay = po.Amount
|
||||||
}
|
}
|
||||||
rowFor(po).Pay = po.Amount
|
|
||||||
}
|
|
||||||
var counts []analyticsAggPO
|
|
||||||
q, id = analyticsRoot(db, kind, scope, f, "tab_sw_report_day_key", mode, false)
|
|
||||||
if err := q.Select("r.source_db," + id + " AS entity_id,r.metric,COUNT(DISTINCT r.identity_key) AS count").Group("r.source_db," + id + ",r.metric").Scan(&counts).Error; err != nil {
|
|
||||||
return nil, business.ErrStorage
|
|
||||||
}
|
}
|
||||||
for _, po := range counts {
|
for _, po := range counts {
|
||||||
if po.ID == nil {
|
if po.ID != nil {
|
||||||
continue
|
putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count)
|
||||||
}
|
}
|
||||||
putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count)
|
|
||||||
}
|
}
|
||||||
if kind == business.ChannelAnalytics && !scope.Viewer.All {
|
if kind == business.ChannelAnalytics && !scope.Viewer.All {
|
||||||
if err := filterAnalyticsRows(db, scope, f, rowMap); err != nil {
|
if err := filterAnalyticsRows(db, scope, f, rowMap); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if kind == business.ChannelAnalytics && scope.Viewer.All {
|
if withGlobal {
|
||||||
var global []portraitCountPO
|
|
||||||
q := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode)
|
|
||||||
if err := q.Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&global).Error; err != nil {
|
|
||||||
return nil, business.ErrStorage
|
|
||||||
}
|
|
||||||
totals := toPortraitCounts(global)
|
totals := toPortraitCounts(global)
|
||||||
result.GlobalCounts = &totals
|
result.GlobalCounts, result.GlobalPay = &totals, globalPay
|
||||||
if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(&result.GlobalPay).Error; err != nil {
|
|
||||||
return nil, business.ErrStorage
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(rowMap) == 0 {
|
if len(rowMap) == 0 {
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter
|
|||||||
}
|
}
|
||||||
var pos []channelPO
|
var pos []channelPO
|
||||||
o := business.NewListOptions(options...)
|
o := business.NewListOptions(options...)
|
||||||
if err := q.Select("p.id,p.source_db,p.account,p.nickname,p.parent_name,p.real_name,p.promote_level,p.register_type,p.status,p.lock_status,p.parent_id,p.top_promote_id,p.busier_id").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Offset(o.Offset).Limit(o.Limit).Scan(&pos).Error; err != nil {
|
keysQuery := q.Select("p.id,p.source_db").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Offset(o.Offset).Limit(o.Limit)
|
||||||
|
pageQuery := db.Table("tab_promote p").Joins("JOIN (?) page_ids ON page_ids.id=p.id AND page_ids.source_db=p.source_db", keysQuery)
|
||||||
|
if err := pageQuery.Select("p.id,p.source_db,p.account,p.nickname,p.parent_name,p.real_name,p.promote_level,p.register_type,p.status,p.lock_status,p.parent_id,p.top_promote_id,p.busier_id").Order("p.create_time DESC,p.id DESC,p.source_db ASC").Scan(&pos).Error; err != nil {
|
||||||
return nil, business.ErrStorage
|
return nil, business.ErrStorage
|
||||||
}
|
}
|
||||||
if len(pos) == 0 {
|
if len(pos) == 0 {
|
||||||
@@ -184,7 +186,13 @@ func (r *channelRepo) StatChannels(ctx context.Context, roots []*business.Channe
|
|||||||
for start := 0; start < len(keys); start += 200 {
|
for start := 0; start < len(keys); start += 200 {
|
||||||
pairs := channelPairs(keys[start:min(start+200, len(keys))])
|
pairs := channelPairs(keys[start:min(start+200, len(keys))])
|
||||||
var pos []channelPO
|
var pos []channelPO
|
||||||
err := r.data.DB().WithContext(ctx).Table("tab_promote").Select("id,source_db,promote_level,parent_id,top_promote_id").Where("(source_db,parent_id) IN ? OR (source_db,top_promote_id) IN ?", pairs, pairs).Scan(&pos).Error
|
db := r.data.DB().WithContext(ctx)
|
||||||
|
columns := "id,source_db,promote_level,parent_id,top_promote_id"
|
||||||
|
parents := db.Table("tab_promote").Select(columns).Where("(source_db,parent_id) IN ?", pairs)
|
||||||
|
tops := db.Table("tab_promote").Select(columns).Where("(source_db,top_promote_id) IN ?", pairs)
|
||||||
|
// 分别走 parent_id / top_promote_id 索引,避免跨列 OR 退化为全表扫描。
|
||||||
|
// 同时命中两条关系的渠道继续由下面的 ChannelKey 集合去重。
|
||||||
|
err := db.Raw("? UNION ALL ?", parents, tops).Scan(&pos).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, business.ErrStorage
|
return nil, business.ErrStorage
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ func (r *channelWriteRepo) Create(ctx context.Context, input *business.ChannelWr
|
|||||||
conn.WithContext(cleanup).Exec("DO RELEASE_LOCK(?)", lockName)
|
conn.WithContext(cleanup).Exec("DO RELEASE_LOCK(?)", lockName)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
return conn.Transaction(func(tx *gorm.DB) error {
|
// GET_LOCK 的标量 Scan 会修改固定连接上的 Statement.Model。
|
||||||
|
// 清空查询状态后再开事务,仍复用持锁连接,避免后续结构体 Take 读取到标量模型。
|
||||||
|
return conn.Session(&gorm.Session{NewDB: true}).Transaction(func(tx *gorm.DB) error {
|
||||||
var count int64
|
var count int64
|
||||||
if err := tx.Model(&platformChannelPO{}).Where("account = ?", input.Account).Count(&count).Error; err != nil {
|
if err := tx.Model(&platformChannelPO{}).Where("account = ?", input.Account).Count(&count).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -87,11 +87,24 @@ type registrationPO struct {
|
|||||||
PromoteID *int64
|
PromoteID *int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func registrationQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter) *gorm.DB {
|
func registrationTimeExpression(db *gorm.DB) (string, error) {
|
||||||
cast := "CAST(r.register_time AS INTEGER)"
|
cast := "CAST(r.register_time AS INTEGER)"
|
||||||
if db.Dialector.Name() == "mysql" {
|
if db.Dialector.Name() == "mysql" {
|
||||||
cast = "CAST(r.register_time AS UNSIGNED)"
|
cast = "CAST(r.register_time AS UNSIGNED)"
|
||||||
|
var count int64
|
||||||
|
if err := db.Raw("SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='tab_user' AND column_name='sw_register_time_unix'").Scan(&count).Error; err != nil {
|
||||||
|
return "", business.ErrStorage
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
// 直接使用等价生成列,使 COUNT 和分页主键查询走覆盖索引。
|
||||||
|
// CAST 虽可用于范围定位,但 MySQL 5.7 仍会回表读取原字符串。
|
||||||
|
cast = "r.sw_register_time_unix"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return cast, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func registrationQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, cast string) *gorm.DB {
|
||||||
q := ledgerScopeQuery(db, scope, filter, "tab_user").Where("r.puid = 0").Where(cast+" BETWEEN ? AND ?", filter.BeginUnix, filter.EndUnix)
|
q := ledgerScopeQuery(db, scope, filter, "tab_user").Where("r.puid = 0").Where(cast+" BETWEEN ? AND ?", filter.BeginUnix, filter.EndUnix)
|
||||||
if filter.UserID != nil {
|
if filter.UserID != nil {
|
||||||
q = q.Where("r.id = ?", *filter.UserID)
|
q = q.Where("r.id = ?", *filter.UserID)
|
||||||
@@ -135,7 +148,11 @@ func (r *ledgerRepo) Registrations(ctx context.Context, scope business.LedgerSco
|
|||||||
page.Available = false
|
page.Available = false
|
||||||
return page, nil
|
return page, nil
|
||||||
}
|
}
|
||||||
q := registrationQuery(db, scope, filter)
|
timeExpression, err := registrationTimeExpression(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := registrationQuery(db, scope, filter, timeExpression)
|
||||||
if err := q.Count(&page.Total).Error; err != nil {
|
if err := q.Count(&page.Total).Error; err != nil {
|
||||||
return nil, business.ErrStorage
|
return nil, business.ErrStorage
|
||||||
}
|
}
|
||||||
@@ -146,15 +163,14 @@ func (r *ledgerRepo) Registrations(ctx context.Context, scope business.LedgerSco
|
|||||||
if filter.Ascending {
|
if filter.Ascending {
|
||||||
direction = " ASC"
|
direction = " ASC"
|
||||||
}
|
}
|
||||||
order := "CAST(r.register_time AS INTEGER)"
|
order := timeExpression
|
||||||
if db.Dialector.Name() == "mysql" {
|
|
||||||
order = "CAST(r.register_time AS UNSIGNED)"
|
|
||||||
}
|
|
||||||
if filter.SortField == "totalPayAmount" {
|
if filter.SortField == "totalPayAmount" {
|
||||||
order = "(SELECT COALESCE(SUM(s.pay_amount),0) FROM tab_spend s WHERE s.source_db=r.source_db AND s.user_id=r.id AND s.pay_status=1)"
|
order = "(SELECT COALESCE(SUM(s.pay_amount),0) FROM tab_spend s WHERE s.source_db=r.source_db AND s.user_id=r.id AND s.pay_status=1)"
|
||||||
}
|
}
|
||||||
var pos []registrationPO
|
var pos []registrationPO
|
||||||
if err := q.Select("r.id,r.source_db,r.register_time,r.fgame_id,r.fgame_name,r.register_ip,r.device_name,r.promote_id").Order(order + direction).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit).Scan(&pos).Error; err != nil {
|
keysQuery := q.Select("r.id,r.source_db").Order(order + direction).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit)
|
||||||
|
pageQuery := db.Table("tab_user r").Joins("JOIN (?) page_ids ON page_ids.id=r.id AND page_ids.source_db=r.source_db", keysQuery)
|
||||||
|
if err := pageQuery.Select("r.id,r.source_db,r.register_time,r.fgame_id,r.fgame_name,r.register_ip,r.device_name,r.promote_id").Order(order + direction).Order("r.id DESC,r.source_db ASC").Scan(&pos).Error; err != nil {
|
||||||
return nil, business.ErrStorage
|
return nil, business.ErrStorage
|
||||||
}
|
}
|
||||||
meta := []ledgerMetaTarget{}
|
meta := []ledgerMetaTarget{}
|
||||||
@@ -280,7 +296,10 @@ func (r *ledgerRepo) Spends(ctx context.Context, scope business.LedgerScope, fil
|
|||||||
order += " DESC"
|
order += " DESC"
|
||||||
}
|
}
|
||||||
var pos []spendPO
|
var pos []spendPO
|
||||||
if err := q.Select("r.id,r.source_db,r.user_id,r.pay_order_number,r.game_id,r.game_name,r.server_id,r.server_name,r.game_player_name,r.pay_amount,r.pay_time,r.pay_way,r.pay_status,r.spend_ip,r.promote_id,r.promote_account").Order(order).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit).Scan(&pos).Error; err != nil {
|
// 先在窄查询中选出当前页主键,再读取明细列,避免为排序读取整段流水。
|
||||||
|
keysQuery := q.Select("r.id,r.source_db").Order(order).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit)
|
||||||
|
pageQuery := db.Table("tab_spend r").Joins("JOIN (?) page_ids ON page_ids.id=r.id AND page_ids.source_db=r.source_db", keysQuery)
|
||||||
|
if err := pageQuery.Select("r.id,r.source_db,r.user_id,r.pay_order_number,r.game_id,r.game_name,r.server_id,r.server_name,r.game_player_name,r.pay_amount,r.pay_time,r.pay_way,r.pay_status,r.spend_ip,r.promote_id,r.promote_account").Order(order).Order("r.id DESC,r.source_db ASC").Scan(&pos).Error; err != nil {
|
||||||
return nil, business.ErrStorage
|
return nil, business.ErrStorage
|
||||||
}
|
}
|
||||||
meta := []ledgerMetaTarget{}
|
meta := []ledgerMetaTarget{}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Migrations() []migration.Step {
|
func Migrations() []migration.Step {
|
||||||
return []migration.Step{{ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
|
return []migration.Step{{ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
|
||||||
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
|
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
|
||||||
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
|
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
|
||||||
return migration.CreateMissingTables(db, &bindingLockPO{})
|
return migration.CreateMissingTables(db, &bindingLockPO{})
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
drivermysql "github.com/go-sql-driver/mysql"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 显式设置本地隔离 MySQL DSN 才执行;每项测试只创建并清理自己的随机数据库。
|
||||||
|
func openRehearsalMySQL(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("SW_TEST_MYSQL_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("未配置隔离 MySQL")
|
||||||
|
}
|
||||||
|
cfg, err := drivermysql.ParseDSN(dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") {
|
||||||
|
t.Fatal("MySQL 演练测试只接受本地隔离实例")
|
||||||
|
}
|
||||||
|
cfg.DBName = ""
|
||||||
|
cfg.ParseTime = true
|
||||||
|
cfg.Loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
|
admin, err := sql.Open("mysql", cfg.FormatDSN())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("sw_test_business_%d", time.Now().UnixNano())
|
||||||
|
if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
|
||||||
|
_ = admin.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, err := admin.Exec("DROP DATABASE `" + name + "`")
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
_ = admin.Close()
|
||||||
|
})
|
||||||
|
cfg.DBName = name
|
||||||
|
db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool, _ := db.DB()
|
||||||
|
pool.SetMaxOpenConns(20)
|
||||||
|
t.Cleanup(func() { _ = pool.Close() })
|
||||||
|
for _, step := range Migrations() {
|
||||||
|
if err := step.Migrate(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLAlertTriggerConcurrentDeduplication(t *testing.T) {
|
||||||
|
db := openRehearsalMySQL(t)
|
||||||
|
if err := db.Exec("ALTER TABLE sys_alert_record ADD COLUMN unclosed_flag TINYINT GENERATED ALWAYS AS (CASE WHEN status=5 THEN NULL ELSE 1 END) VIRTUAL, ADD UNIQUE INDEX uk_alert_record_unclosed(platform_code,target_promote_id,rule_type,unclosed_flag)").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := NewAlertTriggerRepo(testData{db})
|
||||||
|
now := time.Now().Truncate(time.Millisecond)
|
||||||
|
hit := &business.AlertRecord{RuleID: 1, PlatformCode: "lx", PlatformName: "龙翔", TargetPromoteID: 10, RuleType: "注册", Status: 1, BusinessID: 10, CreatedAt: now, UpdatedAt: now}
|
||||||
|
decide := func(current *business.AlertRecord) bool { return current == nil || current.Status == 1 }
|
||||||
|
errors := make(chan error, 16)
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
group.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer group.Done()
|
||||||
|
_, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide)
|
||||||
|
errors <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
group.Wait()
|
||||||
|
close(errors)
|
||||||
|
for err := range errors {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&alertRecordPO{}).Count(&count).Error; err != nil || count != 1 {
|
||||||
|
t.Fatalf("并发重复预警 count=%d error=%v", count, err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&alertRecordPO{}).Where("platform_code='lx'").Update("status", 2).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
changed := *hit
|
||||||
|
changed.AlertContent = "不应覆盖处理中记录"
|
||||||
|
written, err := r.StoreHit(context.Background(), &changed, []int{1, 2, 3, 4}, decide)
|
||||||
|
if err != nil || written {
|
||||||
|
t.Fatalf("在途记录被刷新: %v %v", written, err)
|
||||||
|
}
|
||||||
|
var row alertRecordPO
|
||||||
|
if err := db.First(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if row.Status != 2 || row.AlertContent != "" {
|
||||||
|
t.Fatalf("状态或快照发生变化: %+v", row)
|
||||||
|
}
|
||||||
|
if err := db.Model(&row).Update("status", 4).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
written, err = r.StoreHit(context.Background(), &changed, []int{1, 2, 3, 4}, decide)
|
||||||
|
if err != nil || written {
|
||||||
|
t.Fatalf("状态 4 是驳回,不能另建或覆盖未关闭记录: %v %v", written, err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&row).Update("status", 5).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
written, err = r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide)
|
||||||
|
db.Model(&alertRecordPO{}).Count(&count)
|
||||||
|
if err != nil || !written || count != 2 {
|
||||||
|
t.Fatalf("关闭后新一轮预警失败: %v count=%d %v", written, count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLRegistrationIndexPreservesCastAndUsesRange(t *testing.T) {
|
||||||
|
db := openRehearsalMySQL(t)
|
||||||
|
for _, statement := range []string{
|
||||||
|
"CREATE TABLE tab_user(id INT,source_db VARCHAR(10),puid INT,register_time VARCHAR(11),PRIMARY KEY(id,source_db),KEY(register_time))",
|
||||||
|
"INSERT INTO tab_user VALUES(1,'lx',0,'1793635200'),(2,'lx',0,'01793635200'),(3,'lx',0,'1793635200 '),(4,'lx',1,'1793635200'),(5,'hw',0,'1793635200'),(6,'lx',0,'1793000000')",
|
||||||
|
} {
|
||||||
|
if err := db.Exec(statement).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 让命中比例接近实际分页查询,避免微型表上优化器合理选择全表扫描。
|
||||||
|
outside := make([]map[string]any, 1000)
|
||||||
|
for i := range outside {
|
||||||
|
outside[i] = map[string]any{"id": 100 + i, "source_db": "lx", "puid": 0, "register_time": "1793000000"}
|
||||||
|
}
|
||||||
|
if err := db.Table("tab_user").CreateInBatches(outside, 200).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool, _ := db.DB()
|
||||||
|
pool.SetMaxOpenConns(1)
|
||||||
|
var beforeMode string
|
||||||
|
if err := db.Raw("SELECT @@session.sql_mode").Scan(&beforeMode).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
query := "SELECT id,register_time FROM tab_user r WHERE r.source_db='lx' AND r.puid=0 AND CAST(r.register_time AS UNSIGNED) BETWEEN 1793635200 AND 1793721599 ORDER BY id"
|
||||||
|
var before, after []struct {
|
||||||
|
ID int
|
||||||
|
RegisterTime string
|
||||||
|
}
|
||||||
|
if err := db.Raw(query).Scan(&before).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := migrateRegistrationIndex(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var afterMode string
|
||||||
|
if err := db.Raw("SELECT @@session.sql_mode").Scan(&afterMode).Error; err != nil || beforeMode != afterMode {
|
||||||
|
t.Fatalf("索引创建未恢复会话模式: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Raw(query).Scan(&after).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(before) != 3 || fmt.Sprint(before) != fmt.Sprint(after) {
|
||||||
|
t.Fatalf("时间转换或原字符串改变: before=%+v after=%+v", before, after)
|
||||||
|
}
|
||||||
|
var plan []struct{ Key, Type string }
|
||||||
|
if err := db.Raw("EXPLAIN SELECT COUNT(*) FROM tab_user r WHERE r.source_db='lx' AND r.puid=0 AND CAST(r.register_time AS UNSIGNED) BETWEEN 1793635200 AND 1793721599").Scan(&plan).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(plan) != 1 || plan[0].Key != "idx_sw_user_registration" || plan[0].Type != "range" {
|
||||||
|
t.Fatalf("未使用时间范围索引: %+v", plan)
|
||||||
|
}
|
||||||
|
queryAll, err := reportRegistrationQuery(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var allIDs []int
|
||||||
|
if err := queryAll.Select("id").Where("puid=0 AND CAST(register_time AS UNSIGNED) BETWEEN ? AND ?", 1793635200, 1793721599).Order("id").Scan(&allIDs).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(allIDs) != "[1 2 3 5]" {
|
||||||
|
t.Fatalf("跨平台重建筛选丢失记录: %v", allIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLChannelCreationSerializesAccount(t *testing.T) {
|
||||||
|
db := openRehearsalMySQL(t)
|
||||||
|
if err := db.AutoMigrate(&platformChannelPO{}, &channelSettlementPO{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, statement := range []string{"CREATE TABLE tab_promote_business(id BIGINT PRIMARY KEY,promote_ids TEXT)", "INSERT INTO tab_promote_business VALUES(7,'5,6')"} {
|
||||||
|
if err := db.Exec(statement).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repo := NewChannelWriteRepo(channelDatabases{db})
|
||||||
|
input := &business.ChannelWrite{ChannelCreate: business.ChannelCreate{SourceDB: "lx", Account: "concurrentChannel", Password: "encoded", BusierID: 7},
|
||||||
|
Status: -3, Level: 1, SettlementDays: 7, CreatedUnix: 1000, NextCountUnix: 605800}
|
||||||
|
results := make(chan error, 8)
|
||||||
|
var group sync.WaitGroup
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
group.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer group.Done()
|
||||||
|
_, err := repo.Create(context.Background(), input)
|
||||||
|
results <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
group.Wait()
|
||||||
|
close(results)
|
||||||
|
created, duplicates := 0, 0
|
||||||
|
for err := range results {
|
||||||
|
if err == nil {
|
||||||
|
created++
|
||||||
|
} else if err == business.ErrChannelExists {
|
||||||
|
duplicates++
|
||||||
|
} else {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var channelCount, settlementCount int64
|
||||||
|
db.Model(&platformChannelPO{}).Count(&channelCount)
|
||||||
|
db.Model(&channelSettlementPO{}).Count(&settlementCount)
|
||||||
|
if created != 1 || duplicates != 7 || channelCount != 1 || settlementCount != 1 {
|
||||||
|
t.Fatalf("并发创建结果错误: created=%d duplicate=%d channels=%d settlements=%d", created, duplicates, channelCount, settlementCount)
|
||||||
|
}
|
||||||
|
var relation string
|
||||||
|
db.Table("tab_promote_business").Select("promote_ids").Where("id=7").Scan(&relation)
|
||||||
|
if relation != "5,6,1" {
|
||||||
|
t.Fatalf("商务渠道关系未完整保留: %s", relation)
|
||||||
|
}
|
||||||
|
if err := db.Exec("CREATE TRIGGER fail_settlement BEFORE INSERT ON tab_promote_settlement_time FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test failure'").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
failed := *input
|
||||||
|
failed.Account = "rollbackChannel"
|
||||||
|
if _, err := repo.Create(context.Background(), &failed); err == nil {
|
||||||
|
t.Fatal("结算写入失败应该回滚")
|
||||||
|
}
|
||||||
|
db.Model(&platformChannelPO{}).Count(&channelCount)
|
||||||
|
if channelCount != 1 {
|
||||||
|
t.Fatal("失败的渠道记录没有回滚")
|
||||||
|
}
|
||||||
|
if err := db.Exec("DROP TRIGGER fail_settlement").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := repo.Create(context.Background(), &failed); err != nil {
|
||||||
|
t.Fatalf("失败后命名锁未释放或无法重试: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMySQLServerReportIndexIsAdditiveAndIdempotent(t *testing.T) {
|
||||||
|
db := openRehearsalMySQL(t)
|
||||||
|
if err := db.Exec("CREATE TABLE tab_user_play_info(id INT PRIMARY KEY,create_time INT,play_time INT,KEY(play_time))").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := migrateServerReportIndex(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Raw("SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tab_user_play_info' AND column_name='create_time' AND seq_in_index=1").Scan(&count)
|
||||||
|
if count != 1 || !db.Migrator().HasIndex("tab_user_play_info", "play_time") {
|
||||||
|
t.Fatalf("重复创建索引或覆盖旧索引: %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type businessQueryIndex struct {
|
||||||
|
table, name string
|
||||||
|
columns []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var businessQueryIndexes = []businessQueryIndex{
|
||||||
|
{"tab_promote", "idx_sw_promote_created", []string{"source_db", "create_time", "id"}},
|
||||||
|
{"tab_spend", "idx_sw_spend_time_summary", []string{"pay_status", "pay_time", "source_db", "pay_amount"}},
|
||||||
|
{"tab_spend", "idx_sw_spend_promote_summary", []string{"promote_id", "pay_status", "source_db", "pay_amount"}},
|
||||||
|
{"tab_sw_report_day", "idx_sw_report_pay_scan", []string{"dedup_mode", "stat_date", "source_db", "promote_id", "game_id", "total_pay"}},
|
||||||
|
{"tab_sw_report_day_key", "idx_sw_report_key_scan", []string{"dedup_mode", "stat_date", "source_db", "promote_id", "game_id", "metric", "identity_key"}},
|
||||||
|
{"tab_sw_report_server_day", "idx_sw_server_pay_game_scan", []string{"game_name", "dedup_mode", "stat_date", "source_db", "game_id", "server_num", "total_pay"}},
|
||||||
|
{"tab_sw_report_server_day_key", "idx_sw_server_key_game_scan", []string{"game_name", "dedup_mode", "stat_date", "source_db", "game_id", "server_num", "metric", "identity_key"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只为已经接入的业务表增加索引,不创建平台表、不改写记录。
|
||||||
|
// 按实际筛选前缀排列,并覆盖聚合读取列,避免大范围回表。
|
||||||
|
func migrateBusinessQueryIndexes(db *gorm.DB) error {
|
||||||
|
if db.Dialector.Name() != "mysql" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
groups := make(map[string][]businessQueryIndex)
|
||||||
|
tables := []string{}
|
||||||
|
for _, index := range businessQueryIndexes {
|
||||||
|
if _, exists := groups[index.table]; !exists {
|
||||||
|
tables = append(tables, index.table)
|
||||||
|
}
|
||||||
|
groups[index.table] = append(groups[index.table], index)
|
||||||
|
}
|
||||||
|
for _, table := range tables {
|
||||||
|
available, err := businessTableAvailable(db, table)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
additions := []string{}
|
||||||
|
for _, index := range groups[table] {
|
||||||
|
var existing []string
|
||||||
|
if err := db.Raw("SELECT column_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=? ORDER BY seq_in_index", index.table, index.name).Scan(&existing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(existing) > 0 {
|
||||||
|
if strings.Join(existing, ",") != strings.Join(index.columns, ",") {
|
||||||
|
return fmt.Errorf("%s.%s 的现有索引列不符", index.table, index.name)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
columns := "`" + strings.Join(index.columns, "`,`") + "`"
|
||||||
|
additions = append(additions, "ADD INDEX `"+index.name+"` ("+columns+")")
|
||||||
|
}
|
||||||
|
// 同一大表的多个索引合并构建,减少重复扫描。
|
||||||
|
if len(additions) > 0 {
|
||||||
|
if err := db.Exec("ALTER TABLE `" + table + "` " + strings.Join(additions, ",") + ", ALGORITHM=INPLACE, LOCK=NONE").Error; err != nil {
|
||||||
|
return fmt.Errorf("add query indexes on %s: %w", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMySQLBusinessQueryIndexesAreAdditiveAndIdempotent(t *testing.T) {
|
||||||
|
db := openRehearsalMySQL(t)
|
||||||
|
definitions := map[string]string{
|
||||||
|
"tab_promote": "id BIGINT NOT NULL,source_db VARCHAR(10) NOT NULL,create_time BIGINT,PRIMARY KEY(id,source_db)",
|
||||||
|
"tab_spend": "id BIGINT NOT NULL,source_db VARCHAR(10) NOT NULL,promote_id BIGINT,pay_status INT,pay_time BIGINT,pay_amount DECIMAL(20,2),PRIMARY KEY(id,source_db)",
|
||||||
|
"tab_sw_report_day": "dedup_mode INT,stat_date DATE,source_db VARCHAR(10),promote_id BIGINT,game_id BIGINT,total_pay DECIMAL(20,2)",
|
||||||
|
"tab_sw_report_day_key": "dedup_mode INT,stat_date DATE,source_db VARCHAR(10),promote_id BIGINT,game_id BIGINT,metric INT,identity_key VARCHAR(64)",
|
||||||
|
"tab_sw_report_server_day": "game_name VARCHAR(64),dedup_mode INT,stat_date DATE,source_db VARCHAR(10),game_id BIGINT,server_num VARCHAR(32),total_pay DECIMAL(20,2)",
|
||||||
|
"tab_sw_report_server_day_key": "game_name VARCHAR(64),dedup_mode INT,stat_date DATE,source_db VARCHAR(10),game_id BIGINT,server_num VARCHAR(32),metric INT,identity_key VARCHAR(64)",
|
||||||
|
}
|
||||||
|
for table, columns := range definitions {
|
||||||
|
if err := db.Exec("CREATE TABLE `" + table + "` (" + columns + ") ENGINE=InnoDB").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Exec("INSERT INTO tab_spend VALUES(77,'lx',55,1,123456,12.30)").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
if err := migrateBusinessQueryIndexes(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, index := range businessQueryIndexes {
|
||||||
|
var columns []string
|
||||||
|
if err := db.Raw("SELECT column_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name=? AND index_name=? ORDER BY seq_in_index", index.table, index.name).Scan(&columns).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(columns, ",") != strings.Join(index.columns, ",") {
|
||||||
|
t.Fatalf("index %s has unexpected columns: %v", index.name, columns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var amount string
|
||||||
|
if err := db.Raw("SELECT pay_amount FROM tab_spend WHERE id=77 AND source_db='lx'").Scan(&amount).Error; err != nil || amount != "12.30" {
|
||||||
|
t.Fatalf("index migration changed business data: %s, %v", amount, err)
|
||||||
|
}
|
||||||
|
if err := db.Exec("ALTER TABLE tab_spend DROP INDEX idx_sw_spend_time_summary, ADD INDEX idx_sw_spend_time_summary(pay_time)").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := migrateBusinessQueryIndexes(db); err == nil || !strings.Contains(err.Error(), "idx_sw_spend_time_summary") {
|
||||||
|
t.Fatalf("must reject a conflicting existing index: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package business
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"kra/internal/biz/business"
|
"kra/internal/biz/business"
|
||||||
)
|
)
|
||||||
@@ -44,7 +46,10 @@ func rankingReportQuery(db *gorm.DB, kind business.RankingKind, scope business.A
|
|||||||
return analyticsQuery(db, scope, f, table, mode)
|
return analyticsQuery(db, scope, f, table, mode)
|
||||||
}
|
}
|
||||||
all := business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true}
|
all := business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true}
|
||||||
return analyticsQuery(db, all, f, table, mode).Where("EXISTS (?)", rankingBusinessPromotes(db, scope, f.Sources).Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
|
// 同一渠道会出现在多级商务的范围中,先去重并一次性展开可见渠道。
|
||||||
|
// 避免对每一条报表身份记录重复扫描整棵商务关系树。
|
||||||
|
visible := rankingBusinessPromotes(db, scope, f.Sources).Select("DISTINCT p.source_db,p.id")
|
||||||
|
return analyticsQuery(db, all, f, table, mode).Joins("JOIN (?) visible_promotes ON visible_promotes.source_db=r.source_db AND visible_promotes.id=r.promote_id", visible)
|
||||||
}
|
}
|
||||||
func (r *rankingRepo) Collect(ctx context.Context, kind business.RankingKind, scope business.AnalyticsScope, f business.RankingRange) (*business.RankingData, error) {
|
func (r *rankingRepo) Collect(ctx context.Context, kind business.RankingKind, scope business.AnalyticsScope, f business.RankingRange) (*business.RankingData, error) {
|
||||||
db := r.data.DB().WithContext(ctx)
|
db := r.data.DB().WithContext(ctx)
|
||||||
@@ -109,60 +114,72 @@ func (r *rankingRepo) Collect(ctx context.Context, kind business.RankingKind, sc
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
group, queryContext := errgroup.WithContext(ctx)
|
||||||
|
group.SetLimit(2)
|
||||||
|
queryDB := db.WithContext(queryContext)
|
||||||
for _, period := range []struct {
|
for _, period := range []struct {
|
||||||
begin, end string
|
begin, end string
|
||||||
target map[business.AnalyticsKey]*business.RankingMetrics
|
target map[business.AnalyticsKey]*business.RankingMetrics
|
||||||
}{{f.Begin, f.End, out.Current}, {f.PreviousBegin, f.PreviousEnd, out.Previous}} {
|
}{{f.Begin, f.End, out.Current}, {f.PreviousBegin, f.PreviousEnd, out.Previous}} {
|
||||||
query := f.AnalyticsFilter
|
group.Go(func() error {
|
||||||
query.Begin, query.End = period.begin, period.end
|
query := f.AnalyticsFilter
|
||||||
rowFor := func(po analyticsAggPO) *business.RankingMetrics {
|
query.Begin, query.End = period.begin, period.end
|
||||||
key := business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}
|
rowFor := func(po analyticsAggPO) *business.RankingMetrics {
|
||||||
if period.target[key] == nil {
|
key := business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}
|
||||||
period.target[key] = &business.RankingMetrics{}
|
if period.target[key] == nil {
|
||||||
}
|
period.target[key] = &business.RankingMetrics{}
|
||||||
return period.target[key]
|
|
||||||
}
|
|
||||||
for _, pay := range []bool{true, false} {
|
|
||||||
table, mode := "tab_sw_report_day", 0
|
|
||||||
if !pay {
|
|
||||||
table = "tab_sw_report_day_key"
|
|
||||||
if scope.IDCardDedup {
|
|
||||||
mode = 1
|
|
||||||
}
|
}
|
||||||
|
return period.target[key]
|
||||||
}
|
}
|
||||||
q := rankingReportQuery(db, kind, scope, query, table, mode)
|
for _, pay := range []bool{true, false} {
|
||||||
id := "r.promote_id"
|
table, mode := "tab_sw_report_day", 0
|
||||||
if kind == business.GameRanking {
|
if !pay {
|
||||||
id = "r.game_id"
|
table = "tab_sw_report_day_key"
|
||||||
}
|
if scope.IDCardDedup {
|
||||||
if kind == business.ChannelRanking {
|
mode = 1
|
||||||
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
|
}
|
||||||
id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)"
|
|
||||||
}
|
|
||||||
selects := "r.source_db," + id + " AS entity_id"
|
|
||||||
group := "r.source_db," + id
|
|
||||||
if pay {
|
|
||||||
selects += ",COALESCE(SUM(r.total_pay),0) AS amount"
|
|
||||||
} else {
|
|
||||||
selects += ",r.metric,COUNT(DISTINCT r.identity_key) AS count"
|
|
||||||
group += ",r.metric"
|
|
||||||
}
|
|
||||||
var rows []analyticsAggPO
|
|
||||||
if err := q.Select(selects).Group(group).Scan(&rows).Error; err != nil {
|
|
||||||
return nil, business.ErrStorage
|
|
||||||
}
|
|
||||||
for _, po := range rows {
|
|
||||||
if po.ID == nil || (pay && *po.ID < 0) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
row := rowFor(po)
|
q := rankingReportQuery(queryDB, kind, scope, query, table, mode)
|
||||||
|
id := "r.promote_id"
|
||||||
|
if kind == business.GameRanking {
|
||||||
|
id = "r.game_id"
|
||||||
|
}
|
||||||
|
if kind == business.ChannelRanking {
|
||||||
|
if pay {
|
||||||
|
q = analyticsPayLeaves(queryDB, q)
|
||||||
|
}
|
||||||
|
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
|
||||||
|
id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)"
|
||||||
|
}
|
||||||
|
selects := "r.source_db," + id + " AS entity_id"
|
||||||
|
group := "r.source_db," + id
|
||||||
if pay {
|
if pay {
|
||||||
row.Pay = po.Amount
|
selects += ",COALESCE(SUM(r.total_pay),0) AS amount"
|
||||||
} else {
|
} else {
|
||||||
putAnalyticsCount(&row.Counts, po.Metric, po.Count)
|
selects += ",r.metric,COUNT(DISTINCT r.identity_key) AS count"
|
||||||
|
group += ",r.metric"
|
||||||
|
}
|
||||||
|
var rows []analyticsAggPO
|
||||||
|
if err := q.Select(selects).Group(group).Scan(&rows).Error; err != nil {
|
||||||
|
return business.ErrStorage
|
||||||
|
}
|
||||||
|
for _, po := range rows {
|
||||||
|
if po.ID == nil || (pay && *po.ID < 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := rowFor(po)
|
||||||
|
if pay {
|
||||||
|
row.Pay = po.Amount
|
||||||
|
} else {
|
||||||
|
putAnalyticsCount(&row.Counts, po.Metric, po.Count)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := group.Wait(); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
if kind != business.BusinessRanking && len(out.Current) > 0 {
|
if kind != business.BusinessRanking && len(out.Current) > 0 {
|
||||||
table, column := "tab_promote", "account"
|
table, column := "tab_promote", "account"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 保留旧 register_time 字符串及其 CAST 语义,用虚拟列索引避免逐行扫描主账号。
|
||||||
|
// MySQL 可将查询中相同的 CAST 表达式匹配到该索引,既有查询无需更改统计口径。
|
||||||
|
func migrateRegistrationIndex(db *gorm.DB) error {
|
||||||
|
if db.Dialector.Name() != "mysql" || !db.Migrator().HasTable("tab_user") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
const column = "sw_register_time_unix"
|
||||||
|
const index = "idx_sw_user_registration"
|
||||||
|
if !db.Migrator().HasColumn("tab_user", column) {
|
||||||
|
if err := db.Exec("ALTER TABLE tab_user ADD COLUMN sw_register_time_unix BIGINT UNSIGNED GENERATED ALWAYS AS (CAST(register_time AS UNSIGNED)) VIRTUAL, ALGORITHM=INPLACE, LOCK=NONE").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var expression string
|
||||||
|
if err := db.Raw("SELECT generation_expression FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='tab_user' AND column_name=?", column).Scan(&expression).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
normalized := strings.NewReplacer("`", "", " ", "", "\n", "", "\t", "").Replace(strings.ToLower(expression))
|
||||||
|
if normalized != "cast(register_timeasunsigned)" {
|
||||||
|
return fmt.Errorf("tab_user.%s 的现有定义与注册时间转换不符", column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasIndex("tab_user", index) {
|
||||||
|
return addRegistrationIndex(db)
|
||||||
|
}
|
||||||
|
var columns []string
|
||||||
|
if err := db.Raw("SELECT column_name FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tab_user' AND index_name=? ORDER BY seq_in_index", index).Scan(&columns).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.Join(columns, ",") != "source_db,puid,sw_register_time_unix,id" {
|
||||||
|
return fmt.Errorf("tab_user.%s 的现有索引列不符", index)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addRegistrationIndex(db *gorm.DB) error {
|
||||||
|
// 旧 SELECT CAST 会以警告方式接受尾部空格等历史值。建索引属于 DDL,
|
||||||
|
// 严格模式会把同一警告升级为错误;仅在固定连接的索引构建期间保持旧转换行为。
|
||||||
|
return db.Connection(func(conn *gorm.DB) (result error) {
|
||||||
|
var previous string
|
||||||
|
if err := conn.Raw("SELECT @@session.sql_mode").Scan(&previous).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
modes := []string{}
|
||||||
|
for _, mode := range strings.Split(previous, ",") {
|
||||||
|
if mode != "STRICT_TRANS_TABLES" && mode != "STRICT_ALL_TABLES" {
|
||||||
|
modes = append(modes, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := conn.Exec("SET SESSION sql_mode=?", strings.Join(modes, ",")).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := conn.Exec("SET SESSION sql_mode=?", previous).Error; err != nil {
|
||||||
|
result = errors.Join(result, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return conn.Exec("ALTER TABLE tab_user ADD INDEX idx_sw_user_registration(source_db,puid,sw_register_time_unix,id), ALGORITHM=INPLACE, LOCK=NONE").Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -41,14 +41,12 @@ func (r *reportBuildRepo) Rebuild(ctx context.Context, kind business.ReportBuild
|
|||||||
reportTable, keyTable = "tab_sw_report_server_day", "tab_sw_report_server_day_key"
|
reportTable, keyTable = "tab_sw_report_server_day", "tab_sw_report_server_day_key"
|
||||||
rawTable = "tab_user_play_info"
|
rawTable = "tab_user_play_info"
|
||||||
}
|
}
|
||||||
for _, table := range []string{reportTable, keyTable, rawTable, "tab_user", "tab_spend", "tab_game"} {
|
available, err := businessTablesAvailable(tx, reportTable, keyTable, rawTable, "tab_user", "tab_spend", "tab_game")
|
||||||
available, err := businessTableAvailable(tx, table)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
if !available {
|
||||||
if !available {
|
return business.ErrReportBuildUnavailable
|
||||||
return business.ErrReportBuildUnavailable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
statDate := day.Format("2006-01-02")
|
statDate := day.Format("2006-01-02")
|
||||||
lock := reportBuildLockPO{Kind: string(kind), StatDate: statDate}
|
lock := reportBuildLockPO{Kind: string(kind), StatDate: statDate}
|
||||||
@@ -133,7 +131,11 @@ func loadReportInput(db *gorm.DB, kind business.ReportBuildKind, day time.Time)
|
|||||||
cast = "CAST(register_time AS UNSIGNED)"
|
cast = "CAST(register_time AS UNSIGNED)"
|
||||||
}
|
}
|
||||||
var registers, actives []reportEventPO
|
var registers, actives []reportEventPO
|
||||||
if err := db.Table("tab_user").Select("id AS user_id,source_db,promote_id,fgame_id AS game_id").Where("puid=0").Where(cast+" BETWEEN ? AND ?", begin, end).Scan(®isters).Error; err != nil {
|
registerQuery, err := reportRegistrationQuery(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := registerQuery.Select("id AS user_id,source_db,promote_id,fgame_id AS game_id").Where("puid=0").Where(cast+" BETWEEN ? AND ?", begin, end).Scan(®isters).Error; err != nil {
|
||||||
return nil, business.ErrStorage
|
return nil, business.ErrStorage
|
||||||
}
|
}
|
||||||
if err := db.Table("tab_user_day_login").Select("user_id,source_db,promote_id,game_id").Where("login_time BETWEEN ? AND ?", begin, end).Scan(&actives).Error; err != nil {
|
if err := db.Table("tab_user_day_login").Select("user_id,source_db,promote_id,game_id").Where("login_time BETWEEN ? AND ?", begin, end).Scan(&actives).Error; err != nil {
|
||||||
@@ -204,3 +206,37 @@ func loadReportInput(db *gorm.DB, kind business.ReportBuildKind, day time.Time)
|
|||||||
}
|
}
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reportRegistrationQuery(db *gorm.DB) (*gorm.DB, error) {
|
||||||
|
q := db.Table("tab_user")
|
||||||
|
if db.Dialector.Name() != "mysql" || !db.Migrator().HasIndex("tab_user", "idx_sw_user_registration") {
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
// 跨平台任务也必须提供组合索引的首列,避免又回到数百万主账号扫描。
|
||||||
|
// 平台值取同一事务中的实际数据,不写死平台名单,也保留历史 NULL。
|
||||||
|
var rows []struct {
|
||||||
|
SourceDB *string `gorm:"column:source_db"`
|
||||||
|
}
|
||||||
|
if err := db.Table("tab_user").Distinct("source_db").Scan(&rows).Error; err != nil {
|
||||||
|
return nil, business.ErrStorage
|
||||||
|
}
|
||||||
|
sources := []string{}
|
||||||
|
hasNull := false
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.SourceDB == nil {
|
||||||
|
hasNull = true
|
||||||
|
} else {
|
||||||
|
sources = append(sources, *row.SourceDB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sources) == 0 {
|
||||||
|
if hasNull {
|
||||||
|
return q.Where("source_db IS NULL"), nil
|
||||||
|
}
|
||||||
|
return q.Where("1=0"), nil
|
||||||
|
}
|
||||||
|
if hasNull {
|
||||||
|
return q.Where("(source_db IN ? OR source_db IS NULL)", sources), nil
|
||||||
|
}
|
||||||
|
return q.Where("source_db IN ?", sources), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,14 +19,36 @@ func storageError(err error) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func businessTableAvailable(db *gorm.DB, name string) (bool, error) {
|
func businessTableAvailable(db *gorm.DB, name string) (bool, error) {
|
||||||
tables, err := db.Migrator().GetTables()
|
return businessTablesAvailable(db, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一次检查请求所需的表,不为每个依赖重复枚举整个业务库。
|
||||||
|
// 不缓存结果,保留迁移后新建表和运行时连接异常的可见性。
|
||||||
|
func businessTablesAvailable(db *gorm.DB, names ...string) (bool, error) {
|
||||||
|
if len(names) == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
var tables []string
|
||||||
|
var err error
|
||||||
|
switch db.Dialector.Name() {
|
||||||
|
case "mysql":
|
||||||
|
err = db.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ?", names).Scan(&tables).Error
|
||||||
|
case "sqlite":
|
||||||
|
err = db.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name IN ?", names).Scan(&tables).Error
|
||||||
|
default:
|
||||||
|
tables, err = db.Migrator().GetTables()
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, business.ErrStorage
|
return false, business.ErrStorage
|
||||||
}
|
}
|
||||||
|
found := make(map[string]bool, len(tables))
|
||||||
for _, table := range tables {
|
for _, table := range tables {
|
||||||
if table == name {
|
found[table] = true
|
||||||
return true, nil
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
if !found[name] {
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBusinessTablesAvailabilityIsFreshAndPropagatesFailures(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "schema.db")), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = pool.Close() })
|
||||||
|
check := func(want bool, names ...string) {
|
||||||
|
t.Helper()
|
||||||
|
got, err := businessTablesAvailable(db, names...)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Fatalf("tables %v: got %v, %v; want %v", names, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(true)
|
||||||
|
check(false, "first", "second")
|
||||||
|
for _, table := range []string{"first", "second"} {
|
||||||
|
if err := db.Exec("CREATE TABLE " + table + " (id INTEGER)").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(true, "first", "second", "first")
|
||||||
|
check(false, "first", "missing")
|
||||||
|
check(false, "first') OR 1=1 --")
|
||||||
|
if err := db.Exec("DROP TABLE second").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
check(false, "first", "second")
|
||||||
|
if err := pool.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if available, err := businessTablesAvailable(db, "first"); available || err != business.ErrStorage {
|
||||||
|
t.Fatalf("connection error must not become a missing-table result: %v, %v", available, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
// 区服汇总按创角时间读取原始记录;旧表只有 play_time 索引,导致每日创角扫描全表。
|
||||||
|
func migrateServerReportIndex(db *gorm.DB) error {
|
||||||
|
if db.Dialector.Name() != "mysql" || !db.Migrator().HasTable("tab_user_play_info") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var existing int64
|
||||||
|
if err := db.Raw("SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tab_user_play_info' AND column_name='create_time' AND seq_in_index=1").Scan(&existing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return db.Exec("ALTER TABLE tab_user_play_info ADD INDEX idx_sw_play_create_time(create_time), ALGORITHM=INPLACE, LOCK=NONE").Error
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"kra/internal/biz/business"
|
"kra/internal/biz/business"
|
||||||
)
|
)
|
||||||
@@ -23,13 +25,8 @@ func workbenchAvailable(db *gorm.DB, scope business.AnalyticsScope, sources []st
|
|||||||
if kind == "" || kind == business.WorkbenchRegister {
|
if kind == "" || kind == business.WorkbenchRegister {
|
||||||
tables = append(tables, "tab_sw_report_day_key")
|
tables = append(tables, "tab_sw_report_day_key")
|
||||||
}
|
}
|
||||||
for _, table := range tables {
|
available, err = businessTablesAvailable(db, tables...)
|
||||||
available, err := businessTableAvailable(db, table)
|
return false, available, err
|
||||||
if err != nil || !available {
|
|
||||||
return false, available, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, true, nil
|
|
||||||
}
|
}
|
||||||
func (r *workbenchRepo) Summary(ctx context.Context, scope business.AnalyticsScope, sources []string, w business.WorkbenchWindow) (*business.WorkbenchTotals, error) {
|
func (r *workbenchRepo) Summary(ctx context.Context, scope business.AnalyticsScope, sources []string, w business.WorkbenchWindow) (*business.WorkbenchTotals, error) {
|
||||||
db := r.data.DB().WithContext(ctx)
|
db := r.data.DB().WithContext(ctx)
|
||||||
@@ -46,47 +43,83 @@ func (r *workbenchRepo) Summary(ctx context.Context, scope business.AnalyticsSco
|
|||||||
if scope.IDCardDedup {
|
if scope.IDCardDedup {
|
||||||
mode = 1
|
mode = 1
|
||||||
}
|
}
|
||||||
for _, period := range []struct {
|
date := func(value time.Time) string { return value.Format("2006-01-02") }
|
||||||
day time.Time
|
today, yesterday := date(w.Today), date(w.Yesterday)
|
||||||
target *business.WorkbenchDay
|
monthStart, previousStart, previousEnd := date(w.MonthStart), date(w.PreviousMonthStart), date(w.PreviousMonthEnd)
|
||||||
}{{w.Today, &out.Today}, {w.Yesterday, &out.Yesterday}} {
|
dayFilter := business.AnalyticsFilter{Sources: sources, Begin: yesterday, End: today}
|
||||||
f := business.AnalyticsFilter{Sources: sources, Begin: period.day.Format("2006-01-02"), End: period.day.Format("2006-01-02")}
|
type datedCount struct {
|
||||||
var counts []portraitCountPO
|
StatDate string
|
||||||
if err := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode).Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Where("r.metric IN (1,2)").Group("r.metric").Scan(&counts).Error; err != nil {
|
Metric int
|
||||||
return nil, business.ErrStorage
|
Count int64
|
||||||
}
|
}
|
||||||
period.target.Counts = toPortraitCounts(counts)
|
type datedChannel struct {
|
||||||
if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(&period.target.Pay).Error; err != nil {
|
StatDate string
|
||||||
return nil, business.ErrStorage
|
SourceDB string `gorm:"column:source_db"`
|
||||||
}
|
ID *int64 `gorm:"column:entity_id"`
|
||||||
// 活跃渠道按自身注册或正充值统计,不能归并一级渠道后再计数。
|
}
|
||||||
active := map[business.AnalyticsKey]bool{}
|
var counts []datedCount
|
||||||
var registrations, pays []analyticsAggPO
|
var registrations, pays []datedChannel
|
||||||
if err := analyticsQuery(db, scope, f, "tab_sw_report_day_key", mode).Where("r.metric=1").Select("r.source_db,r.promote_id AS entity_id").Group("r.source_db,r.promote_id").Having("COUNT(DISTINCT r.identity_key)>0").Scan(®istrations).Error; err != nil {
|
var amounts struct{ TodayPay, YesterdayPay, MonthPay, PreviousMonthPay string }
|
||||||
return nil, business.ErrStorage
|
var created struct{ TodayCount, YesterdayCount int64 }
|
||||||
}
|
group, queryContext := errgroup.WithContext(ctx)
|
||||||
if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("r.source_db,r.promote_id AS entity_id").Group("r.source_db,r.promote_id").Having("SUM(r.total_pay)>0").Scan(&pays).Error; err != nil {
|
group.SetLimit(2)
|
||||||
return nil, business.ErrStorage
|
queryDB := db.WithContext(queryContext)
|
||||||
}
|
group.Go(func() error {
|
||||||
for _, po := range append(registrations, pays...) {
|
return storageError(analyticsQuery(queryDB, scope, dayFilter, "tab_sw_report_day_key", mode).
|
||||||
if po.ID != nil {
|
Select("CAST(r.stat_date AS CHAR) AS stat_date,r.metric,COUNT(DISTINCT " + portraitIdentity(queryDB, scope.IDCardDedup) + ") AS count").
|
||||||
active[business.AnalyticsKey{SourceDB: po.SourceDB, ID: *po.ID}] = true
|
Where("r.metric IN (1,2)").Group("r.stat_date,r.metric").Scan(&counts).Error)
|
||||||
}
|
})
|
||||||
}
|
group.Go(func() error {
|
||||||
period.target.ActiveChannels = int64(len(active))
|
f := business.AnalyticsFilter{Sources: sources, Begin: previousStart, End: today}
|
||||||
if err := analyticsBasePromotes(db, scope, f, "p").Where("p.create_time BETWEEN ? AND ?", period.day.Unix(), period.day.AddDate(0, 0, 1).Unix()-1).Count(&period.target.NewChannels).Error; err != nil {
|
// 同时取得两日和两个自然月区间的金额;月初的昨日须额外纳入扫描范围。
|
||||||
return nil, business.ErrStorage
|
return storageError(analyticsQuery(queryDB, scope, f, "tab_sw_report_day", 0).
|
||||||
|
Where("((r.stat_date BETWEEN ? AND ?) OR (r.stat_date BETWEEN ? AND ?) OR r.stat_date=?)", monthStart, today, previousStart, previousEnd, yesterday).
|
||||||
|
Select("COALESCE(SUM(CASE WHEN r.stat_date=? THEN r.total_pay ELSE 0 END),0) AS today_pay,"+
|
||||||
|
"COALESCE(SUM(CASE WHEN r.stat_date=? THEN r.total_pay ELSE 0 END),0) AS yesterday_pay,"+
|
||||||
|
"COALESCE(SUM(CASE WHEN r.stat_date BETWEEN ? AND ? THEN r.total_pay ELSE 0 END),0) AS month_pay,"+
|
||||||
|
"COALESCE(SUM(CASE WHEN r.stat_date BETWEEN ? AND ? THEN r.total_pay ELSE 0 END),0) AS previous_month_pay",
|
||||||
|
today, yesterday, monthStart, today, previousStart, previousEnd).Scan(&amounts).Error)
|
||||||
|
})
|
||||||
|
group.Go(func() error {
|
||||||
|
// COUNT(DISTINCT identity_key)>0 等价于存在非 NULL 的注册键;保留空字符串的旧语义。
|
||||||
|
return storageError(analyticsQuery(queryDB, scope, dayFilter, "tab_sw_report_day_key", mode).
|
||||||
|
Where("r.metric=1 AND r.identity_key IS NOT NULL").Distinct().
|
||||||
|
Select("CAST(r.stat_date AS CHAR) AS stat_date,r.source_db,r.promote_id AS entity_id").Scan(®istrations).Error)
|
||||||
|
})
|
||||||
|
group.Go(func() error {
|
||||||
|
return storageError(analyticsQuery(queryDB, scope, dayFilter, "tab_sw_report_day", 0).
|
||||||
|
Select("CAST(r.stat_date AS CHAR) AS stat_date,r.source_db,r.promote_id AS entity_id").
|
||||||
|
Group("r.stat_date,r.source_db,r.promote_id").Having("SUM(r.total_pay)>0").Scan(&pays).Error)
|
||||||
|
})
|
||||||
|
group.Go(func() error {
|
||||||
|
todayEnd := w.Today.AddDate(0, 0, 1).Unix() - 1
|
||||||
|
yesterdayEnd := w.Yesterday.AddDate(0, 0, 1).Unix() - 1
|
||||||
|
return storageError(analyticsBasePromotes(queryDB, scope, dayFilter, "p").
|
||||||
|
Where("p.create_time BETWEEN ? AND ?", w.Yesterday.Unix(), todayEnd).
|
||||||
|
Select("COALESCE(SUM(CASE WHEN p.create_time BETWEEN ? AND ? THEN 1 ELSE 0 END),0) AS today_count,"+
|
||||||
|
"COALESCE(SUM(CASE WHEN p.create_time BETWEEN ? AND ? THEN 1 ELSE 0 END),0) AS yesterday_count",
|
||||||
|
w.Today.Unix(), todayEnd, w.Yesterday.Unix(), yesterdayEnd).Scan(&created).Error)
|
||||||
|
})
|
||||||
|
if err := group.Wait(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
days := map[string]*business.WorkbenchDay{today: &out.Today, yesterday: &out.Yesterday}
|
||||||
|
for _, row := range counts {
|
||||||
|
if day := days[row.StatDate]; day != nil {
|
||||||
|
putAnalyticsCount(&day.Counts, row.Metric, row.Count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, period := range []struct {
|
active := map[string]map[business.AnalyticsKey]bool{today: {}, yesterday: {}}
|
||||||
begin, end time.Time
|
for _, row := range append(registrations, pays...) {
|
||||||
target *string
|
if keys := active[row.StatDate]; keys != nil && row.ID != nil {
|
||||||
}{{w.MonthStart, w.Today, &out.MonthPay}, {w.PreviousMonthStart, w.PreviousMonthEnd, &out.PreviousMonthPay}} {
|
keys[business.AnalyticsKey{SourceDB: row.SourceDB, ID: *row.ID}] = true
|
||||||
f := business.AnalyticsFilter{Sources: sources, Begin: period.begin.Format("2006-01-02"), End: period.end.Format("2006-01-02")}
|
|
||||||
if err := analyticsQuery(db, scope, f, "tab_sw_report_day", 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(period.target).Error; err != nil {
|
|
||||||
return nil, business.ErrStorage
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.Today.ActiveChannels, out.Yesterday.ActiveChannels = int64(len(active[today])), int64(len(active[yesterday]))
|
||||||
|
out.Today.Pay, out.Yesterday.Pay = amounts.TodayPay, amounts.YesterdayPay
|
||||||
|
out.Today.NewChannels, out.Yesterday.NewChannels = created.TodayCount, created.YesterdayCount
|
||||||
|
out.MonthPay, out.PreviousMonthPay = amounts.MonthPay, amounts.PreviousMonthPay
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,12 +151,12 @@ func (r *workbenchRepo) Ranks(ctx context.Context, scope business.AnalyticsScope
|
|||||||
}
|
}
|
||||||
if kind == business.WorkbenchPayLow || kind == business.WorkbenchUnlogin {
|
if kind == business.WorkbenchPayLow || kind == business.WorkbenchUnlogin {
|
||||||
var channels []workbenchPromotePO
|
var channels []workbenchPromotePO
|
||||||
if err := analyticsBasePromotes(db, scope, f, "p").Select("p.id,p.source_db,p.account,p.create_time,p.last_login_time").Where("p.promote_level=1").Scan(&channels).Error; err != nil {
|
if err := analyticsBasePromotes(db, scope, f, "p").Select("p.id,p.source_db,p.create_time,p.last_login_time").Where("p.promote_level=1").Scan(&channels).Error; err != nil {
|
||||||
return nil, business.ErrStorage
|
return nil, business.ErrStorage
|
||||||
}
|
}
|
||||||
for _, po := range channels {
|
for _, po := range channels {
|
||||||
row := rowFor(po.SourceDB, po.ID)
|
row := rowFor(po.SourceDB, po.ID)
|
||||||
row.Name, row.CreatedUnix, row.LastLoginUnix = po.Account, po.CreateTime, po.LastLoginTime
|
row.CreatedUnix, row.LastLoginUnix = po.CreateTime, po.LastLoginTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if kind != business.WorkbenchUnlogin {
|
if kind != business.WorkbenchUnlogin {
|
||||||
@@ -144,6 +177,7 @@ func (r *workbenchRepo) Ranks(ctx context.Context, scope business.AnalyticsScope
|
|||||||
id = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE root_p.id END"
|
id = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE root_p.id END"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
q = analyticsPayLeaves(db, q)
|
||||||
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
|
q = q.Joins("LEFT JOIN tab_promote root_p ON root_p.source_db=r.source_db AND root_p.id=r.promote_id")
|
||||||
id = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE COALESCE(root_p.id,r.promote_id) END"
|
id = "CASE WHEN root_p.promote_level=1 THEN root_p.id WHEN root_p.top_promote_id>0 THEN root_p.top_promote_id ELSE COALESCE(root_p.id,r.promote_id) END"
|
||||||
}
|
}
|
||||||
@@ -160,22 +194,32 @@ func (r *workbenchRepo) Ranks(ctx context.Context, scope business.AnalyticsScope
|
|||||||
rowFor(po.SourceDB, *po.ID).Value = po.Amount
|
rowFor(po.SourceDB, *po.ID).Value = po.Amount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
keys := make([]business.AnalyticsKey, 0, len(rows))
|
|
||||||
for key := range rows {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
for start := 0; start < len(keys); start += 300 {
|
|
||||||
var names []workbenchPromotePO
|
|
||||||
if err := db.Table("tab_promote").Select("id,source_db,account").Where("(source_db,id) IN ?", analyticsPairs(keys[start:min(start+300, len(keys))])).Scan(&names).Error; err != nil {
|
|
||||||
return nil, business.ErrStorage
|
|
||||||
}
|
|
||||||
for _, po := range names {
|
|
||||||
rowFor(po.SourceDB, po.ID).Name = po.Account
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
out.Rows = append(out.Rows, row)
|
out.Rows = append(out.Rows, row)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *workbenchRepo) FillRankNames(ctx context.Context, rows []*business.WorkbenchRankRow) error {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keys := make([]business.AnalyticsKey, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
keys = append(keys, row.AnalyticsKey)
|
||||||
|
}
|
||||||
|
var names []workbenchPromotePO
|
||||||
|
if err := r.data.DB().WithContext(ctx).Table("tab_promote").Select("id,source_db,account").Where("(source_db,id) IN ?", analyticsPairs(keys)).Scan(&names).Error; err != nil {
|
||||||
|
return business.ErrStorage
|
||||||
|
}
|
||||||
|
byKey := make(map[business.AnalyticsKey]string, len(names))
|
||||||
|
for _, po := range names {
|
||||||
|
byKey[business.AnalyticsKey{SourceDB: po.SourceDB, ID: po.ID}] = po.Account
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
row.Name = byKey[row.AnalyticsKey]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,3 +85,32 @@ func TestWorkbenchStorageSummaryAndRankRules(t *testing.T) {
|
|||||||
t.Fatal("六卡缺表应明确未接入")
|
t.Fatal("六卡缺表应明确未接入")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkbenchSummaryMonthStartAndNullableRegistrationKeys(t *testing.T) {
|
||||||
|
db, _ := prepareChannels(t)
|
||||||
|
for _, statement := range []string{
|
||||||
|
"DELETE FROM tab_sw_report_day",
|
||||||
|
"DELETE FROM tab_sw_report_day_key",
|
||||||
|
"INSERT INTO tab_sw_report_day VALUES('lx',100,1,0,'2026-10-01',10),('lx',100,1,0,'2026-10-02',999),('lx',100,1,0,'2026-10-31',30),('lx',100,1,0,'2026-11-01',20),('lx',103,1,0,'2026-11-01',-1)",
|
||||||
|
"INSERT INTO tab_sw_report_day_key VALUES('lx',103,1,1,'2026-11-01',1,NULL),('lx',999,1,1,'2026-11-01',1,''),('lx',100,1,1,'2026-10-31',1,'A')",
|
||||||
|
} {
|
||||||
|
if err := db.Exec(statement).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
day := time.Date(2026, 11, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||||
|
if err := db.Exec("UPDATE tab_promote SET create_time=CASE WHEN id=100 THEN ? ELSE ? END WHERE source_db='lx' AND id IN(100,101)", day.Unix(), day.AddDate(0, 0, -1).Unix()).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
window := business.WorkbenchWindow{Today: day, Yesterday: day.AddDate(0, 0, -1), MonthStart: day, PreviousMonthStart: day.AddDate(0, -1, 0), PreviousMonthEnd: day.AddDate(0, -1, 0)}
|
||||||
|
out, err := NewWorkbenchRepo(testData{db}).Summary(context.Background(), business.AnalyticsScope{Viewer: business.UserScope{All: true}, IncludeSub: true, IDCardDedup: true}, []string{"lx"}, window)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if portraitTestMoney(out.Today.Pay) != "19.00" || portraitTestMoney(out.Yesterday.Pay) != "30.00" || portraitTestMoney(out.MonthPay) != "19.00" || portraitTestMoney(out.PreviousMonthPay) != "10.00" {
|
||||||
|
t.Fatalf("month-first comparison mixed yesterday with the previous-month window: %+v", out)
|
||||||
|
}
|
||||||
|
if out.Today.ActiveChannels != 2 || out.Today.Counts.Register != 1 || out.Yesterday.ActiveChannels != 1 || out.Today.NewChannels != 1 || out.Yesterday.NewChannels != 1 {
|
||||||
|
t.Fatalf("daily groups or NULL/empty registration-key behavior changed: %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user