From 075d94a51e02acac24156959d6cec6776673ea0e Mon Sep 17 00:00:00 2001
From: Yvan <8574526@qq,com>
Date: Tue, 3 Nov 2026 21:32:51 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E6=B8=A0=E9=81=93?=
=?UTF-8?q?=E5=88=97=E8=A1=A8=E6=96=B0=E5=A2=9E=E5=92=8C=E6=B8=B8=E6=88=8F?=
=?UTF-8?q?=E7=B4=AF=E8=AE=A1=E5=88=86=E5=B8=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
cmd/channel_http_test.go | 202 ++++++++++
cmd/wire_gen.go | 13 +-
docs/sw6-to-kra/channel-http-2026-11-03.json | 87 +++++
docs/sw6-to-kra/channel-page-2026-11-03.json | 14 +
docs/sw6-to-kra/channel-source-notes.md | 8 +-
docs/sw6-to-kra/implementation.md | 7 +-
docs/sw6-to-kra/page-parity.md | 21 +-
internal/biz/biz.go | 2 +-
internal/biz/business/channel.go | 349 ++++++++++++++++++
internal/biz/business/channel_test.go | 142 +++++++
internal/data/business/channel.go | 199 ++++++++++
internal/data/business/channel_metrics.go | 179 +++++++++
internal/data/business/channel_test.go | 165 +++++++++
internal/data/business/channel_write.go | 130 +++++++
internal/data/business/channel_write_test.go | 87 +++++
internal/data/data.go | 2 +
internal/integration/gameplatform/password.go | 14 +
.../integration/gameplatform/password_test.go | 11 +
internal/integration/provider.go | 1 +
internal/modules/business/definition.go | 1 +
internal/modules/catalog_test.go | 4 +-
internal/routecatalog/catalog.go | 4 +
internal/server/gin_test.go | 3 +-
internal/server/handler/channel.go | 61 +++
internal/server/handler/provider.go | 1 +
internal/server/handler/set.go | 1 +
internal/server/router/channel.go | 14 +
internal/server/router/routes.go | 1 +
internal/service/business/channel.go | 100 +++++
internal/service/dto/channel.go | 68 ++++
internal/service/service.go | 1 +
scripts/sw_channel_http_smoke.py | 52 +++
scripts/sw_channel_page_smoke.cjs | 94 +++++
web/src/api/channel.js | 6 +
web/src/pathInfo.json | 1 +
web/src/view/sw/ChannelList.vue | 79 ++++
web/src/view/sw/channel-display.js | 8 +
.../view/sw/components/AddChannelDrawer.vue | 58 +++
.../view/sw/components/ChannelGameDrawer.vue | 38 ++
39 files changed, 2216 insertions(+), 12 deletions(-)
create mode 100644 cmd/channel_http_test.go
create mode 100644 docs/sw6-to-kra/channel-http-2026-11-03.json
create mode 100644 docs/sw6-to-kra/channel-page-2026-11-03.json
create mode 100644 internal/biz/business/channel.go
create mode 100644 internal/biz/business/channel_test.go
create mode 100644 internal/data/business/channel.go
create mode 100644 internal/data/business/channel_metrics.go
create mode 100644 internal/data/business/channel_test.go
create mode 100644 internal/data/business/channel_write.go
create mode 100644 internal/data/business/channel_write_test.go
create mode 100644 internal/server/handler/channel.go
create mode 100644 internal/server/router/channel.go
create mode 100644 internal/service/business/channel.go
create mode 100644 internal/service/dto/channel.go
create mode 100644 scripts/sw_channel_http_smoke.py
create mode 100644 scripts/sw_channel_page_smoke.cjs
create mode 100644 web/src/api/channel.js
create mode 100644 web/src/view/sw/ChannelList.vue
create mode 100644 web/src/view/sw/channel-display.js
create mode 100644 web/src/view/sw/components/AddChannelDrawer.vue
create mode 100644 web/src/view/sw/components/ChannelGameDrawer.vue
diff --git a/cmd/channel_http_test.go b/cmd/channel_http_test.go
new file mode 100644
index 0000000..128c672
--- /dev/null
+++ b/cmd/channel_http_test.go
@@ -0,0 +1,202 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "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/integration/gameplatform"
+ "kra/internal/server/handler"
+ "kra/internal/server/router"
+ businessservice "kra/internal/service/business"
+)
+
+type channelHTTPDatabases struct{ platform *gorm.DB }
+
+func (d channelHTTPDatabases) Database(name string) (*gorm.DB, error) {
+ if name == "lx" {
+ return d.platform, nil
+ }
+ return nil, fmt.Errorf("未配置")
+}
+func TestChannelHTTPListsMetricsOptionsAndAtomicCreation(t *testing.T) {
+ open := func(name string) *gorm.DB {
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), name)), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ pool, _ := db.DB()
+ t.Cleanup(func() { _ = pool.Close() })
+ return db
+ }
+ db, platform := open("main.db"), open("platform.db")
+ 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','admin',0,0,'0',1),(7,'owner','owner',1,0,'0',1),(8,'child','child',2,7,'0,7',1),(9,'leaf','leaf',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,platform_busier_account,status) VALUES(9,'lx',14,'sdk-account','0'),(99,'hw',14,'other','0')",
+ "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,nickname TEXT,parent_name TEXT,real_name TEXT,promote_level INTEGER,register_type INTEGER,status INTEGER,lock_status INTEGER,parent_id INTEGER,top_promote_id INTEGER,busier_id INTEGER,create_time INTEGER)",
+ "INSERT INTO tab_promote VALUES(100,'lx','main-channel','主渠道','','负责人',1,0,1,0,0,0,14,1000),(101,'lx','sub-channel','下级渠道','main-channel','',2,1,1,-1,100,100,14,1000),(100,'hw','other-platform','','','',1,0,1,0,0,0,14,1000)",
+ "CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
+ "INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('lx',100,1,1),('lx',101,2,1)",
+ "CREATE TABLE tab_user(source_db TEXT,id INTEGER,promote_id INTEGER,puid INTEGER,idcard TEXT)",
+ "INSERT INTO tab_user VALUES('lx',1,100,0,'A-X'),('lx',2,101,0,'A-x'),('lx',3,101,0,''),('lx',4,101,2,'sub'),('hw',1,100,0,'other')",
+ "CREATE TABLE tab_spend(source_db TEXT,promote_id INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))",
+ "INSERT INTO tab_spend VALUES('lx',100,1,10.10),('lx',101,1,20.20),('lx',100,0,999),('hw',100,1,999)",
+ } {
+ if err := db.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, sql := range []string{
+ "CREATE TABLE tab_promote(id INTEGER PRIMARY KEY AUTOINCREMENT,account TEXT UNIQUE,password TEXT,status INTEGER,busier_id INTEGER,create_time INTEGER,register_type INTEGER,parent_id INTEGER,promote_level INTEGER,top_promote_id INTEGER,game_ids2 TEXT)",
+ "CREATE TABLE tab_promote_settlement_time(id INTEGER PRIMARY KEY AUTOINCREMENT,promote_id INTEGER,promote_account TEXT,time_type INTEGER,day_period INTEGER,create_time INTEGER,update_time INTEGER,next_count_time INTEGER)",
+ "CREATE TABLE tab_promote_business(id INTEGER PRIMARY KEY,promote_ids TEXT)",
+ "INSERT INTO tab_promote_business VALUES(14,'5,6')",
+ } {
+ if err := platform.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ provider := businessHTTPData{db}
+ passwords := &gameplatform.Passwords{AuthCode: "test-auth"}
+ uc := businessbiz.NewChannelUsecase(businessdata.NewChannelRepo(provider), businessdata.NewChannelWriteRepo(channelHTTPDatabases{platform}), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)), passwords)
+ 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()
+ })
+ router.RegisterChannel(engine.Group(""), handler.NewChannel(businessservice.NewChannelService(uc)))
+ server := httptest.NewServer(engine)
+ defer server.Close()
+ call := func(user int, method, path, body string, expected int) any {
+ t.Helper()
+ req, _ := http.NewRequest(method, server.URL+path, strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Test-User", strconv.Itoa(user))
+ res, err := server.Client().Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ var payload map[string]any
+ if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload["code"] != float64(expected) {
+ t.Fatalf("%s %s 用户%d: %+v", method, path, user, payload)
+ }
+ return payload["data"]
+ }
+ list := func(user int, query string, total int) map[string]any {
+ t.Helper()
+ data := call(user, "GET", "/sw/promote/list"+query, "", 0).(map[string]any)
+ if data["total"] != float64(total) {
+ t.Fatalf("范围错误: %+v", data)
+ }
+ return data
+ }
+ rootRow := func(data map[string]any) map[string]any {
+ for _, raw := range data["rows"].([]any) {
+ row := raw.(map[string]any)
+ if row["id"] == float64(100) && row["sourceDb"] == "lx" {
+ return row
+ }
+ }
+ t.Fatal("主渠道不见了")
+ return nil
+ }
+ row := rootRow(list(8, "", 2))
+ if row["gameCount"] != float64(1) || row["playerCount"] != float64(1) || row["totalPayAmount"] != "30.30" || row["busierDisplayName"] != "sdk-account(leaf)" {
+ t.Fatalf("默认偏好或展示字段错误: %+v", row)
+ }
+ if err := db.Exec("INSERT INTO sys_user_preference(user_id,include_sub_promote,idcard_dedup) VALUES(8,1,0)").Error; err != nil {
+ t.Fatal(err)
+ }
+ row = rootRow(list(8, "", 2))
+ if row["playerCount"] != float64(3) || row["totalPayAmount"] != "30.30" {
+ t.Fatal("关闭身份证去重不应改变金额")
+ }
+ if err := db.Exec("UPDATE sys_user_preference SET include_sub_promote=0 WHERE user_id=8").Error; err != nil {
+ t.Fatal(err)
+ }
+ row = rootRow(list(8, "", 2))
+ if row["playerCount"] != float64(1) || row["totalPayAmount"] != "10.10" {
+ t.Fatal("关闭下级没有改变统计范围")
+ }
+ list(1, "", 3)
+ list(8, "?sourceDb=hw", 0)
+ list(8, "?busierUserId=99", 0)
+ list(8, "?busierUserId=8", 0)
+ list(8, "?lockStatus=-1", 1)
+ list(1, "?busierAccount=leaf", 2)
+ options := call(8, "GET", "/sw/promote/bindOptions?sourceDb=lx", "", 0).([]any)
+ if len(options) != 1 || options[0].(map[string]any)["platformBusierId"] != float64(14) {
+ t.Fatal("上级未获取下级正常绑定")
+ }
+ body := `{"sourceDb":"lx","platformBusierId":"14","account":"中文渠道","password":"demo123","registerType":1}`
+ created := call(8, "POST", "/sw/promote", body, 0).(map[string]any)
+ if created["sourceDb"] != "lx" {
+ t.Fatal("返回平台丢失")
+ }
+ var persisted struct {
+ Status, PromoteLevel int
+ Password string
+ }
+ platform.Table("tab_promote").Take(&persisted)
+ if persisted.Status != -3 || persisted.PromoteLevel != 1 || persisted.Password != "###eef6d9b7160f32a869f3e03b7ea575a5" {
+ t.Fatalf("源平台创建不兼容: %+v", persisted)
+ }
+ call(8, "POST", "/sw/promote", body, 7)
+ call(99, "POST", "/sw/promote", body, 7)
+ list(8, "", 2)
+ call(8, "GET", "/sw/promote/game-distribution?sourceDb=hw&promoteId=100", "", 7)
+ call(8, "GET", "/sw/promote/game-distribution?sourceDb=lx&promoteId=100", "", 7)
+ for _, sql := range []string{
+ "CREATE TABLE tab_game(source_db TEXT,id INTEGER,game_name TEXT)",
+ "INSERT INTO tab_game VALUES('lx',1,'汇总游戏')",
+ "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,'2000-01-01',70.00),('lx',100,1,1,'2000-01-01',700.00)",
+ "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)",
+ "INSERT INTO tab_sw_report_day_key VALUES('lx',100,1,0,'2000-01-01',1,'one'),('lx',100,1,0,'2000-01-01',1,'two')",
+ } {
+ if err := db.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ games := call(8, "GET", "/sw/promote/game-distribution?sourceDb=lx&promoteId=100", "", 0).([]any)
+ if len(games) != 1 {
+ t.Fatal("游戏分布没有按当前渠道的推广游戏过滤")
+ }
+ game := games[0].(map[string]any)
+ if game["gameName"] != "汇总游戏" || game["registerCount"] != float64(2) || game["payAmount"] != "70.00" {
+ t.Fatalf("游戏分布误用了原始注册/充值或错误金额口径: %+v", game)
+ }
+ if err := db.Exec("DROP TABLE tab_promote").Error; err != nil {
+ t.Fatal(err)
+ }
+ data := list(8, "", 0)
+ if data["available"] != false {
+ t.Fatal("缺业务表必须明确未接入")
+ }
+}
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index 86dff01..4037252 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -82,9 +82,17 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
registry := taskRegistry()
- activationRepo := business.NewActivationRepo(dataData, dataData)
+ channelRepo := business.NewChannelRepo(dataData)
+ channelWriteRepo := business.NewChannelWriteRepo(dataData)
accountRepo := business.NewAccountRepo(dataData)
accountUsecase := business2.NewAccountUsecase(accountRepo)
+ userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
+ userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
+ platformPasswordEncoder := gameplatform.NewPasswordEncoder()
+ channelUsecase := business2.NewChannelUsecase(channelRepo, channelWriteRepo, accountUsecase, userPreferenceUsecase, platformPasswordEncoder)
+ channelService := business3.NewChannelService(channelUsecase)
+ channel := handler.NewChannel(channelService)
+ activationRepo := business.NewActivationRepo(dataData, dataData)
activationUsecase := business2.NewActivationUsecase(activationRepo, accountUsecase)
activationService := business3.NewActivationService(activationUsecase)
activation := handler.NewActivation(activationService)
@@ -100,8 +108,6 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
businessPromoteUsecase := business2.NewBusinessPromoteUsecase(businessPromoteRepo, accountUsecase)
businessPromoteService := business3.NewBusinessPromoteService(businessPromoteUsecase)
businessPromote := handler.NewBusinessPromote(businessPromoteService)
- userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
- userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
userPreferenceService := business3.NewUserPreferenceService(userPreferenceUsecase)
accountService := business3.NewAccountService(accountUsecase)
bindingRepo := business.NewBindingRepo(dataData)
@@ -213,6 +219,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
set := &handler.Set{
+ Channel: channel,
Activation: activation,
AlertRecord: alertRecord,
AlertRule: alertRule,
diff --git a/docs/sw6-to-kra/channel-http-2026-11-03.json b/docs/sw6-to-kra/channel-http-2026-11-03.json
new file mode 100644
index 0000000..bbb37b1
--- /dev/null
+++ b/docs/sw6-to-kra/channel-http-2026-11-03.json
@@ -0,0 +1,87 @@
+{
+ "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
+}
diff --git a/docs/sw6-to-kra/channel-page-2026-11-03.json b/docs/sw6-to-kra/channel-page-2026-11-03.json
new file mode 100644
index 0000000..2d26db8
--- /dev/null
+++ b/docs/sw6-to-kra/channel-page-2026-11-03.json
@@ -0,0 +1,14 @@
+{
+ "method": "真实 Chrome 渲染;受控页面数据、身份及平台创建响应,不创建真实平台渠道",
+ "passed": true,
+ "level": 1,
+ "noEditEntry": true,
+ "createAllowedForUpperBusiness": true,
+ "platformSwitchClearsBinding": true,
+ "crossPlatformSameID": true,
+ "zeroGameCountClickable": true,
+ "missingMetricsVisible": true,
+ "sourceDatabaseCreatePayload": true,
+ "portraitPending": true,
+ "pageErrors": []
+}
diff --git a/docs/sw6-to-kra/channel-source-notes.md b/docs/sw6-to-kra/channel-source-notes.md
index 22a5ca9..793f5cb 100644
--- a/docs/sw6-to-kra/channel-source-notes.md
+++ b/docs/sw6-to-kra/channel-source-notes.md
@@ -1,6 +1,6 @@
# 渠道列表与新增渠道:源码核对记录
-以下是下一批实现的契约,不代表已经完成。源码基线与 `implementation.md` 一致。
+以下是实现核对契约,源码基线与 `implementation.md` 一致。列表、新增、绑定选项及游戏分布已接入并验证;完整画像及报表构建任务仍未完成。
## 页面
@@ -48,6 +48,8 @@
5. 游戏商务必须存在,原子追加新渠道 ID 到 `tab_promote_business.promote_ids`,避免并发覆盖;任一步失败整体回滚。
6. 返回新 ID 与 `sourceDb`,原成功文案提示“添加成功,数据同步到列表可能有延迟”。不直接制造主库同步副本,不在真实平台库创建测试渠道。
-## 尚需读取的依赖
+## 游戏分布的已核对依赖
-游戏分布还依赖 `PromotePortraitServiceImpl.buildGameDistribution` 及其累计报表范围。实现前继续核对该方法及报表查询,不可按列表的直接注册/支付聚合臆造游戏分布。完整画像、报表汇总任务和预警生成任务仍属于后续范围。
+游戏分布依赖 `PromotePortraitServiceImpl.buildGameDistribution`、`SwReportDayQueryServiceImpl` 和日汇总/身份键 Mapper。当前实现只列根渠道已通过的游戏,用同渠道统计树读取日汇总:人数对 metric=1 的 identity_key 去重,dedup_mode 随偏好;金额固定 dedup_mode=0。累计起点从当前人数模式的最早日汇总取得,缺失则使用上海当天,即使模式 0 有更早数据也不改写该起点。截止上海当天,排除未来日期。先将金额保留两位再按金额降序、游戏 ID 升序排列。
+
+完整画像、报表汇总任务和预警生成任务仍属于后续范围。列表没有用无效画像按钮或占位响应冒充画像已迁移。
diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md
index 874bdad..348bc9b 100644
--- a/docs/sw6-to-kra/implementation.md
+++ b/docs/sw6-to-kra/implementation.md
@@ -24,7 +24,7 @@
| 1 | 商务用户字段、层级与角色、账号资料和审核、偏好、登录及找回密码 | 进行中 |
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、OA 激活只读接口已接入;画像与外部审核联调待完成 |
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 |
-| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 进行中;渠道激活已接入,渠道列表/新增及推广游戏待迁移 |
+| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 进行中;渠道列表/新增、激活、游戏分布已接入,渠道画像待迁移 |
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 待实现 |
| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 |
| 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 |
@@ -73,4 +73,9 @@
- 激活流水从主库 `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` 核对一级商务新增入口、平台切换清空绑定、跨平台同号、零游戏数入口、缺失指标提示及新增提交参数。画像入口继续随画像用例迁移,当前不将整个渠道菜单标为完成。
- 当前没有执行最终旧数据库复制与迁移验收。
diff --git a/docs/sw6-to-kra/page-parity.md b/docs/sw6-to-kra/page-parity.md
index e0951cd..9912626 100644
--- a/docs/sw6-to-kra/page-parity.md
+++ b/docs/sw6-to-kra/page-parity.md
@@ -35,7 +35,7 @@
## 其他页面
-渠道列表、渠道统计、充值明细、注册明细、游戏数据、区服分析和排行榜,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。
+渠道统计、充值明细、注册明细、游戏数据、区服分析和排行榜,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。
## 预警规则
@@ -93,3 +93,22 @@
| 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 + 隔离主库/平台库。整菜单仍因画像待迁移而保持未完成。
diff --git a/internal/biz/biz.go b/internal/biz/biz.go
index 2e92b83..340fe17 100644
--- a/internal/biz/biz.go
+++ b/internal/biz/biz.go
@@ -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)
+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)
diff --git a/internal/biz/business/channel.go b/internal/biz/business/channel.go
new file mode 100644
index 0000000..e9dd5ee
--- /dev/null
+++ b/internal/biz/business/channel.go
@@ -0,0 +1,349 @@
+package business
+
+import (
+ "context"
+ "math/big"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+ "unicode"
+ "unicode/utf16"
+
+ "github.com/go-kratos/kratos/v3/errors"
+)
+
+var (
+ ErrChannelNotFound = errors.NotFound("SW_CHANNEL_NOT_FOUND", "渠道不存在")
+ ErrChannelScope = errors.Forbidden("SW_CHANNEL_SCOPE", "无权查看该渠道")
+ ErrChannelInput = errors.BadRequest("SW_CHANNEL_INPUT", "渠道查询参数不正确")
+ ErrChannelAccount = errors.BadRequest("SW_CHANNEL_ACCOUNT", "渠道账号需为中文/英文/数字,长度 4-30 位")
+ ErrChannelPassword = errors.BadRequest("SW_CHANNEL_PASSWORD", "渠道密码长度需为 6-15 位")
+ ErrChannelBinding = errors.BadRequest("SW_CHANNEL_BINDING", "请选择有效的游戏商务绑定")
+ ErrChannelExists = errors.BadRequest("SW_CHANNEL_EXISTS", "渠道账号已存在")
+ ErrChannelUnavailable = errors.BadRequest("SW_CHANNEL_UNAVAILABLE", "渠道数据尚未接入")
+ ErrChannelReportsUnavailable = errors.BadRequest("SW_CHANNEL_REPORTS_UNAVAILABLE", "游戏日汇总数据尚未接入")
+ ErrChannelBusy = errors.Conflict("SW_CHANNEL_BUSY", "该渠道正在创建,请稍后重试")
+ ErrChannelPlatformOwner = errors.BadRequest("SW_CHANNEL_PLATFORM_OWNER", "游戏商务不存在或渠道关系更新失败")
+)
+
+type ChannelKey struct {
+ SourceDB string
+ ID uint64
+}
+type Channel struct {
+ ChannelKey
+ Account, Nickname, ParentName, RealName string
+ Level, RegisterType, Status, LockStatus *int
+ ParentID, TopPromoteID, BusierID uint64
+ BusierUserID *uint
+ BindingAccount, BindingUserName string
+ GameCount, PlayerCount *int64
+ TotalPayAmount *string
+}
+
+func (c *Channel) BusinessDisplayName() string {
+ account, user := strings.TrimSpace(c.BindingAccount), strings.TrimSpace(c.BindingUserName)
+ if account == "" {
+ return user
+ }
+ if user == "" || account == user {
+ return account
+ }
+ return account + "(" + user + ")"
+}
+func (c *Channel) IncludesStat(other *Channel, includeSub bool) bool {
+ if c.SourceDB != other.SourceDB {
+ return false
+ }
+ if c.ID == other.ID {
+ return true
+ }
+ if !includeSub || c.Level == nil || *c.Level >= 3 {
+ return false
+ }
+ if *c.Level == 2 {
+ return other.ParentID == c.ID && other.Level != nil && *other.Level == 3
+ }
+ return other.ParentID == c.ID || other.TopPromoteID == c.ID
+}
+
+type ChannelFilter struct {
+ Sources []string
+ BusierUserID *uint
+ BusierAccount, Keyword string
+ Level, RegisterType, Status, LockStatus *int
+}
+type ChannelPage struct {
+ Rows []*Channel
+ Total int64
+ Available bool
+}
+type ChannelPlayer struct {
+ ChannelKey
+ UserID uint64
+ IDCard string
+}
+type ChannelMetricData struct {
+ Games map[ChannelKey]int64
+ Players map[ChannelKey]int64
+ Pay map[ChannelKey]string
+ PlayerCards []ChannelPlayer
+ GamesAvailable, PlayersAvailable, PayAvailable bool
+}
+type ChannelGame struct {
+ ID uint64
+ Name string
+ RegisterCount int64
+ PayAmount string
+}
+type ChannelGameScope struct {
+ Root *Channel
+ IncludeSub, IDCardDedup bool
+ EndDate string
+}
+type ChannelRepo interface {
+ List(context.Context, UserScope, ChannelFilter, ...ListOption) (*ChannelPage, error)
+ Bindings(context.Context, UserScope, string) ([]*Binding, error)
+ Find(context.Context, UserScope, ChannelKey) (*Channel, error)
+ StatChannels(context.Context, []*Channel) ([]*Channel, error)
+ Metrics(context.Context, []ChannelKey, []ChannelKey, bool) (*ChannelMetricData, error)
+ Games(context.Context, ChannelGameScope) ([]*ChannelGame, error)
+}
+type ChannelCreate struct {
+ SourceDB, Account, Password string
+ BusierID uint64
+ RegisterType int
+}
+type ChannelCreateResult struct {
+ ID uint64
+ SourceDB string
+}
+type ChannelWrite struct {
+ ChannelCreate
+ Status, Level, SettlementType, SettlementDays int
+ ParentID, TopPromoteID uint64
+ GameIDs2 string
+ CreatedUnix, NextCountUnix int64
+}
+type ChannelWriteRepo interface {
+ Create(context.Context, *ChannelWrite) (uint64, error)
+}
+type PlatformPasswordEncoder interface{ Encode(string) (string, error) }
+type ChannelUsecase struct {
+ repo ChannelRepo
+ writer ChannelWriteRepo
+ accounts *AccountUsecase
+ preferences *UserPreferenceUsecase
+ passwords PlatformPasswordEncoder
+ now func() time.Time
+}
+
+func NewChannelUsecase(repo ChannelRepo, writer ChannelWriteRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase, passwords PlatformPasswordEncoder) *ChannelUsecase {
+ return &ChannelUsecase{repo: repo, writer: writer, accounts: accounts, preferences: preferences, passwords: passwords, now: time.Now}
+}
+func (uc *ChannelUsecase) scope(ctx context.Context) (UserScope, error) {
+ actor, a, err := uc.accounts.actor(ctx)
+ if err != nil {
+ return UserScope{}, err
+ }
+ if actor.AuthorityID == 888 {
+ return UserScope{All: true}, nil
+ }
+ return UserScope{SelfID: a.ID, IncludeSelf: true, OnlySelf: a.Level >= 3, DescendantPath: descendantPath(a)}, nil
+}
+func normalizeIDCard(raw string) string {
+ // Java isBlank 排除不换行空格;其余空白证件仍不计入去重人数。
+ if strings.TrimFunc(raw, func(r rune) bool { return unicode.IsSpace(r) && r != '\u00a0' && r != '\u2007' && r != '\u202f' }) == "" {
+ return ""
+ }
+ value := strings.TrimFunc(raw, func(r rune) bool { return r <= 32 })
+ if strings.HasSuffix(value, "X") {
+ value = strings.TrimSuffix(value, "X") + "x"
+ }
+ return value
+}
+func (uc *ChannelUsecase) List(ctx context.Context, filter ChannelFilter, options ...ListOption) (*ChannelPage, error) {
+ scope, err := uc.scope(ctx)
+ if err != nil {
+ return nil, err
+ }
+ page, err := uc.repo.List(ctx, scope, filter, options...)
+ if err != nil || len(page.Rows) == 0 {
+ return page, err
+ }
+ pref, err := uc.preferences.Current(ctx)
+ if err != nil {
+ return nil, err
+ }
+ statChannels := page.Rows
+ if pref.IncludeSubPromote {
+ statChannels, err = uc.repo.StatChannels(ctx, page.Rows)
+ if err != nil {
+ return nil, err
+ }
+ }
+ roots, statKeys := []ChannelKey{}, []ChannelKey{}
+ groups := map[ChannelKey][]ChannelKey{}
+ seen := map[ChannelKey]bool{}
+ for _, root := range page.Rows {
+ roots = append(roots, root.ChannelKey)
+ groupSeen := map[ChannelKey]bool{root.ChannelKey: true}
+ groups[root.ChannelKey] = []ChannelKey{root.ChannelKey}
+ for _, candidate := range statChannels {
+ if root.IncludesStat(candidate, pref.IncludeSubPromote) && !groupSeen[candidate.ChannelKey] {
+ groups[root.ChannelKey] = append(groups[root.ChannelKey], candidate.ChannelKey)
+ groupSeen[candidate.ChannelKey] = true
+ }
+ }
+ for _, key := range groups[root.ChannelKey] {
+ if !seen[key] {
+ statKeys = append(statKeys, key)
+ seen[key] = true
+ }
+ }
+ }
+ metrics, err := uc.repo.Metrics(ctx, roots, statKeys, pref.IDCardDedup)
+ if err != nil {
+ return nil, err
+ }
+ cards := map[ChannelKey][]ChannelPlayer{}
+ for _, player := range metrics.PlayerCards {
+ cards[player.ChannelKey] = append(cards[player.ChannelKey], player)
+ }
+ for _, root := range page.Rows {
+ if metrics.GamesAvailable {
+ count := metrics.Games[root.ChannelKey]
+ root.GameCount = &count
+ }
+ count := int64(0)
+ userCards := map[uint64]string{}
+ pay := new(big.Rat)
+ for _, key := range groups[root.ChannelKey] {
+ count += metrics.Players[key]
+ for _, player := range cards[key] {
+ userCards[player.UserID] = normalizeIDCard(player.IDCard)
+ }
+ if amount := metrics.Pay[key]; amount != "" {
+ n, ok := new(big.Rat).SetString(amount)
+ if !ok {
+ return nil, ErrStorage
+ }
+ pay.Add(pay, n)
+ }
+ }
+ if metrics.PlayersAvailable {
+ if pref.IDCardDedup {
+ identities := map[string]bool{}
+ for _, card := range userCards {
+ if card != "" {
+ identities[card] = true
+ }
+ }
+ count = int64(len(identities))
+ }
+ root.PlayerCount = &count
+ }
+ if metrics.PayAvailable {
+ amount := pay.FloatString(2)
+ root.TotalPayAmount = &amount
+ }
+ }
+ return page, nil
+}
+func (uc *ChannelUsecase) BindOptions(ctx context.Context, source string) ([]*Binding, error) {
+ source, err := NormalizePlatform(source)
+ if err != nil {
+ return nil, err
+ }
+ scope, err := uc.scope(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return uc.repo.Bindings(ctx, scope, source)
+}
+
+var channelAccountPattern = regexp.MustCompile(`^[\x{4e00}-\x{9fa5}a-zA-Z0-9]{4,30}$`)
+
+func (uc *ChannelUsecase) Create(ctx context.Context, input *ChannelCreate) (*ChannelCreateResult, error) {
+ if input == nil {
+ return nil, ErrChannelInput
+ }
+ account := strings.TrimSpace(input.Account)
+ if !channelAccountPattern.MatchString(account) {
+ return nil, ErrChannelAccount
+ }
+ size := len(utf16.Encode([]rune(input.Password)))
+ if strings.TrimSpace(input.Password) == "" || size < 6 || size > 15 {
+ return nil, ErrChannelPassword
+ }
+ bindings, err := uc.BindOptions(ctx, input.SourceDB)
+ if err != nil {
+ return nil, err
+ }
+ valid := false
+ for _, binding := range bindings {
+ if binding.BusierID == input.BusierID {
+ valid = true
+ break
+ }
+ }
+ if !valid {
+ return nil, ErrChannelBinding
+ }
+ source, _ := NormalizePlatform(input.SourceDB)
+ password, err := uc.passwords.Encode(input.Password)
+ if err != nil {
+ return nil, err
+ }
+ now := uc.now().Unix()
+ write := &ChannelWrite{ChannelCreate: ChannelCreate{SourceDB: source, Account: account, Password: password, BusierID: input.BusierID, RegisterType: input.RegisterType}, Status: -3, Level: 1, SettlementType: 0, SettlementDays: 7, CreatedUnix: now, NextCountUnix: now + 7*24*60*60}
+ id, err := uc.writer.Create(ctx, write)
+ if err != nil {
+ return nil, err
+ }
+ return &ChannelCreateResult{ID: id, SourceDB: source}, nil
+}
+func (uc *ChannelUsecase) Games(ctx context.Context, key ChannelKey) ([]*ChannelGame, error) {
+ source, err := NormalizePlatform(key.SourceDB)
+ if err != nil {
+ return nil, err
+ }
+ key.SourceDB = source
+ scope, err := uc.scope(ctx)
+ if err != nil {
+ return nil, err
+ }
+ root, err := uc.repo.Find(ctx, scope, key)
+ if err != nil {
+ return nil, err
+ }
+ pref, err := uc.preferences.Current(ctx)
+ if err != nil {
+ return nil, err
+ }
+ day := uc.now().In(time.FixedZone("Asia/Shanghai", 8*3600)).Format("2006-01-02")
+ games, err := uc.repo.Games(ctx, ChannelGameScope{Root: root, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup, EndDate: day})
+ if err != nil {
+ return nil, err
+ }
+ amounts := map[uint64]*big.Rat{}
+ for _, game := range games {
+ game.RegisterCount = min(game.RegisterCount, int64(2147483647))
+ n, ok := new(big.Rat).SetString(game.PayAmount)
+ if !ok {
+ return nil, ErrStorage
+ }
+ game.PayAmount = n.FloatString(2)
+ // 原画像先把金额保留两位再排序;同额时按游戏 ID 稳定排序。
+ amounts[game.ID], _ = new(big.Rat).SetString(game.PayAmount)
+ }
+ sort.Slice(games, func(i, j int) bool {
+ order := amounts[games[i].ID].Cmp(amounts[games[j].ID])
+ if order != 0 {
+ return order > 0
+ }
+ return games[i].ID < games[j].ID
+ })
+ return games, nil
+}
diff --git a/internal/biz/business/channel_test.go b/internal/biz/business/channel_test.go
new file mode 100644
index 0000000..393b93d
--- /dev/null
+++ b/internal/biz/business/channel_test.go
@@ -0,0 +1,142 @@
+package business
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+type channelRepoFake struct {
+ ChannelRepo
+ roots, descendants []*Channel
+ metrics *ChannelMetricData
+ scope UserScope
+ bindings []*Binding
+ gameScope ChannelGameScope
+}
+
+func (r *channelRepoFake) List(_ context.Context, scope UserScope, _ ChannelFilter, _ ...ListOption) (*ChannelPage, error) {
+ r.scope = scope
+ rows := []*Channel{}
+ for _, root := range r.roots {
+ copy := *root
+ rows = append(rows, ©)
+ }
+ return &ChannelPage{Rows: rows, Available: true}, nil
+}
+func (r *channelRepoFake) StatChannels(context.Context, []*Channel) ([]*Channel, error) {
+ return r.descendants, nil
+}
+func (r *channelRepoFake) Metrics(context.Context, []ChannelKey, []ChannelKey, bool) (*ChannelMetricData, error) {
+ return r.metrics, nil
+}
+func (r *channelRepoFake) Bindings(_ context.Context, scope UserScope, _ string) ([]*Binding, error) {
+ r.scope = scope
+ return r.bindings, nil
+}
+func (r *channelRepoFake) Find(context.Context, UserScope, ChannelKey) (*Channel, error) {
+ return r.roots[0], nil
+}
+func (r *channelRepoFake) Games(_ context.Context, scope ChannelGameScope) ([]*ChannelGame, error) {
+ r.gameScope = scope
+ return []*ChannelGame{{ID: 2, PayAmount: "10.004"}, {ID: 1, PayAmount: "10.003"}}, nil
+}
+
+type channelWriteFake struct{ input *ChannelWrite }
+
+func (w *channelWriteFake) Create(_ context.Context, input *ChannelWrite) (uint64, error) {
+ w.input = input
+ return 123, nil
+}
+
+type channelEncoderFake struct{ received string }
+
+func (e *channelEncoderFake) Encode(raw string) (string, error) {
+ e.received = raw
+ return "encoded", nil
+}
+
+type channelPreferenceFake struct{ pref UserPreference }
+
+func (p *channelPreferenceFake) Find(context.Context, uint) (*UserPreference, error) {
+ copy := p.pref
+ return ©, nil
+}
+func (p *channelPreferenceFake) Save(context.Context, uint, UserPreference) error { return nil }
+
+func channelFixture() (*ChannelUsecase, *channelRepoFake, *channelPreferenceFake, *channelWriteFake, *channelEncoderFake) {
+ key := func(id uint64) ChannelKey { return ChannelKey{SourceDB: "lx", ID: id} }
+ r := &channelRepoFake{roots: []*Channel{{ChannelKey: key(100), Level: intPointer(1)}}, descendants: []*Channel{{ChannelKey: key(101), Level: intPointer(2), ParentID: 100, TopPromoteID: 100}, {ChannelKey: key(102), Level: intPointer(3), ParentID: 101, TopPromoteID: 100}, {ChannelKey: key(103), Level: intPointer(3), ParentID: 101}, {ChannelKey: ChannelKey{SourceDB: "hw", ID: 100}, Level: intPointer(1)}}, bindings: []*Binding{{UserID: 9, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 14}, Status: "0"}}}
+ r.metrics = &ChannelMetricData{GamesAvailable: true, PlayersAvailable: true, PayAvailable: true, Games: map[ChannelKey]int64{key(100): 2, key(101): 1}, Players: map[ChannelKey]int64{key(100): 1, key(101): 1, key(102): 1, key(103): 1}, Pay: map[ChannelKey]string{key(100): "10.10", key(101): "20.20", key(102): "30.30", key(103): "40.40"}, PlayerCards: []ChannelPlayer{{ChannelKey: key(100), UserID: 1, IDCard: " abcX "}, {ChannelKey: key(101), UserID: 2, IDCard: "abcx"}, {ChannelKey: key(102), UserID: 3, IDCard: ""}, {ChannelKey: key(103), UserID: 4, IDCard: "third"}}}
+ p := &channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}
+ w, e := &channelWriteFake{}, &channelEncoderFake{}
+ a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1}, 7: {ID: 7, Level: 1, IDPath: "0"}, 8: {ID: 8, Level: 2, IDPath: "0,7"}, 9: {ID: 9, Level: 3, IDPath: "0,7,8"}}}
+ uc := NewChannelUsecase(r, w, NewAccountUsecase(a), NewUserPreferenceUsecase(p), e)
+ uc.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC) }
+ return uc, r, p, w, e
+}
+func TestChannelMetricsHonorBothPreferencesWithoutChangingGameCount(t *testing.T) {
+ uc, _, pref, _, _ := channelFixture()
+ for _, tc := range []struct {
+ include, dedup bool
+ count int64
+ pay string
+ }{{true, true, 1, "60.60"}, {true, false, 3, "60.60"}, {false, true, 1, "10.10"}, {false, false, 1, "10.10"}} {
+ pref.pref = UserPreference{IncludeSubPromote: tc.include, IDCardDedup: tc.dedup}
+ page, err := uc.List(accountContext(8, 902), ChannelFilter{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ row := page.Rows[0]
+ if *row.GameCount != 2 || *row.PlayerCount != tc.count || *row.TotalPayAmount != tc.pay {
+ t.Fatalf("偏好统计错误 include=%v dedup=%v row=%+v", tc.include, tc.dedup, row)
+ }
+ }
+}
+func TestChannelLevelTwoScopeAndUnavailableMetrics(t *testing.T) {
+ uc, r, _, _, _ := channelFixture()
+ r.roots = []*Channel{r.descendants[0]}
+ page, err := uc.List(accountContext(9, 903), ChannelFilter{})
+ if err != nil || *page.Rows[0].PlayerCount != 2 || *page.Rows[0].TotalPayAmount != "90.90" || !r.scope.OnlySelf {
+ t.Fatal("二级渠道仅聚合直属三级或商务范围错误")
+ }
+ r.metrics.PlayersAvailable = false
+ r.metrics.PayAvailable = false
+ page, err = uc.List(accountContext(9, 903), ChannelFilter{})
+ if err != nil || page.Rows[0].PlayerCount != nil || page.Rows[0].TotalPayAmount != nil || page.Rows[0].GameCount == nil {
+ t.Fatal("未接入指标不能伪造零,已接入指标应保留")
+ }
+}
+func TestChannelCreateOwnsHashAndSettlementDefaults(t *testing.T) {
+ uc, r, _, writer, encoder := channelFixture()
+ input := &ChannelCreate{SourceDB: " LX ", Account: " 中文渠道 ", Password: " pass123 ", BusierID: 14, RegisterType: 1}
+ result, err := uc.Create(accountContext(8, 902), input)
+ if err != nil || result.ID != 123 || result.SourceDB != "lx" {
+ t.Fatalf("创建失败: %+v %v", result, err)
+ }
+ if writer.input.Account != "中文渠道" || writer.input.Password != "encoded" || encoder.received != " pass123 " || writer.input.NextCountUnix-writer.input.CreatedUnix != 604800 || writer.input.Status != -3 || writer.input.Level != 1 || writer.input.SettlementDays != 7 || !r.scope.IncludeSelf {
+ t.Fatalf("创建参数或结算周期错误: %+v", writer.input)
+ }
+ input.BusierID = 999
+ if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelBinding {
+ t.Fatal("选择了范围外的绑定")
+ }
+ input.BusierID = 14
+ input.Account = "bad_underscore"
+ if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelAccount {
+ t.Fatal("渠道账号字符限制丢失")
+ }
+ input.Account = "valid123"
+ input.Password = "12345"
+ if _, err := uc.Create(accountContext(8, 902), input); err != ErrChannelPassword {
+ t.Fatal("密码长度未校验")
+ }
+}
+func TestChannelGamesUseShanghaiDateAndStableMoneyOrder(t *testing.T) {
+ uc, r, _, _, _ := channelFixture()
+ uc.now = func() time.Time { return time.Date(2026, 11, 3, 17, 0, 0, 0, time.UTC) }
+ games, err := uc.Games(accountContext(9, 903), ChannelKey{SourceDB: "lx", ID: 100})
+ if err != nil || r.gameScope.EndDate != "2026-11-04" || !r.gameScope.IDCardDedup || games[0].ID != 1 {
+ t.Fatal("累计日期、口径或并列排序错误")
+ }
+}
diff --git a/internal/data/business/channel.go b/internal/data/business/channel.go
new file mode 100644
index 0000000..33cb316
--- /dev/null
+++ b/internal/data/business/channel.go
@@ -0,0 +1,199 @@
+package business
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "gorm.io/gorm"
+ "kra/internal/biz/business"
+)
+
+type channelPO struct {
+ ID uint64
+ SourceDB string `gorm:"column:source_db"`
+ Account, Nickname, ParentName, RealName string
+ PromoteLevel, RegisterType, Status, LockStatus *int
+ ParentID, TopPromoteID, BusierID uint64
+ CreateTime *int64
+}
+
+func (channelPO) TableName() string { return "tab_promote" }
+func toBizChannel(p channelPO) *business.Channel {
+ return &business.Channel{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.ID}, Account: p.Account, Nickname: p.Nickname, ParentName: p.ParentName, RealName: p.RealName, Level: p.PromoteLevel, RegisterType: p.RegisterType, Status: p.Status, LockStatus: p.LockStatus, ParentID: p.ParentID, TopPromoteID: p.TopPromoteID, BusierID: p.BusierID}
+}
+
+type channelRepo struct{ data Provider }
+
+func NewChannelRepo(data Provider) business.ChannelRepo { return &channelRepo{data: data} }
+
+func channelBindingQuery(db *gorm.DB, scope business.UserScope, filter business.ChannelFilter) *gorm.DB {
+ q := db.Table("sys_user_bind_platform b").Joins("LEFT JOIN sys_users u ON u.id=b.user_id").Where("b.status = ?", "0")
+ if !scope.All {
+ q = applyUserScope(q.Where("u.deleted_at IS NULL"), scope, "u.")
+ }
+ if filter.BusierUserID != nil {
+ q = q.Where("b.user_id = ?", *filter.BusierUserID)
+ }
+ if len(filter.Sources) > 0 {
+ q = q.Where("b.source_db IN ?", filter.Sources)
+ }
+ if keyword := strings.ToLower(strings.TrimSpace(filter.BusierAccount)); keyword != "" {
+ q = q.Where("(INSTR(LOWER(b.platform_busier_account),?) > 0 OR INSTR(LOWER(u.username),?) > 0)", keyword, keyword)
+ }
+ return q
+}
+func (r *channelRepo) Bindings(ctx context.Context, scope business.UserScope, source string) ([]*business.Binding, error) {
+ var pos []bindingPO
+ if err := channelBindingQuery(r.data.DB().WithContext(ctx), scope, business.ChannelFilter{Sources: []string{source}}).Select("b.*").Order("b.user_id,b.source_db,b.platform_busier_id").Scan(&pos).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ rows := make([]*business.Binding, 0, len(pos))
+ for _, po := range pos {
+ rows = append(rows, toBizBinding(po))
+ }
+ return rows, nil
+}
+func (r *channelRepo) List(ctx context.Context, scope business.UserScope, filter business.ChannelFilter, options ...business.ListOption) (*business.ChannelPage, error) {
+ db := r.data.DB().WithContext(ctx)
+ page := &business.ChannelPage{Rows: []*business.Channel{}, Available: true}
+ bindings := channelBindingQuery(db, scope, filter)
+ constrain := !scope.All || filter.BusierUserID != nil || filter.BusierAccount != ""
+ if constrain {
+ var count int64
+ if err := bindings.Count(&count).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ if count == 0 {
+ return page, nil
+ }
+ }
+ available, err := businessTableAvailable(db, "tab_promote")
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ page.Available = false
+ return page, nil
+ }
+ q := db.Table("tab_promote p")
+ if constrain {
+ q = q.Where("EXISTS (?)", bindings.Select("1").Where("b.source_db=p.source_db AND b.platform_busier_id=p.busier_id"))
+ }
+ if len(filter.Sources) > 0 {
+ q = q.Where("p.source_db IN ?", filter.Sources)
+ }
+ if filter.Keyword != "" {
+ q = q.Where("p.account LIKE ?", "%"+filter.Keyword+"%")
+ }
+ for _, field := range []struct {
+ column string
+ value *int
+ }{{"promote_level", filter.Level}, {"register_type", filter.RegisterType}, {"status", filter.Status}, {"lock_status", filter.LockStatus}} {
+ if field.value != nil {
+ q = q.Where("p."+field.column+" = ?", *field.value)
+ }
+ }
+ if err := q.Count(&page.Total).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ 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 {
+ return nil, business.ErrStorage
+ }
+ if len(pos) == 0 {
+ return page, nil
+ }
+ pairs := make([][]any, 0, len(pos))
+ for _, po := range pos {
+ pairs = append(pairs, []any{po.SourceDB, po.BusierID})
+ }
+ var bound []struct {
+ SourceDB string `gorm:"column:source_db"`
+ PlatformBusierID uint64
+ UserID uint
+ PlatformBusierAccount, UserName string
+ }
+ if err := channelBindingQuery(db, scope, filter).Select("b.source_db,b.platform_busier_id,b.user_id,b.platform_busier_account,u.username AS user_name").Where("(b.source_db,b.platform_busier_id) IN ?", pairs).Order("b.user_id").Scan(&bound).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ byKey := map[business.PlatformKey]int{}
+ for i, b := range bound {
+ key := business.PlatformKey{SourceDB: b.SourceDB, BusierID: b.PlatformBusierID}
+ if _, ok := byKey[key]; !ok {
+ byKey[key] = i
+ }
+ }
+ for _, po := range pos {
+ c := toBizChannel(po)
+ if i, ok := byKey[business.PlatformKey{SourceDB: po.SourceDB, BusierID: po.BusierID}]; ok {
+ b := bound[i]
+ c.BusierUserID = &b.UserID
+ c.BindingAccount = b.PlatformBusierAccount
+ c.BindingUserName = b.UserName
+ }
+ page.Rows = append(page.Rows, c)
+ }
+ return page, nil
+}
+func (r *channelRepo) Find(ctx context.Context, scope business.UserScope, key business.ChannelKey) (*business.Channel, error) {
+ db := r.data.DB().WithContext(ctx)
+ available, err := businessTableAvailable(db, "tab_promote")
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ return nil, business.ErrChannelUnavailable
+ }
+ var po channelPO
+ if err := db.Where("source_db = ? AND id = ?", key.SourceDB, key.ID).Take(&po).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, business.ErrChannelNotFound
+ }
+ return nil, business.ErrStorage
+ }
+ if !scope.All {
+ var count int64
+ if err := channelBindingQuery(db, scope, business.ChannelFilter{Sources: []string{key.SourceDB}}).Where("b.platform_busier_id = ?", po.BusierID).Count(&count).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ if count == 0 {
+ return nil, business.ErrChannelScope
+ }
+ }
+ return toBizChannel(po), nil
+}
+func channelPairs(keys []business.ChannelKey) [][]any {
+ pairs := make([][]any, 0, len(keys))
+ for _, key := range keys {
+ pairs = append(pairs, []any{key.SourceDB, key.ID})
+ }
+ return pairs
+}
+func (r *channelRepo) StatChannels(ctx context.Context, roots []*business.Channel) ([]*business.Channel, error) {
+ keys := make([]business.ChannelKey, 0, len(roots))
+ for _, root := range roots {
+ if root.Level != nil && *root.Level < 3 {
+ keys = append(keys, root.ChannelKey)
+ }
+ }
+ rows := []*business.Channel{}
+ seen := map[business.ChannelKey]bool{}
+ 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
+ if err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, po := range pos {
+ c := toBizChannel(po)
+ if !seen[c.ChannelKey] {
+ rows = append(rows, c)
+ seen[c.ChannelKey] = true
+ }
+ }
+ }
+ return rows, nil
+}
diff --git a/internal/data/business/channel_metrics.go b/internal/data/business/channel_metrics.go
new file mode 100644
index 0000000..74519c2
--- /dev/null
+++ b/internal/data/business/channel_metrics.go
@@ -0,0 +1,179 @@
+package business
+
+import (
+ "context"
+
+ "gorm.io/gorm"
+ "kra/internal/biz/business"
+)
+
+func (r *channelRepo) Metrics(ctx context.Context, roots, statKeys []business.ChannelKey, dedup bool) (*business.ChannelMetricData, error) {
+ db := r.data.DB().WithContext(ctx)
+ metrics := &business.ChannelMetricData{Games: map[business.ChannelKey]int64{}, Players: map[business.ChannelKey]int64{}, Pay: map[business.ChannelKey]string{}}
+ var err error
+ metrics.GamesAvailable, err = businessTableAvailable(db, "tab_promote_apply")
+ if err != nil {
+ return nil, err
+ }
+ metrics.PlayersAvailable, err = businessTableAvailable(db, "tab_user")
+ if err != nil {
+ return nil, err
+ }
+ metrics.PayAvailable, err = businessTableAvailable(db, "tab_spend")
+ if err != nil {
+ return nil, err
+ }
+ type countRow struct {
+ SourceDB string `gorm:"column:source_db"`
+ PromoteID uint64
+ Count int64
+ }
+ if metrics.GamesAvailable {
+ for start := 0; start < len(roots); start += 300 {
+ var rows []countRow
+ if err := db.Table("tab_promote_apply").Select("source_db,promote_id,COUNT(DISTINCT game_id) AS count").Where("status = 1 AND (source_db,promote_id) IN ?", channelPairs(roots[start:min(start+300, len(roots))])).Group("source_db,promote_id").Scan(&rows).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, row := range rows {
+ metrics.Games[business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}] = row.Count
+ }
+ }
+ }
+ // 每批按平台/渠道一起查询,避免逐个渠道访问数据库,也避免参数数量无限增长。
+ for start := 0; start < len(statKeys); start += 300 {
+ pairs := channelPairs(statKeys[start:min(start+300, len(statKeys))])
+ if metrics.PlayersAvailable {
+ q := db.Table("tab_user").Where("(puid = 0 OR puid IS NULL) AND (source_db,promote_id) IN ?", pairs)
+ if dedup {
+ var players []struct {
+ SourceDB string `gorm:"column:source_db"`
+ PromoteID, UserID uint64
+ IDCard string `gorm:"column:idcard"`
+ }
+ if err := q.Select("source_db,promote_id,id AS user_id,idcard").Scan(&players).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, p := range players {
+ metrics.PlayerCards = append(metrics.PlayerCards, business.ChannelPlayer{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}, UserID: p.UserID, IDCard: p.IDCard})
+ }
+ } else {
+ var rows []countRow
+ if err := q.Select("source_db,promote_id,COUNT(*) AS count").Group("source_db,promote_id").Scan(&rows).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, row := range rows {
+ metrics.Players[business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}] = row.Count
+ }
+ }
+ }
+ if metrics.PayAvailable {
+ var rows []struct {
+ SourceDB string `gorm:"column:source_db"`
+ PromoteID uint64
+ Amount string
+ }
+ if err := db.Table("tab_spend").Select("source_db,promote_id,COALESCE(SUM(pay_amount),0) AS amount").Where("pay_status = 1 AND (source_db,promote_id) IN ?", pairs).Group("source_db,promote_id").Scan(&rows).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, row := range rows {
+ metrics.Pay[business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}] = row.Amount
+ }
+ }
+ }
+ return metrics, nil
+}
+
+func channelGameTree(db *gorm.DB, scope business.ChannelGameScope) *gorm.DB {
+ root := scope.Root
+ q := db.Table("tab_promote p").Where("p.source_db = ?", root.SourceDB)
+ if !scope.IncludeSub || root.Level == nil || *root.Level >= 3 {
+ return q.Where("p.id = ?", root.ID)
+ }
+ if *root.Level == 2 {
+ return q.Where("p.id = ? OR (p.parent_id = ? AND p.promote_level = 3)", root.ID, root.ID)
+ }
+ return q.Where("p.id = ? OR p.parent_id = ? OR p.top_promote_id = ?", root.ID, root.ID, root.ID)
+}
+func (r *channelRepo) Games(ctx context.Context, scope business.ChannelGameScope) ([]*business.ChannelGame, error) {
+ db := r.data.DB().WithContext(ctx)
+ available, err := businessTableAvailable(db, "tab_promote_apply")
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ return nil, business.ErrChannelReportsUnavailable
+ }
+ ids := []uint64{}
+ if err := db.Table("tab_promote_apply").Where("source_db = ? AND promote_id = ? AND status = 1 AND game_id IS NOT NULL", scope.Root.SourceDB, scope.Root.ID).Distinct("game_id").Order("game_id").Pluck("game_id", &ids).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ result := []*business.ChannelGame{}
+ if len(ids) == 0 {
+ return result, nil
+ }
+ for _, table := range []string{"tab_game", "tab_sw_report_day", "tab_sw_report_day_key"} {
+ available, err := businessTableAvailable(db, table)
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ return nil, business.ErrChannelReportsUnavailable
+ }
+ }
+ mode := 0
+ if scope.IDCardDedup {
+ mode = 1
+ }
+ query := func(table string, dedup int) *gorm.DB {
+ return db.Table(table+" r").Where("r.source_db = ? AND r.game_id > 0 AND r.dedup_mode = ?", scope.Root.SourceDB, dedup).Where("EXISTS (?)", channelGameTree(db, scope).Select("1").Where("p.id=r.promote_id AND p.source_db=r.source_db"))
+ }
+ // 累计起点按当前人数口径的日汇总最早日期决定,与原画像查询一致。
+ var minimum struct{ FirstDate *string }
+ if err := query("tab_sw_report_day", mode).Select("CAST(MIN(r.stat_date) AS CHAR) AS first_date").Scan(&minimum).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ begin := scope.EndDate
+ if minimum.FirstDate != nil && *minimum.FirstDate != "" {
+ begin = *minimum.FirstDate
+ }
+ byID := map[uint64]*business.ChannelGame{}
+ for _, id := range ids {
+ item := &business.ChannelGame{ID: id, PayAmount: "0.00"}
+ byID[id] = item
+ result = append(result, item)
+ }
+ for start := 0; start < len(ids); start += 300 {
+ batch := ids[start:min(start+300, len(ids))]
+ var names []struct {
+ ID uint64
+ GameName string
+ }
+ if err := db.Table("tab_game").Select("id,game_name").Where("source_db = ? AND id IN ?", scope.Root.SourceDB, batch).Scan(&names).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, n := range names {
+ byID[n.ID].Name = n.GameName
+ }
+ var counts []struct {
+ GameID uint64
+ Count int64
+ }
+ if err := query("tab_sw_report_day_key", mode).Select("r.game_id,COUNT(DISTINCT r.identity_key) AS count").Where("r.metric = 1 AND r.stat_date >= ? AND r.stat_date <= ? AND r.game_id IN ?", begin, scope.EndDate, batch).Group("r.game_id").Scan(&counts).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, c := range counts {
+ byID[c.GameID].RegisterCount = c.Count
+ }
+ var amounts []struct {
+ GameID uint64
+ Amount string
+ }
+ if err := query("tab_sw_report_day", 0).Select("r.game_id,COALESCE(SUM(r.total_pay),0) AS amount").Where("r.stat_date >= ? AND r.stat_date <= ? AND r.game_id IN ?", begin, scope.EndDate, batch).Group("r.game_id").Scan(&amounts).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, a := range amounts {
+ byID[a.GameID].PayAmount = a.Amount
+ }
+ }
+ return result, nil
+}
diff --git a/internal/data/business/channel_test.go b/internal/data/business/channel_test.go
new file mode 100644
index 0000000..3b541d1
--- /dev/null
+++ b/internal/data/business/channel_test.go
@@ -0,0 +1,165 @@
+package business
+
+import (
+ "context"
+ "math/big"
+ "testing"
+
+ "gorm.io/gorm"
+ "kra/internal/biz/business"
+)
+
+func prepareChannels(t *testing.T) (*gorm.DB, business.ChannelRepo) {
+ t.Helper()
+ db := openTestDB(t)
+ if err := db.AutoMigrate(&accountPO{}); err != nil {
+ t.Fatal(err)
+ }
+ for _, sql := range []string{
+ "INSERT INTO sys_users(id,username,level,parent_id,id_path) VALUES(1,'root',0,0,'0'),(7,'parent',1,0,'0'),(8,'branch',2,7,'0,7'),(9,'leaf',3,8,'0,7,8'),(10,'sibling',3,7,'0,7'),(99,'outsider',1,0,'0')",
+ "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(9,'lx',14,'lx-business','0'),(8,'hw',14,'hw-business','0'),(10,'lx',16,'sibling-business','0'),(99,'yq',14,'other-business','0'),(9,'yq',14,'pending','3')",
+ "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,nickname TEXT,parent_name TEXT,real_name TEXT,promote_level INTEGER,register_type INTEGER DEFAULT 0,status INTEGER DEFAULT 1,lock_status INTEGER DEFAULT 0,parent_id INTEGER,top_promote_id INTEGER,busier_id INTEGER,create_time INTEGER DEFAULT 1000,PRIMARY KEY(source_db,id))",
+ "INSERT INTO tab_promote(id,source_db,account,promote_level,parent_id,top_promote_id,busier_id) VALUES(100,'lx','root-channel',1,0,0,14),(101,'lx','child-channel',2,100,100,14),(102,'lx','grandchild',3,101,100,14),(103,'lx','missing-top',3,101,0,14),(104,'lx','other-branch',2,999,999,16),(100,'hw','same-id',1,0,0,14),(100,'yq','outside',1,0,0,14)",
+ "CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
+ "INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('lx',100,1,1),('lx',100,2,1),('lx',100,3,0),('lx',101,3,1),('hw',100,99,1)",
+ "CREATE TABLE tab_user(source_db TEXT,id INTEGER,promote_id INTEGER,puid INTEGER,idcard TEXT,PRIMARY KEY(source_db,id))",
+ "INSERT INTO tab_user VALUES('lx',1,100,0,' abcX '),('lx',2,101,NULL,'abcx'),('lx',3,102,0,''),('lx',4,101,2,'subaccount'),('lx',5,103,0,'thirdX'),('hw',1,100,0,'different')",
+ "CREATE TABLE tab_spend(source_db TEXT,promote_id INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))",
+ "INSERT INTO tab_spend VALUES('lx',100,1,10.10),('lx',101,1,20.20),('lx',102,1,30.30),('lx',103,1,40.40),('hw',100,1,99.90),('lx',100,0,999)",
+ "CREATE TABLE tab_game(source_db TEXT,id INTEGER,game_name TEXT)",
+ "INSERT INTO tab_game VALUES('lx',1,'游戏甲'),('lx',2,'游戏乙'),('hw',1,'同号其他游戏')",
+ "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-10-01',1.10),('lx',100,1,0,'2026-11-01',10.10),('lx',101,1,0,'2026-11-02',20.20),('lx',102,2,0,'2026-11-02',30.30),('lx',103,1,0,'2026-11-02',999),('lx',100,1,0,'2026-11-04',999),('lx',100,1,1,'2026-11-01',9999),('hw',100,1,0,'2026-11-01',999)",
+ "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)",
+ "INSERT INTO tab_sw_report_day_key VALUES('lx',100,1,1,'2026-11-01',1,'A'),('lx',101,1,1,'2026-11-02',1,'A'),('lx',102,2,1,'2026-11-02',1,'B'),('lx',102,2,1,'2026-11-02',2,'C'),('lx',103,1,1,'2026-11-02',1,'N'),('lx',100,1,1,'2026-11-04',1,'F'),('hw',100,1,1,'2026-11-01',1,'Z'),('lx',100,1,0,'2026-10-01',1,'old'),('lx',100,1,0,'2026-11-01',1,'u1'),('lx',101,1,0,'2026-11-02',1,'u2')",
+ } {
+ if err := db.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ return db, NewChannelRepo(testData{db})
+}
+
+func channelTestScope() business.UserScope {
+ return business.UserScope{SelfID: 8, IncludeSelf: true, DescendantPath: "0,7,8"}
+}
+func TestChannelListMatchesBindingsAndLiteralBusinessKeyword(t *testing.T) {
+ db, repo := prepareChannels(t)
+ ctx := context.Background()
+ scope := channelTestScope()
+ page, err := repo.List(ctx, scope, business.ChannelFilter{})
+ if err != nil || page.Total != 5 {
+ t.Fatalf("本人及下级绑定范围错误: %+v %v", page, err)
+ }
+ user := uint(9)
+ page, err = repo.List(ctx, scope, business.ChannelFilter{BusierUserID: &user})
+ if err != nil || page.Total != 4 {
+ t.Fatalf("指定商务应只取本人绑定: %+v %v", page, err)
+ }
+ for _, row := range page.Rows {
+ if row.SourceDB != "lx" || row.BusierUserID == nil || *row.BusierUserID != 9 || row.BusinessDisplayName() != "lx-business(leaf)" {
+ t.Fatalf("归属展示错误: %+v", row)
+ }
+ }
+ for _, keyword := range []string{"LEAF", "lx-business"} {
+ page, err = repo.List(ctx, scope, business.ChannelFilter{BusierAccount: keyword})
+ if err != nil || page.Total != 4 {
+ t.Fatal("商务账号或 SW 用户名匹配失败")
+ }
+ }
+ page, err = repo.List(ctx, scope, business.ChannelFilter{BusierAccount: "%"})
+ if err != nil || page.Total != 0 {
+ t.Fatal("商务关键词被当成 SQL 通配符")
+ }
+ page, err = repo.List(ctx, business.UserScope{All: true}, business.ChannelFilter{})
+ if err != nil || page.Total != 7 {
+ t.Fatal("超管默认不限绑定")
+ }
+ page, err = repo.List(ctx, business.UserScope{All: true}, business.ChannelFilter{BusierAccount: "leaf"})
+ if err != nil || page.Total != 4 {
+ t.Fatal("超管输入商务账号后没有收窄")
+ }
+ if _, err := repo.Find(ctx, scope, business.ChannelKey{SourceDB: "yq", ID: 100}); err != business.ErrChannelScope {
+ t.Fatal("同号其他平台未拒绝")
+ }
+ if err := db.Exec("DROP TABLE tab_promote").Error; err != nil {
+ t.Fatal(err)
+ }
+ page, err = repo.List(ctx, scope, business.ChannelFilter{})
+ if err != nil || page.Available {
+ t.Fatal("缺渠道表未标记不可用")
+ }
+ page, err = repo.List(ctx, scope, business.ChannelFilter{BusierAccount: "not-found"})
+ if err != nil || !page.Available || page.Total != 0 {
+ t.Fatal("没有绑定是确定空集")
+ }
+}
+func TestChannelMetricStorageSeparatesSourcesAndMissingTables(t *testing.T) {
+ db, repo := prepareChannels(t)
+ ctx := context.Background()
+ keys := []business.ChannelKey{{SourceDB: "lx", ID: 100}, {SourceDB: "lx", ID: 101}, {SourceDB: "lx", ID: 102}}
+ metrics, err := repo.Metrics(ctx, keys[:1], keys, true)
+ if err != nil || metrics.Games[keys[0]] != 2 || len(metrics.PlayerCards) != 3 || metrics.Pay[keys[0]] != "10.1" {
+ t.Fatalf("原始指标查询范围错误: %+v %v", metrics, err)
+ }
+ var card string
+ for _, player := range metrics.PlayerCards {
+ if player.UserID == 1 {
+ card = player.IDCard
+ }
+ }
+ if card != " abcX " {
+ t.Fatal("idcard 存储字段未映射,导致身份证去重人数错误")
+ }
+ if err := db.Exec("DROP TABLE tab_user").Error; err != nil {
+ t.Fatal(err)
+ }
+ metrics, err = repo.Metrics(ctx, keys[:1], keys, false)
+ if err != nil || metrics.PlayersAvailable || !metrics.GamesAvailable || !metrics.PayAvailable {
+ t.Fatal("一个指标缺表不应掩盖其余指标")
+ }
+}
+func TestChannelGameDistributionUsesReportsAndCurrentDedupStart(t *testing.T) {
+ db, repo := prepareChannels(t)
+ ctx := context.Background()
+ root, err := repo.Find(ctx, channelTestScope(), business.ChannelKey{SourceDB: "lx", ID: 100})
+ if err != nil {
+ t.Fatal(err)
+ }
+ scope := business.ChannelGameScope{Root: root, IncludeSub: true, IDCardDedup: true, EndDate: "2026-11-03"}
+ money := func(raw string) string {
+ n, ok := new(big.Rat).SetString(raw)
+ if !ok {
+ return "invalid"
+ }
+ return n.FloatString(2)
+ }
+ games, err := repo.Games(ctx, scope)
+ if err != nil || len(games) != 2 || games[0].ID != 1 || games[0].Name != "游戏甲" || games[0].RegisterCount != 1 || money(games[0].PayAmount) != "30.30" || games[1].RegisterCount != 1 || money(games[1].PayAmount) != "30.30" {
+ t.Fatalf("报表日期、去重口径、直接推广范围或渠道树错误: %+v %v", games, err)
+ }
+ scope.IDCardDedup = false
+ games, err = repo.Games(ctx, scope)
+ if err != nil || games[0].RegisterCount != 3 || money(games[0].PayAmount) != "31.40" {
+ t.Fatal("不去重口径没有使用自己的累计起点")
+ }
+ scope.IDCardDedup = true
+ scope.IncludeSub = false
+ games, err = repo.Games(ctx, scope)
+ if err != nil || money(games[0].PayAmount) != "10.10" || money(games[1].PayAmount) != "0.00" {
+ t.Fatal("不包含下级仍汇总了子渠道")
+ }
+ if err := db.Exec("DELETE FROM tab_sw_report_day WHERE dedup_mode=1").Error; err != nil {
+ t.Fatal(err)
+ }
+ games, err = repo.Games(ctx, scope)
+ if err != nil || games[0].RegisterCount != 0 || money(games[0].PayAmount) != "0.00" {
+ t.Fatal("无当前口径日汇总时必须沿用旧接口的当天起点")
+ }
+ if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
+ t.Fatal(err)
+ }
+ if _, err := repo.Games(ctx, scope); err != business.ErrChannelReportsUnavailable {
+ t.Fatal("缺报表表不能回退原始流水冒充同口径")
+ }
+}
diff --git a/internal/data/business/channel_write.go b/internal/data/business/channel_write.go
new file mode 100644
index 0000000..0da5bae
--- /dev/null
+++ b/internal/data/business/channel_write.go
@@ -0,0 +1,130 @@
+package business
+
+import (
+ "context"
+ "errors"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-sql-driver/mysql"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+ "kra/internal/biz/business"
+)
+
+type platformChannelPO struct {
+ ID uint64 `gorm:"primaryKey"`
+ Account, Password string
+ Status int
+ BusierID uint64
+ CreateTime int64
+ RegisterType, PromoteLevel int
+ ParentID, TopPromoteID uint64
+ GameIDs2 string `gorm:"column:game_ids2"`
+}
+
+func (platformChannelPO) TableName() string { return "tab_promote" }
+
+type channelSettlementPO struct {
+ ID uint64 `gorm:"primaryKey"`
+ PromoteID uint64
+ PromoteAccount string
+ TimeType, DayPeriod int
+ CreateTime, UpdateTime, NextCountTime int64
+}
+
+func (channelSettlementPO) TableName() string { return "tab_promote_settlement_time" }
+
+func newPlatformChannel(input *business.ChannelWrite) platformChannelPO {
+ return platformChannelPO{Account: input.Account, Password: input.Password, Status: input.Status, BusierID: input.BusierID, CreateTime: input.CreatedUnix, RegisterType: input.RegisterType, PromoteLevel: input.Level, ParentID: input.ParentID, TopPromoteID: input.TopPromoteID, GameIDs2: input.GameIDs2}
+}
+func newChannelSettlement(input *business.ChannelWrite, promoteID uint64) channelSettlementPO {
+ return channelSettlementPO{PromoteID: promoteID, PromoteAccount: input.Account, TimeType: input.SettlementType, DayPeriod: input.SettlementDays, CreateTime: input.CreatedUnix, UpdateTime: input.CreatedUnix, NextCountTime: input.NextCountUnix}
+}
+
+type channelWriteRepo struct{ data PlatformProvider }
+
+func NewChannelWriteRepo(data PlatformProvider) business.ChannelWriteRepo {
+ return &channelWriteRepo{data: data}
+}
+
+func (r *channelWriteRepo) Create(ctx context.Context, input *business.ChannelWrite) (uint64, error) {
+ db, err := r.data.Database(input.SourceDB)
+ if err != nil || db == nil {
+ return 0, business.ErrPlatformUnavailable
+ }
+ var id uint64
+ err = db.WithContext(ctx).Connection(func(conn *gorm.DB) error {
+ if conn.Dialector.Name() == "mysql" {
+ // 命名锁覆盖账号尚不存在时的竞争;固定借用连接,提交事务后才释放锁。
+ var lockName string
+ if err := conn.Raw("SELECT CONCAT('sw-channel:', MD5(CONCAT(DATABASE(), ':', LOWER(?))))", input.Account).Scan(&lockName).Error; err != nil {
+ return err
+ }
+ var locked int
+ if err := conn.Raw("SELECT GET_LOCK(?, 10)", lockName).Scan(&locked).Error; err != nil {
+ return err
+ }
+ if locked != 1 {
+ return business.ErrChannelBusy
+ }
+ defer func() {
+ cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
+ defer cancel()
+ conn.WithContext(cleanup).Exec("DO RELEASE_LOCK(?)", lockName)
+ }()
+ }
+ return conn.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
+ }
+ if count > 0 {
+ return business.ErrChannelExists
+ }
+ var owner struct {
+ ID uint64
+ PromoteIDs *string
+ }
+ if err := tx.Table("tab_promote_business").Clauses(clause.Locking{Strength: "UPDATE"}).Select("id,promote_ids").Where("id = ?", input.BusierID).Take(&owner).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return business.ErrChannelPlatformOwner
+ }
+ return err
+ }
+ po := newPlatformChannel(input)
+ if err := tx.Create(&po).Error; err != nil {
+ var duplicate *mysql.MySQLError
+ if errors.Is(err, gorm.ErrDuplicatedKey) || (errors.As(err, &duplicate) && duplicate.Number == 1062) {
+ return business.ErrChannelExists
+ }
+ return err
+ }
+ settlement := newChannelSettlement(input, po.ID)
+ if err := tx.Create(&settlement).Error; err != nil {
+ return err
+ }
+ ids := strconv.FormatUint(po.ID, 10)
+ if owner.PromoteIDs != nil && *owner.PromoteIDs != "" {
+ ids = *owner.PromoteIDs + "," + ids
+ }
+ result := tx.Table("tab_promote_business").Where("id = ?", input.BusierID).Update("promote_ids", ids)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return business.ErrChannelPlatformOwner
+ }
+ id = po.ID
+ return nil
+ })
+ })
+ if err != nil {
+ if strings.Contains(strings.ToLower(err.Error()), "unique constraint failed: tab_promote.account") {
+ return 0, business.ErrChannelExists
+ }
+ return 0, storageError(err)
+ }
+ return id, nil
+}
diff --git a/internal/data/business/channel_write_test.go b/internal/data/business/channel_write_test.go
new file mode 100644
index 0000000..65b78d3
--- /dev/null
+++ b/internal/data/business/channel_write_test.go
@@ -0,0 +1,87 @@
+package business
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "testing"
+
+ "github.com/glebarez/sqlite"
+ "gorm.io/gorm"
+ "kra/internal/biz/business"
+)
+
+type channelDatabases struct{ platform *gorm.DB }
+
+func (d channelDatabases) Database(name string) (*gorm.DB, error) {
+ if name == "lx" {
+ return d.platform, nil
+ }
+ return nil, errors.New("数据源未配置")
+}
+func TestChannelWriteIsAtomicInNamedPlatformDatabase(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "platform.db")), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ pool, _ := db.DB()
+ t.Cleanup(func() { _ = pool.Close() })
+ if err := db.AutoMigrate(&platformChannelPO{}, &channelSettlementPO{}); err != nil {
+ t.Fatal(err)
+ }
+ for _, sql := range []string{"CREATE UNIQUE INDEX channel_account ON tab_promote(account)", "CREATE TABLE tab_promote_business(id INTEGER PRIMARY KEY,promote_ids TEXT)", "INSERT INTO tab_promote_business VALUES(14,'5,6')"} {
+ if err := db.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ repo := NewChannelWriteRepo(channelDatabases{db})
+ input := &business.ChannelWrite{ChannelCreate: business.ChannelCreate{SourceDB: "lx", Account: "中文渠道", Password: "encoded-password", BusierID: 14, RegisterType: 1}, Status: -3, Level: 1, SettlementDays: 7, CreatedUnix: 1000, NextCountUnix: 605800}
+ id, err := repo.Create(context.Background(), input)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var channel platformChannelPO
+ if err := db.First(&channel, id).Error; err != nil {
+ t.Fatal(err)
+ }
+ if channel.Status != -3 || channel.PromoteLevel != 1 || channel.ParentID != 0 || channel.TopPromoteID != 0 || channel.Password != "encoded-password" || channel.CreateTime != 1000 || channel.RegisterType != 1 {
+ t.Fatalf("源平台渠道默认字段错误: %+v", channel)
+ }
+ var settlement channelSettlementPO
+ if err := db.Where("promote_id = ?", id).First(&settlement).Error; err != nil {
+ t.Fatal(err)
+ }
+ if settlement.TimeType != 0 || settlement.DayPeriod != 7 || settlement.NextCountTime != 605800 || settlement.PromoteAccount != "中文渠道" {
+ t.Fatalf("结算周期字段错误: %+v", settlement)
+ }
+ var related string
+ db.Table("tab_promote_business").Select("promote_ids").Where("id=14").Scan(&related)
+ if related != fmt.Sprintf("5,6,%d", id) {
+ t.Fatal("追加关系覆盖了已有渠道")
+ }
+ if _, err := repo.Create(context.Background(), input); err != business.ErrChannelExists {
+ t.Fatal("重复账号没有拒绝")
+ }
+ if err := db.Exec("DROP TABLE tab_promote_settlement_time").Error; err != nil {
+ t.Fatal(err)
+ }
+ input.Account = "rollback"
+ if _, err := repo.Create(context.Background(), input); err == nil {
+ t.Fatal("缺结算表应回滚")
+ }
+ var count int64
+ db.Model(&platformChannelPO{}).Count(&count)
+ if count != 1 {
+ t.Fatal("创建失败留下了渠道")
+ }
+ var after string
+ db.Table("tab_promote_business").Select("promote_ids").Where("id=14").Scan(&after)
+ if after != related {
+ t.Fatal("创建失败仍改动关联")
+ }
+ input.SourceDB = "hw"
+ if _, err := repo.Create(context.Background(), input); err != business.ErrPlatformUnavailable {
+ t.Fatal("未配置平台不应回退到其他数据源")
+ }
+}
diff --git a/internal/data/data.go b/internal/data/data.go
index 5e41550..7f1c3b4 100644
--- a/internal/data/data.go
+++ b/internal/data/data.go
@@ -37,6 +37,8 @@ var ProviderSet = wire.NewSet(
databusiness.NewAlertRuleRepo,
databusiness.NewAlertRecordRepo,
databusiness.NewActivationRepo,
+ databusiness.NewChannelRepo,
+ databusiness.NewChannelWriteRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
diff --git a/internal/integration/gameplatform/password.go b/internal/integration/gameplatform/password.go
index ebc03b8..2cd8afc 100644
--- a/internal/integration/gameplatform/password.go
+++ b/internal/integration/gameplatform/password.go
@@ -13,12 +13,26 @@ import (
type Passwords struct{ AuthCode, MigrateAuthCode, TablePrefix string }
func NewPasswords() business.PlatformPasswordVerifier {
+ return loadPasswords()
+}
+func NewPasswordEncoder() business.PlatformPasswordEncoder {
+ return loadPasswords()
+}
+func loadPasswords() *Passwords {
prefix := os.Getenv("SW_SDK_TABLE_PREFIX")
if prefix == "" {
prefix = "sys_"
}
return &Passwords{AuthCode: os.Getenv("SW_SDK_AUTH_CODE"), MigrateAuthCode: os.Getenv("SW_SDK_MIGRATE_AUTH_CODE"), TablePrefix: prefix}
}
+
+// Encode 保留源平台 cmfPassword 的存储格式,新增渠道不能改用 Kra 账号密码格式。
+func (p *Passwords) Encode(password string) (string, error) {
+ if p.AuthCode == "" {
+ return "", business.ErrPlatformPasswordConfig
+ }
+ return "###" + md5Hex(md5Hex(p.AuthCode+password)), nil
+}
func md5Hex(value string) string { sum := md5.Sum([]byte(value)); return hex.EncodeToString(sum[:]) }
func (p *Passwords) Verify(password, stored string) (bool, error) {
diff --git a/internal/integration/gameplatform/password_test.go b/internal/integration/gameplatform/password_test.go
index 73328bc..2aaf4c3 100644
--- a/internal/integration/gameplatform/password_test.go
+++ b/internal/integration/gameplatform/password_test.go
@@ -17,3 +17,14 @@ func TestLegacyPasswordFormats(t *testing.T) {
t.Fatal("缺少兼容盐时必须明确报错")
}
}
+
+func TestNewChannelPasswordMatchesLegacyFixedVector(t *testing.T) {
+ passwords := &Passwords{AuthCode: "test-auth"}
+ hash, err := passwords.Encode("demo123")
+ if err != nil || hash != "###eef6d9b7160f32a869f3e03b7ea575a5" {
+ t.Fatal("新渠道密码与旧平台格式不兼容")
+ }
+ if _, err := (&Passwords{}).Encode("demo123"); err == nil {
+ t.Fatal("缺少盐不能写入不可登录的渠道密码")
+ }
+}
diff --git a/internal/integration/provider.go b/internal/integration/provider.go
index 27bfa38..ba11cb8 100644
--- a/internal/integration/provider.go
+++ b/internal/integration/provider.go
@@ -24,6 +24,7 @@ import (
var ProviderSet = wire.NewSet(
gameplatform.NewPasswords,
+ gameplatform.NewPasswordEncoder,
gameplatform.NewPasswordSMS,
cache.New,
email.NewEmailRepo,
diff --git a/internal/modules/business/definition.go b/internal/modules/business/definition.go
index 3081afb..aa9b1a2 100644
--- a/internal/modules/business/definition.go
+++ b/internal/modules/business/definition.go
@@ -9,6 +9,7 @@ import (
func Definition() module.Definition {
return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{
Menus: []module.Menu{
+ {Name: "sw_ChannelList", Path: "ChannelList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelList.vue", Title: "渠道列表", Icon: "company", Sort: 2},
{Name: "sw_ChannelActivationList", Path: "ChannelActivationList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelActivationList.vue", Title: "渠道激活", Icon: "company", Sort: 3},
{Name: "sw_AlertList", Path: "AlertList", ParentName: "sw_AlertCenter", Component: "view/sw/AlertList.vue", Title: "预警列表", Icon: "warn", Sort: 1},
{Name: "sw_AlertCenter", Path: "sw-AlertCenter", Component: "view/routerHolder.vue", Title: "预警中心", Icon: "warn", Sort: 4},
diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go
index c3d9303..d5a1580 100644
--- a/internal/modules/catalog_test.go
+++ b/internal/modules/catalog_test.go
@@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs)
}
- if menus != 10 || apis != 46 {
- t.Fatalf("admin surface = %d menus/%d APIs, want 10/46", menus, apis)
+ if menus != 11 || apis != 50 {
+ t.Fatalf("admin surface = %d menus/%d APIs, want 11/50", menus, apis)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got))
diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go
index 2be5fce..bbaed47 100644
--- a/internal/routecatalog/catalog.go
+++ b/internal/routecatalog/catalog.go
@@ -41,6 +41,10 @@ type routeValue struct {
// Gin router files still bind handlers; tests keep those registrations aligned
// with this catalog while public, audit and API metadata live only here.
var routes = map[string]routeValue{
+ "GET /sw/promote/list": {group: "渠道管理", description: "查询渠道列表及累计指标"},
+ "GET /sw/promote/bindOptions": {group: "渠道管理", description: "查询可用游戏商务绑定"},
+ "GET /sw/promote/game-distribution": {group: "渠道管理", description: "查询推广游戏累计分布"},
+ "POST /sw/promote": {group: "渠道管理", description: "创建源平台渠道", audit: true},
"GET /sw/promoteActivePool/bindList": {group: "渠道激活", description: "查询 OA 渠道激活绑定"},
"GET /sw/promoteActivePool/bindSpendDetail": {group: "渠道激活", description: "查询绑定期间游戏流水"},
"GET /sw/alertRecord/list": {group: "预警记录", description: "查询预警列表及状态统计"},
diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go
index dec419b..51a386d 100644
--- a/internal/server/gin_test.go
+++ b/internal/server/gin_test.go
@@ -26,6 +26,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
func emptyHandlers() *handler.Set {
return &handler.Set{
+ Channel: &handler.Channel{},
Activation: &handler.Activation{},
AlertRecord: &handler.AlertRecord{},
AlertRule: &handler.AlertRule{},
@@ -102,7 +103,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
- if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":229`) {
+ if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":233`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
diff --git a/internal/server/handler/channel.go b/internal/server/handler/channel.go
new file mode 100644
index 0000000..01d367b
--- /dev/null
+++ b/internal/server/handler/channel.go
@@ -0,0 +1,61 @@
+package handler
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/go-kratos/kratos/v3/errors"
+ "kra/internal/service/business"
+ "kra/internal/service/dto"
+ "net/http"
+)
+
+type Channel struct{ service *business.ChannelService }
+
+func NewChannel(service *business.ChannelService) *Channel { return &Channel{service: service} }
+func (h *Channel) List(c *gin.Context) {
+ var req dto.ChannelQuery
+ if c.ShouldBindQuery(&req) != nil {
+ Fail(c, "渠道查询参数不正确")
+ return
+ }
+ result, err := h.service.List(c.Request.Context(), &req)
+ if err != nil {
+ Fail(c, errors.FromError(err).Message)
+ return
+ }
+ OKWithData(c, result)
+}
+func (h *Channel) Bindings(c *gin.Context) {
+ result, err := h.service.BindOptions(c.Request.Context(), c.Query("sourceDb"))
+ if err != nil {
+ Fail(c, errors.FromError(err).Message)
+ return
+ }
+ OKWithData(c, result)
+}
+func (h *Channel) Create(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
+ var req dto.ChannelCreateRequest
+ if c.ShouldBindJSON(&req) != nil {
+ Fail(c, "渠道参数不正确")
+ return
+ }
+ result, err := h.service.Create(c.Request.Context(), &req)
+ if err != nil {
+ Fail(c, errors.FromError(err).Message)
+ return
+ }
+ Write(c, CodeSuccess, result, "添加成功,数据同步到列表可能有延迟")
+}
+func (h *Channel) Games(c *gin.Context) {
+ var req dto.ChannelGameQuery
+ if c.ShouldBindQuery(&req) != nil {
+ Fail(c, "渠道查询参数不正确")
+ return
+ }
+ result, err := h.service.Games(c.Request.Context(), &req)
+ if err != nil {
+ Fail(c, errors.FromError(err).Message)
+ return
+ }
+ OKWithData(c, result)
+}
diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go
index 0e6a579..621cd5a 100644
--- a/internal/server/handler/provider.go
+++ b/internal/server/handler/provider.go
@@ -11,6 +11,7 @@ var ProviderSet = wire.NewSet(
NewAlertRule,
NewAlertRecord,
NewActivation,
+ NewChannel,
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go
index 0933186..f32d7fc 100644
--- a/internal/server/handler/set.go
+++ b/internal/server/handler/set.go
@@ -1,6 +1,7 @@
package handler
type Set struct {
+ Channel *Channel
Activation *Activation
AlertRecord *AlertRecord
AlertRule *AlertRule
diff --git a/internal/server/router/channel.go b/internal/server/router/channel.go
new file mode 100644
index 0000000..38beba1
--- /dev/null
+++ b/internal/server/router/channel.go
@@ -0,0 +1,14 @@
+package router
+
+import (
+ "github.com/gin-gonic/gin"
+ "kra/internal/server/handler"
+)
+
+func RegisterChannel(private *gin.RouterGroup, h *handler.Channel) {
+ g := private.Group("/sw/promote")
+ g.GET("/list", h.List)
+ g.GET("/bindOptions", h.Bindings)
+ g.GET("/game-distribution", h.Games)
+ g.POST("", h.Create)
+}
diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go
index 052ad86..8839aea 100644
--- a/internal/server/router/routes.go
+++ b/internal/server/router/routes.go
@@ -20,6 +20,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
return
}
registrations := []func(){
+ func() { RegisterChannel(private, r.handlers.Channel) },
func() {
private.GET("/sw/promoteActivePool/bindList", r.handlers.Activation.List)
private.GET("/sw/promoteActivePool/bindSpendDetail", r.handlers.Activation.SpendDetail)
diff --git a/internal/service/business/channel.go b/internal/service/business/channel.go
new file mode 100644
index 0000000..3ef9c96
--- /dev/null
+++ b/internal/service/business/channel.go
@@ -0,0 +1,100 @@
+package business
+
+import (
+ "context"
+ "math"
+ "strconv"
+ "strings"
+ "unicode/utf16"
+
+ "kra/internal/biz/business"
+ "kra/internal/service/dto"
+)
+
+type ChannelUsecase interface {
+ List(context.Context, business.ChannelFilter, ...business.ListOption) (*business.ChannelPage, error)
+ BindOptions(context.Context, string) ([]*business.Binding, error)
+ Create(context.Context, *business.ChannelCreate) (*business.ChannelCreateResult, error)
+ Games(context.Context, business.ChannelKey) ([]*business.ChannelGame, error)
+}
+type ChannelService struct{ uc ChannelUsecase }
+
+func NewChannelService(uc *business.ChannelUsecase) *ChannelService { return &ChannelService{uc: uc} }
+func (s *ChannelService) List(ctx context.Context, req *dto.ChannelQuery) (*dto.ChannelPage, error) {
+ if req == nil {
+ return nil, business.ErrChannelInput
+ }
+ sources, err := normalizePlatforms(req.Sources)
+ if err != nil {
+ return nil, err
+ }
+ page, size := req.Page, req.PageSize
+ if page < 1 {
+ page = 1
+ }
+ if size < 1 {
+ size = 10
+ }
+ if size > 500 {
+ size = 500
+ }
+ if page > int(^uint(0)>>1)/size {
+ return nil, business.ErrChannelInput
+ }
+ filter := business.ChannelFilter{Sources: sources, BusierUserID: req.BusierUserID, BusierAccount: strings.TrimSpace(req.BusierAccount), Keyword: strings.TrimSpace(req.Keyword), Level: req.Level, RegisterType: req.RegisterType, Status: req.Status, LockStatus: req.LockStatus}
+ result, err := s.uc.List(ctx, filter, business.ListOffset((page-1)*size), business.ListLimit(size))
+ if err != nil {
+ return nil, err
+ }
+ out := &dto.ChannelPage{Rows: []*dto.ChannelResponse{}, Total: result.Total, Available: result.Available}
+ for _, c := range result.Rows {
+ out.Rows = append(out.Rows, &dto.ChannelResponse{ID: c.ID, SourceDB: c.SourceDB, PlatformName: business.PlatformName(c.SourceDB), Account: c.Account, Nickname: c.Nickname, Level: c.Level, ParentID: c.ParentID, ParentName: c.ParentName, RealName: c.RealName, RegisterType: c.RegisterType, BusierID: c.BusierID, BusierUserID: c.BusierUserID, BusierDisplayName: c.BusinessDisplayName(), Status: c.Status, LockStatus: c.LockStatus, GameCount: c.GameCount, PlayerCount: c.PlayerCount, TotalPayAmount: c.TotalPayAmount})
+ }
+ return out, nil
+}
+func (s *ChannelService) BindOptions(ctx context.Context, source string) ([]*dto.ChannelBindOption, error) {
+ rows, err := s.uc.BindOptions(ctx, source)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]*dto.ChannelBindOption, 0, len(rows))
+ for _, b := range rows {
+ out = append(out, &dto.ChannelBindOption{SourceDB: b.SourceDB, PlatformBusierID: b.BusierID, Account: b.Account, UserID: b.UserID})
+ }
+ return out, nil
+}
+func (s *ChannelService) Create(ctx context.Context, req *dto.ChannelCreateRequest) (*dto.ChannelCreateResult, error) {
+ if req == nil || len(utf16.Encode([]rune(req.SourceDB))) > 10 || len(utf16.Encode([]rune(req.Account))) > 30 {
+ return nil, business.ErrChannelInput
+ }
+ id, err := strconv.ParseUint(req.PlatformBusierID.String(), 10, 32)
+ if err != nil || id == 0 || id > math.MaxInt32 {
+ return nil, business.ErrChannelBinding
+ }
+ registerType := 0
+ if req.RegisterType != nil {
+ registerType = *req.RegisterType
+ }
+ if registerType > math.MaxInt32 || registerType < math.MinInt32 {
+ return nil, business.ErrChannelInput
+ }
+ result, err := s.uc.Create(ctx, &business.ChannelCreate{SourceDB: req.SourceDB, Account: req.Account, Password: req.Password, BusierID: id, RegisterType: registerType})
+ if err != nil {
+ return nil, err
+ }
+ return &dto.ChannelCreateResult{ID: result.ID, SourceDB: result.SourceDB}, nil
+}
+func (s *ChannelService) Games(ctx context.Context, req *dto.ChannelGameQuery) ([]*dto.ChannelGameResponse, error) {
+ if req == nil || req.PromoteID == 0 {
+ return nil, business.ErrChannelInput
+ }
+ games, err := s.uc.Games(ctx, business.ChannelKey{SourceDB: req.SourceDB, ID: req.PromoteID})
+ if err != nil {
+ return nil, err
+ }
+ out := make([]*dto.ChannelGameResponse, 0, len(games))
+ for _, game := range games {
+ out = append(out, &dto.ChannelGameResponse{GameID: game.ID, GameName: game.Name, RegisterCount: game.RegisterCount, PayAmount: game.PayAmount})
+ }
+ return out, nil
+}
diff --git a/internal/service/dto/channel.go b/internal/service/dto/channel.go
new file mode 100644
index 0000000..39ebf98
--- /dev/null
+++ b/internal/service/dto/channel.go
@@ -0,0 +1,68 @@
+package dto
+
+import "encoding/json"
+
+type ChannelQuery struct {
+ Page int `form:"pageNum"`
+ PageSize int `form:"pageSize"`
+ Sources []string `form:"sourceDb"`
+ BusierUserID *uint `form:"busierUserId"`
+ BusierAccount string `form:"busierAccount"`
+ Keyword string `form:"keyword"`
+ Level *int `form:"promoteLevel"`
+ RegisterType *int `form:"registerType"`
+ Status *int `form:"status"`
+ LockStatus *int `form:"lockStatus"`
+}
+type ChannelResponse struct {
+ ID uint64 `json:"id"`
+ SourceDB string `json:"sourceDb"`
+ PlatformName string `json:"platformName"`
+ Account string `json:"account"`
+ Nickname string `json:"nickname"`
+ Level *int `json:"promoteLevel"`
+ ParentID uint64 `json:"parentId"`
+ ParentName string `json:"parentName"`
+ RealName string `json:"realName"`
+ RegisterType *int `json:"registerType"`
+ BusierID uint64 `json:"busierId"`
+ BusierUserID *uint `json:"busierUserId"`
+ BusierDisplayName string `json:"busierDisplayName"`
+ Status *int `json:"status"`
+ LockStatus *int `json:"lockStatus"`
+ GameCount *int64 `json:"gameCount"`
+ PlayerCount *int64 `json:"playerCount"`
+ TotalPayAmount *string `json:"totalPayAmount"`
+}
+type ChannelPage struct {
+ Rows []*ChannelResponse `json:"rows"`
+ Total int64 `json:"total"`
+ Available bool `json:"available"`
+}
+type ChannelBindOption struct {
+ SourceDB string `json:"sourceDb"`
+ PlatformBusierID uint64 `json:"platformBusierId"`
+ Account string `json:"platformBusierAccount"`
+ UserID uint `json:"userId"`
+}
+type ChannelCreateRequest struct {
+ SourceDB string `json:"sourceDb"`
+ PlatformBusierID json.Number `json:"platformBusierId"`
+ Account string `json:"account"`
+ Password string `json:"password"`
+ RegisterType *int `json:"registerType"`
+}
+type ChannelCreateResult struct {
+ ID uint64 `json:"id"`
+ SourceDB string `json:"sourceDb"`
+}
+type ChannelGameQuery struct {
+ SourceDB string `form:"sourceDb"`
+ PromoteID uint64 `form:"promoteId"`
+}
+type ChannelGameResponse struct {
+ GameID uint64 `json:"gameId"`
+ GameName string `json:"gameName"`
+ RegisterCount int64 `json:"registerCount"`
+ PayAmount string `json:"payAmount"`
+}
diff --git a/internal/service/service.go b/internal/service/service.go
index 2ae8b55..9182b28 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -20,6 +20,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewAlertRuleService,
businessservice.NewAlertRecordService,
businessservice.NewActivationService,
+ businessservice.NewChannelService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
diff --git a/scripts/sw_channel_http_smoke.py b/scripts/sw_channel_http_smoke.py
new file mode 100644
index 0000000..de789d1
--- /dev/null
+++ b/scripts/sw_channel_http_smoke.py
@@ -0,0 +1,52 @@
+"""实测渠道只读接口及必然拒绝的无效创建请求,不写入实际平台渠道。"""
+
+import argparse
+import json
+import urllib.request
+from pathlib import Path
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--base-url", default="http://127.0.0.1:18000")
+ parser.add_argument("--token-file", required=True, type=Path)
+ parser.add_argument("--output", required=True, type=Path)
+ args = parser.parse_args()
+ token = args.token_file.read_text(encoding="utf-8").strip()
+ checks = []
+
+ def call(method, path, expected=0, body=None):
+ request = urllib.request.Request(args.base_url + path, method=method,
+ headers={"x-token": token, "Content-Type": "application/json"},
+ data=json.dumps(body).encode() if body is not None else None)
+ with urllib.request.urlopen(request, timeout=30) as response:
+ payload = json.load(response)
+ item = {"method": method, "path": path, "http": response.status, "code": payload.get("code"),
+ "msg": payload.get("msg"), "passed": response.status == 200 and payload.get("code") == expected}
+ data = payload.get("data", {})
+ if isinstance(data, dict) and "rows" in data:
+ item.update(total=data["total"], available=data.get("available"))
+ if isinstance(data, list):
+ item["rows"] = len(data)
+ checks.append(item)
+ print(json.dumps(item, ensure_ascii=False))
+ return data
+
+ data = call("GET", "/sw/promote/list?pageNum=1&pageSize=10")
+ call("GET", "/sw/promote/list?sourceDb=lx,hw&lockStatus=-1&promoteLevel=1")
+ call("GET", "/sw/promote/list?busierAccount=does-not-exist")
+ for source in ["lx", "hw", "yq"]:
+ call("GET", "/sw/promote/bindOptions?sourceDb=" + source)
+ call("GET", "/sw/promote/game-distribution?sourceDb=lx&promoteId=0", expected=7)
+ if isinstance(data, dict) and data.get("available") is False:
+ call("GET", "/sw/promote/game-distribution?sourceDb=lx&promoteId=1", expected=7)
+ call("POST", "/sw/promote", expected=7, body={})
+ report = {"scope": "当前 sw7 实际登录会话;只读查询和无效创建。完整查询、偏好、游戏分布及源库创建已在隔离数据库通过 TCP HTTP 验证。",
+ "checks": checks, "passed": all(item["passed"] for item in checks)}
+ args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ if not report["passed"]:
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/sw_channel_page_smoke.cjs b/scripts/sw_channel_page_smoke.cjs
new file mode 100644
index 0000000..6b8bdb9
--- /dev/null
+++ b/scripts/sw_channel_page_smoke.cjs
@@ -0,0 +1,94 @@
+// 独立 Chrome 对照渠道列表、添加抽屉和游戏分布;平台创建接口使用测试替身。
+const fs = require('node:fs/promises')
+const path = require('node:path')
+const assert = require('node:assert/strict')
+const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright')
+
+;(async () => {
+ const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim()
+ const browser = await chromium.launch({ channel: 'chrome', headless: true })
+ try {
+ const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } })
+ await context.addInitScript(value => localStorage.setItem('token', value), token)
+ const page = await context.newPage()
+ const errors = [], queries = [], gameQueries = [], writes = []
+ let available = true
+ const rows = [
+ { id: 100, sourceDb: 'lx', platformName: '龙翔', account: 'lx-channel', nickname: '测试渠道', promoteLevel: 1, busierDisplayName: 'SDK-LX(测试商务)', parentName: '', realName: '负责人', registerType: 0, gameCount: 2, playerCount: 3, totalPayAmount: '30.30', lockStatus: 0 },
+ { id: 100, sourceDb: 'hw', platformName: '汇玩', account: 'hw-channel', promoteLevel: 1, registerType: 1, gameCount: 0, playerCount: null, totalPayAmount: null, lockStatus: -1 }
+ ]
+ page.on('pageerror', error => errors.push(error.message))
+ await page.route('http://127.0.0.1:8080/api/**', async route => {
+ const request = route.request(), url = new URL(request.url()), api = url.pathname.slice(4)
+ const ok = data => route.fulfill({ json: { code: 0, msg: '成功', data } })
+ if (api === '/sw/promote/list') { queries.push(url.search); return ok({ rows: available ? rows : [], total: available ? 2 : 0, available }) }
+ if (api === '/sw/promote/bindOptions') {
+ const source = url.searchParams.get('sourceDb')
+ const option = { sourceDb: source, platformBusierId: source === 'lx' ? 14 : 18, platformBusierAccount: source === 'lx' ? 'SDK-LX' : 'SDK-HW', userId: 70009 }
+ return ok([option, option])
+ }
+ if (api === '/sw/promote/game-distribution') {
+ gameQueries.push({ sourceDb: url.searchParams.get('sourceDb'), promoteId: url.searchParams.get('promoteId') })
+ return ok(url.searchParams.get('sourceDb') === 'lx' ? [{ gameId: 1, gameName: '测试游戏', registerCount: 2, payAmount: '70.00' }] : [])
+ }
+ if (api === '/sw/promote' && request.method() === 'POST') { writes.push(request.postDataJSON()); return ok({ id: 200, sourceDb: 'lx' }) }
+ const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } })
+ if (api === '/user/getUserInfo') {
+ const body = await response.json()
+ Object.assign(body.data.userInfo, { ID: 70001, level: 1, authorityId: 901, nickName: '页面核对账号', phone: '', email: '' })
+ return route.fulfill({ response, json: body })
+ }
+ return route.fulfill({ response })
+ })
+ await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/ChannelList')
+ await page.getByText('lx-channel', { exact: true }).waitFor()
+ const lx = page.locator('.el-table__body-wrapper tr').filter({ hasText: 'lx-channel' })
+ const hw = page.locator('.el-table__body-wrapper tr').filter({ hasText: 'hw-channel' })
+ assert.equal(await page.getByRole('button', { name: '编辑', exact: true }).count(), 0)
+ assert.equal(await hw.getByText('未接入', { exact: true }).count(), 2)
+ await lx.getByRole('button', { name: '2 款', exact: true }).click()
+ const games = page.getByRole('dialog', { name: '推广游戏数据 — 测试渠道', exact: true })
+ await games.getByText('测试游戏', { exact: true }).waitFor()
+ await games.locator('.el-loading-mask').waitFor({ state: 'hidden' })
+ await games.getByText('¥70.00', { exact: true }).waitFor()
+ assert.deepEqual(gameQueries.at(-1), { sourceDb: 'lx', promoteId: '100' })
+ await page.screenshot({ path: path.join(process.env.TEMP, 'sw-channel-games.png'), animations: 'disabled' })
+ await games.locator('.el-drawer__close-btn').click(); await games.waitFor({ state: 'hidden' })
+ await hw.getByRole('button', { name: '0 款', exact: true }).click()
+ const emptyGames = page.getByRole('dialog', { name: '推广游戏数据 — hw-channel', exact: true })
+ await emptyGames.waitFor()
+ await emptyGames.locator('.el-loading-mask').waitFor({ state: 'hidden' })
+ assert.deepEqual(gameQueries.at(-1), { sourceDb: 'hw', promoteId: '100' })
+ await emptyGames.locator('.el-drawer__close-btn').click(); await emptyGames.waitFor({ state: 'hidden' })
+ await page.getByRole('button', { name: '添加渠道', exact: true }).click()
+ const drawer = page.getByRole('dialog', { name: '添加渠道', exact: true })
+ const select = async (label, option) => {
+ await drawer.locator('.el-form-item').filter({ hasText: label }).locator('.el-select__wrapper').click()
+ await page.getByRole('option', { name: option, exact: true }).click()
+ }
+ await select('所属平台', '龙翔')
+ await select('游戏商务', 'SDK-LX')
+ await select('所属平台', '汇玩')
+ assert.equal(await drawer.getByText('SDK-LX', { exact: true }).count(), 0)
+ await select('游戏商务', 'SDK-HW')
+ await select('所属平台', '龙翔')
+ await select('游戏商务', 'SDK-LX')
+ await select('推广类型', '企业')
+ await drawer.getByPlaceholder('中文/英文/数字,4-30位', { exact: true }).fill('中文渠道')
+ await drawer.getByPlaceholder('6-15位', { exact: true }).fill('demo123')
+ await drawer.getByRole('button', { name: '确认添加', exact: true }).click()
+ await drawer.waitFor({ state: 'hidden' })
+ assert.equal(writes.length, 1)
+ assert.equal(writes[0].platformBusierId, 14)
+ assert.equal(writes[0].sourceDb, 'lx')
+ assert.equal(writes[0].registerType, 1)
+ available = false
+ await page.locator('.kra-search-box').getByRole('button', { name: '搜索', exact: true }).click()
+ await page.getByText('渠道数据尚未接入。', { exact: true }).waitFor()
+ assert.deepEqual(errors, [])
+ const result = { passed: true, level: 1, noEditEntry: true, createAllowedForUpperBusiness: true, platformSwitchClearsBinding: true, crossPlatformSameID: true, zeroGameCountClickable: true, missingMetricsVisible: true, sourceDatabaseCreatePayload: true, portraitPending: true, pageErrors: errors }
+ await fs.writeFile('docs/sw6-to-kra/channel-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome 渲染;受控页面数据、身份及平台创建响应,不创建真实平台渠道', ...result }, null, 2) + '\n')
+ console.log(JSON.stringify(result))
+ await context.close()
+ } finally { await browser.close() }
+})().catch(error => { console.error(error); process.exitCode = 1 })
diff --git a/web/src/api/channel.js b/web/src/api/channel.js
new file mode 100644
index 0000000..88058ad
--- /dev/null
+++ b/web/src/api/channel.js
@@ -0,0 +1,6 @@
+import service from '@/utils/request'
+
+export const listChannels = params => service({ url: '/sw/promote/list', method: 'get', params })
+export const channelBindOptions = sourceDb => service({ url: '/sw/promote/bindOptions', method: 'get', params: { sourceDb } })
+export const createChannel = data => service({ url: '/sw/promote', method: 'post', data })
+export const channelGames = params => service({ url: '/sw/promote/game-distribution', method: 'get', params })
diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json
index c06781d..a92fbef 100644
--- a/web/src/pathInfo.json
+++ b/web/src/pathInfo.json
@@ -56,6 +56,7 @@
"/src/view/sw/AlertRules.vue": "SwAlertRules",
"/src/view/sw/BusinessList.vue": "SwBusinessList",
"/src/view/sw/ChannelActivationList.vue": "SwChannelActivationList",
+ "/src/view/sw/ChannelList.vue": "SwChannelList",
"/src/view/sw/components/BusinessBindings.vue": "SwBusinessBindings",
"/src/view/sw/components/BusinessChannels.vue": "SwBusinessChannels",
"/src/view/system/security/forceChangePassword.vue": "ForceChangePassword",
diff --git a/web/src/view/sw/ChannelList.vue b/web/src/view/sw/ChannelList.vue
new file mode 100644
index 0000000..3a0b415
--- /dev/null
+++ b/web/src/view/sw/ChannelList.vue
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+ 搜索
+
+
+
+
添加渠道
+
+
+
+
+
+ {{ levels[row.promoteLevel] || row.promoteLevel || '—' }}
+
+ {{ row.parentName || '—' }}
+ {{ row.realName || '—' }}
+ {{ { 0: '个人', 1: '公会' }[row.registerType] ?? row.registerType ?? '—' }}
+ {{ channelCount(row.gameCount) }} 款未接入
+ {{ channelCount(row.playerCount) }}
+ {{ channelMoney(row.totalPayAmount) }}
+ {{ statuses[row.lockStatus] ?? row.lockStatus ?? '—' }}
+
+
+
+
+
+
+
+
+
diff --git a/web/src/view/sw/channel-display.js b/web/src/view/sw/channel-display.js
new file mode 100644
index 0000000..66b9729
--- /dev/null
+++ b/web/src/view/sw/channel-display.js
@@ -0,0 +1,8 @@
+export const channelCount = value => value == null ? '未接入' : String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
+export const channelMoney = value => {
+ if (value == null) return '未接入'
+ const text = String(value)
+ if (!/^-?\d+(\.\d+)?$/.test(text)) return '—'
+ const [whole, fraction = ''] = text.split('.')
+ return `¥${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${fraction.padEnd(2, '0').slice(0, 2)}`
+}
diff --git a/web/src/view/sw/components/AddChannelDrawer.vue b/web/src/view/sw/components/AddChannelDrawer.vue
new file mode 100644
index 0000000..9131ea6
--- /dev/null
+++ b/web/src/view/sw/components/AddChannelDrawer.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+ 取消确认添加
+
+
+
+
diff --git a/web/src/view/sw/components/ChannelGameDrawer.vue b/web/src/view/sw/components/ChannelGameDrawer.vue
new file mode 100644
index 0000000..2954c59
--- /dev/null
+++ b/web/src/view/sw/components/ChannelGameDrawer.vue
@@ -0,0 +1,38 @@
+
+
+ 该渠道在各推广游戏的注册与充值分布(累计口径)。
+
+
+ {{ row.gameName || '—' }}
+ {{ channelCount(row.registerCount) }}
+ {{ channelMoney(row.payAmount) }}
+
+
+
+
+