Compare commits
10
Commits
0bef29ae4f
...
f866cac768
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f866cac768 | ||
|
|
19926d275c | ||
|
|
2efce4d821 | ||
|
|
503f3b1a7d | ||
|
|
34535c90c3 | ||
|
|
7764b03906 | ||
|
|
297e80827d | ||
|
|
5a96be6a79 | ||
|
|
ac4e13e610 | ||
|
|
cbf0ee9be3 |
@@ -42,7 +42,16 @@ bin/
|
||||
web/dist/
|
||||
web/node_modules/
|
||||
.pnpm-store/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Local reference sources and desktop-only tooling state
|
||||
/gva/
|
||||
/.claude/settings.local.json
|
||||
|
||||
# One-off migration notes, verification scripts, and generated reports
|
||||
/docs/sw6-to-kra/
|
||||
/docs/sw6tokra/
|
||||
/scripts/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
|
||||
@@ -149,3 +149,7 @@ Regenerate via `make generate` or `make all`; never hand-edit `wire_gen.go`.
|
||||
`docs:`, `test:`. Regenerated files belong in the same commit as
|
||||
their source.
|
||||
- Never commit real credentials in `configs/config.yaml`.
|
||||
- Do not commit one-off scripts, migration scratch notes, test reports, or
|
||||
screenshots. Keep temporary work outside the repository and delete it after
|
||||
use. Do not recreate `docs/sw6-to-kra/`, `docs/sw6tokra/`, or `scripts/`.
|
||||
Permanent regression tests belong beside the source they cover.
|
||||
|
||||
@@ -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("手动触发不应启用自动计划")
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ func TestChannelAndGameAnalyticsHTTP(t *testing.T) {
|
||||
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(9,'lx',14,'SDK账号','0'),(99,'hw',20,'其他账号','0')",
|
||||
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER,register_type INTEGER)",
|
||||
"INSERT INTO tab_promote VALUES(100,'lx','root-channel',1,0,14,0),(101,'lx','child-channel',2,100,14,0),(200,'lx','guild-channel',1,0,14,1),(100,'hw','other-platform',1,0,20,0)",
|
||||
"CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
|
||||
"INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('hw',100,1,1),('lx',200,2,1)",
|
||||
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
|
||||
"INSERT INTO tab_game VALUES(1,'lx','当前游戏'),(2,'lx','公会游戏'),(1,'hw','外服游戏')",
|
||||
"CREATE TABLE tab_sw_report_day(source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,stat_date DATE,total_pay DECIMAL(20,2))",
|
||||
|
||||
@@ -43,6 +43,9 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) {
|
||||
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(9,'lx',14,'sdk','0'),(99,'hw',20,'other','0')",
|
||||
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER)",
|
||||
"INSERT INTO tab_promote VALUES(100,'lx','root-channel',1,0,14),(101,'lx','child-channel',2,100,14),(100,'hw','outside',1,0,20)",
|
||||
"CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
|
||||
"INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('hw',100,1,1)",
|
||||
"ALTER TABLE tab_promote ADD COLUMN nickname TEXT",
|
||||
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
|
||||
"INSERT INTO tab_game VALUES(1,'lx','当前游戏'),(1,'hw','同号其他游戏')",
|
||||
"CREATE TABLE tab_user(id INTEGER,source_db TEXT,register_time TEXT,fgame_id INTEGER,fgame_name TEXT,register_ip TEXT,device_name TEXT,promote_id INTEGER,puid INTEGER)",
|
||||
@@ -107,7 +110,7 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) {
|
||||
data := call(7, "GET", "/sw/register/list?sourceDb=lx&sortField=totalPayAmount", "", 0).(map[string]any)
|
||||
rows := data["rows"].([]any)
|
||||
first := rows[0].(map[string]any)
|
||||
if data["total"] != float64(2) || first["userId"] != float64(1) || first["totalPayAmount"] != "1012.34" || first["fgameName"] != "历史游戏" || first["busierName"] != "sdk(leaf)" {
|
||||
if data["total"] != float64(2) || first["userId"] != float64(1) || first["totalPayAmount"] != "1012.34" || first["fgameName"] != "历史游戏" || first["busierName"] != "owner→leaf" {
|
||||
t.Fatalf("注册明细口径或字段错误: %+v", data)
|
||||
}
|
||||
games := call(7, "GET", "/sw/serverAnalysis/games?sourceDb=lx&limit=50", "", 0).([]any)
|
||||
@@ -119,8 +122,8 @@ func TestLedgerHTTPQueriesAndExcelExports(t *testing.T) {
|
||||
t.Fatal("游戏筛选跨平台串号")
|
||||
}
|
||||
data = call(7, "GET", "/sw/spend/list?sourceDb=lx&payStatus=0", "", 0).(map[string]any)
|
||||
if data["total"] != float64(1) || data["totalPayAmount"] != "15.34" {
|
||||
t.Fatal("失败列表的合计仍须取对应成功订单")
|
||||
if data["total"] != float64(1) || data["totalPayAmount"] != "0.00" {
|
||||
t.Fatal("合计不得包含筛选条件外的成功订单")
|
||||
}
|
||||
data = call(1, "GET", "/sw/spend/list?sourceDb=lx", "", 0).(map[string]any)
|
||||
if data["total"] != float64(4) || data["totalPayAmount"] != "20.34" {
|
||||
|
||||
+2
-5
@@ -103,11 +103,8 @@ func httpAddress(server *config.Server) string {
|
||||
}
|
||||
|
||||
func swaggerAddress(server *config.Server, admin *config.Admin) string {
|
||||
if admin != nil && admin.App != nil {
|
||||
switch strings.ToLower(strings.TrimSpace(admin.App.Env)) {
|
||||
case "production", "prod", "live", "staging":
|
||||
return "disabled"
|
||||
}
|
||||
if !config.SwaggerEnabled(admin) {
|
||||
return "disabled"
|
||||
}
|
||||
address := httpAddress(server)
|
||||
if strings.HasPrefix(address, ":") {
|
||||
|
||||
+5
-3
@@ -13,9 +13,11 @@ func TestSwaggerAddress(t *testing.T) {
|
||||
admin *config.Admin
|
||||
want string
|
||||
}{
|
||||
{name: "default", 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/"}, want: "http://127.0.0.1:9000/admin/swagger/index.html"},
|
||||
{name: "host", server: &config.Server{HTTP: &config.ServerHTTP{Addr: "example.test:8080"}}, want: "http://example.test:8080/swagger/index.html"},
|
||||
{name: "missing environment", want: "disabled"},
|
||||
{name: "qa", admin: &config.Admin{App: &config.App{Env: "qa"}}, want: "disabled"},
|
||||
{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 {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
businessbiz "kra/internal/biz/business"
|
||||
"kra/internal/biz/system"
|
||||
businessdata "kra/internal/data/business"
|
||||
systemdata "kra/internal/data/system"
|
||||
"kra/internal/server/handler"
|
||||
businessservice "kra/internal/service/business"
|
||||
)
|
||||
|
||||
func TestRankingHTTP(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ranking.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, _ := db.DB()
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
for _, step := range append(systemdata.Migrations(), businessdata.Migrations()...) {
|
||||
if err := step.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, sql := range []string{
|
||||
"INSERT INTO sys_users(id,uuid,username,level,parent_id,id_path,enable) VALUES(1,'root','root',0,0,'0',1),(7,'owner','owner',1,0,'0',1),(8,'branch','branch',2,7,'0,7',1),(9,'leaf','leaf',3,8,'0,7,8',1),(10,'leaf2','leaf2',3,8,'0,7,8',1),(99,'other','other',1,0,'0',1)",
|
||||
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(9,'lx',14,'0'),(10,'hw',14,'0'),(99,'yq',14,'0')",
|
||||
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER)",
|
||||
"INSERT INTO tab_promote VALUES(100,'lx','渠道甲',1,0,14),(101,'lx','渠道甲下级',2,100,14),(100,'hw','汇玩同号渠道',1,0,14),(100,'yq','其他商务渠道',1,0,14)",
|
||||
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
|
||||
"INSERT INTO tab_game VALUES(1,'lx','游戏甲'),(1,'hw','同号游戏'),(1,'yq','无权游戏')",
|
||||
"CREATE TABLE tab_sw_report_day(source_db TEXT,promote_id INTEGER,game_id INTEGER,dedup_mode INTEGER,stat_date DATE,total_pay DECIMAL(20,2))",
|
||||
"INSERT INTO tab_sw_report_day VALUES('lx',100,1,0,'2026-11-04',10),('lx',101,1,0,'2026-11-04',20),('hw',100,1,0,'2026-11-04',40),('yq',100,1,0,'2026-11-04',80),('lx',100,1,0,'2026-11-03',10),('lx',100,1,1,'2026-11-04',9999)",
|
||||
"CREATE TABLE tab_sw_report_day_key(source_db TEXT,promote_id INTEGER,game_id INTEGER,dedup_mode INTEGER,stat_date DATE,metric INTEGER,identity_key TEXT)",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, row := range []struct {
|
||||
source string
|
||||
promote int
|
||||
}{{"lx", 100}, {"lx", 101}, {"hw", 100}, {"yq", 100}} {
|
||||
for _, metric := range []int{1, 2, 3} {
|
||||
if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES(?,?,1,1,'2026-11-04',?,'A')", row.source, row.promote, metric).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.Exec("UPDATE tab_sw_report_day_key SET identity_key='B' WHERE source_db='lx' AND promote_id=101 AND metric=2").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO tab_sw_report_day_key SELECT source_db,promote_id,game_id,dedup_mode,'2026-11-03',metric,identity_key FROM tab_sw_report_day_key WHERE source_db='lx' AND promote_id=100").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := businessHTTPData{db}
|
||||
uc := businessbiz.NewRankingUsecase(businessdata.NewRankingRepo(provider), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)))
|
||||
h := handler.NewRanking(businessservice.NewRankingService(uc))
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64)
|
||||
aid := uint(901)
|
||||
if id == 1 {
|
||||
aid = 888
|
||||
}
|
||||
c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), system.Actor{UserID: uint(id), AuthorityID: aid}))
|
||||
c.Next()
|
||||
})
|
||||
engine.GET("/sw/rank/channel", h.Channel)
|
||||
engine.GET("/sw/rank/business", h.Business)
|
||||
engine.GET("/sw/rank/game", h.Game)
|
||||
srv := httptest.NewServer(engine)
|
||||
defer srv.Close()
|
||||
call := func(user int, kind, query string, code int) map[string]any {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", srv.URL+"/sw/rank/"+kind+"?"+query, nil)
|
||||
req.Header.Set("X-Test-User", strconv.Itoa(user))
|
||||
res, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StatusCode != 200 || body["code"] != float64(code) {
|
||||
t.Fatalf("%s: %+v", kind, body)
|
||||
}
|
||||
if code != 0 {
|
||||
return nil
|
||||
}
|
||||
return body["data"].(map[string]any)
|
||||
}
|
||||
query := "beginTime=2026-11-04&endTime=2026-11-04&sourceDb=lx,hw,yq"
|
||||
p := call(7, "channel", query, 0)
|
||||
regs := p["registerRankList"].([]any)
|
||||
if len(regs) != 2 {
|
||||
t.Fatal("其他商务数据不应出现")
|
||||
}
|
||||
var lx map[string]any
|
||||
for _, value := range p["payRateRankList"].([]any) {
|
||||
row := value.(map[string]any)
|
||||
if row["sourceDb"] == "lx" {
|
||||
lx = row
|
||||
}
|
||||
}
|
||||
if lx == nil || lx["metricValue"] != "50.00" || lx["momValue"] != "-50.00" || lx["momStatus"] != float64(-1) {
|
||||
t.Fatalf("付费率应计算相对上期增幅: %+v", lx)
|
||||
}
|
||||
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)
|
||||
rows := p["registerRankList"].([]any)
|
||||
row := rows[0].(map[string]any)
|
||||
if len(rows) != 1 || row["objectId"] != "8" || row["objectName"] != "branch (L2)" || row["metricValue"] != "3" || row["sourceDb"] != nil {
|
||||
t.Fatalf("商务范围或逐渠道加总错误: %+v", p)
|
||||
}
|
||||
row = p["rechargeRankList"].([]any)[0].(map[string]any)
|
||||
if row["metricValue"] != "70.00" || row["momValue"] != "600.00" {
|
||||
t.Fatal("商务金额环比错误")
|
||||
}
|
||||
p = call(8, "business", query, 0)
|
||||
if len(p["registerRankList"].([]any)) != 2 {
|
||||
t.Fatal("二级应看直属三级")
|
||||
}
|
||||
p = call(9, "business", query, 0)
|
||||
if len(p["registerRankList"].([]any)) != 0 {
|
||||
t.Fatal("三级不应展示商务排行")
|
||||
}
|
||||
p = call(1, "business", query, 0)
|
||||
if len(p["registerRankList"].([]any)) != 5 {
|
||||
t.Fatal("超管应展示所有有数据的商务层级")
|
||||
}
|
||||
p = call(7, "game", query+"&gameId=999", 0)
|
||||
if len(p["registerRankList"].([]any)) != 2 {
|
||||
t.Fatal("旧 gameId 参数未用于筛选,不能悄悄改变范围")
|
||||
}
|
||||
for _, v := range p["registerRankList"].([]any) {
|
||||
row := v.(map[string]any)
|
||||
if row["objectId"] != row["sourceDb"].(string)+"|1" {
|
||||
t.Fatal("游戏键应包含平台")
|
||||
}
|
||||
}
|
||||
call(7, "channel", "beginTime=bad&endTime=2026-11-04", 7)
|
||||
call(7, "game", "beginTime=2026-11-05&endTime=2026-11-04", 7)
|
||||
if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p = call(7, "channel", query, 0)
|
||||
if p["available"] != false || len(p["registerRankList"].([]any)) != 0 {
|
||||
t.Fatal("缺表不应返回假排行")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type reportTaskHTTPData struct{ businessHTTPData }
|
||||
|
||||
func (reportTaskHTTPData) DatabaseReady() bool { return true }
|
||||
|
||||
func TestReportBuildThroughNativeTaskHTTP(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "report-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 tab_user(id INTEGER,source_db TEXT,puid INTEGER,promote_id INTEGER,fgame_id INTEGER,idcard TEXT,register_time TEXT)",
|
||||
"CREATE TABLE tab_user_day_login(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,login_time INTEGER)",
|
||||
"CREATE TABLE tab_user_play_info(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,create_time INTEGER,play_time INTEGER)",
|
||||
"CREATE TABLE tab_spend(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,pay_time INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))",
|
||||
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
|
||||
"INSERT INTO tab_game VALUES(1,'lx','核验游戏')",
|
||||
"CREATE TABLE tab_sw_report_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,new_pay_cnt INTEGER,total_pay DECIMAL(20,2))",
|
||||
"CREATE TABLE tab_sw_report_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)",
|
||||
"CREATE TABLE tab_sw_report_server_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,total_pay DECIMAL(20,2))",
|
||||
"CREATE TABLE tab_sw_report_server_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stamp := time.Date(2026, 11, 4, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).Unix()
|
||||
for _, sql := range []string{"INSERT INTO tab_user VALUES(1,'lx',0,10,1,'aX',?)", "INSERT INTO tab_user_day_login VALUES(1,'lx',10,1,?)", "INSERT INTO tab_spend VALUES(1,'lx',10,1,'001',?,1,12.50)"} {
|
||||
if err := db.Exec(sql, stamp).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec("INSERT INTO tab_user_play_info VALUES(1,'lx',10,1,'001',?,?)", stamp, stamp).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := businessHTTPData{db}
|
||||
registry := platformtask.NewRegistry()
|
||||
tasks := taskbiz.NewTaskUsecaseWithRegistry(taskdata.NewTaskRepo(reportTaskHTTPData{provider}), registry)
|
||||
reports := businessbiz.NewReportBuildUsecase(businessdata.NewReportBuildRepo(provider))
|
||||
worker.NewTaskMethods(tasks, nil, nil, nil, reports, nil).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)
|
||||
}()
|
||||
h := handler.NewTask(taskservice.NewTaskService(taskbiz.NewTaskApplicationUsecase(tasks, scheduler)))
|
||||
engine := gin.New()
|
||||
router.RegisterTask(engine.Group(""), h)
|
||||
srv := httptest.NewServer(engine)
|
||||
defer srv.Close()
|
||||
call := func(method, path string, body any) map[string]any {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest(method, srv.URL+"/timedTask/"+path, bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var value map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value["code"] != float64(0) {
|
||||
t.Fatalf("原生任务接口失败: %+v", value)
|
||||
}
|
||||
return value["data"].(map[string]any)
|
||||
}
|
||||
methods := call("GET", "getRegisteredMethods", nil)["methods"].([]any)
|
||||
if len(methods) != 9 {
|
||||
t.Fatalf("六个商务汇总方法未注册: %d", len(methods))
|
||||
}
|
||||
for index, method := range []string{"swReportDayBackfillTask", "swReportServerDayBackfillTask"} {
|
||||
name := fmt.Sprintf("隔离汇总任务%d", index)
|
||||
call("POST", "createTimedTask", map[string]any{"name": name, "spec": "@daily", "executorType": "method", "methodName": method, "params": map[string]any{"startDate": "2026-11-04", "endDate": "2026-11-04"}, "enabled": false})
|
||||
var id uint
|
||||
if err := db.Table("sys_timed_tasks").Select("id").Where("name=?", name).Scan(&id).Error; err != nil || id == 0 {
|
||||
t.Fatal("任务创建未落库")
|
||||
}
|
||||
call("POST", "triggerTimedTask", map[string]any{"id": id})
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
done := 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) > 0 {
|
||||
if logs[0].(map[string]any)["status"] != "success" {
|
||||
t.Fatalf("汇总任务失败: %+v", logs)
|
||||
}
|
||||
done = true
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if !done {
|
||||
t.Fatal("未等到汇总任务完成")
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"tab_sw_report_day", "tab_sw_report_server_day"} {
|
||||
var rows []struct {
|
||||
TotalPay string
|
||||
NewRegisterCnt, ActiveCnt, PayCnt int
|
||||
}
|
||||
if err := db.Table(table).Scan(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 || rows[0].TotalPay != "12.5" || rows[0].NewRegisterCnt != 1 || rows[0].ActiveCnt != 1 || rows[0].PayCnt != 1 {
|
||||
t.Fatalf("任务没有实际生成双口径汇总: %+v", rows)
|
||||
}
|
||||
}
|
||||
var enabled int64
|
||||
db.Table("sys_timed_tasks").Where("enabled=1").Count(&enabled)
|
||||
if enabled != 0 {
|
||||
t.Fatal("手动回跑不应启用自动计划")
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ func TestServerAnalysisHTTP(t *testing.T) {
|
||||
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(9,'lx',14,'0'),(99,'hw',20,'0')",
|
||||
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,promote_level INTEGER,busier_id INTEGER)",
|
||||
"INSERT INTO tab_promote VALUES(100,'lx',1,14),(101,'lx',2,14),(100,'hw',1,20)",
|
||||
"CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
|
||||
"INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('hw',100,1,1)",
|
||||
"CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)",
|
||||
"INSERT INTO tab_game VALUES(1,'lx','游戏'),(1,'hw','其他游戏')",
|
||||
"CREATE TABLE tab_game_server(id INTEGER,source_db TEXT,game_id INTEGER,server_num TEXT,server_name TEXT,start_time INTEGER)",
|
||||
|
||||
Generated
+16
-2
@@ -82,11 +82,19 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
|
||||
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
|
||||
registry := taskRegistry()
|
||||
serverAnalysisRepo := business.NewServerAnalysisRepo(dataData)
|
||||
rankingRepo := business.NewRankingRepo(dataData)
|
||||
accountRepo := business.NewAccountRepo(dataData)
|
||||
accountUsecase := business2.NewAccountUsecase(accountRepo)
|
||||
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
|
||||
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
|
||||
rankingUsecase := business2.NewRankingUsecase(rankingRepo, accountUsecase, userPreferenceUsecase)
|
||||
rankingService := business3.NewRankingService(rankingUsecase)
|
||||
ranking := handler.NewRanking(rankingService)
|
||||
workbenchRepo := business.NewWorkbenchRepo(dataData)
|
||||
workbenchUsecase := business2.NewWorkbenchUsecase(workbenchRepo, accountUsecase, userPreferenceUsecase)
|
||||
workbenchService := business3.NewWorkbenchService(workbenchUsecase)
|
||||
workbench := handler.NewWorkbench(workbenchService)
|
||||
serverAnalysisRepo := business.NewServerAnalysisRepo(dataData)
|
||||
serverAnalysisUsecase := business2.NewServerAnalysisUsecase(serverAnalysisRepo, accountUsecase, userPreferenceUsecase)
|
||||
serverAnalysisService := business3.NewServerAnalysisService(serverAnalysisUsecase)
|
||||
serverAnalysis := handler.NewServerAnalysis(serverAnalysisService)
|
||||
@@ -239,6 +247,8 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
|
||||
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
|
||||
set := &handler.Set{
|
||||
Ranking: ranking,
|
||||
Workbench: workbench,
|
||||
ServerAnalysis: serverAnalysis,
|
||||
Analytics: analytics,
|
||||
Ledger: ledger,
|
||||
@@ -278,7 +288,11 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
||||
routes := router.NewRoutes(set)
|
||||
maintenanceRepo := system.NewMaintenanceRepo(dataData)
|
||||
maintenanceUsecase := system2.NewMaintenanceUsecase(maintenanceRepo)
|
||||
taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store)
|
||||
reportBuildRepo := business.NewReportBuildRepo(dataData)
|
||||
reportBuildUsecase := business2.NewReportBuildUsecase(reportBuildRepo)
|
||||
alertTriggerRepo := business.NewAlertTriggerRepo(dataData)
|
||||
alertTriggerUsecase := business2.NewAlertTriggerUsecase(alertTriggerRepo)
|
||||
taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store, reportBuildUsecase, alertTriggerUsecase)
|
||||
routeRegistrar := buildRuntime(registry, routes, taskMethods)
|
||||
websocketServer, cleanup2, err := websocket.New(runtimeconfigStore)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
businessbiz "kra/internal/biz/business"
|
||||
"kra/internal/biz/system"
|
||||
businessdata "kra/internal/data/business"
|
||||
systemdata "kra/internal/data/system"
|
||||
"kra/internal/server/handler"
|
||||
businessservice "kra/internal/service/business"
|
||||
)
|
||||
|
||||
func TestWorkbenchHTTP(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "workbench.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, _ := db.DB()
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
for _, step := range append(systemdata.Migrations(), businessdata.Migrations()...) {
|
||||
if err := step.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, sql := range []string{
|
||||
"INSERT INTO sys_users(id,uuid,username,level,parent_id,id_path,enable) VALUES(1,'root','root',0,0,'0',1),(7,'owner','owner',1,0,'0',1),(9,'leaf','leaf',3,7,'0,7',1),(99,'other','other',1,0,'0',1)",
|
||||
"INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(9,'lx',14,'0'),(99,'hw',20,'0')",
|
||||
"CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER,create_time INTEGER,last_login_time INTEGER)",
|
||||
"INSERT INTO tab_promote VALUES(100,'lx','渠道甲',1,0,14,0,0),(101,'lx','渠道甲下级',2,100,14,0,0),(200,'lx','零充值渠道',1,0,14,0,0),(100,'hw','外部商务渠道',1,0,20,0,0)",
|
||||
"CREATE TABLE tab_sw_report_day(source_db TEXT,promote_id INTEGER,game_id INTEGER,dedup_mode INTEGER,stat_date DATE,total_pay DECIMAL(20,2))",
|
||||
"CREATE TABLE tab_sw_report_day_key(source_db TEXT,promote_id INTEGER,game_id INTEGER,dedup_mode INTEGER,stat_date DATE,metric INTEGER,identity_key TEXT)",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
location := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
now := time.Now().In(location)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location)
|
||||
for _, row := range []struct {
|
||||
source string
|
||||
promote int
|
||||
amount int
|
||||
}{{"lx", 100, 10}, {"lx", 101, 20}, {"hw", 100, 40}} {
|
||||
if err := db.Exec("INSERT INTO tab_sw_report_day VALUES(?,?,1,0,?,?)", row.source, row.promote, today.Format("2006-01-02"), row.amount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, metric := range []int{1, 2} {
|
||||
for _, mode := range []int{0, 1} {
|
||||
key := "A"
|
||||
if mode == 0 {
|
||||
key = strconv.Itoa(row.promote)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES(?,?,1,?,?,?,?)", row.source, row.promote, mode, today.Format("2006-01-02"), metric, key).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.Exec("UPDATE tab_promote SET create_time=?,last_login_time=? WHERE source_db='lx' AND id IN(100,101)", today.Unix(), today.AddDate(0, 0, -5).Unix()).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := businessHTTPData{db}
|
||||
uc := businessbiz.NewWorkbenchUsecase(businessdata.NewWorkbenchRepo(provider), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)))
|
||||
h := handler.NewWorkbench(businessservice.NewWorkbenchService(uc))
|
||||
engine := gin.New()
|
||||
engine.Use(func(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64)
|
||||
aid := uint(901)
|
||||
if id == 1 {
|
||||
aid = 888
|
||||
}
|
||||
c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), system.Actor{UserID: uint(id), AuthorityID: aid}))
|
||||
c.Next()
|
||||
})
|
||||
engine.GET("/sw/workbench/summary", h.Summary)
|
||||
engine.GET("/sw/workbench/registerTop", h.RegisterTop)
|
||||
engine.GET("/sw/workbench/payRank", h.PayRank)
|
||||
engine.GET("/sw/workbench/unloginTop", h.UnloginTop)
|
||||
srv := httptest.NewServer(engine)
|
||||
defer srv.Close()
|
||||
call := func(user int, endpoint, query string, code int) any {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", srv.URL+"/sw/workbench/"+endpoint+"?"+query, nil)
|
||||
req.Header.Set("X-Test-User", strconv.Itoa(user))
|
||||
res, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StatusCode != 200 || body["code"] != float64(code) {
|
||||
t.Fatalf("%s: %+v", endpoint, body)
|
||||
}
|
||||
return body["data"]
|
||||
}
|
||||
q := "sourceDbList=lx,hw"
|
||||
card := func(data map[string]any, name, field string) any { return data[name].(map[string]any)[field] }
|
||||
summary := call(7, "summary", q, 0).(map[string]any)
|
||||
if card(summary, "todayRegister", "value") != "1.00" || card(summary, "todayPay", "value") != "30.00" || card(summary, "activePromote", "value") != "2.00" || card(summary, "newPromote", "value") != "2.00" || card(summary, "todayPay", "changeRate") != nil {
|
||||
t.Fatalf("工作台六卡口径错误: %+v", summary)
|
||||
}
|
||||
summary = call(1, "summary", q, 0).(map[string]any)
|
||||
if card(summary, "todayRegister", "value") != "1.00" || card(summary, "todayPay", "value") != "70.00" {
|
||||
t.Fatal("超管跨平台汇总错误")
|
||||
}
|
||||
rows := call(7, "registerTop", q, 0).([]any)
|
||||
if len(rows) != 1 || rows[0].(map[string]any)["value"] != "1.00" || rows[0].(map[string]any)["promoteName"] != "渠道甲" {
|
||||
t.Fatal("注册榜应按整棵树去重")
|
||||
}
|
||||
rows = call(7, "payRank", q+"&period=today&order=desc", 0).([]any)
|
||||
if len(rows) != 1 || rows[0].(map[string]any)["value"] != "30.00" {
|
||||
t.Fatal("充值榜范围或金额错误")
|
||||
}
|
||||
rows = call(7, "payRank", q+"&order=asc", 0).([]any)
|
||||
if len(rows) != 2 || rows[0].(map[string]any)["value"] != "0.00" || rows[0].(map[string]any)["promoteName"] != "零充值渠道" {
|
||||
t.Fatal("最低榜漏掉零充值一级渠道")
|
||||
}
|
||||
rows = call(7, "unloginTop", q, 0).([]any)
|
||||
if len(rows) != 2 || rows[0].(map[string]any)["value"] != "99999" || rows[1].(map[string]any)["value"] != "5" {
|
||||
t.Fatal("未登录榜不应包含子渠道")
|
||||
}
|
||||
rows = call(99, "payRank", "sourceDbList=lx", 0).([]any)
|
||||
if len(rows) != 0 {
|
||||
t.Fatal("其他平台商务越权查询")
|
||||
}
|
||||
if err := db.Exec("INSERT INTO sys_user_preference(user_id,include_sub_promote,idcard_dedup) VALUES(7,1,0)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summary = call(7, "summary", q, 0).(map[string]any)
|
||||
if card(summary, "todayRegister", "value") != "2.00" || card(summary, "todayPay", "value") != "30.00" {
|
||||
t.Fatal("偏好切换错误")
|
||||
}
|
||||
if err := db.Exec("UPDATE sys_user_preference SET include_sub_promote=0 WHERE user_id=7").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summary = call(7, "summary", q, 0).(map[string]any)
|
||||
if card(summary, "todayPay", "value") != "10.00" || card(summary, "newPromote", "value") != "1.00" {
|
||||
t.Fatal("渠道数和金额未排除下级")
|
||||
}
|
||||
for _, endpoint := range []string{"summary", "registerTop", "payRank", "unloginTop"} {
|
||||
call(7, endpoint, "", 7)
|
||||
}
|
||||
call(7, "payRank", q+"&period=invalid", 7)
|
||||
call(7, "summary", "sourceDbList=invalid", 7)
|
||||
if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
summary = call(7, "summary", q, 0).(map[string]any)
|
||||
if summary["available"] != false || card(summary, "todayPay", "value") != nil {
|
||||
t.Fatal("缺表不能伪造零")
|
||||
}
|
||||
call(7, "registerTop", q, 7)
|
||||
call(7, "payRank", q, 0)
|
||||
call(7, "unloginTop", q, 0)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
# KRA CI/CD 部署
|
||||
|
||||
sw2.fuhua91.com 在 226 服务器的 Docker 部署见 [Docker 部署说明](docker/README.md)。下面保留的是通用 Kubernetes/Jenkins 模板说明。
|
||||
|
||||
deploy/ 目录是一套与项目名解耦的部署模板,基于 kra 框架的新项目可以整体复制后只改一个参数块。
|
||||
|
||||
> 完整的环境配置、凭据来源与排障说明见 [DEPLOYMENT.md](DEPLOYMENT.md)。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# sw2 Docker 部署
|
||||
|
||||
沿用 226 服务器 `/www/wwwroot/live.fuhua91.com` 的 Docker 网络、持久化目录和宝塔反向代理结构。使用独立的 `sw2-backend`、`sw2-frontend`、`sw2-net`,兼容服务器现有 Docker 18.06,不依赖 Compose。
|
||||
|
||||
## 目录与端口
|
||||
|
||||
部署根目录为 `/www/wwwroot/sw2.fuhua91.com`:
|
||||
|
||||
- `config/config.yaml`:后端运行配置,挂载到 `/data/conf`,UID 1000 可写。
|
||||
- `runtime.env`:SDK 兼容参数,通过 Docker `--env-file` 加载,权限 600。
|
||||
- `log/`、`uploads/`:日志与文件持久化目录。
|
||||
- `releases/<commit>/`:已校验的发布包;`current-release` 记录当前版本。
|
||||
- 后端仅绑定 `127.0.0.1:9602`,前端仅绑定 `127.0.0.1:9601`。
|
||||
- 宝塔 `sw2.fuhua91.com` 代理至 9601,前端容器将 `/api/` 转发至后端并去除前缀,支持 WebSocket、SSE 和文件上传。
|
||||
|
||||
数据库沿用已迁移并验证的 `sw9`,平台别名保留 `lx/hw/yq/oa`,Redis 沿用当前关闭状态。生产配置使用 `admin.app.env: production`,关闭开发 Swagger。五条常规商务任务启用,两个历史回填停用。**使用同一 sw9 时只运行一个调度实例,切换到服务器前停止本机的后端服务。**
|
||||
|
||||
## 构建与发布
|
||||
|
||||
Windows 本机安装 Go、Node、pnpm、tar,先提交源代码,再执行:
|
||||
|
||||
```powershell
|
||||
.\deploy\docker\build-package.ps1
|
||||
```
|
||||
|
||||
输出位于 `.cache/deploy/sw2-<commit>.tar.gz` 及对应 SHA-256 文件。后端交叉编译为 Linux amd64,前端执行生产构建;包内只含二进制、前端产物、Dockerfile 和部署配置,真实运行配置及环境变量不进入镜像或 Git。
|
||||
|
||||
通过 SSH 将包上传到 226,校验 SHA-256 后解压到 `releases/<commit>/`。首次部署另行准备上述私有配置文件,保持 `admin.router_prefix` 为空,并确认主库为 sw9。SDK 环境变量名称为 `SW_SDK_AUTH_CODE`、`SW_SDK_MIGRATE_AUTH_CODE`、`SW_SDK_TABLE_PREFIX`,值从已有私有运行配置取得。
|
||||
|
||||
```bash
|
||||
bash /www/wwwroot/sw2.fuhua91.com/releases/<commit>/deploy.sh <commit> --build-only
|
||||
# 构建和容器配置检查通过后,停止连接同一 sw9 的本机后端。
|
||||
bash /www/wwwroot/sw2.fuhua91.com/releases/<commit>/deploy.sh <commit>
|
||||
```
|
||||
|
||||
脚本先构建和检查镜像,再停止当前受管容器,保留一份停止的 `-previous` 容器。健康检查失败时恢复前一组容器。配置、上传文件和数据库不随镜像替换;索引迁移由后端启动按版本执行。
|
||||
|
||||
首次接入域名时,备份宝塔原站点配置,再安装 `bt-nginx-sw2.fuhua91.com.conf` 至 `/www/server/panel/vhost/nginx/`,执行 `/www/server/nginx/sbin/nginx -t` 通过后重载。模板保留证书验证目录,当前监听 HTTP;后续由宝塔为 sw2 单独配置匹配域名的证书。
|
||||
|
||||
验证首页、静态资源、`/api/health`、登录状态、商务接口和任务启停/执行日志。回退版本可执行 `bash /www/wwwroot/sw2.fuhua91.com/deploy.sh <旧commit>`;数据库迁移及业务数据不由镜像回退脚本撤销。
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM docker.m.daocloud.io/alpine:3.22@sha256:2e0b81d60597f449baa56dc25d355367869d2dca4cd57e866f1e2b52af3eb55f
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& addgroup -S -g 1000 sw2 \
|
||||
&& adduser -S -D -H -u 1000 -G sw2 sw2 \
|
||||
&& mkdir -p /app/logs /app/uploads/file /app/uploads/chunks /data/conf \
|
||||
&& chown -R sw2:sw2 /app /data/conf
|
||||
|
||||
ARG VERSION
|
||||
LABEL org.opencontainers.image.revision=$VERSION
|
||||
ENV TZ=Asia/Shanghai
|
||||
COPY --chown=sw2:sw2 kratos-admin /app/kratos-admin
|
||||
RUN chmod 0755 /app/kratos-admin
|
||||
WORKDIR /app
|
||||
USER sw2
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8000/health || exit 1
|
||||
CMD ["/app/kratos-admin", "-conf", "/data/conf"]
|
||||
@@ -0,0 +1,41 @@
|
||||
map $http_upgrade $sw2_site_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name sw2.fuhua91.com;
|
||||
root /www/wwwroot/sw2.fuhua91.com;
|
||||
server_tokens off;
|
||||
client_max_body_size 1024m;
|
||||
|
||||
# 保留宝塔证书验证目录,配置、日志和发布包不作为静态站点提供。
|
||||
include /www/server/panel/vhost/nginx/well-known/sw2.fuhua91.com.conf;
|
||||
include /www/server/panel/vhost/nginx/extension/sw2.fuhua91.com/*.conf;
|
||||
location ^~ /.well-known/ {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9601;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $sw2_site_connection_upgrade;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/sw2.fuhua91.com.log;
|
||||
error_log /www/wwwlogs/sw2.fuhua91.com.error.log;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
param([string]$Tag = '')
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
if (-not $Tag) { $Tag = (& git rev-parse --short=12 HEAD).Trim() }
|
||||
if ($Tag -notmatch '^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$') { throw 'Invalid release tag' }
|
||||
& git diff --quiet -- . ':!configs/config.yaml'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Commit source changes before packaging' }
|
||||
& git diff --cached --quiet
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Commit staged changes before packaging' }
|
||||
|
||||
$outputRoot = Join-Path $repoRoot '.cache\deploy'
|
||||
$stage = Join-Path $outputRoot $Tag
|
||||
$archive = Join-Path $outputRoot "sw2-$Tag.tar.gz"
|
||||
if (Test-Path -LiteralPath $archive) { throw "Release archive already exists: $archive" }
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'backend'), (Join-Path $stage 'frontend') | Out-Null
|
||||
$previous = @{}
|
||||
try {
|
||||
foreach ($name in @('GOOS', 'GOARCH', 'CGO_ENABLED')) {
|
||||
$previous[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
}
|
||||
$env:GOOS = 'linux'
|
||||
$env:GOARCH = 'amd64'
|
||||
$env:CGO_ENABLED = '0'
|
||||
& go build -trimpath -ldflags "-s -w -X main.Version=$Tag" -o (Join-Path $stage 'backend\kratos-admin') ./cmd
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Linux backend build failed' }
|
||||
} finally {
|
||||
foreach ($name in $previous.Keys) { [Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process') }
|
||||
}
|
||||
|
||||
Push-Location (Join-Path $repoRoot 'web')
|
||||
try {
|
||||
& pnpm build
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Frontend build failed' }
|
||||
} finally { Pop-Location }
|
||||
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'backend.Dockerfile') -Destination (Join-Path $stage 'backend\Dockerfile')
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'frontend.Dockerfile') -Destination (Join-Path $stage 'frontend\Dockerfile')
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'nginx.conf') -Destination (Join-Path $stage 'frontend\nginx.conf')
|
||||
$frontendDist = Join-Path $stage 'frontend\dist'
|
||||
New-Item -ItemType Directory -Force -Path $frontendDist | Out-Null
|
||||
Get-ChildItem -LiteralPath (Join-Path $repoRoot 'web\dist') | ForEach-Object {
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $frontendDist -Recurse -Force
|
||||
}
|
||||
foreach ($name in @('deploy.sh', 'bt-nginx-sw2.fuhua91.com.conf')) {
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination (Join-Path $stage $name)
|
||||
}
|
||||
& tar -czf $archive -C $stage .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Release archive failed' }
|
||||
$hash = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
[IO.File]::WriteAllText("$archive.sha256", "$hash $([IO.Path]::GetFileName($archive))`n", [Text.UTF8Encoding]::new($false))
|
||||
Write-Output "Release: $archive"
|
||||
Write-Output "SHA256: $hash"
|
||||
} finally { Pop-Location }
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
TAG="${1:?Usage: deploy.sh TAG [--build-only]}"
|
||||
MODE="${2:-}"
|
||||
[[ "$TAG" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$ ]] || { echo 'Invalid release tag' >&2; exit 1; }
|
||||
[[ -z "$MODE" || "$MODE" == '--build-only' ]] || { echo 'Unknown mode' >&2; exit 1; }
|
||||
BASE_DIR=/www/wwwroot/sw2.fuhua91.com
|
||||
RELEASE="$BASE_DIR/releases/$TAG"
|
||||
BACKEND_IMAGE="sw2/backend:$TAG"
|
||||
FRONTEND_IMAGE="sw2/frontend:$TAG"
|
||||
NETWORK=sw2-net
|
||||
NAMES=(sw2-backend sw2-frontend)
|
||||
|
||||
command -v docker >/dev/null
|
||||
command -v curl >/dev/null
|
||||
test -f "$RELEASE/backend/Dockerfile"
|
||||
test -f "$RELEASE/frontend/Dockerfile"
|
||||
|
||||
docker build --build-arg "VERSION=$TAG" -t "$BACKEND_IMAGE" "$RELEASE/backend"
|
||||
docker build --build-arg "VERSION=$TAG" -t "$FRONTEND_IMAGE" "$RELEASE/frontend"
|
||||
docker run --rm "$BACKEND_IMAGE" /app/kratos-admin -h >/dev/null
|
||||
docker run --rm --add-host sw2-backend:127.0.0.1 "$FRONTEND_IMAGE" nginx -t
|
||||
if [[ "$MODE" == '--build-only' ]]; then exit 0; fi
|
||||
|
||||
test -s "$BASE_DIR/config/config.yaml"
|
||||
test -f "$BASE_DIR/runtime.env"
|
||||
install -d -o 1000 -g 1000 -m 0750 "$BASE_DIR/config" "$BASE_DIR/log" "$BASE_DIR/uploads"
|
||||
chown 1000:1000 "$BASE_DIR/config/config.yaml"
|
||||
chmod 0600 "$BASE_DIR/config/config.yaml" "$BASE_DIR/runtime.env"
|
||||
if ! docker network inspect "$NETWORK" >/dev/null 2>&1; then
|
||||
docker network create --label com.sw2.application=sw2 "$NETWORK"
|
||||
fi
|
||||
|
||||
managed() {
|
||||
[[ "$(docker inspect --format '{{index .Config.Labels "com.sw2.application"}}' "$1")" == sw2 ]]
|
||||
}
|
||||
exists() { docker inspect "$1" >/dev/null 2>&1; }
|
||||
for name in "${NAMES[@]}"; do
|
||||
for candidate in "$name" "$name-previous"; do
|
||||
if exists "$candidate"; then
|
||||
managed "$candidate" || { echo "Refusing to replace unmanaged container: $candidate" >&2; exit 1; }
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
moved=()
|
||||
created=()
|
||||
rollback() {
|
||||
result=$?
|
||||
trap - ERR
|
||||
set +eu
|
||||
for name in "${created[@]}"; do docker rm -f "$name" >/dev/null 2>&1; done
|
||||
for name in "${moved[@]}"; do
|
||||
if exists "$name-previous"; then docker rename "$name-previous" "$name"; fi
|
||||
docker start "$name"
|
||||
done
|
||||
echo 'Deployment failed; previous containers restored where present.' >&2
|
||||
exit "$result"
|
||||
}
|
||||
trap rollback ERR
|
||||
for name in "${NAMES[@]}"; do
|
||||
if exists "$name-previous"; then docker rm "$name-previous" >/dev/null; fi
|
||||
if exists "$name"; then
|
||||
moved+=("$name")
|
||||
docker stop -t 30 "$name" >/dev/null
|
||||
docker rename "$name" "$name-previous"
|
||||
fi
|
||||
done
|
||||
|
||||
created+=(sw2-backend)
|
||||
docker run -d --name sw2-backend --label com.sw2.application=sw2 \
|
||||
--restart unless-stopped --network "$NETWORK" \
|
||||
--log-opt max-size=20m --log-opt max-file=3 \
|
||||
-p 127.0.0.1:9602:8000 --env-file "$BASE_DIR/runtime.env" \
|
||||
-v "$BASE_DIR/config:/data/conf" -v "$BASE_DIR/log:/app/logs" -v "$BASE_DIR/uploads:/app/uploads" \
|
||||
"$BACKEND_IMAGE"
|
||||
|
||||
ready=false
|
||||
for attempt in $(seq 1 45); do
|
||||
if curl -fsS --max-time 3 http://127.0.0.1:9602/health >/dev/null 2>&1; then ready=true; break; fi
|
||||
sleep 2
|
||||
done
|
||||
[[ "$ready" == true ]] || { echo 'Backend health check failed' >&2; false; }
|
||||
|
||||
created+=(sw2-frontend)
|
||||
docker run -d --name sw2-frontend --label com.sw2.application=sw2 \
|
||||
--restart unless-stopped --network "$NETWORK" \
|
||||
--log-opt max-size=20m --log-opt max-file=3 \
|
||||
-p 127.0.0.1:9601:80 "$FRONTEND_IMAGE"
|
||||
|
||||
ready=false
|
||||
for attempt in $(seq 1 15); do
|
||||
if curl -fsS --max-time 3 http://127.0.0.1:9601/api/health >/dev/null 2>&1; then ready=true; break; fi
|
||||
sleep 2
|
||||
done
|
||||
[[ "$ready" == true ]] || { echo 'Frontend API proxy health check failed' >&2; false; }
|
||||
trap - ERR
|
||||
printf '%s\n' "$TAG" > "$BASE_DIR/current-release"
|
||||
install -m 0750 "$RELEASE/deploy.sh" "$BASE_DIR/deploy.sh"
|
||||
docker ps --filter label=com.sw2.application=sw2 --format '{{.Names}} {{.Image}} {{.Status}} {{.Ports}}'
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM docker.m.daocloud.io/nginx:1.28-alpine@sha256:a8b39bd9cf0f83869a2162827a0caf6137ddf759d50a171451b335cecc87d236
|
||||
|
||||
ARG VERSION
|
||||
LABEL org.opencontainers.image.revision=$VERSION
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY dist/ /usr/share/nginx/html/
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
|
||||
@@ -0,0 +1,53 @@
|
||||
map $http_upgrade $sw2_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
map $http_x_forwarded_proto $sw2_forwarded_proto {
|
||||
default $scheme;
|
||||
http http;
|
||||
https https;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
server_tokens off;
|
||||
client_max_body_size 1024m;
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 'ok';
|
||||
}
|
||||
|
||||
location = /api {
|
||||
return 308 /api/;
|
||||
}
|
||||
|
||||
location ^~ /api/ {
|
||||
proxy_pass http://sw2-backend:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $http_x_real_ip;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $sw2_forwarded_proto;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $sw2_connection_upgrade;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control 'no-cache';
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ Kra 管理后台由 Kratos 应用承载生命周期,Gin 提供兼容管理端
|
||||
| 目录 | 职责 |
|
||||
|---|---|
|
||||
| `internal/server` | Gin 路由、中间件和 Kratos transport 生命周期 |
|
||||
| `internal/service` | HTTP DTO 与领域对象转换、用例编排 |
|
||||
| `internal/service` | HTTP 输入校验、DTO 与领域对象转换 |
|
||||
| `internal/biz` | 领域对象、业务规则和仓储接口 |
|
||||
| `internal/data` | GORM、MySQL、Redis 和本地文件存储实现 |
|
||||
| `web` | Vue 3 管理端 |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# 文档入口
|
||||
|
||||
- [后台说明](ADMIN_BACKEND.md)、[支付接入](PAYMENT.md)、[短信接入](SMS_INTEGRATION.md)。
|
||||
- [OA 迁移计划](OA_MIGRATION_PLAN.md):独立 OA 工作的设计材料,不是当前商务迁移的完成证明。
|
||||
|
||||
此目录仅保留持续维护的文档。临时迁移笔记、验证报告和脚本用完即删,不纳入版本控制。
|
||||
@@ -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 路由矩阵
|
||||
@@ -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,15 +0,0 @@
|
||||
# 预警日志附件的文件 ID 迁移补充
|
||||
|
||||
现有 `sw_to_kra_migrate.sql` 为 `sys_oss.oss_id` 建立 `entity='file'` 映射并导入 `media_file_upload_and_downloads`,同时只更新预警操作日志的操作人 ID,没有转换 `attachment_ids` 中的文件 ID。日志数字数组仍引用旧文件,不能直接当成 Kra 文件 ID 查询。
|
||||
|
||||
代码迁移步骤 `202611030007_business_alert_attachments` 在服务启动的迁移流程中执行,不修改用户提供的 SQL。最终数据库复制验收时,先完成原 SQL,再启动包含本步骤的新服务。
|
||||
|
||||
处理规则:
|
||||
|
||||
1. 新库没有旧日志备份和映射表时不执行转换。
|
||||
2. 按日志 ID 与 `bak_sw2kra_20261103_sys_alert_operation_log` 关联,只处理当前附件与旧备份一致的纯数字数组,兼容 JSON 数字和数字字符串。
|
||||
3. 根据文件映射写入新文件 ID,保留原数组顺序。没有旧文件映射的引用原本无法展示,转换后不指向任何同号新文件;原始引用仍在备份中保留。
|
||||
4. URL 数组、混合 URL 数组、新日志、已改动的旧日志均不转换。分批查询和事务写入,重复执行不会二次映射。
|
||||
5. 运行时附件解析支持数字 ID 与 URL;文件查询分批执行,已删除或不存在文件不展示。大数字 ID 在解析及返回过程中不经过浮点数。
|
||||
|
||||
隔离测试覆盖超出 JavaScript 精确整数范围的旧 ID、失效引用、新旧文件同号、URL、新日志、已转换日志及重复执行。最终实库验收应以旧、新附件解析出的 URL 对比,而不是要求转换后的 JSON 字节仍与旧 ID 数组一致。
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
# 渠道统计与游戏分析核对
|
||||
|
||||
核对基线至旧后端 main 最终代码中的 `ChannelDataServiceImpl`、`GameDataServiceImpl`、`BusinessBindScopeServiceImpl`、`ReportQueryScopes` 及日汇总/身份键 Mapper;页面直接核对旧 `ChannelDataStatistics.vue` 与 `GameAnalytics.vue`。
|
||||
|
||||
## 数据与范围
|
||||
|
||||
- 数据来自 `tab_sw_report_day` 和 `tab_sw_report_day_key`,金额固定 `dedup_mode=0`,人数按查看者的身份证偏好。金额用精确十进制处理,保留两位。
|
||||
- 渠道查询保留本人/下级绑定范围和“包含下级渠道”开关;模糊查询匹配账号,不匹配昵称。开启下级时,命中的下级扩展到同根渠道树,再与原权限范围、类型筛选取交集。
|
||||
- 金额根渠道:一级用自身,否则正 top ID,再否则自身。人数根渠道沿用旧 SQL 的 `COALESCE(NULLIF(top_promote_id,0),promote_id)`;两者在异常历史层级数据上可能不同,不能擅自统一。
|
||||
- 超管全平台、包含下级且无渠道筛选时包含官方 0 号和已无渠道资料的历史行;普通商务只返回允许展示的根渠道。
|
||||
- 游戏分组按平台+游戏 ID,过滤 `game_id > 0`。游戏名称筛选使用报表中的历史名,展示使用 `tab_game` 当前名。
|
||||
- 渠道商务名称先用平台商务账号,否则 SW 用户名;此处不追加画像/明细页面使用的括号用户名。
|
||||
|
||||
## 汇总与计算
|
||||
|
||||
- 游戏 summary 累加全部筛选后游戏行,与当前页无关。相同身份出现在不同游戏时分别计数,不做全局去重。
|
||||
- 普通商务渠道 summary 累加可见行。超管渠道 summary 使用筛选范围内全局去重;模式 0 按平台前缀区分身份,模式 1 跨平台去重。
|
||||
- 付费率=充值人数÷活跃人数×100,ARPU=充值金额÷活跃人数,ARPPU=充值金额÷充值人数。分母为零时为零。
|
||||
- 保留原逐行人数的 Java int 上限,游戏汇总改用 int64 避免相加溢出。相同排序值使用平台、ID 稳定排序。
|
||||
- 后端任一日期缺失时周期为上海当天;前端默认近 30 天含今天。后端页长缺失、非正或超过上限时为 500,页面显式传 10。
|
||||
- 缺少必要数据表时 `available=false`、`summary=null`,顶部指标为 null,不把缺表当成零。
|
||||
|
||||
## 验证与优化
|
||||
|
||||
- 使用聚合查询和批量资料查询代替逐行查询,不改变业务口径。日期非法或倒置返回明确错误。
|
||||
- 隔离 TCP HTTP 和仓储测试覆盖全量汇总与分页、跨平台同号、官方/孤立渠道、根渠道树去重、类型交集、历史游戏名过滤、当前资料展示、偏好切换及空范围。
|
||||
- 领域测试覆盖大金额精确排序、分母和人数上限。前端检查筛选回第一页、30 天默认值、汇总不随分页变化、缺数据提示、没有额外导出入口。
|
||||
- 真实 sw7 库只做只读请求;当前缺少完整业务报表,最终旧库复制与同源数据比较仍未开始。
|
||||
@@ -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,32 +0,0 @@
|
||||
# 渠道画像迁移核对
|
||||
|
||||
已实现 `GET /sw/promote/portrait` 及渠道列表画像入口,来源为旧 `PromotePortraitServiceImpl`、`ChannelProfileDrawer.vue`、日汇总/身份键和支付 Mapper。完整业务库数据仍待最终复制验收。
|
||||
|
||||
## 数据范围与基本资料
|
||||
|
||||
- 先按当前商务本人/下级的正常绑定校验渠道可见性,平台与渠道 ID 组合定位;超管不限。
|
||||
- 统计树与原渠道画像 helper 一致:关闭下级只看自身;二级看自身及直属三级;一级看自身及同平台 top/parent 对应的渠道。
|
||||
- 基本信息包含昵称、账号、负责人、级别、审批状态、注册类型、注册/登录时间、归属商务、上级渠道与已通过推广游戏数。一级渠道不显示父级;其他渠道的父级账号从同平台记录重新读取。
|
||||
- 画像状态取 status,与列表锁定状态不同:0 待审核、1 正常、-2 驳回、-3 商务新增。第四张指标卡是活跃人数,不能套用商务画像的人均充值。
|
||||
|
||||
## 时间与累计
|
||||
|
||||
- 支持 today、yesterday、last7、last30、total;累计起点取当前人数去重模式的最早日汇总,缺省上海当天。
|
||||
- 累计模式无对照期,compareValue/changeRate/changeDiff/compareLabel 均为空;其他周期保持原对比规则。
|
||||
- 游戏分布和付费分层始终按累计身份范围,与顶部指标周期独立。
|
||||
- 今日/昨日趋势读取原始主账号和成功支付;其余按日汇总。金额固定模式 0,人数遵守全局去重偏好。
|
||||
|
||||
## 付费分层
|
||||
|
||||
- 参与者是累计注册身份键集合,重复键只计一次;无键时保留原注册人数回退规则。
|
||||
- 分层金额来自统计渠道树内的所有历史成功支付,没有支付时间过滤。因此历史或未来时间戳的支付仍可能影响已注册身份的分层,不擅自改成顶部日期范围。
|
||||
- 模式 1 按付款用户的原始 idcard 汇总,再按旧 trim/末尾 X 规则归一并合并;不擅自加主账号过滤或把子账号付款并到父账号。模式 0 按原始付款 user_id 汇总,并使用平台前缀与注册身份对应。
|
||||
- 大R ≥10000,中R ≥1000 且 <10000,小R >0 且 <1000,其余免费。先按旧金额规则保留两位再归档,比例独立保留一位,不强制凑成 100%。未出现在注册集合中的付款人不增加分层人数。
|
||||
|
||||
## 修复的旧页面参数错误
|
||||
|
||||
- 旧“近7天/近30天”发送 7d/30d,而旧后台只识别 last7/last30,导致回退今日;新页面发送后台正式参数。
|
||||
- 旧关联预警误将渠道 ID 传入 businessId;新页面使用 targetPromoteId 与 platformCodes,并继续由预警接口执行查看者数据范围隔离。
|
||||
- 小R/免费区间文案按实际后端 >0 / ≤0 调整,避免把小于一元的付费玩家误解为免费。
|
||||
|
||||
验证覆盖领域规则、真实仓储查询、隔离数据库 TCP HTTP、浏览器完整交互及当前 sw7 只读接口。详见 `channel-portrait-http-2026-11-03.json` 与 `channel-portrait-page-2026-11-03.json`;全量 Go 测试与前端构建通过。
|
||||
@@ -1,55 +0,0 @@
|
||||
# 渠道列表与新增渠道:源码核对记录
|
||||
|
||||
以下是实现核对契约,源码基线与 `implementation.md` 一致。列表、新增、绑定选项、游戏分布及渠道画像已接入并验证;报表构建任务及完整数据验收仍未完成。
|
||||
|
||||
## 页面
|
||||
|
||||
来源 `sw/web/src/views/BusMgmt/ChannelList.vue`、`components/AddChannelDrawer.vue`、`components/ChannelGameDrawer.vue`。
|
||||
|
||||
- 筛选:平台默认全选、商务账号、关键词、渠道层级、注册类型、锁定状态。实际关键词后端只查询渠道账号;旧占位文案“渠道账号 / 负责人姓名”与实现不一致。
|
||||
- 列:平台、ID、渠道账号、渠道层级、归属商务、上级渠道、负责人、渠道类型、推广游戏、玩家数量、累计充值、锁定状态、画像。没有编辑入口。
|
||||
- 列表的注册类型选项只有“个人 0 / 公会 1”,其余选项被注释;新增抽屉则是“个人 0 / 企业 1”,不能擅自根据其中一处统一名称。
|
||||
- 状态筛选及列使用 `lockStatus`:0 正常、-1 已锁定、-2 解除合作,与渠道审批字段 `status` 不同。
|
||||
- “添加渠道”只按按钮权限判断,旧代码没有额外要求三级商务。游戏商务下拉随平台变化加载,选项取平台商务 ID 并去重。
|
||||
- 新增字段:平台、游戏商务、推广类型、渠道账号、密码。账号为 `[\u4e00-\u9fa5a-zA-Z0-9]{4,30}`,提交前 trim;密码原值保留,按 Java UTF-16 长度 6–15 校验。
|
||||
- 推广游戏与画像均是右侧抽屉,必须同时传 `sourceDb` 与渠道 ID。游戏抽屉调用 `/system/promote/game-distribution`,累计口径,列为游戏、玩家数量、充值金额。画像尚待迁移,不能宣称渠道整个菜单完成。
|
||||
|
||||
## 列表权限和查询
|
||||
|
||||
来源 `PromoteServiceImpl`、`BusinessBindScopeServiceImpl`、`BusierAccountFilters`。
|
||||
|
||||
- 非超管查询本人及全部下级的已通过平台绑定,三级仅本人。列表自身并不会因全局“包含下级渠道”关闭而只列一级;该偏好用于指标聚合。
|
||||
- 超管默认不限绑定;一旦指定 `busierUserId` 或商务账号关键词,必须按匹配绑定收窄。
|
||||
- `busierUserId` 是指定用户本人绑定,不能套用预警筛选“该商务及全部下级”的语义。
|
||||
- 商务账号关键词在 Java 中按忽略大小写的文字子串匹配平台商务账号或 SW 用户名。`%`、`_` 在这里是普通字符,不能随意变成 SQL 通配符。
|
||||
- 平台商务与渠道以 `(source_db, busier_id)` 关联,跨平台同号不得混用。用户名展示为 `平台账号(SW用户名)`;两者相同或其中为空时不重复拼接。
|
||||
- 支持平台、层级、注册类型、审批状态、锁定状态、渠道账号关键词。默认按创建时间、ID 倒序;新增稳定的平台排序可用于跨平台同号并列。
|
||||
|
||||
## 列表指标
|
||||
|
||||
来源 `PromoteListMetricsSupport`、`PromotePortraitScopeHelper` 和三个 Mapper。
|
||||
|
||||
- `gameCount`:`tab_promote_apply.status=1`,按该行渠道自身去重 `game_id`;不随包含下级扩展。
|
||||
- `playerCount`:`tab_user` 主账号(`puid=0 OR puid IS NULL`)。不去重时累计行数;身份证去重时在整棵统计树内去重,空证件不计入。
|
||||
- 身份证规范化仅 trim,并将末尾大写 X 改为小写 x,不能做额外证件合法性校验或把无证件用户按 ID 计入。
|
||||
- `totalPayAmount`:`tab_spend.pay_status=1` 累计 `pay_amount`,不按身份证去重,也没有日期上限。
|
||||
- 关闭包含下级、层级为空或 >=3:只统计当前渠道。二级:本级与 `parent_id=当前ID AND promote_level=3`。一级:本级与同平台 `top_promote_id=当前ID OR parent_id=当前ID`。
|
||||
- 注意一级旧加载函数还会加载第二跳子渠道,但最终 `PromotePortraitScopeHelper` 再按 top/parent 条件过滤;不能仅看加载函数就扩大最终统计集合。
|
||||
- 当前 sw7 缺业务表,列表本体及各指标要明确表示未接入,不能伪造真实零。
|
||||
|
||||
## 源平台创建事务
|
||||
|
||||
来源 `PlatformPromoteWriteServiceImpl`、`PlatformTabPromote`、`TabPromoteSettlementTime`、`TabPromoteBusinessMapper`。
|
||||
|
||||
1. 先验证当前用户可用的已通过绑定与目标平台商务匹配,再进入对应 `lx/hw/yq` 源库写入事务。
|
||||
2. 源库 `tab_promote` 账号不得重复。密码为 `XiguPasswordUtils.cmfPassword` 格式,需要复用已有环境盐适配器,不能用 Kra 登录密码算法替换。
|
||||
3. 新渠道 `status=-3`、`promote_level=1`、`parent_id=0`、`top_promote_id=0`、`game_ids2=''`,注册类型默认 0;时间为 Unix 秒。
|
||||
4. 同事务创建 `tab_promote_settlement_time`:`time_type=0`、`day_period=7`,下一次结算为当前秒数加 7 天。
|
||||
5. 游戏商务必须存在,原子追加新渠道 ID 到 `tab_promote_business.promote_ids`,避免并发覆盖;任一步失败整体回滚。
|
||||
6. 返回新 ID 与 `sourceDb`,原成功文案提示“添加成功,数据同步到列表可能有延迟”。不直接制造主库同步副本,不在真实平台库创建测试渠道。
|
||||
|
||||
## 游戏分布的已核对依赖
|
||||
|
||||
游戏分布依赖 `PromotePortraitServiceImpl.buildGameDistribution`、`SwReportDayQueryServiceImpl` 和日汇总/身份键 Mapper。当前实现只列根渠道已通过的游戏,用同渠道统计树读取日汇总:人数对 metric=1 的 identity_key 去重,dedup_mode 随偏好;金额固定 dedup_mode=0。累计起点从当前人数模式的最早日汇总取得,缺失则使用上海当天,即使模式 0 有更早数据也不改写该起点。截止上海当天,排除未来日期。先将金额保留两位再按金额降序、游戏 ID 升序排列。
|
||||
|
||||
完整渠道画像已接入,规则及验证见 `channel-portrait-source-notes.md`。报表汇总任务、预警生成任务和最终实库验收仍属于后续范围。
|
||||
@@ -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,98 +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`。覆盖跨平台同号、全量汇总、零行处理、偏好切换、资料补全及缺表;按用户新要求未执行页面交互检查。最终旧库验收仍未开始。
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
# 注册与充值明细迁移核对
|
||||
|
||||
已接入注册/充值列表、对应 XLSX 导出,以及页面依赖的游戏下拉接口。来源为旧 `RegisterServiceImpl`、`SpendServiceImpl`、`BusinessBindScopeServiceImpl`、`ReportQueryScopes` 和实际明细 Vue 页面。
|
||||
|
||||
## 查询口径
|
||||
|
||||
- 注册列表严格 `puid=0`,不包含 NULL,也不受身份证去重开关影响。注册时间使用 Unix 秒;未传完整日期时默认截至昨日的七天,页面主动传入的默认范围则是含今日的近30天。
|
||||
- 注册累计充值按同平台+玩家 ID 汇总全部历史成功支付,不受本次注册日期和渠道过滤影响,也不归并子账号。
|
||||
- 充值列表按选定状态过滤;成功支付合计强制 pay_status=1,忽略选定状态,保留其余过滤。因此筛选失败订单时合计仍可非零。
|
||||
- 充值保留历史游戏名和渠道账号,注册保留首登游戏名快照。渠道资料缺失时依次保留历史账号、显示官方渠道(ID=0)或未知渠道及 ID。
|
||||
- 游戏选项来自平台游戏表,列表按游戏名称传参;注册游戏筛选通过同平台游戏 ID 关联,充值直接筛订单的游戏名快照。
|
||||
|
||||
## 可见范围
|
||||
|
||||
- 普通商务只用本人和下级正常绑定;三级只用本人。指定商务账号按平台账号或 SW 登录名的文字子串收窄。
|
||||
- 渠道账号为区分大小写的精确匹配。包含下级时,先解析命中渠道所属一级树,再与原可见渠道范围取交集;不包含下级时只保留一级。
|
||||
- 超管未按商务/渠道收窄且包含下级时,可以看到官方和已无当前渠道资料的历史数据;其他情况必须命中真实可见渠道。
|
||||
- 所有平台实体关联均带 source_db,不将同号玩家、渠道或游戏混为同一条。
|
||||
|
||||
## 修复与优化
|
||||
|
||||
- 修复旧注册游戏 EXISTS 内未限定外层 source_db 导致跨平台串号。
|
||||
- 保留旧累计充值排序已修复的外层用户关联,并按数值注册时间排序。
|
||||
- 移除“命中超过200个渠道后退回全平台”的筛选失效行为;用相关 EXISTS 表达渠道树,不需要把大量 ID 拼进请求。
|
||||
- 只读取当前页/导出范围所需的渠道与绑定资料,累计充值按玩家组合键分批查询。
|
||||
- 注册导出按钮使用正确的注册导出权限,不再误用充值导出权限;充值 ID 列不显示后台不支持的排序箭头。
|
||||
|
||||
## 导出与页面
|
||||
|
||||
- 超过10000条在读取完整结果和生成工作簿前拒绝;缺业务表不导出假空数据,确定无匹配绑定可导出只有表头的工作簿。
|
||||
- 保留原 VO 导出字段顺序,包括没有中文 ExcelProperty 的辅助字段;时间保留 Unix 秒,状态/方式保留原代码。
|
||||
- 数值直接写入数值 XML,不先转 float64;账号、订单号等强制文本,保留前导零并避免当作公式。
|
||||
- 页面按旧模板提供筛选、游戏远程下拉、日期快捷项、排序、分页、导出;充值金额≥5000的行高亮。查询失败会清除旧行和旧合计。
|
||||
- 游戏下拉的默认/批量 limit 行为沿用旧接口;区服分析的 servers/list 尚待后续实现,不能因 games 可用而标记整个区服页面完成。
|
||||
|
||||
验证:领域/存储测试,实际 TCP HTTP 与 XLSX 内容校验,真实 sw7 的只读查询及确定空查询导出,Chrome 筛选/下载回归。见 `ledger-http-2026-11-04.json`、`ledger-page-2026-11-04.json`。未修改真实业务数据,最终旧库复制和数据比对尚未开始。
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"method": "真实 Chrome 渲染;找回密码接口使用受控响应,不发送真实短信",
|
||||
"title": "遨游龙翔游戏平台",
|
||||
"actions": [
|
||||
"check",
|
||||
"send-code",
|
||||
"verify-code",
|
||||
"reset"
|
||||
],
|
||||
"pageErrors": [],
|
||||
"passed": true
|
||||
}
|
||||
@@ -1,188 +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`。
|
||||
@@ -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,43 +0,0 @@
|
||||
# 商务与 SDK 商务画像源码核对
|
||||
|
||||
状态:两种画像接口、三个页面入口及报表读取已实现并通过隔离 HTTP、真实会话和浏览器验证。完整平台数据源与最终旧库数据验收仍未完成。账号状态联动已在 `fc17b38` 完成。
|
||||
|
||||
## 范围与入口
|
||||
|
||||
- `GET /sw/business/:userId/portrait`:普通商务画像。旧 `BusinessPortraitQueryBo` 取 `sourceDbList`,必须选择平台;不是渠道接口的 `sourceDb` 参数。通过目标商务本人及下级的正常绑定解析渠道范围。
|
||||
- `GET /sw/business/bindPlatform/portrait`:指定 `sourceDb` 与 `platformBusierId` 的正常绑定,校验当前登录人的可见范围。基本资料读取对应平台 `tab_promote_business`,不能用 SW 用户资料冒充。
|
||||
- 两种画像共用旧 `BusinessProfileDrawer.vue`。商务列表“画像”、绑定正常行“画像”、只读 SDK 绑定详情的平台入口都需要接上;此前移除的“编辑”不能恢复。
|
||||
- SDK 基本信息中 `userId` 实际是平台商务 ID。关联预警及旗下渠道的查询仍应使用打开抽屉时的 SW 用户 ID 和绑定参数,不能误用这个平台 ID。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- 普通商务:账号、姓名、级别、父商务账号/姓名、创建时间、审核状态;渠道数只数所选范围内一级渠道,本月新增判断渠道创建 Unix 秒位于上海本月首日(含)至明天零点(不含)。
|
||||
- SDK 商务:账号、姓名、级别、创建 Unix 秒。二级的父商务是 `commerce1`,三级是 `commerce2`,一级没有父级;父记录不存在时不填父资料。
|
||||
- SDK 状态与 SW 不同:SDK 的 1 为正常、0 为待审核、其余为停用;不能把 SDK 字符串 `"0"` 套用成 SW 正常。
|
||||
- 普通商务画像的渠道数包含目标商务的下级绑定;旧“旗下渠道”抽屉接口仍是直属口径,需要保留并记录两者差异。
|
||||
|
||||
## 时间、卡片与趋势
|
||||
|
||||
来源:`BusinessPortraitServiceImpl`、`BusinessPortraitSupport`。
|
||||
|
||||
- 画像服务支持 today、yesterday、last7、last30、thismonth,大小写归一,空/未知回退 today。公共 helper 另外支持 total,但普通商务画像服务不接受 total;渠道画像才使用累计模式。
|
||||
- today 对比昨日;yesterday 对比前日;last7/last30 包含今天并对比前一完整同长周期;thismonth 对比上月首日至相同日号,上月不足该日时截到月末。
|
||||
- 注册玩家来自日汇总身份键的 REGISTER=1;付费率为 PAY=3 人数 / ACTIVE=2 人数 ×100,保留 1 位;人均充值为金额 / 注册人数,保留 2 位。
|
||||
- 当前活跃为零时付费率为 0.0;对照期活跃为零时 compareValue 为 null,百分点差也为 null。
|
||||
- 注册、金额、ARPU 的相对变化率为 `(current-previous)*100/previous`,保留 1 位;对照为零/null 时 changeRate 为 null。付费率用 changeDiff 百分点差。
|
||||
- today/yesterday 的趋势读取原始主账号注册与成功支付,补齐 24 个小时;小时注册趋势不做身份证去重。MySQL 与原 Mapper 一致使用 `HOUR(FROM_UNIXTIME(...))`,原服务与新服务的数据库会话时区在最终实库验收时核对;SQLite 测试按传入上海日边界计算小时。
|
||||
- 其他周期趋势来自日汇总/身份键,按天补零。金额查询固定 dedup_mode=0;人数随当前登录人的偏好。
|
||||
- 跨平台不去重人数需要给身份键加 source_db 前缀;身份证去重模式按身份键跨平台去重。具体 SQL 在 `TabSwReportDayKeyMapper.xml` 的 summary / summaryByDate 中。
|
||||
|
||||
## 排行和关联预警
|
||||
|
||||
- 注册 TOP 5 经 `ChannelTreeMetricsSupport.countRegisterByDisplayChannel` 汇总:含下级时按源平台和一级渠道树 COUNT DISTINCT;一级取自身 ID,其他渠道 top_promote_id>0 时取 top,否则取自身 ID。不能把子渠道人数简单求和。人数先按旧 int 上限归一,再排序和截取 TOP 5。
|
||||
- 充值 TOP 5 先按渠道取金额,再按 `resolveRootPromoteId` 归根;同平台分组,过滤 <=0,只取前五。按值降序、渠道 ID 升序,返回 rank、平台、渠道 ID/账号、value(保留两位)。
|
||||
- 关联预警不在画像 DTO 中,由前端独立请求 `/sw/alertRecord/list`,参数为 `platformCodes`、SW `businessId`、分页。
|
||||
- 旧 SDK 画像的预警仍按 SW 商务及平台筛选,没有单独按 platformBusierId 收窄;需按实际模板核对,不从基本资料 ID 推断范围。
|
||||
|
||||
## 验证与实现要求
|
||||
|
||||
沿用 DTO → service → biz → data,金额用精确计算,页面采用 Kra 图表/表格风格。不要在 stats 缺表时伪造零;没有任何绑定是已知空范围,可返回真实零和零趋势,有绑定但渠道/汇总未接入时保留可读取基本信息并明确未知指标。
|
||||
|
||||
当前真实测试库仍缺完整业务表、平台/OA 别名尚未配齐。正向指标和权限测试继续用隔离数据与真实 TCP HTTP,真实 18000 实例用现有会话验证查询和缺数据场景。最终仍需用户确认旧库后复制验收。
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
# 区服分析迁移核对
|
||||
|
||||
核对旧 main 的 `ServerAnalysisServiceImpl`、`SwReportServerDayQueryServiceImpl`、两个区服日汇总 Mapper,以及 dev/dev1.0 的 `ServerAnalysis.vue`。
|
||||
|
||||
- `/sw/serverAnalysis/games` 复用已迁移游戏选项;新增 `/servers`、`/list`。两者均要求游戏名称;来源支持逗号分隔或重复参数。
|
||||
- 选项通过当前 `tab_game.game_name` 确定平台+游戏,匹配区服名称;保留区服编号的字符串及前导零。原接口不按商务绑定裁剪资料选项,统计查询独立按可见范围隔离。
|
||||
- 选项默认 50,超过 100 时允许 100000,沿用旧批量选项请求。使用 EXISTS 代替为每个游戏生成 OR,保持成对关联并避免重复行。
|
||||
- 统计读取 `tab_sw_report_server_day` 和身份键表,按平台+游戏+区服编号分组;空白编号不展示。不额外加日游戏报表的 `game_id > 0` 限制。
|
||||
- 人数在每个区服内 COUNT DISTINCT,金额固定模式 0;筛选历史报表游戏名。展示优先金额汇总的历史游戏名,无金额行时回退当前游戏名,保留旧行为。
|
||||
- 区服名称/开服日期按三元组批量补充;重复资料固定取最小 ID,提高原不确定顺序的稳定性。相同主排序后保留区服编号顺序,再按平台、游戏 ID 稳定排列。
|
||||
- 未指定区服时忽略新增/活跃/付费人数和四舍五入金额均为零的行;指定区服保留报表已有零行,不凭资料表制造新行。
|
||||
- 汇总是全部筛选后展示行之和,不随分页变化;ARPU 的分母是活跃人数。逐行人数保留 Java int 上限,summary 改用 int64 避免原累加溢出;金额与比率精确处理。
|
||||
- 缺任一必要业务表时明确未接入,summary 为 null;不能混用不存在区服和缺表错误。未知开服时间保持 null。
|
||||
- 后端默认上海当天,前端默认近 30 天;非法日期/倒置周期明确拒绝。页长沿用最大 500。
|
||||
|
||||
已通过领域/仓储测试、独立数据库真实 TCP HTTP、全量 Go、ESLint/生产构建与真实 sw7 接口只读请求。用户已明确不需要页面交互检查;没有执行旧数据库复制或最终同源比对。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,42 +0,0 @@
|
||||
# 系统接口承接核对
|
||||
|
||||
核对基线:旧后端 `d31082f4a7816d4c83544b8f54e6f42a3b2132b1` 至 `main`。原 SQL 的接口表按控制器收集接口,因此包含基线前已有的 RuoYi 用户管理接口;不能把所有记录都当成新商务新增功能,也不能只凭新路由存在就宣布功能完成。
|
||||
|
||||
## 商务新增与改动
|
||||
|
||||
`SysUserController` 的 Git 差异主要是商务列表语义、添加子商务,以及直属渠道、平台绑定和两种商务画像。用户列表查询新增商务树、姓名/等级过滤、直属渠道与正常绑定数量;添加子商务新增层级、归属与偏好初始化。当前列表、子商务、偏好和平台绑定已迁移;商务/平台商务画像仍未完成。
|
||||
|
||||
原用户控制器中的以下方法在基线前已经存在,旧 `BusMgmt/BusinessList.vue` 没有为它们增加实际操作入口。新商务页面不据此增加编辑、删除、导入、导出、角色分配等按钮。
|
||||
|
||||
| 旧目标别名 | 当前 Kra 能力 / 核对结果 |
|
||||
| --- | --- |
|
||||
| `GET /sw/business`(新增表单初始化) | 原生用户表单通过用户、角色、部门接口加载;商务新增表单由后端决定商务层级,不开放角色选择 |
|
||||
| `DELETE /sw/business/:userIds` | 原生 `DELETE /user/deleteUser`;旧商务列表没有删除入口 |
|
||||
| `GET /sw/business/optionselect` | 原生用户列表/部门用户查询;商务与报表实际使用各自的可见范围接口 |
|
||||
| `PUT /sw/business/resetPwd` | 原生 `POST /user/resetPassword`;商务自助找回密码使用已迁移的独立流程 |
|
||||
| `PUT /sw/business/changeStatus` | 原生 `PUT /user/setUserInfo`;本批补齐 enable 与 business_status 联动 |
|
||||
| `GET /sw/business/authRole/:userId` | 原生用户角色资料及角色列表 |
|
||||
| `PUT /sw/business/authRole` | 原生 `POST /user/setUserAuthorities` |
|
||||
| `GET /sw/business/deptTree` | 原生 `POST /department/getDepartmentList` |
|
||||
| `GET /sw/business/list/dept/:deptId` | 原生 `GET /department/getDepartmentUsers` |
|
||||
| `POST /sw/business/export` | RuoYi Excel 导出协议尚未增加别名;旧导出调用共享查询构造器,会继承商务过滤,不能把原生通用导出直接算成相同协议已实现 |
|
||||
| `POST /sw/business/importData` | RuoYi Excel 导入协议尚未实现;当前商务模板没有导入入口 |
|
||||
| `GET /sw/business/unlock/:userId` | 原先删除 RuoYi Redis 密码错误键;新系统使用 Kra 鉴权/限制机制,不读取旧 Redis 键。旧协议别名尚未注册 |
|
||||
|
||||
以上记录说明框架能力的对应关系,并非宣称旧别名已可调用。别名与原生接口的请求/响应并不相同;最终接口核对仍须保留这项差异,尤其不能把导入/导出列为已经通过兼容验收。当前不删除用户 SQL 中的这些历史接口元数据。
|
||||
|
||||
## 账号启停与商务状态
|
||||
|
||||
原 RuoYi 用 `status` 同时表示正常、停用、驳回和待审核。迁移后有 Kra 的 `enable` 和保留语义的 `business_status`。原生用户启停此前只改前者,可能导致商务列表仍显示正常,或启用后仍因旧状态无法登录。
|
||||
|
||||
本批处理:
|
||||
|
||||
- 新建原生账号时,默认启用与禁用状态同步到商务状态。
|
||||
- 管理员明确启用时恢复正常;关闭启用时,普通状态变为停用,待审核/驳回继续保留,避免普通资料编辑丢失审核状态。
|
||||
- 读取当前业务状态与写入两个字段在同一行锁事务内完成,规则由 biz 回调决定,data 只做持久化。
|
||||
- 个人资料不传 enable 时仍按原有字段更新规则处理;传 enable 时不能越过管理员已经写入的停用、待审或驳回状态。
|
||||
- 角色范围检查继续执行,不增加商务列表的编辑入口。
|
||||
|
||||
隔离 TCP HTTP 测试覆盖原生启停后商务接口返回状态、待审/驳回资料编辑、自行启用被拒绝和不存在用户;仓储测试覆盖失败回滚与越权。现有鉴权测试继续检查停用/待审/驳回会拒绝密码登录和已有会话。
|
||||
|
||||
当前 sw7 的 45 个用户已做只读一致性检查,未发现 enable 与 business_status 冲突,因此没有批量改写真实账号。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase)
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
|
||||
|
||||
@@ -32,9 +32,13 @@ func (a *Activation) EndAt() *time.Time {
|
||||
return a.UnbindAt
|
||||
}
|
||||
|
||||
type ActivationAccount struct {
|
||||
SourceDB, Account string
|
||||
}
|
||||
|
||||
type ActivationScope struct {
|
||||
All bool
|
||||
Accounts []string
|
||||
Accounts []ActivationAccount
|
||||
}
|
||||
type ActivationFilter struct {
|
||||
PlatformNames, Statuses []string
|
||||
@@ -59,7 +63,7 @@ type ActivationSpendDetail struct {
|
||||
}
|
||||
|
||||
type ActivationRepo interface {
|
||||
OwnAccounts(context.Context, uint) ([]string, error)
|
||||
OwnAccounts(context.Context, uint) ([]ActivationAccount, error)
|
||||
List(context.Context, ActivationScope, ActivationFilter, ...ListOption) (*ActivationPage, error)
|
||||
Find(context.Context, uint64) (*Activation, error)
|
||||
Spend(context.Context, ActivationSpendQuery) ([]*ActivationSpendItem, error)
|
||||
@@ -87,11 +91,12 @@ func (uc *ActivationUsecase) scope(ctx context.Context) (ActivationScope, error)
|
||||
return ActivationScope{}, err
|
||||
}
|
||||
scope := ActivationScope{}
|
||||
seen := map[string]bool{}
|
||||
seen := map[ActivationAccount]bool{}
|
||||
for _, account := range accounts {
|
||||
account = strings.TrimSpace(account)
|
||||
key := strings.ToLower(account)
|
||||
if key != "" && !seen[key] {
|
||||
account.Account = strings.TrimSpace(account.Account)
|
||||
account.SourceDB, err = NormalizePlatform(account.SourceDB)
|
||||
key := ActivationAccount{SourceDB: account.SourceDB, Account: strings.ToLower(account.Account)}
|
||||
if err == nil && key.Account != "" && !seen[key] {
|
||||
scope.Accounts = append(scope.Accounts, account)
|
||||
seen[key] = true
|
||||
}
|
||||
@@ -105,10 +110,10 @@ func (uc *ActivationUsecase) List(ctx context.Context, filter ActivationFilter,
|
||||
return nil, err
|
||||
}
|
||||
if !scope.All && filter.BusierAccount != "" {
|
||||
matched := []string{}
|
||||
matched := []ActivationAccount{}
|
||||
keyword := strings.ToLower(strings.TrimSpace(filter.BusierAccount))
|
||||
for _, account := range scope.Accounts {
|
||||
if strings.Contains(strings.ToLower(account), keyword) {
|
||||
if strings.Contains(strings.ToLower(account.Account), keyword) {
|
||||
matched = append(matched, account)
|
||||
}
|
||||
}
|
||||
@@ -139,7 +144,7 @@ func (uc *ActivationUsecase) SpendDetail(ctx context.Context, id uint64) (*Activ
|
||||
if !scope.All {
|
||||
found := false
|
||||
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
|
||||
break
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type activationRepoFake struct {
|
||||
ActivationRepo
|
||||
accounts []string
|
||||
accounts []ActivationAccount
|
||||
requested uint
|
||||
scope ActivationScope
|
||||
filter ActivationFilter
|
||||
@@ -18,7 +18,7 @@ type activationRepoFake struct {
|
||||
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
|
||||
return r.accounts, nil
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func (r *activationRepoFake) Spend(_ context.Context, q ActivationSpendQuery) ([
|
||||
}
|
||||
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"}}}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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() {
|
||||
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"
|
||||
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != ErrActivationScope {
|
||||
t.Fatal("非本人绑定可读取流水")
|
||||
|
||||
@@ -76,6 +76,14 @@ type AlertRecordRepo interface {
|
||||
|
||||
type AlertAction string
|
||||
|
||||
const (
|
||||
AlertUnprocessed = 1
|
||||
AlertProcessing = 2
|
||||
AlertPendingAudit = 3
|
||||
AlertAuditRejected = 4
|
||||
AlertProcessed = 5
|
||||
)
|
||||
|
||||
const (
|
||||
AlertStart AlertAction = "start"
|
||||
AlertSubmit AlertAction = "submit"
|
||||
@@ -148,15 +156,15 @@ func alertTransition(status int, action AlertAction) (int, error) {
|
||||
var message string
|
||||
switch action {
|
||||
case AlertStart:
|
||||
from, to, message = 1, 2, "仅未处理状态的预警可开始处理"
|
||||
from, to, message = AlertUnprocessed, AlertProcessing, "仅未处理状态的预警可开始处理"
|
||||
case AlertSubmit:
|
||||
from, to, message = 2, 3, "仅处理中状态的预警可提交审核"
|
||||
from, to, message = AlertProcessing, AlertPendingAudit, "仅处理中状态的预警可提交审核"
|
||||
case AlertApprove:
|
||||
from, to, message = 3, 5, "仅待审核状态的预警可审核通过"
|
||||
from, to, message = AlertPendingAudit, AlertProcessed, "仅待审核状态的预警可审核通过"
|
||||
case AlertReject:
|
||||
from, to, message = 3, 4, "仅待审核状态的预警可审核驳回"
|
||||
from, to, message = AlertPendingAudit, AlertAuditRejected, "仅待审核状态的预警可审核驳回"
|
||||
case AlertReprocess:
|
||||
from, to, message = 4, 2, "仅审核驳回状态的预警可重新处理"
|
||||
from, to, message = AlertAuditRejected, AlertProcessing, "仅审核驳回状态的预警可重新处理"
|
||||
default:
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ type AnalyticsPage struct {
|
||||
}
|
||||
type AnalyticsRepo interface {
|
||||
Aggregate(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter) (*AnalyticsData, error)
|
||||
FillNames(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter, []*AnalyticsRow) error
|
||||
}
|
||||
type AnalyticsUsecase struct {
|
||||
repo AnalyticsRepo
|
||||
@@ -138,7 +139,8 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter
|
||||
filter.Begin = filter.StartDate.Format("2006-01-02")
|
||||
filter.End = filter.EndDate.Format("2006-01-02")
|
||||
out := &AnalyticsPage{Rows: []*AnalyticsRow{}, Begin: filter.Begin, End: filter.End, Available: true}
|
||||
data, err := uc.repo.Aggregate(ctx, kind, AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, filter)
|
||||
scope := AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}
|
||||
data, err := uc.repo.Aggregate(ctx, kind, scope, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -165,9 +167,6 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter
|
||||
pay.Add(pay, rounded)
|
||||
}
|
||||
counts = analyticsAdd(counts, row.Counts)
|
||||
if kind == ChannelAnalytics {
|
||||
row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote()
|
||||
}
|
||||
}
|
||||
if kind == ChannelAnalytics {
|
||||
counts = analyticsCap(counts)
|
||||
@@ -187,6 +186,28 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter
|
||||
if err := analyticsValues(out.Summary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 仅按渠道名称或类型排序时需要提前读取全部资料;数字指标排序
|
||||
// 确定当前页后再补名称和商务信息,汇总仍使用全部可见统计行。
|
||||
namesBeforeSort := kind == ChannelAnalytics && (filter.SortField == "promoteName" || filter.SortField == "promoteType")
|
||||
fillNames := func(rows []*AnalyticsRow) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := uc.repo.FillNames(ctx, kind, scope, filter, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
if kind == ChannelAnalytics {
|
||||
for _, row := range rows {
|
||||
row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if namesBeforeSort {
|
||||
if err := fillNames(data.Rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := sortAnalytics(data.Rows, kind, filter.SortField, filter.Ascending); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -205,9 +226,23 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter
|
||||
if from < len(data.Rows) {
|
||||
out.Rows = data.Rows[from:to]
|
||||
}
|
||||
if !namesBeforeSort {
|
||||
if err := fillNames(out.Rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func sortAnalytics(rows []*AnalyticsRow, kind AnalyticsKind, field string, ascending bool) error {
|
||||
if kind == GameAnalytics && field == "" {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].SourceDB != rows[j].SourceDB {
|
||||
return rows[i].SourceDB < rows[j].SourceDB
|
||||
}
|
||||
return rows[i].ID < rows[j].ID
|
||||
})
|
||||
return nil
|
||||
}
|
||||
allowed := map[string]bool{"newRegisterCount": true, "activeCount": true, "payUserCount": true, "newPayUserCount": true, "totalPay": true, "payRate": true}
|
||||
if kind == GameAnalytics {
|
||||
allowed["arpu"] = true
|
||||
|
||||
@@ -7,9 +7,23 @@ import (
|
||||
)
|
||||
|
||||
type analyticsRepoFake struct {
|
||||
data *AnalyticsData
|
||||
scope AnalyticsScope
|
||||
filter AnalyticsFilter
|
||||
data *AnalyticsData
|
||||
scope AnalyticsScope
|
||||
filter AnalyticsFilter
|
||||
fillCalls [][]AnalyticsKey
|
||||
fill func([]*AnalyticsRow) error
|
||||
}
|
||||
|
||||
func (r *analyticsRepoFake) FillNames(_ context.Context, _ AnalyticsKind, _ AnalyticsScope, _ AnalyticsFilter, rows []*AnalyticsRow) error {
|
||||
keys := make([]AnalyticsKey, len(rows))
|
||||
for i, row := range rows {
|
||||
keys[i] = row.AnalyticsKey
|
||||
}
|
||||
r.fillCalls = append(r.fillCalls, keys)
|
||||
if r.fill != nil {
|
||||
return r.fill(rows)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *analyticsRepoFake) Aggregate(_ context.Context, _ AnalyticsKind, s AnalyticsScope, f AnalyticsFilter) (*AnalyticsData, error) {
|
||||
@@ -76,3 +90,94 @@ func TestAnalyticsSortAndMissingData(t *testing.T) {
|
||||
t.Fatal("倒置日期应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameAnalyticsCancelSortRestoresDefaultOrder(t *testing.T) {
|
||||
uc, repo := analyticsFixture()
|
||||
repo.data.Rows = []*AnalyticsRow{
|
||||
{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 2}, Pay: "5"},
|
||||
{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 1}, Pay: "10"},
|
||||
{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 3}, Pay: "20"},
|
||||
}
|
||||
for _, sample := range []struct {
|
||||
field string
|
||||
ascending bool
|
||||
first int64
|
||||
}{{"totalPay", true, 2}, {"totalPay", false, 3}, {"", false, 1}} {
|
||||
page, err := uc.List(accountContext(7, 901), GameAnalytics, AnalyticsFilter{SortField: sample.field, Ascending: sample.ascending}, 1, 1)
|
||||
if err != nil || page.Rows[0].ID != sample.first || page.Summary.Pay != "35.00" {
|
||||
t.Fatalf("切换排序不应改变汇总,取消时恢复默认顺序: %+v %v", page, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsLoadsMetadataOnlyForSelectedPage(t *testing.T) {
|
||||
for _, kind := range []AnalyticsKind{ChannelAnalytics, GameAnalytics} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
uc, repo := analyticsFixture()
|
||||
for _, row := range repo.data.Rows {
|
||||
row.Name = ""
|
||||
}
|
||||
repo.fill = func(rows []*AnalyticsRow) error {
|
||||
for _, row := range rows {
|
||||
row.Name = "本页资料"
|
||||
row.BindingAccount = "本页商务"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
page, err := uc.List(accountContext(7, 901), kind, AnalyticsFilter{SortField: "totalPay"}, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.fillCalls) != 1 || len(repo.fillCalls[0]) != 1 || repo.fillCalls[0][0].ID != 2 {
|
||||
t.Fatalf("应只读取排序后当前页的资料: %+v", repo.fillCalls)
|
||||
}
|
||||
if page.Total != 2 || page.Summary.Pay != "50.00" || page.Rows[0].Name != "本页资料" || page.Rows[0].BindingAccount != "本页商务" {
|
||||
t.Fatalf("延迟读取影响汇总或展示资料: %+v", page)
|
||||
}
|
||||
repo.fillCalls = nil
|
||||
page, err = uc.List(accountContext(7, 901), kind, AnalyticsFilter{}, 3, 1)
|
||||
if err != nil || len(page.Rows) != 0 || len(repo.fillCalls) != 0 || page.Summary.Pay != "50.00" {
|
||||
t.Fatal("空页不应读取资料,汇总仍须保留")
|
||||
}
|
||||
repo.fill = func([]*AnalyticsRow) error { return ErrStorage }
|
||||
if _, err := uc.List(accountContext(7, 901), kind, AnalyticsFilter{}, 1, 1); err != ErrStorage {
|
||||
t.Fatalf("资料读取错误必须向上传递: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsLoadsMetadataBeforeNameOrTypeSort(t *testing.T) {
|
||||
for _, field := range []string{"promoteName", "promoteType"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
uc, repo := analyticsFixture()
|
||||
for _, row := range repo.data.Rows {
|
||||
row.Name = ""
|
||||
}
|
||||
repo.fill = func(rows []*AnalyticsRow) error {
|
||||
for _, row := range rows {
|
||||
name, kind := "Zulu", 9
|
||||
if row.ID == 2 {
|
||||
name, kind = "Alpha", 1
|
||||
}
|
||||
row.Name, row.PromoteType = name, &kind
|
||||
}
|
||||
return nil
|
||||
}
|
||||
page, err := uc.List(accountContext(7, 901), ChannelAnalytics, AnalyticsFilter{SortField: field, Ascending: true}, 1, 1)
|
||||
if err != nil || len(page.Rows) != 1 || page.Rows[0].ID != 2 || len(repo.fillCalls) != 1 || len(repo.fillCalls[0]) != 2 {
|
||||
t.Fatalf("按资料字段排序前必须读取全体资料: page=%+v calls=%+v err=%v", page, repo.fillCalls, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsDeferredNamesPreserveOfficialAndUnknownLabels(t *testing.T) {
|
||||
uc, repo := analyticsFixture()
|
||||
repo.data.Rows[0].ID, repo.data.Rows[0].Name = 0, ""
|
||||
repo.data.Rows[1].Name = ""
|
||||
page, err := uc.List(accountContext(1, 888), ChannelAnalytics, AnalyticsFilter{SortField: "promoteId", Ascending: true}, 1, 10)
|
||||
if err != nil || page.Rows[0].Name != "官方渠道" || page.Rows[1].Name != "未知渠道(ID:2)" {
|
||||
t.Fatalf("默认渠道名丢失: %+v %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type LedgerListOptions struct {
|
||||
type LedgerNames struct {
|
||||
PromoteID *int64
|
||||
PromoteAccount, BindingAccount, BindingUserName string
|
||||
BusinessPath []string
|
||||
}
|
||||
|
||||
func (n *LedgerNames) DisplayPromote() string {
|
||||
@@ -52,6 +53,9 @@ func (n *LedgerNames) DisplayPromote() string {
|
||||
return "未知渠道(ID:" + strconv.FormatInt(*n.PromoteID, 10) + ")"
|
||||
}
|
||||
func (n *LedgerNames) DisplayBusiness() string {
|
||||
if len(n.BusinessPath) > 0 {
|
||||
return strings.Join(n.BusinessPath, "→")
|
||||
}
|
||||
return (&Channel{BindingAccount: n.BindingAccount, BindingUserName: n.BindingUserName}).BusinessDisplayName()
|
||||
}
|
||||
|
||||
@@ -91,7 +95,7 @@ type GameOption struct {
|
||||
type LedgerRepo interface {
|
||||
Registrations(context.Context, LedgerScope, LedgerFilter, LedgerListOptions) (*RegistrationPage, error)
|
||||
Spends(context.Context, LedgerScope, LedgerFilter, LedgerListOptions) (*SpendPage, error)
|
||||
Games(context.Context, []string, string, int) ([]*GameOption, error)
|
||||
Games(context.Context, UserScope, []string, string, int) ([]*GameOption, error)
|
||||
}
|
||||
type LedgerUsecase struct {
|
||||
repo LedgerRepo
|
||||
@@ -226,14 +230,17 @@ func (uc *LedgerUsecase) ExportSpends(ctx context.Context, filter LedgerFilter)
|
||||
return page.Rows, nil
|
||||
}
|
||||
func (uc *LedgerUsecase) Games(ctx context.Context, sources []string, keyword string, limit int) ([]*GameOption, error) {
|
||||
if _, _, err := uc.accounts.actor(ctx); err != nil {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
viewer := portraitUserScope(account)
|
||||
viewer.All = actor.AuthorityID == 888
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 10000
|
||||
}
|
||||
return uc.repo.Games(ctx, sources, strings.TrimSpace(keyword), limit)
|
||||
return uc.repo.Games(ctx, viewer, sources, strings.TrimSpace(keyword), limit)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ func (r *ledgerRepoFake) Spends(_ context.Context, s LedgerScope, f LedgerFilter
|
||||
r.options = o
|
||||
return &SpendPage{Rows: []*Spend{}, Total: r.total, Available: r.available, TotalPayAmount: stringPointer("0")}, nil
|
||||
}
|
||||
func (r *ledgerRepoFake) Games(_ context.Context, _ []string, _ string, limit int) ([]*GameOption, error) {
|
||||
func (r *ledgerRepoFake) Games(_ context.Context, viewer UserScope, _ []string, _ string, limit int) ([]*GameOption, error) {
|
||||
r.scope.Viewer = viewer
|
||||
r.gameLimit = limit
|
||||
return []*GameOption{}, nil
|
||||
}
|
||||
@@ -89,4 +90,10 @@ func TestLedgerExportLimitAndDisplayFallback(t *testing.T) {
|
||||
if r.gameLimit != 10000 {
|
||||
t.Fatal("原游戏选项的特殊批量上限丢失")
|
||||
}
|
||||
if !r.scope.Viewer.All {
|
||||
t.Fatal("超管游戏选项范围未传递")
|
||||
}
|
||||
if _, err := uc.Games(accountContext(9, 903), nil, "", 20); err != nil || !r.scope.Viewer.OnlySelf || r.scope.Viewer.All {
|
||||
t.Fatal("普通商务游戏选项必须限定本人范围")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RankingKind string
|
||||
|
||||
const (
|
||||
ChannelRanking RankingKind = "channel"
|
||||
BusinessRanking RankingKind = "business"
|
||||
GameRanking RankingKind = "game"
|
||||
)
|
||||
|
||||
type RankingRange struct {
|
||||
AnalyticsFilter
|
||||
PreviousBegin, PreviousEnd string
|
||||
}
|
||||
type RankingMetrics struct {
|
||||
Counts PortraitCounts
|
||||
Pay, Name string
|
||||
}
|
||||
type RankingBusiness struct {
|
||||
ID uint
|
||||
Name string
|
||||
Level int
|
||||
Channels []AnalyticsKey
|
||||
}
|
||||
type RankingData struct {
|
||||
Current, Previous map[AnalyticsKey]*RankingMetrics
|
||||
Businesses []*RankingBusiness
|
||||
Available bool
|
||||
}
|
||||
type RankingItem struct {
|
||||
AnalyticsKey
|
||||
ObjectID, Name, Value string
|
||||
Rank int
|
||||
MoM *string
|
||||
MoMStatus *int
|
||||
}
|
||||
type RankingPage struct {
|
||||
Register, Recharge, PayRate, Active []*RankingItem
|
||||
Begin, End string
|
||||
Available bool
|
||||
}
|
||||
type RankingRepo interface {
|
||||
Collect(context.Context, RankingKind, AnalyticsScope, RankingRange) (*RankingData, error)
|
||||
}
|
||||
type RankingUsecase struct {
|
||||
repo RankingRepo
|
||||
accounts *AccountUsecase
|
||||
preferences *UserPreferenceUsecase
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewRankingUsecase(repo RankingRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *RankingUsecase {
|
||||
return &RankingUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
|
||||
}
|
||||
func (uc *RankingUsecase) List(ctx context.Context, kind RankingKind, f AnalyticsFilter) (*RankingPage, error) {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind != ChannelRanking && kind != BusinessRanking && kind != GameRanking {
|
||||
return nil, ErrAnalyticsInput
|
||||
}
|
||||
if f.StartDate == nil || f.EndDate == nil {
|
||||
day := workbenchWindow(uc.now()).Today
|
||||
f.StartDate, f.EndDate = &day, &day
|
||||
}
|
||||
if f.EndDate.Before(*f.StartDate) {
|
||||
return nil, ErrAnalyticsDateOrder
|
||||
}
|
||||
f.Begin, f.End = f.StartDate.Format("2006-01-02"), f.EndDate.Format("2006-01-02")
|
||||
out := &RankingPage{Register: []*RankingItem{}, Recharge: []*RankingItem{}, PayRate: []*RankingItem{}, Active: []*RankingItem{}, Begin: f.Begin, End: f.End, Available: true}
|
||||
if kind == BusinessRanking && actor.AuthorityID != 888 && account.Level >= 3 {
|
||||
return out, nil
|
||||
}
|
||||
pref, err := uc.preferences.Current(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
viewer := portraitUserScope(account)
|
||||
viewer.All = actor.AuthorityID == 888
|
||||
days := int((f.EndDate.Unix()-f.StartDate.Unix())/86400) + 1
|
||||
rangeFilter := RankingRange{AnalyticsFilter: f, PreviousBegin: f.StartDate.AddDate(0, 0, -days).Format("2006-01-02"), PreviousEnd: f.StartDate.AddDate(0, 0, -1).Format("2006-01-02")}
|
||||
data, err := uc.repo.Collect(ctx, kind, AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, rangeFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Available = data.Available
|
||||
if !data.Available {
|
||||
return out, nil
|
||||
}
|
||||
for _, period := range []map[AnalyticsKey]*RankingMetrics{data.Current, data.Previous} {
|
||||
for _, row := range period {
|
||||
row.Counts = analyticsCap(row.Counts)
|
||||
}
|
||||
}
|
||||
if kind == BusinessRanking {
|
||||
data.Current, err = aggregateRankingBusinesses(data.Businesses, data.Current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.Previous, err = aggregateRankingBusinesses(data.Businesses, data.Previous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, metric := range []struct {
|
||||
name string
|
||||
target *[]*RankingItem
|
||||
}{{"register", &out.Register}, {"pay", &out.Recharge}, {"rate", &out.PayRate}, {"active", &out.Active}} {
|
||||
rows, err := buildRankingItems(kind, metric.name, data.Current, data.Previous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*metric.target = rows
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func aggregateRankingBusinesses(businesses []*RankingBusiness, channels map[AnalyticsKey]*RankingMetrics) (map[AnalyticsKey]*RankingMetrics, error) {
|
||||
out := map[AnalyticsKey]*RankingMetrics{}
|
||||
for _, target := range businesses {
|
||||
if len(target.Channels) == 0 {
|
||||
continue
|
||||
}
|
||||
row := &RankingMetrics{Name: fmt.Sprintf("%s (L%d)", target.Name, target.Level)}
|
||||
pay := new(big.Rat)
|
||||
for _, key := range target.Channels {
|
||||
if c := channels[key]; c != nil {
|
||||
row.Counts = analyticsAdd(row.Counts, c.Counts)
|
||||
n, err := portraitNumber(c.Pay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pay.Add(pay, n)
|
||||
}
|
||||
}
|
||||
// 商务人数沿用各渠道人数相加;使用 int64 避免原 Java int 溢出,不跨渠道重新去重。
|
||||
row.Pay = pay.RatString()
|
||||
out[AnalyticsKey{ID: int64(target.ID)}] = row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func rankingValue(row *RankingMetrics, metric string) (*big.Rat, bool, error) {
|
||||
if row == nil {
|
||||
return new(big.Rat), false, nil
|
||||
}
|
||||
switch metric {
|
||||
case "register":
|
||||
return big.NewRat(row.Counts.Register, 1), row.Counts.Register > 0, nil
|
||||
case "active":
|
||||
return big.NewRat(row.Counts.Active, 1), row.Counts.Active > 0, nil
|
||||
case "rate":
|
||||
value := analyticsRatio(big.NewRat(row.Counts.Pay, 1), row.Counts.Active, true)
|
||||
n, _ := portraitNumber(value)
|
||||
return n, row.Counts.Active > 0, nil
|
||||
default:
|
||||
n, err := portraitNumber(row.Pay)
|
||||
return n, err == nil && n.Sign() > 0, err
|
||||
}
|
||||
}
|
||||
func buildRankingItems(kind RankingKind, metric string, current, previous map[AnalyticsKey]*RankingMetrics) ([]*RankingItem, error) {
|
||||
rows := []*RankingItem{}
|
||||
values := map[*RankingItem]*big.Rat{}
|
||||
for key, row := range current {
|
||||
value, keep, err := rankingValue(row, metric)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
item := &RankingItem{AnalyticsKey: key, ObjectID: strconv.FormatInt(key.ID, 10), Name: row.Name}
|
||||
if kind == GameRanking {
|
||||
item.ObjectID = key.SourceDB + "|" + item.ObjectID
|
||||
if strings.TrimSpace(item.Name) == "" {
|
||||
item.Name = "游戏" + strconv.FormatInt(key.ID, 10)
|
||||
}
|
||||
}
|
||||
if kind == ChannelRanking {
|
||||
item.Name = (&LedgerNames{PromoteID: &key.ID, PromoteAccount: row.Name}).DisplayPromote()
|
||||
}
|
||||
digits := 0
|
||||
if metric == "pay" || metric == "rate" {
|
||||
digits = 2
|
||||
}
|
||||
item.Value = portraitDecimal(value, digits)
|
||||
values[item], _ = portraitNumber(item.Value)
|
||||
prev := previous[key]
|
||||
if prev != nil {
|
||||
prior, valid, err := rankingValue(prev, metric)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 四个榜单统一使用相对上期的增长率。上期为零或无有效
|
||||
// 活跃基数时不能计算环比,保留 nil 供页面显示无可比数据。
|
||||
if valid && prior.Sign() > 0 {
|
||||
change := new(big.Rat).Sub(value, prior)
|
||||
change.Mul(change, big.NewRat(100, 1))
|
||||
change.Quo(change, prior)
|
||||
item.MoM = stringPointer(portraitDecimal(change, 2))
|
||||
rounded, _ := portraitNumber(*item.MoM)
|
||||
status := 0
|
||||
if new(big.Rat).Abs(rounded).Cmp(big.NewRat(1, 100)) >= 0 {
|
||||
status = rounded.Sign()
|
||||
}
|
||||
item.MoMStatus = &status
|
||||
}
|
||||
}
|
||||
rows = append(rows, item)
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
if c := values[a].Cmp(values[b]); c != 0 {
|
||||
return c > 0
|
||||
}
|
||||
if a.SourceDB != b.SourceDB {
|
||||
return a.SourceDB < b.SourceDB
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
rows = rows[:min(20, len(rows))]
|
||||
for i, row := range rows {
|
||||
row.Rank = i + 1
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rankingRepoFake struct {
|
||||
data *RankingData
|
||||
filter RankingRange
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *rankingRepoFake) Collect(_ context.Context, _ RankingKind, _ AnalyticsScope, f RankingRange) (*RankingData, error) {
|
||||
r.filter = f
|
||||
r.calls++
|
||||
out := &RankingData{Available: r.data.Available, Businesses: r.data.Businesses, Current: map[AnalyticsKey]*RankingMetrics{}, Previous: map[AnalyticsKey]*RankingMetrics{}}
|
||||
for k, v := range r.data.Current {
|
||||
c := *v
|
||||
out.Current[k] = &c
|
||||
}
|
||||
for k, v := range r.data.Previous {
|
||||
c := *v
|
||||
out.Previous[k] = &c
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func TestRankingComparisonsAndBusinessCountSemantics(t *testing.T) {
|
||||
k := AnalyticsKey{SourceDB: "lx", ID: 1}
|
||||
zero := AnalyticsKey{SourceDB: "lx", ID: 2}
|
||||
r := &rankingRepoFake{data: &RankingData{Available: true, Current: map[AnalyticsKey]*RankingMetrics{k: {Counts: PortraitCounts{Register: 3, Active: 4, Pay: 2}, Pay: "20.005"}, zero: {Counts: PortraitCounts{Active: 1}}}, Previous: map[AnalyticsKey]*RankingMetrics{k: {Counts: PortraitCounts{Register: 2, Active: 2, Pay: 1}, Pay: "10"}}}}
|
||||
a := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Level: 1, IDPath: "0"}, 9: {ID: 9, Level: 3, IDPath: "0,7"}}}
|
||||
uc := NewRankingUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}))
|
||||
uc.now = func() time.Time { return time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation) }
|
||||
begin, end := time.Date(2026, 10, 29, 0, 0, 0, 0, portraitLocation), time.Date(2026, 11, 4, 0, 0, 0, 0, portraitLocation)
|
||||
p, err := uc.List(accountContext(7, 901), GameRanking, AnalyticsFilter{StartDate: &begin, EndDate: &end})
|
||||
if err != nil || len(p.Register) != 1 || p.Register[0].ObjectID != "lx|1" || p.Register[0].Name != "游戏1" || *p.Register[0].MoM != "50.00" || p.Recharge[0].Value != "20.01" || *p.Recharge[0].MoM != "100.05" || len(p.PayRate) != 2 || p.PayRate[1].Value != "0.00" || p.PayRate[1].MoM != nil {
|
||||
t.Fatalf("排行筛选、命名或环比错误: %+v %v", p, err)
|
||||
}
|
||||
if *p.PayRate[0].MoM != "0.00" || *p.PayRate[0].MoMStatus != 0 || r.filter.PreviousBegin != "2026-10-22" || r.filter.PreviousEnd != "2026-10-28" {
|
||||
t.Fatal("等长上期或付费率环比错误")
|
||||
}
|
||||
r.data.Businesses = []*RankingBusiness{{ID: 8, Name: "二级商务", Level: 2, Channels: []AnalyticsKey{k, zero}}}
|
||||
p, err = uc.List(accountContext(7, 901), BusinessRanking, AnalyticsFilter{})
|
||||
if err != nil || p.Active[0].Value != "5" || p.PayRate[0].Value != "40.00" || p.Register[0].Name != "二级商务 (L2)" || p.Register[0].SourceDB != "" {
|
||||
t.Fatal("商务需加总各渠道人数,不全局去重")
|
||||
}
|
||||
calls := r.calls
|
||||
p, err = uc.List(accountContext(9, 903), BusinessRanking, AnalyticsFilter{})
|
||||
if err != nil || len(p.Register) != 0 || r.calls != calls {
|
||||
t.Fatal("三级商务不得展开商务榜查询")
|
||||
}
|
||||
r.data.Available = false
|
||||
p, err = uc.List(accountContext(7, 901), ChannelRanking, AnalyticsFilter{})
|
||||
if err != nil || p.Available || len(p.Active) != 0 {
|
||||
t.Fatal("缺数据不能产生伪造排行")
|
||||
}
|
||||
}
|
||||
func TestRankingTopTwentyAndStatusThreshold(t *testing.T) {
|
||||
current, previous := map[AnalyticsKey]*RankingMetrics{}, map[AnalyticsKey]*RankingMetrics{}
|
||||
for i := int64(1); i <= 25; i++ {
|
||||
k := AnalyticsKey{SourceDB: "lx", ID: i}
|
||||
current[k] = &RankingMetrics{Counts: PortraitCounts{Active: i}, Pay: "9007199254740992.99"}
|
||||
previous[k] = &RankingMetrics{Counts: PortraitCounts{Active: i}}
|
||||
}
|
||||
current[AnalyticsKey{SourceDB: "lx", ID: 25}].Pay = "9007199254740993.00"
|
||||
rows, err := buildRankingItems(ChannelRanking, "pay", current, previous)
|
||||
if err != nil || len(rows) != 20 || rows[0].ID != 25 || rows[19].Rank != 20 {
|
||||
t.Fatal("TOP20 或精确金额排序错误")
|
||||
}
|
||||
rows, err = buildRankingItems(ChannelRanking, "active", current, previous)
|
||||
if err != nil || *rows[0].MoMStatus != 0 || *rows[0].MoM != "0.00" {
|
||||
t.Fatal("持平环比错误")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var ErrReportBuildUnavailable = errors.BadRequest("SW_REPORT_BUILD_UNAVAILABLE", "原始数据或汇总表尚未接入,不能重建报表")
|
||||
|
||||
type ReportBuildKind string
|
||||
|
||||
const (
|
||||
DailyReport ReportBuildKind = "day"
|
||||
ServerReport ReportBuildKind = "server"
|
||||
)
|
||||
|
||||
type ReportEvent struct {
|
||||
SourceDB string
|
||||
UserID, PromoteID, GameID *int64
|
||||
ServerNum string
|
||||
Amount string
|
||||
}
|
||||
type ReportUserKey struct {
|
||||
SourceDB string
|
||||
ID int64
|
||||
}
|
||||
type ReportUser struct {
|
||||
ReportUserKey
|
||||
ParentID, PromoteID *int64
|
||||
IDCard string
|
||||
}
|
||||
type ReportInput struct {
|
||||
Registers, Actives, Payments []ReportEvent
|
||||
Users map[ReportUserKey]ReportUser
|
||||
Games map[AnalyticsKey]string
|
||||
}
|
||||
type ReportGroup struct {
|
||||
SourceDB string
|
||||
PromoteID, GameID int64
|
||||
ServerNum string
|
||||
}
|
||||
type ReportBuiltRow struct {
|
||||
ReportGroup
|
||||
Mode int
|
||||
GameName, Pay string
|
||||
Counts PortraitCounts
|
||||
Keys map[int][]string
|
||||
}
|
||||
type ReportBuildRepo interface {
|
||||
Rebuild(context.Context, ReportBuildKind, time.Time, func(*ReportInput) ([]*ReportBuiltRow, error)) error
|
||||
}
|
||||
type ReportBuildUsecase struct {
|
||||
repo ReportBuildRepo
|
||||
now func() time.Time
|
||||
pause func(context.Context) error
|
||||
}
|
||||
|
||||
func NewReportBuildUsecase(repo ReportBuildRepo) *ReportBuildUsecase {
|
||||
return &ReportBuildUsecase{repo: repo, now: time.Now, pause: func(ctx context.Context) error {
|
||||
timer := time.NewTimer(time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}}
|
||||
}
|
||||
func (uc *ReportBuildUsecase) Run(ctx context.Context, kind ReportBuildKind, period, start, end string) error {
|
||||
if kind != DailyReport && kind != ServerReport {
|
||||
return ErrAnalyticsInput
|
||||
}
|
||||
w := workbenchWindow(uc.now())
|
||||
begin, finish := w.Yesterday, w.Yesterday
|
||||
switch period {
|
||||
case "today":
|
||||
begin, finish = w.Today, w.Today
|
||||
case "yesterday":
|
||||
case "backfill":
|
||||
var err error
|
||||
if strings.TrimSpace(start) != "" {
|
||||
begin, err = time.ParseInLocation("2006-01-02", strings.TrimSpace(start), portraitLocation)
|
||||
if err != nil {
|
||||
return ErrAnalyticsDate
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(end) != "" {
|
||||
finish, err = time.ParseInLocation("2006-01-02", strings.TrimSpace(end), portraitLocation)
|
||||
if err != nil {
|
||||
return ErrAnalyticsDate
|
||||
}
|
||||
}
|
||||
default:
|
||||
return ErrAnalyticsInput
|
||||
}
|
||||
if finish.Before(begin) {
|
||||
return ErrAnalyticsDateOrder
|
||||
}
|
||||
for day := begin; !day.After(finish); day = day.AddDate(0, 0, 1) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := uc.repo.Rebuild(ctx, kind, day, func(input *ReportInput) ([]*ReportBuiltRow, error) { return compileReport(kind, input) }); err != nil {
|
||||
return err
|
||||
}
|
||||
if day.Before(finish) {
|
||||
if err := uc.pause(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type reportAggregate struct {
|
||||
users map[int]map[int64]bool
|
||||
pay *big.Rat
|
||||
}
|
||||
|
||||
func compileReport(kind ReportBuildKind, input *ReportInput) ([]*ReportBuiltRow, error) {
|
||||
groups := map[ReportGroup]*reportAggregate{}
|
||||
number := func(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
group := func(event ReportEvent) ReportGroup {
|
||||
return ReportGroup{SourceDB: event.SourceDB, PromoteID: number(event.PromoteID), GameID: number(event.GameID), ServerNum: func() string {
|
||||
if kind == ServerReport {
|
||||
return strings.TrimSpace(event.ServerNum)
|
||||
}
|
||||
return ""
|
||||
}()}
|
||||
}
|
||||
get := func(key ReportGroup) *reportAggregate {
|
||||
if groups[key] == nil {
|
||||
groups[key] = &reportAggregate{pay: new(big.Rat), users: map[int]map[int64]bool{1: {}, 2: {}, 3: {}, 4: {}}}
|
||||
}
|
||||
return groups[key]
|
||||
}
|
||||
// 无平台登录仅在全库唯一主账号匹配时归属,提前建立索引避免逐条扫描账号。
|
||||
unique := map[int64]*ReportUser{}
|
||||
ambiguous := map[int64]bool{}
|
||||
for _, user := range input.Users {
|
||||
if user.ParentID == nil || *user.ParentID != 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := unique[user.ID]; ok {
|
||||
ambiguous[user.ID] = true
|
||||
}
|
||||
copy := user
|
||||
unique[user.ID] = ©
|
||||
}
|
||||
for _, event := range input.Registers {
|
||||
if event.UserID == nil {
|
||||
continue
|
||||
}
|
||||
if kind == ServerReport && (strings.TrimSpace(event.ServerNum) == "" || event.GameID == nil) {
|
||||
continue
|
||||
}
|
||||
get(group(event)).users[1][*event.UserID] = true
|
||||
}
|
||||
for _, event := range input.Actives {
|
||||
if event.UserID == nil {
|
||||
continue
|
||||
}
|
||||
if kind == ServerReport {
|
||||
if strings.TrimSpace(event.ServerNum) == "" || event.GameID == nil {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
var user *ReportUser
|
||||
if strings.TrimSpace(event.SourceDB) == "" {
|
||||
if ambiguous[*event.UserID] {
|
||||
continue
|
||||
}
|
||||
user = unique[*event.UserID]
|
||||
if user != nil {
|
||||
event.SourceDB = user.SourceDB
|
||||
}
|
||||
} else {
|
||||
if value, ok := input.Users[ReportUserKey{SourceDB: event.SourceDB, ID: *event.UserID}]; ok {
|
||||
user = &value
|
||||
}
|
||||
}
|
||||
if user == nil || user.ParentID == nil || *user.ParentID != 0 {
|
||||
continue
|
||||
}
|
||||
if event.PromoteID == nil {
|
||||
event.PromoteID = user.PromoteID
|
||||
}
|
||||
}
|
||||
get(group(event)).users[2][*event.UserID] = true
|
||||
}
|
||||
for _, event := range input.Payments {
|
||||
if kind == ServerReport && strings.TrimSpace(event.ServerNum) == "" {
|
||||
continue
|
||||
}
|
||||
a := get(group(event))
|
||||
if event.UserID != nil {
|
||||
a.users[3][*event.UserID] = true
|
||||
}
|
||||
amount, err := portraitNumber(event.Amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.pay.Add(a.pay, amount)
|
||||
}
|
||||
keys := make([]ReportGroup, 0, len(groups))
|
||||
for key := range groups {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
a, b := keys[i], keys[j]
|
||||
if a.SourceDB != b.SourceDB {
|
||||
return a.SourceDB < b.SourceDB
|
||||
}
|
||||
if a.PromoteID != b.PromoteID {
|
||||
return a.PromoteID < b.PromoteID
|
||||
}
|
||||
if a.GameID != b.GameID {
|
||||
return a.GameID < b.GameID
|
||||
}
|
||||
return a.ServerNum < b.ServerNum
|
||||
})
|
||||
out := []*ReportBuiltRow{}
|
||||
for _, key := range keys {
|
||||
a := groups[key]
|
||||
if len(a.users[1])+len(a.users[2])+len(a.users[3]) == 0 && a.pay.Sign() == 0 {
|
||||
continue
|
||||
}
|
||||
if kind == DailyReport {
|
||||
for id := range a.users[3] {
|
||||
if a.users[1][id] {
|
||||
a.users[4][id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for mode := 0; mode <= 1; mode++ {
|
||||
row := &ReportBuiltRow{ReportGroup: key, Mode: mode, GameName: input.Games[AnalyticsKey{SourceDB: key.SourceDB, ID: key.GameID}], Pay: portraitDecimal(a.pay, 2), Keys: map[int][]string{}}
|
||||
for metric, ids := range a.users {
|
||||
if metric == 4 && kind == ServerReport {
|
||||
continue
|
||||
}
|
||||
identities := map[string]bool{}
|
||||
for id := range ids {
|
||||
identity := strconv.FormatInt(id, 10)
|
||||
if mode == 1 {
|
||||
identity = normalizeIDCard(input.Users[ReportUserKey{SourceDB: key.SourceDB, ID: id}].IDCard)
|
||||
}
|
||||
if identity != "" {
|
||||
identities[identity] = true
|
||||
}
|
||||
}
|
||||
values := make([]string, 0, len(identities))
|
||||
for value := range identities {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Strings(values)
|
||||
row.Keys[metric] = values
|
||||
switch metric {
|
||||
case 1:
|
||||
row.Counts.Register = int64(len(values))
|
||||
case 2:
|
||||
row.Counts.Active = int64(len(values))
|
||||
case 3:
|
||||
row.Counts.Pay = int64(len(values))
|
||||
case 4:
|
||||
row.Counts.NewPay = int64(len(values))
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func reportPtr(n int64) *int64 { return &n }
|
||||
func TestReportBuildIdentityRulesAndAnonymousLogins(t *testing.T) {
|
||||
input := &ReportInput{Users: map[ReportUserKey]ReportUser{}, Games: map[AnalyticsKey]string{{SourceDB: "lx", ID: 1}: "游戏"}}
|
||||
for _, u := range []ReportUser{{ReportUserKey: ReportUserKey{"lx", 1}, ParentID: reportPtr(0), PromoteID: reportPtr(10), IDCard: " aaX "}, {ReportUserKey: ReportUserKey{"hw", 1}, ParentID: reportPtr(0), PromoteID: reportPtr(20), IDCard: "b"}, {ReportUserKey: ReportUserKey{"lx", 2}, ParentID: reportPtr(0), PromoteID: reportPtr(10), IDCard: "aax"}, {ReportUserKey: ReportUserKey{"lx", 3}, ParentID: reportPtr(1), PromoteID: reportPtr(10), IDCard: "sub"}, {ReportUserKey: ReportUserKey{"lx", 4}, PromoteID: reportPtr(10)}, {ReportUserKey: ReportUserKey{"lx", 5}, ParentID: reportPtr(0), PromoteID: reportPtr(10)}} {
|
||||
input.Users[u.ReportUserKey] = u
|
||||
}
|
||||
event := func(id int64) ReportEvent {
|
||||
return ReportEvent{SourceDB: "lx", UserID: reportPtr(id), PromoteID: reportPtr(10), GameID: reportPtr(1)}
|
||||
}
|
||||
input.Registers = []ReportEvent{event(1), event(2), event(5)}
|
||||
missing := event(2)
|
||||
missing.SourceDB = ""
|
||||
missing.PromoteID = nil
|
||||
ambiguous := event(1)
|
||||
ambiguous.SourceDB = ""
|
||||
input.Actives = []ReportEvent{event(1), missing, ambiguous, event(3), event(4)}
|
||||
for _, id := range []int64{1, 3, 99} {
|
||||
p := event(id)
|
||||
p.Amount = "10"
|
||||
input.Payments = append(input.Payments, p)
|
||||
}
|
||||
noUser := event(1)
|
||||
noUser.UserID = nil
|
||||
noUser.Amount = "1"
|
||||
input.Payments = append(input.Payments, noUser)
|
||||
rows, err := compileReport(DailyReport, input)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("汇总分组错误: %+v %v", rows, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Pay != "31.00" || row.GameName != "游戏" || row.Counts.NewPay != 1 {
|
||||
t.Fatalf("金额、名称或新增付费错误: %+v", row)
|
||||
}
|
||||
if row.Mode == 0 && (row.Counts.Register != 3 || row.Counts.Active != 2 || row.Counts.Pay != 3) {
|
||||
t.Fatalf("账号口径未保留主账号登录/原始付费账号: %+v", row)
|
||||
}
|
||||
if row.Mode == 1 && (row.Counts.Register != 1 || row.Counts.Active != 1 || row.Counts.Pay != 2) {
|
||||
t.Fatalf("身份证口径、空证件或末尾 X 归一错误: %+v", row)
|
||||
}
|
||||
}
|
||||
input.Registers = []ReportEvent{event(2)}
|
||||
input.Payments = []ReportEvent{event(1)}
|
||||
input.Payments[0].Amount = "10"
|
||||
rows, err = compileReport(DailyReport, input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Counts.NewPay != 0 {
|
||||
t.Fatal("新增付费必须先取原始账号交集,不能把相同证件的其他付费账号算入")
|
||||
}
|
||||
}
|
||||
server := event(3)
|
||||
server.ServerNum = " 001 "
|
||||
input.Registers = []ReportEvent{server}
|
||||
input.Actives = []ReportEvent{server}
|
||||
input.Payments = []ReportEvent{server}
|
||||
input.Payments[0].Amount = "20"
|
||||
rows, err = compileReport(ServerReport, input)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatal("区服汇总失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.ServerNum != "001" || row.Counts.Register != 1 || row.Counts.Active != 1 || row.Counts.Pay != 1 || len(row.Keys[4]) != 0 {
|
||||
t.Fatal("区服创角/活跃不能套用主账号限制,也不能生成新增付费指标")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type reportBuildFake struct{ days []string }
|
||||
|
||||
func (r *reportBuildFake) Rebuild(_ context.Context, _ ReportBuildKind, day time.Time, _ func(*ReportInput) ([]*ReportBuiltRow, error)) error {
|
||||
r.days = append(r.days, day.Format("2006-01-02"))
|
||||
return nil
|
||||
}
|
||||
func TestReportBuildBackfillRangeAndCancellation(t *testing.T) {
|
||||
r := &reportBuildFake{}
|
||||
uc := NewReportBuildUsecase(r)
|
||||
uc.now = func() time.Time { return time.Date(2026, 11, 4, 9, 0, 0, 0, portraitLocation) }
|
||||
pauses := 0
|
||||
uc.pause = func(context.Context) error { pauses++; return nil }
|
||||
if err := uc.Run(context.Background(), DailyReport, "backfill", "2026-10-31", "2026-11-02"); err != nil || len(r.days) != 3 || pauses != 2 || r.days[2] != "2026-11-02" {
|
||||
t.Fatal("回跑日期应闭区间且仅日间暂停")
|
||||
}
|
||||
if err := uc.Run(context.Background(), DailyReport, "backfill", "bad", ""); err != ErrAnalyticsDate {
|
||||
t.Fatal("非法日期未拒绝")
|
||||
}
|
||||
if err := uc.Run(context.Background(), DailyReport, "backfill", "2026-11-05", "2026-11-04"); err != ErrAnalyticsDateOrder {
|
||||
t.Fatal("倒置区间未拒绝")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
count := len(r.days)
|
||||
if err := uc.Run(ctx, ServerReport, "today", "", ""); err != context.Canceled || len(r.days) != count {
|
||||
t.Fatal("取消后仍重建报表")
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ type ServerOption struct {
|
||||
}
|
||||
type ServerAnalysisRepo interface {
|
||||
Aggregate(context.Context, AnalyticsScope, ServerAnalysisFilter) (*ServerAnalysisData, error)
|
||||
Servers(context.Context, []string, []string, string, int) ([]*ServerOption, error)
|
||||
Servers(context.Context, UserScope, []string, []string, string, int) ([]*ServerOption, error)
|
||||
}
|
||||
type ServerAnalysisUsecase struct {
|
||||
repo ServerAnalysisRepo
|
||||
@@ -60,9 +60,12 @@ func NewServerAnalysisUsecase(repo ServerAnalysisRepo, accounts *AccountUsecase,
|
||||
return &ServerAnalysisUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
|
||||
}
|
||||
func (uc *ServerAnalysisUsecase) Servers(ctx context.Context, sources, games []string, keyword string, limit int) ([]*ServerOption, error) {
|
||||
if _, _, err := uc.accounts.actor(ctx); err != nil {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
viewer := portraitUserScope(account)
|
||||
viewer.All = actor.AuthorityID == 888
|
||||
if len(games) == 0 {
|
||||
return nil, ErrServerAnalysisGame
|
||||
}
|
||||
@@ -72,7 +75,7 @@ func (uc *ServerAnalysisUsecase) Servers(ctx context.Context, sources, games []s
|
||||
if limit > 100 {
|
||||
limit = 100000
|
||||
}
|
||||
return uc.repo.Servers(ctx, sources, games, strings.TrimSpace(keyword), limit)
|
||||
return uc.repo.Servers(ctx, viewer, sources, games, strings.TrimSpace(keyword), limit)
|
||||
}
|
||||
func (uc *ServerAnalysisUsecase) List(ctx context.Context, f ServerAnalysisFilter, page, size int) (*ServerAnalysisPage, error) {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
@@ -151,6 +154,19 @@ func (uc *ServerAnalysisUsecase) List(ctx context.Context, f ServerAnalysisFilte
|
||||
return out, nil
|
||||
}
|
||||
func sortServerAnalysis(rows []*ServerAnalysisRow, field string, ascending bool) error {
|
||||
if field == "" {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
if a.SourceDB != b.SourceDB {
|
||||
return a.SourceDB < b.SourceDB
|
||||
}
|
||||
if a.GameID != b.GameID {
|
||||
return a.GameID < b.GameID
|
||||
}
|
||||
return a.ServerNum < b.ServerNum
|
||||
})
|
||||
return nil
|
||||
}
|
||||
values := map[*ServerAnalysisRow]*big.Rat{}
|
||||
for _, row := range rows {
|
||||
var n *big.Rat
|
||||
|
||||
@@ -22,7 +22,8 @@ func (r *serverAnalysisFake) Aggregate(_ context.Context, s AnalyticsScope, f Se
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (r *serverAnalysisFake) Servers(_ context.Context, _, _ []string, _ string, limit int) ([]*ServerOption, error) {
|
||||
func (r *serverAnalysisFake) Servers(_ context.Context, viewer UserScope, _, _ []string, _ string, limit int) ([]*ServerOption, error) {
|
||||
r.scope.Viewer = viewer
|
||||
r.limit = limit
|
||||
return []*ServerOption{}, nil
|
||||
}
|
||||
@@ -67,3 +68,20 @@ func TestServerAnalysisSummaryZerosSortingAndLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerAnalysisCancelSortRestoresIdentityOrder(t *testing.T) {
|
||||
rows := []*ServerAnalysisRow{
|
||||
{ServerAnalysisKey: ServerAnalysisKey{SourceDB: "lx", GameID: 1, ServerNum: "002"}, Pay: "5"},
|
||||
{ServerAnalysisKey: ServerAnalysisKey{SourceDB: "lx", GameID: 1, ServerNum: "001"}, Pay: "10"},
|
||||
{ServerAnalysisKey: ServerAnalysisKey{SourceDB: "lx", GameID: 1, ServerNum: "003"}, Pay: "20"},
|
||||
}
|
||||
if err := sortServerAnalysis(rows, "totalPay", true); err != nil || rows[0].ServerNum != "002" {
|
||||
t.Fatal("升序错误")
|
||||
}
|
||||
if err := sortServerAnalysis(rows, "totalPay", false); err != nil || rows[0].ServerNum != "003" {
|
||||
t.Fatal("降序错误")
|
||||
}
|
||||
if err := sortServerAnalysis(rows, "", false); err != nil || rows[0].ServerNum != "001" {
|
||||
t.Fatal("取消排序应恢复稳定的区服顺序")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"context"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWorkbenchSource = errors.BadRequest("SW_WORKBENCH_SOURCE", "请选择所属平台后查看数据")
|
||||
ErrWorkbenchPeriod = errors.BadRequest("SW_WORKBENCH_PERIOD", "时间维度无效,应为 today/yesterday/last7/last30")
|
||||
ErrWorkbenchUnavailable = errors.BadRequest("SW_WORKBENCH_UNAVAILABLE", "工作台所需业务数据尚未接入")
|
||||
)
|
||||
|
||||
type WorkbenchRankKind string
|
||||
|
||||
const (
|
||||
WorkbenchRegister WorkbenchRankKind = "register"
|
||||
WorkbenchPayHigh WorkbenchRankKind = "payHigh"
|
||||
WorkbenchPayLow WorkbenchRankKind = "payLow"
|
||||
WorkbenchUnlogin WorkbenchRankKind = "unlogin"
|
||||
)
|
||||
|
||||
type WorkbenchWindow struct{ Today, Yesterday, MonthStart, PreviousMonthStart, PreviousMonthEnd time.Time }
|
||||
type WorkbenchDay struct {
|
||||
Counts PortraitCounts
|
||||
Pay string
|
||||
ActiveChannels, NewChannels int64
|
||||
}
|
||||
type WorkbenchTotals struct {
|
||||
Today, Yesterday WorkbenchDay
|
||||
MonthPay, PreviousMonthPay string
|
||||
Available bool
|
||||
}
|
||||
type WorkbenchCard struct {
|
||||
Value, CompareValue, ChangeRate *string
|
||||
CompareLabel string
|
||||
}
|
||||
type WorkbenchSummary struct {
|
||||
TodayRegister, TodayActive, TodayPay, MonthPay, ActivePromote, NewPromote *WorkbenchCard
|
||||
Available bool
|
||||
}
|
||||
type WorkbenchRankRow struct {
|
||||
AnalyticsKey
|
||||
Rank int
|
||||
Name, Value string
|
||||
CreatedUnix, LastLoginUnix *int64
|
||||
}
|
||||
type WorkbenchRankData struct {
|
||||
Rows []*WorkbenchRankRow
|
||||
Available bool
|
||||
}
|
||||
type WorkbenchRepo interface {
|
||||
Summary(context.Context, AnalyticsScope, []string, WorkbenchWindow) (*WorkbenchTotals, error)
|
||||
Ranks(context.Context, AnalyticsScope, AnalyticsFilter, WorkbenchRankKind) (*WorkbenchRankData, error)
|
||||
FillRankNames(context.Context, []*WorkbenchRankRow) error
|
||||
}
|
||||
type WorkbenchUsecase struct {
|
||||
repo WorkbenchRepo
|
||||
accounts *AccountUsecase
|
||||
preferences *UserPreferenceUsecase
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWorkbenchUsecase(repo WorkbenchRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *WorkbenchUsecase {
|
||||
return &WorkbenchUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
|
||||
}
|
||||
func (uc *WorkbenchUsecase) scope(ctx context.Context, sources []string) (AnalyticsScope, error) {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return AnalyticsScope{}, err
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
return AnalyticsScope{}, ErrWorkbenchSource
|
||||
}
|
||||
pref, err := uc.preferences.Current(ctx)
|
||||
if err != nil {
|
||||
return AnalyticsScope{}, err
|
||||
}
|
||||
viewer := portraitUserScope(account)
|
||||
viewer.All = actor.AuthorityID == 888
|
||||
return AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, nil
|
||||
}
|
||||
func workbenchWindow(now time.Time) WorkbenchWindow {
|
||||
now = now.In(portraitLocation)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation)
|
||||
month := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, portraitLocation)
|
||||
previous := month.AddDate(0, -1, 0)
|
||||
lastDay := month.AddDate(0, 0, -1).Day()
|
||||
previousEnd := previous.AddDate(0, 0, min(today.Day(), lastDay)-1)
|
||||
return WorkbenchWindow{Today: today, Yesterday: today.AddDate(0, 0, -1), MonthStart: month, PreviousMonthStart: previous, PreviousMonthEnd: previousEnd}
|
||||
}
|
||||
func workbenchCard(current, previous, label string, available bool) (*WorkbenchCard, error) {
|
||||
card := &WorkbenchCard{CompareLabel: label}
|
||||
if !available {
|
||||
return card, nil
|
||||
}
|
||||
cur, err := portraitNumber(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prev, err := portraitNumber(previous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
card.Value, card.CompareValue = stringPointer(portraitDecimal(cur, 2)), stringPointer(portraitDecimal(prev, 2))
|
||||
cur, _ = portraitNumber(*card.Value)
|
||||
prev, _ = portraitNumber(*card.CompareValue)
|
||||
if prev.Sign() != 0 {
|
||||
diff := new(big.Rat).Sub(cur, prev)
|
||||
diff.Mul(diff, big.NewRat(100, 1))
|
||||
diff.Quo(diff, prev)
|
||||
card.ChangeRate = stringPointer(portraitDecimal(diff, 1))
|
||||
}
|
||||
return card, nil
|
||||
}
|
||||
func (uc *WorkbenchUsecase) Summary(ctx context.Context, sources []string) (*WorkbenchSummary, error) {
|
||||
scope, err := uc.scope(ctx, sources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := uc.repo.Summary(ctx, scope, sources, workbenchWindow(uc.now()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.Today.Counts, data.Yesterday.Counts = analyticsCap(data.Today.Counts), analyticsCap(data.Yesterday.Counts)
|
||||
integer := func(n int64) string { return big.NewInt(n).String() }
|
||||
out := &WorkbenchSummary{Available: data.Available}
|
||||
for _, item := range []struct {
|
||||
field **WorkbenchCard
|
||||
current, previous, label string
|
||||
}{
|
||||
{&out.TodayRegister, integer(data.Today.Counts.Register), integer(data.Yesterday.Counts.Register), "环比昨日"},
|
||||
{&out.TodayActive, integer(data.Today.Counts.Active), integer(data.Yesterday.Counts.Active), "环比昨日"},
|
||||
{&out.TodayPay, data.Today.Pay, data.Yesterday.Pay, "环比昨日"},
|
||||
{&out.MonthPay, data.MonthPay, data.PreviousMonthPay, "环比上月"},
|
||||
{&out.ActivePromote, integer(data.Today.ActiveChannels), integer(data.Yesterday.ActiveChannels), "环比昨日"},
|
||||
{&out.NewPromote, integer(data.Today.NewChannels), integer(data.Yesterday.NewChannels), "环比昨日"},
|
||||
} {
|
||||
card, err := workbenchCard(item.current, item.previous, item.label, data.Available)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*item.field = card
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (uc *WorkbenchUsecase) Ranks(ctx context.Context, sources []string, period string, kind WorkbenchRankKind) ([]*WorkbenchRankRow, error) {
|
||||
scope, err := uc.scope(ctx, sources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind != WorkbenchRegister && kind != WorkbenchPayHigh && kind != WorkbenchPayLow && kind != WorkbenchUnlogin {
|
||||
return nil, ErrAnalyticsInput
|
||||
}
|
||||
period = strings.ToLower(strings.TrimSpace(period))
|
||||
if period == "" {
|
||||
period = "today"
|
||||
if kind == WorkbenchPayLow {
|
||||
period = "last30"
|
||||
}
|
||||
}
|
||||
w := workbenchWindow(uc.now())
|
||||
begin, end := w.Today, w.Today
|
||||
if kind != WorkbenchUnlogin {
|
||||
switch period {
|
||||
case "today":
|
||||
case "yesterday":
|
||||
begin, end = w.Yesterday, w.Yesterday
|
||||
case "last7":
|
||||
begin = end.AddDate(0, 0, -6)
|
||||
case "last30":
|
||||
begin = end.AddDate(0, 0, -29)
|
||||
default:
|
||||
return nil, ErrWorkbenchPeriod
|
||||
}
|
||||
}
|
||||
data, err := uc.repo.Ranks(ctx, scope, AnalyticsFilter{Sources: sources, Begin: begin.Format("2006-01-02"), End: end.Format("2006-01-02")}, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !data.Available {
|
||||
return nil, ErrWorkbenchUnavailable
|
||||
}
|
||||
top := &workbenchRankHeap{ascending: kind == WorkbenchPayLow}
|
||||
for index, row := range data.Rows {
|
||||
if kind == WorkbenchUnlogin {
|
||||
row.Value = big.NewInt(workbenchUnloginDays(row, w.Today)).String()
|
||||
}
|
||||
n, err := portraitNumber(row.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kind == WorkbenchRegister && n.Cmp(big.NewRat(2147483647, 1)) > 0 {
|
||||
n = big.NewRat(2147483647, 1)
|
||||
}
|
||||
if (kind == WorkbenchRegister || kind == WorkbenchPayHigh) && n.Sign() <= 0 {
|
||||
continue
|
||||
}
|
||||
candidate := workbenchRankCandidate{row: row, value: n, index: index}
|
||||
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
|
||||
}
|
||||
for i, row := range rows {
|
||||
row.Rank = i + 1
|
||||
row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote()
|
||||
if kind != WorkbenchUnlogin {
|
||||
row.Value = portraitDecimal(top.rows[i].value, 2)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
stamp := row.LastLoginUnix
|
||||
if stamp == nil || *stamp <= 0 {
|
||||
stamp = row.CreatedUnix
|
||||
}
|
||||
if stamp == nil || *stamp <= 0 {
|
||||
return 99999
|
||||
}
|
||||
date := time.Unix(*stamp, 0).In(portraitLocation)
|
||||
date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, portraitLocation)
|
||||
if date.After(today) {
|
||||
return 0
|
||||
}
|
||||
return min(int64(today.Sub(date).Hours()/24), 2147483647)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type workbenchRepoFake struct {
|
||||
totals *WorkbenchTotals
|
||||
data *WorkbenchRankData
|
||||
window WorkbenchWindow
|
||||
filter AnalyticsFilter
|
||||
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) {
|
||||
r.window, r.scope = w, s
|
||||
c := *r.totals
|
||||
return &c, nil
|
||||
}
|
||||
func (r *workbenchRepoFake) Ranks(_ context.Context, s AnalyticsScope, f AnalyticsFilter, _ WorkbenchRankKind) (*WorkbenchRankData, error) {
|
||||
r.filter, r.scope = f, s
|
||||
out := &WorkbenchRankData{Available: r.data.Available}
|
||||
for _, row := range r.data.Rows {
|
||||
c := *row
|
||||
out.Rows = append(out.Rows, &c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func workbenchFixture() (*WorkbenchUsecase, *workbenchRepoFake) {
|
||||
r := &workbenchRepoFake{totals: &WorkbenchTotals{Available: true, Today: WorkbenchDay{Counts: PortraitCounts{Register: 3, Active: 4}, Pay: "20.005", ActiveChannels: 2, NewChannels: 1}, Yesterday: WorkbenchDay{Counts: PortraitCounts{Register: 2}, Pay: "10", ActiveChannels: 1}, MonthPay: "300", PreviousMonthPay: "200"}, data: &WorkbenchRankData{Available: true}}
|
||||
a := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Level: 1, IDPath: "0"}}}
|
||||
uc := NewWorkbenchUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}))
|
||||
uc.now = func() time.Time { return time.Date(2026, 3, 31, 9, 0, 0, 0, portraitLocation) }
|
||||
return uc, r
|
||||
}
|
||||
func TestWorkbenchSummaryCalendarAndRatios(t *testing.T) {
|
||||
uc, r := workbenchFixture()
|
||||
out, err := uc.Summary(accountContext(7, 901), []string{"lx"})
|
||||
if err != nil || *out.TodayRegister.Value != "3.00" || *out.TodayRegister.ChangeRate != "50.0" || out.TodayActive.ChangeRate != nil || *out.TodayPay.Value != "20.01" || *out.TodayPay.ChangeRate != "100.1" || *out.MonthPay.ChangeRate != "50.0" || *out.ActivePromote.ChangeRate != "100.0" {
|
||||
t.Fatalf("卡片精度或环比错误: %+v %v", out, err)
|
||||
}
|
||||
if r.window.PreviousMonthEnd.Format("2006-01-02") != "2026-02-28" || r.window.PreviousMonthStart.Format("2006-01-02") != "2026-02-01" {
|
||||
t.Fatal("上月同期需要截断至月末")
|
||||
}
|
||||
r.totals.Available = false
|
||||
out, err = uc.Summary(accountContext(7, 901), []string{"lx"})
|
||||
if err != nil || out.TodayPay.Value != nil || out.TodayPay.CompareValue != nil || out.Available {
|
||||
t.Fatal("缺表不能返回零卡片")
|
||||
}
|
||||
if _, err := uc.Summary(accountContext(7, 901), nil); err != ErrWorkbenchSource {
|
||||
t.Fatal("必须选择平台")
|
||||
}
|
||||
}
|
||||
func TestWorkbenchRankLimitsExactSortDefaultsAndUnlogin(t *testing.T) {
|
||||
uc, r := workbenchFixture()
|
||||
ctx := accountContext(7, 901)
|
||||
for i := 0; i < 14; i++ {
|
||||
r.data.Rows = append(r.data.Rows, &WorkbenchRankRow{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: int64(i)}, Value: fmt.Sprint(i)})
|
||||
}
|
||||
rows, err := uc.Ranks(ctx, []string{"lx"}, "", WorkbenchPayHigh)
|
||||
if err != nil || len(rows) != 10 || rows[0].ID != 13 || rows[9].Rank != 10 {
|
||||
t.Fatal("榜单排序或 TOP10 截断错误")
|
||||
}
|
||||
if len(r.named) != 10 {
|
||||
t.Fatal("仅应补充已入选 TOP10 的渠道名称")
|
||||
}
|
||||
rows, err = uc.Ranks(ctx, []string{"lx"}, "", WorkbenchPayLow)
|
||||
if err != nil || rows[0].ID != 0 || rows[0].Value != "0.00" || rows[0].Name != "官方渠道" || r.filter.Begin != "2026-03-02" {
|
||||
t.Fatal("最低榜应包含零值,默认近30天")
|
||||
}
|
||||
r.data.Rows = []*WorkbenchRankRow{{AnalyticsKey: AnalyticsKey{SourceDB: "lx", ID: 1}, Value: "9007199254740993.00"}, {AnalyticsKey: AnalyticsKey{SourceDB: "hw", ID: 2}, Value: "9007199254740992.99"}}
|
||||
rows, err = uc.Ranks(ctx, []string{"lx", "hw"}, "last7", WorkbenchPayHigh)
|
||||
if err != nil || rows[0].ID != 1 || r.filter.Begin != "2026-03-25" {
|
||||
t.Fatal("金额排序丢失精度或近7天范围错误")
|
||||
}
|
||||
if _, err := uc.Ranks(ctx, []string{"lx"}, "invalid", WorkbenchRegister); err != ErrWorkbenchPeriod {
|
||||
t.Fatal("无效时间维度必须拒绝")
|
||||
}
|
||||
today := workbenchWindow(uc.now()).Today
|
||||
created := today.AddDate(0, 0, -5).Unix()
|
||||
future := today.AddDate(0, 0, 1).Unix()
|
||||
r.data.Rows = []*WorkbenchRankRow{{AnalyticsKey: AnalyticsKey{ID: 1}}, {AnalyticsKey: AnalyticsKey{ID: 2}, CreatedUnix: &created}, {AnalyticsKey: AnalyticsKey{ID: 3}, CreatedUnix: &created, LastLoginUnix: &future}}
|
||||
rows, err = uc.Ranks(ctx, []string{"lx"}, "ignored", WorkbenchUnlogin)
|
||||
if err != nil || rows[0].Value != "99999" || rows[1].Value != "5" || rows[2].Value != "0" {
|
||||
t.Fatal("未登录回退创建时间、无时间或未来时间错误")
|
||||
}
|
||||
r.data.Available = false
|
||||
if _, err := uc.Ranks(ctx, []string{"lx"}, "", WorkbenchUnlogin); err != ErrWorkbenchUnavailable {
|
||||
t.Fatal("缺数据不能返回空榜单")
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
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) {
|
||||
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) {
|
||||
return errors.New("params 必须是合法 JSON")
|
||||
}
|
||||
if _, err := MethodExecutionOptions(value.Params); err != nil {
|
||||
return err
|
||||
}
|
||||
case TaskExecutorHTTP:
|
||||
parsed, parseErr := url.Parse(value.HTTPURL)
|
||||
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}
|
||||
}
|
||||
|
||||
func (r *activationRepo) OwnAccounts(ctx context.Context, id uint) ([]string, error) {
|
||||
accounts := []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
|
||||
func (r *activationRepo) OwnAccounts(ctx context.Context, id uint) ([]business.ActivationAccount, error) {
|
||||
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("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)
|
||||
}
|
||||
|
||||
@@ -68,11 +72,11 @@ func (r *activationRepo) List(ctx context.Context, scope business.ActivationScop
|
||||
}
|
||||
q := db.Model(&activationPO{}).Where("deleted_at IS NULL")
|
||||
if !scope.All {
|
||||
accounts := make([]string, 0, len(scope.Accounts))
|
||||
accounts := make([][]any, 0, len(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 {
|
||||
q = q.Where("platform_name IN ?", filter.PlatformNames)
|
||||
|
||||
@@ -59,11 +59,11 @@ func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) {
|
||||
db, _, repo := prepareActivations(t)
|
||||
ctx := context.Background()
|
||||
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)
|
||||
}
|
||||
page, err := repo.List(ctx, business.ActivationScope{Accounts: []string{"OWN"}}, business.ActivationFilter{})
|
||||
if err != nil || page.Total != 2 || page.Rows[0].ID != 2 {
|
||||
page, err := repo.List(ctx, business.ActivationScope{Accounts: []business.ActivationAccount{{SourceDB: "lx", Account: "OWN"}}}, business.ActivationFilter{})
|
||||
if err != nil || page.Total != 1 || page.Rows[0].ID != 1 {
|
||||
t.Fatalf("OA 列表或排序错误: %+v %v", page, err)
|
||||
}
|
||||
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"
|
||||
"sort"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
@@ -63,7 +64,9 @@ func analyticsQuery(db *gorm.DB, scope business.AnalyticsScope, f business.Analy
|
||||
if scope.Viewer.All && scope.IncludeSub && f.PromoteType == nil && f.PromoteName == "" {
|
||||
return q
|
||||
}
|
||||
return q.Where("EXISTS (?)", analyticsPromotes(db, scope, f, "p").Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
|
||||
// 保留完整的可见渠道筛选,用成对 IN 让 MySQL 5.7 将报表与权限
|
||||
// 查询转换为半连接,避免为每条身份记录重新执行相关渠道子查询。
|
||||
return q.Where("(r.source_db,r.promote_id) IN (?)", analyticsPromotes(db, scope, f, "p").Select("p.source_db,p.id"))
|
||||
}
|
||||
|
||||
type analyticsAggPO struct {
|
||||
@@ -76,10 +79,16 @@ type analyticsAggPO struct {
|
||||
|
||||
func analyticsRoot(db *gorm.DB, kind business.AnalyticsKind, scope business.AnalyticsScope, f business.AnalyticsFilter, table string, mode int, pay bool) (*gorm.DB, string) {
|
||||
q := analyticsQuery(db, scope, f, table, mode)
|
||||
if kind == business.GameAnalytics {
|
||||
q = q.Where("(r.source_db,r.game_id) IN (?)", approvedGamesQuery(db, scope.Viewer, f.Sources).Select("g.source_db,g.id"))
|
||||
}
|
||||
id := "r.game_id"
|
||||
if kind == business.ChannelAnalytics {
|
||||
id = "r.promote_id"
|
||||
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")
|
||||
id = "COALESCE(NULLIF(root_p.top_promote_id,0),r.promote_id)"
|
||||
if pay {
|
||||
@@ -89,6 +98,12 @@ func analyticsRoot(db *gorm.DB, kind business.AnalyticsKind, scope business.Anal
|
||||
}
|
||||
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) {
|
||||
switch metric {
|
||||
case 1:
|
||||
@@ -112,6 +127,16 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
|
||||
if empty || !available {
|
||||
return result, nil
|
||||
}
|
||||
if kind == business.GameAnalytics {
|
||||
available, err := approvedGamesAvailable(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
result.Available = false
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"tab_sw_report_day", "tab_sw_report_day_key"} {
|
||||
available, err := businessTableAvailable(db, table)
|
||||
if err != nil {
|
||||
@@ -134,44 +159,61 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
|
||||
}
|
||||
return rowMap[key]
|
||||
}
|
||||
var pays []analyticsAggPO
|
||||
q, id := analyticsRoot(db, 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 nil, business.ErrStorage
|
||||
var pays, counts []analyticsAggPO
|
||||
var global []portraitCountPO
|
||||
globalPay := "0"
|
||||
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 {
|
||||
if po.ID == nil {
|
||||
continue
|
||||
if po.ID != nil {
|
||||
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 {
|
||||
if po.ID == nil {
|
||||
continue
|
||||
if po.ID != nil {
|
||||
putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count)
|
||||
}
|
||||
putAnalyticsCount(&rowFor(po).Counts, po.Metric, po.Count)
|
||||
}
|
||||
if kind == business.ChannelAnalytics && !scope.Viewer.All {
|
||||
if err := filterAnalyticsRows(db, scope, f, rowMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if kind == business.ChannelAnalytics && scope.Viewer.All {
|
||||
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
|
||||
}
|
||||
if withGlobal {
|
||||
totals := toPortraitCounts(global)
|
||||
result.GlobalCounts = &totals
|
||||
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
|
||||
}
|
||||
result.GlobalCounts, result.GlobalPay = &totals, globalPay
|
||||
}
|
||||
if len(rowMap) == 0 {
|
||||
return result, nil
|
||||
@@ -186,9 +228,6 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
if err := fillAnalyticsNames(db, kind, scope, f, rowMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rowMap {
|
||||
result.Rows = append(result.Rows, row)
|
||||
}
|
||||
@@ -201,6 +240,18 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *analyticsRepo) FillNames(ctx context.Context, kind business.AnalyticsKind, scope business.AnalyticsScope, f business.AnalyticsFilter, rows []*business.AnalyticsRow) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
byKey := make(map[business.AnalyticsKey]*business.AnalyticsRow, len(rows))
|
||||
for _, row := range rows {
|
||||
byKey[row.AnalyticsKey] = row
|
||||
}
|
||||
return fillAnalyticsNames(r.data.DB().WithContext(ctx), kind, scope, f, byKey)
|
||||
}
|
||||
|
||||
func analyticsPairs(keys []business.AnalyticsKey) [][]any {
|
||||
result := make([][]any, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
@@ -242,6 +293,12 @@ func fillAnalyticsNames(db *gorm.DB, kind business.AnalyticsKind, scope business
|
||||
for key := range rows {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].ID != keys[j].ID {
|
||||
return keys[i].ID < keys[j].ID
|
||||
}
|
||||
return keys[i].SourceDB < keys[j].SourceDB
|
||||
})
|
||||
type entityPO struct {
|
||||
ID int64
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
|
||||
@@ -12,6 +12,7 @@ func prepareAnalyticsData(t *testing.T) (*gorm.DB, business.AnalyticsRepo, busin
|
||||
t.Helper()
|
||||
db, _ := prepareChannels(t)
|
||||
for _, sql := range []string{
|
||||
"INSERT INTO tab_promote_apply VALUES('hw',100,1,1)",
|
||||
"ALTER TABLE tab_sw_report_day ADD COLUMN game_name TEXT",
|
||||
"ALTER TABLE tab_sw_report_day_key ADD COLUMN game_name TEXT",
|
||||
"UPDATE tab_sw_report_day SET game_name=CASE WHEN game_id=1 THEN '历史游戏甲' ELSE '游戏乙' END",
|
||||
@@ -34,6 +35,9 @@ func TestAnalyticsChannelTreeAndAdminOfficialRows(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.FillNames(context.Background(), business.ChannelAnalytics, scope, filter, data.Rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data.Rows) != 3 || data.GlobalCounts != nil {
|
||||
t.Fatalf("普通商务统计范围错误: %+v", data)
|
||||
}
|
||||
@@ -69,6 +73,12 @@ func TestAnalyticsFiltersKeepHierarchyAndSnapshotNames(t *testing.T) {
|
||||
kind := 1
|
||||
filter.PromoteType = &kind
|
||||
data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.FillNames(context.Background(), business.ChannelAnalytics, scope, filter, data.Rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err != nil || len(data.Rows) != 1 || data.Rows[0].ID != 100 || portraitTestMoney(data.Rows[0].Pay) != "20.20" || data.Rows[0].PromoteType == nil || *data.Rows[0].PromoteType != 0 {
|
||||
t.Fatalf("类型筛选应限制参与渠道,同时显示归根后的当前资料: %+v %v", data, err)
|
||||
}
|
||||
@@ -78,6 +88,9 @@ func TestAnalyticsFiltersKeepHierarchyAndSnapshotNames(t *testing.T) {
|
||||
if err != nil || len(data.Rows) != 2 {
|
||||
t.Fatalf("按游戏快照名筛选错误: %+v %v", data, err)
|
||||
}
|
||||
if err := repo.FillNames(context.Background(), business.GameAnalytics, scope, filter, data.Rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range data.Rows {
|
||||
if row.SourceDB == "lx" && (row.ID != 1 || row.Name != "游戏甲" || row.Counts.Register != 1) {
|
||||
t.Fatalf("应按报表快照筛选、用当前游戏名展示: %+v", row)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
func approvedGamesAvailable(db *gorm.DB) (bool, error) {
|
||||
for _, table := range []string{"tab_game", "tab_promote", "tab_promote_apply"} {
|
||||
available, err := businessTableAvailable(db, table)
|
||||
if err != nil || !available {
|
||||
return available, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 游戏可选范围来自可见渠道的有效申请,不受统计时是否包含下级的开关影响。
|
||||
// 用平台和 ID 配对,避免同号游戏或其他商务的申请扩大可选范围。
|
||||
func approvedGamesQuery(db *gorm.DB, viewer business.UserScope, sources []string) *gorm.DB {
|
||||
promotes := ledgerBasePromotes(db, business.LedgerScope{Viewer: viewer, IncludeSub: true, Unrestricted: viewer.All}, business.LedgerFilter{Sources: sources}, "approved_p").Select("approved_p.source_db,approved_p.id")
|
||||
applications := db.Table("tab_promote_apply a").Select("a.source_db,a.game_id").Where("a.status=1").Where("(a.source_db,a.promote_id) IN (?)", promotes)
|
||||
q := db.Table("tab_game g").Where("(g.source_db,g.id) IN (?)", applications)
|
||||
if len(sources) > 0 {
|
||||
q = q.Where("g.source_db IN ?", sources)
|
||||
}
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
func TestGameOptionsRequireVisibleApprovedApplications(t *testing.T) {
|
||||
db, _ := prepareChannels(t)
|
||||
for _, sql := range []string{
|
||||
"INSERT INTO tab_game VALUES('lx',3,'子渠道游戏'),('lx',4,'未申请包服'),('lx',5,'其他商务游戏'),('lx',6,'待审核游戏')",
|
||||
"INSERT INTO tab_promote_apply VALUES('lx',104,5,1),('lx',100,6,0)",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
repo := NewLedgerRepo(testData{db})
|
||||
ctx := context.Background()
|
||||
rows, err := repo.Games(ctx, channelTestScope(), nil, "", 100)
|
||||
if err != nil || len(rows) != 3 {
|
||||
t.Fatalf("只能返回可见渠道已通过申请的游戏,重复申请不能重复: %+v %v", rows, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.SourceDB != "lx" || row.ID > 3 {
|
||||
t.Fatalf("泄漏未申请或其他商务的游戏: %+v", row)
|
||||
}
|
||||
}
|
||||
rows, err = repo.Games(ctx, channelTestScope(), []string{"hw"}, "", 100)
|
||||
if err != nil || len(rows) != 0 {
|
||||
t.Fatal("不能借用其他平台同号游戏的申请")
|
||||
}
|
||||
rows, err = repo.Games(ctx, business.UserScope{SelfID: 99, OnlySelf: true}, []string{"lx"}, "", 100)
|
||||
if err != nil || len(rows) != 0 {
|
||||
t.Fatal("无可见绑定不能退化为全部游戏")
|
||||
}
|
||||
rows, err = repo.Games(ctx, channelTestScope(), nil, "子渠道", 1)
|
||||
if err != nil || len(rows) != 1 || rows[0].ID != 3 {
|
||||
t.Fatal("应支持已申请游戏的关键词与数量筛选")
|
||||
}
|
||||
if err := db.Exec("DROP TABLE tab_promote_apply").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.Games(ctx, channelTestScope(), nil, "", 100); err != business.ErrGameOptionsUnavailable {
|
||||
t.Fatal("缺申请表不能开放所有游戏")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameReportsRejectUnapprovedGamesEvenWithVisibleHistory(t *testing.T) {
|
||||
db, repo, scope, filter := prepareAnalyticsData(t)
|
||||
if err := db.Exec("UPDATE tab_promote_apply SET status=0 WHERE source_db='lx' AND game_id=1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filter.Sources = []string{"lx"}
|
||||
filter.GameNames = []string{"历史游戏甲"}
|
||||
rows, err := repo.Aggregate(context.Background(), business.GameAnalytics, scope, filter)
|
||||
if err != nil || !rows.Available || len(rows.Rows) != 0 {
|
||||
t.Fatalf("直接指定未申请游戏也不能返回统计: %+v %v", rows, err)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,9 @@ func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter
|
||||
}
|
||||
var pos []channelPO
|
||||
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
|
||||
}
|
||||
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 {
|
||||
pairs := channelPairs(keys[start:min(start+200, len(keys))])
|
||||
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 {
|
||||
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)
|
||||
}()
|
||||
}
|
||||
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
|
||||
if err := tx.Model(&platformChannelPO{}).Where("account = ?", input.Account).Count(&count).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -32,11 +32,8 @@ func ledgerPromotes(db *gorm.DB, scope business.LedgerScope, filter business.Led
|
||||
return q
|
||||
}
|
||||
hits := ledgerBasePromotes(db, scope, filter, "hit")
|
||||
exact := "TRIM(hit.account) = ? COLLATE BINARY"
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
exact = "BINARY TRIM(hit.account) = ?"
|
||||
}
|
||||
hits = hits.Where(exact, filter.PromoteAccount).Where("hit.source_db=" + alias + ".source_db")
|
||||
keyword := strings.ToLower(strings.TrimSpace(filter.PromoteAccount))
|
||||
hits = hits.Where("(INSTR(LOWER(hit.account),?) > 0 OR INSTR(LOWER(hit.nickname),?) > 0)", keyword, keyword).Where("hit.source_db=" + alias + ".source_db")
|
||||
if scope.IncludeSub {
|
||||
root := "CASE WHEN hit.promote_level=1 THEN hit.id WHEN hit.top_promote_id>0 THEN hit.top_promote_id ELSE hit.id END"
|
||||
hits = hits.Where("(" + alias + ".id = (" + root + ") OR " + alias + ".top_promote_id = (" + root + "))")
|
||||
@@ -87,11 +84,24 @@ type registrationPO struct {
|
||||
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)"
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
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)
|
||||
if filter.UserID != nil {
|
||||
q = q.Where("r.id = ?", *filter.UserID)
|
||||
@@ -135,7 +145,11 @@ func (r *ledgerRepo) Registrations(ctx context.Context, scope business.LedgerSco
|
||||
page.Available = false
|
||||
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 {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
@@ -146,15 +160,14 @@ func (r *ledgerRepo) Registrations(ctx context.Context, scope business.LedgerSco
|
||||
if filter.Ascending {
|
||||
direction = " ASC"
|
||||
}
|
||||
order := "CAST(r.register_time AS INTEGER)"
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
order = "CAST(r.register_time AS UNSIGNED)"
|
||||
}
|
||||
order := timeExpression
|
||||
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)"
|
||||
}
|
||||
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
|
||||
}
|
||||
meta := []ledgerMetaTarget{}
|
||||
@@ -216,7 +229,8 @@ func spendQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerF
|
||||
q := ledgerScopeQuery(db, scope, filter, "tab_spend").Where("r.pay_time BETWEEN ? AND ?", filter.BeginUnix, filter.EndUnix)
|
||||
if successOnly {
|
||||
q = q.Where("r.pay_status = 1")
|
||||
} else if filter.PayStatus != nil {
|
||||
}
|
||||
if filter.PayStatus != nil {
|
||||
q = q.Where("r.pay_status = ?", *filter.PayStatus)
|
||||
}
|
||||
if len(filter.GameNames) > 0 {
|
||||
@@ -280,7 +294,10 @@ func (r *ledgerRepo) Spends(ctx context.Context, scope business.LedgerScope, fil
|
||||
order += " DESC"
|
||||
}
|
||||
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
|
||||
}
|
||||
meta := []ledgerMetaTarget{}
|
||||
@@ -334,11 +351,13 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
PlatformBusierID uint64
|
||||
PlatformBusierAccount, Username string
|
||||
UserID uint
|
||||
IDPath string `gorm:"column:id_path"`
|
||||
}
|
||||
names := map[business.PlatformKey]bindName{}
|
||||
for start := 0; start < len(pairs); start += 300 {
|
||||
var rows []bindName
|
||||
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,u.username").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
|
||||
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,u.username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
|
||||
if err := q.Scan(&rows).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
@@ -349,6 +368,31 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
|
||||
}
|
||||
}
|
||||
}
|
||||
ancestorIDs := map[uint]bool{}
|
||||
paths := map[business.PlatformKey][]uint{}
|
||||
for key, binding := range names {
|
||||
for _, part := range strings.Split(binding.IDPath, ",") {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32)
|
||||
if err == nil && id > 0 && uint(id) != binding.UserID {
|
||||
paths[key] = append(paths[key], uint(id))
|
||||
ancestorIDs[uint(id)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
ids := make([]uint, 0, len(ancestorIDs))
|
||||
for id := range ancestorIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
ancestors := map[uint]string{}
|
||||
for start := 0; start < len(ids); start += 300 {
|
||||
var rows []accountPO
|
||||
if err := db.Select("id,username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
for _, row := range rows {
|
||||
ancestors[row.ID] = row.Username
|
||||
}
|
||||
}
|
||||
for _, target := range targets {
|
||||
n := target.Names
|
||||
if n.PromoteID == nil || *n.PromoteID < 0 {
|
||||
@@ -361,33 +405,38 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
|
||||
if binding, ok := names[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}]; ok {
|
||||
n.BindingAccount = binding.PlatformBusierAccount
|
||||
n.BindingUserName = binding.Username
|
||||
for _, id := range paths[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}] {
|
||||
if name := strings.TrimSpace(ancestors[id]); name != "" {
|
||||
n.BusinessPath = append(n.BusinessPath, name)
|
||||
}
|
||||
}
|
||||
if name := strings.TrimSpace(binding.Username); name != "" {
|
||||
n.BusinessPath = append(n.BusinessPath, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *ledgerRepo) Games(ctx context.Context, sources []string, keyword string, limit int) ([]*business.GameOption, error) {
|
||||
func (r *ledgerRepo) Games(ctx context.Context, viewer business.UserScope, sources []string, keyword string, limit int) ([]*business.GameOption, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
available, err := businessTableAvailable(db, "tab_game")
|
||||
available, err := approvedGamesAvailable(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
return nil, business.ErrGameOptionsUnavailable
|
||||
}
|
||||
q := db.Table("tab_game")
|
||||
if len(sources) > 0 {
|
||||
q = q.Where("source_db IN ?", sources)
|
||||
}
|
||||
q := approvedGamesQuery(db, viewer, sources)
|
||||
if keyword != "" {
|
||||
q = q.Where("game_name LIKE ?", "%"+keyword+"%")
|
||||
q = q.Where("INSTR(LOWER(g.game_name),?) > 0", strings.ToLower(keyword))
|
||||
}
|
||||
var rows []struct {
|
||||
ID uint64
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
GameName string
|
||||
}
|
||||
if err := q.Select("id,source_db,game_name").Order("game_name,id,source_db").Limit(limit).Scan(&rows).Error; err != nil {
|
||||
if err := q.Select("g.id,g.source_db,g.game_name").Distinct().Order("g.game_name,g.id,g.source_db").Limit(limit).Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
result := make([]*business.GameOption, 0, len(rows))
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestRegistrationScopePuidAndUnboundedPlayerPay(t *testing.T) {
|
||||
first = row
|
||||
}
|
||||
}
|
||||
if first == nil || first.TotalPayAmount == nil || portraitTestMoney(*first.TotalPayAmount) != "710.10" || first.FirstGameName != "历史游戏名" || first.DisplayBusiness() != "lx-business(leaf)" {
|
||||
if first == nil || first.TotalPayAmount == nil || portraitTestMoney(*first.TotalPayAmount) != "710.10" || first.FirstGameName != "历史游戏名" || first.DisplayBusiness() != "parent→branch→leaf" {
|
||||
t.Fatalf("累计充值不能受本次时间/渠道限制,且需保留游戏快照: %+v", first)
|
||||
}
|
||||
filter.SortField = "totalPayAmount"
|
||||
@@ -59,19 +59,19 @@ func TestRegistrationScopePuidAndUnboundedPlayerPay(t *testing.T) {
|
||||
if err != nil || page.Total != 2 {
|
||||
t.Fatal("精确子渠道账号应扩到同一一级树内的可见渠道")
|
||||
}
|
||||
filter.PromoteAccount = "CHILD-channel"
|
||||
filter.PromoteAccount = "CHILD-chan"
|
||||
page, err = repo.Registrations(ctx, scope, filter, business.LedgerListOptions{Limit: 20})
|
||||
if err != nil || page.Total != 0 {
|
||||
t.Fatal("渠道账号应保持区分大小写的精确匹配")
|
||||
if err != nil || page.Total != 2 {
|
||||
t.Fatal("渠道应支持不区分大小写的模糊匹配")
|
||||
}
|
||||
}
|
||||
func TestSpendSummaryIgnoresSelectedFailureStatusAndKeepsSnapshot(t *testing.T) {
|
||||
func TestSpendSummaryRespectsSelectedStatusAndKeepsSnapshot(t *testing.T) {
|
||||
_, repo, scope, filter := prepareLedgerData(t)
|
||||
status := 0
|
||||
filter.PayStatus = &status
|
||||
page, err := repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
|
||||
if err != nil || page.Total != 1 || page.Rows[0].ID != 904 || portraitTestMoney(*page.TotalPayAmount) != "80.80" {
|
||||
t.Fatalf("失败列表与成功支付合计不能使用同一个状态条件: %+v %v", page, err)
|
||||
if err != nil || page.Total != 1 || page.Rows[0].ID != 904 || portraitTestMoney(*page.TotalPayAmount) != "0.00" {
|
||||
t.Fatalf("失败状态筛选下不能汇总筛选外成功订单: %+v %v", page, err)
|
||||
}
|
||||
status = 2
|
||||
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
|
||||
@@ -125,3 +125,25 @@ func TestLedgerLargeTreeFilterDoesNotFallBackToAll(t *testing.T) {
|
||||
t.Fatal("缺渠道范围表不能把限制解释为全量")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerChannelNicknameAndLiteralFuzzyFilter(t *testing.T) {
|
||||
db, repo, scope, filter := prepareLedgerData(t)
|
||||
if err := db.Exec("UPDATE tab_promote SET nickname='推广公会' WHERE source_db='lx' AND id=100").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filter.PromoteAccount = "公会"
|
||||
page, err := repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
|
||||
if err != nil || page.Total != 4 {
|
||||
t.Fatalf("昵称片段应命中渠道及可见下级: %+v %v", page, err)
|
||||
}
|
||||
filter.PromoteAccount = "%"
|
||||
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
|
||||
if err != nil || page.Total != 0 {
|
||||
t.Fatal("模糊输入中的百分号应按文字匹配")
|
||||
}
|
||||
filter.PromoteAccount = "other"
|
||||
page, err = repo.Spends(context.Background(), scope, filter, business.LedgerListOptions{Limit: 20})
|
||||
if err != nil || page.Total != 0 {
|
||||
t.Fatal("模糊搜索不得扩大商务范围")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package business
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// 使用 Kra 全局注册的 Element Plus 组件名;不可直接沿用 RuoYi 的 SVG 文件名。
|
||||
var businessMenuIcons = map[string]string{
|
||||
"sw_BusMgmt": "Briefcase",
|
||||
"sw_BusinessList": "UserFilled",
|
||||
"sw_ChannelList": "Connection",
|
||||
"sw_ChannelActivationList": "CircleCheck",
|
||||
"sw_DataAnalysis": "DataAnalysis",
|
||||
"sw_ChannelDataStatistics": "Histogram",
|
||||
"sw_RechargeDetails": "Money",
|
||||
"sw_RegistrationDetails": "User",
|
||||
"sw_GameAnalytics": "TrendCharts",
|
||||
"sw_ServerAnalysis": "Monitor",
|
||||
"sw_RankingAnalysis": "Star",
|
||||
"sw_AlertCenter": "Bell",
|
||||
"sw_AlertList": "Warning",
|
||||
"sw_AlertRules": "Setting",
|
||||
}
|
||||
|
||||
func MenuIcon(name string) string { return businessMenuIcons[name] }
|
||||
|
||||
func migrateBusinessMenuIcons(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("sys_base_menus") {
|
||||
return nil
|
||||
}
|
||||
// 仅替换旧迁移和早期模块默认值,保留管理员后来选取的其他图标。
|
||||
legacy := []string{"", "category", "email", "chart", "company", "international", "color", "dict", "cascader", "education", "excel", "star", "process-definition", "documentation", "example", "data-analysis", "user", "money", "warn"}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for name, icon := range businessMenuIcons {
|
||||
if err := tx.Table("sys_base_menus").Where("name=? AND (icon IS NULL OR icon IN ?)", name, legacy).Update("icon", icon).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package business
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBusinessMenuIconsRepairLegacyAndPreserveCustom(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := db.Exec("CREATE TABLE sys_base_menus(id INTEGER PRIMARY KEY,name TEXT,icon TEXT)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, sql := range []string{
|
||||
"INSERT INTO sys_base_menus VALUES(1,'sw_BusMgmt','category'),(2,'sw_BusinessList','email'),(3,'sw_ChannelList','Connection'),(4,'sw_ChannelActivationList','CirclePlus'),(5,'sw_DataAnalysis',NULL),(6,'dashboard','category')",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := migrateBusinessMenuIcons(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var rows []struct {
|
||||
ID int
|
||||
Icon string
|
||||
}
|
||||
if err := db.Table("sys_base_menus").Order("id").Scan(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, want := range []string{"Briefcase", "UserFilled", "Connection", "CirclePlus", "DataAnalysis", "category"} {
|
||||
if rows[i].Icon != want {
|
||||
t.Fatalf("菜单%d图标错误或误改自定义值: %+v", i, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user