diff --git a/cmd/channel_portrait_http_test.go b/cmd/channel_portrait_http_test.go
new file mode 100644
index 0000000..3341b22
--- /dev/null
+++ b/cmd/channel_portrait_http_test.go
@@ -0,0 +1,195 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "gorm.io/gorm"
+ businessbiz "kra/internal/biz/business"
+ "kra/internal/biz/system"
+ businessdata "kra/internal/data/business"
+ systemdata "kra/internal/data/system"
+ "kra/internal/server/handler"
+ businessservice "kra/internal/service/business"
+)
+
+func TestChannelPortraitHTTPTotalTiersAndScope(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "channel-portrait.db")), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ pool, _ := db.DB()
+ t.Cleanup(func() { _ = pool.Close() })
+ for _, step := range append(systemdata.Migrations(), businessdata.Migrations()...) {
+ if err := step.Migrate(db); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, sql := range []string{
+ "INSERT INTO sys_users(id,uuid,username,level,parent_id,id_path,enable) VALUES(1,'root','root',0,0,'0',1),(7,'owner','owner',1,0,'0',1),(9,'leaf','leaf',3,7,'0,7',1),(99,'outside','outside',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','0'),(99,'hw',20,'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,last_login_time INTEGER)",
+ "INSERT INTO tab_promote VALUES(100,'lx','root-account','主渠道','','负责人',1,1,-3,0,0,0,14,1000,2000),(101,'lx','child-account','子渠道','旧上级名称','',2,0,1,0,100,100,14,1000,0),(100,'hw','outside-channel','','','',1,0,1,0,0,0,20,1000,0)",
+ "CREATE TABLE tab_promote_apply(source_db TEXT,promote_id INTEGER,game_id INTEGER,status INTEGER)",
+ "INSERT INTO tab_promote_apply VALUES('lx',100,1,1),('lx',100,1,1),('lx',100,2,1)",
+ "CREATE TABLE tab_game(source_db TEXT,id INTEGER,game_name TEXT)",
+ "INSERT INTO tab_game VALUES('lx',1,'游戏甲'),('lx',2,'游戏乙')",
+ "CREATE TABLE tab_sw_report_day(source_db TEXT,promote_id INTEGER,game_id INTEGER,dedup_mode INTEGER,stat_date DATE,total_pay DECIMAL(20,2))",
+ "CREATE TABLE tab_sw_report_day_key(source_db TEXT,promote_id INTEGER,game_id INTEGER,dedup_mode INTEGER,stat_date DATE,metric INTEGER,identity_key TEXT)",
+ "CREATE TABLE tab_user(source_db TEXT,id INTEGER,promote_id INTEGER,puid INTEGER,idcard TEXT,register_time INTEGER)",
+ "CREATE TABLE tab_spend(source_db TEXT,promote_id INTEGER,user_id INTEGER,pay_status INTEGER,pay_time INTEGER,pay_amount DECIMAL(20,2))",
+ } {
+ if err := db.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ now := time.Now().In(time.FixedZone("CST", 8*3600))
+ today := now.Format("2006-01-02")
+ day20 := now.AddDate(0, 0, -20).Format("2006-01-02")
+ day30 := now.AddDate(0, 0, -30).Format("2006-01-02")
+ previous := now.AddDate(0, 0, -7).Format("2006-01-02")
+ start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
+ for _, row := range []struct {
+ promote, mode int
+ date, amount string
+ }{{100, 0, day30, "20.00"}, {100, 1, day20, "777.00"}, {100, 0, day20, "10.00"}, {100, 0, previous, "6.00"}, {100, 0, today, "12.00"}, {101, 0, today, "3.00"}} {
+ if err := db.Exec("INSERT INTO tab_sw_report_day VALUES('lx',?,1,?,?,?)", row.promote, row.mode, row.date, row.amount).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ cards := []string{"A-X", " A-x ", "B", "C", "D"}
+ for i, card := range cards {
+ promote := 100
+ if i == 1 {
+ promote = 101
+ }
+ if err := db.Exec("INSERT INTO tab_user VALUES('lx',?,?,0,?,?)", i+1, promote, card, start+3600).Error; err != nil {
+ t.Fatal(err)
+ }
+ for _, mode := range []int{0, 1} {
+ key := strconv.Itoa(i + 1)
+ if mode == 1 {
+ key = card
+ if i < 2 {
+ key = "A-x"
+ }
+ }
+ for _, metric := range []int{1, 2} {
+ if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES('lx',?,1,?,?,?,?)", promote, mode, today, metric, key).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ }
+ if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES('lx',100,1,1,?,3,'A-x')", today).Error; err != nil {
+ t.Fatal(err)
+ }
+ for _, row := range []struct {
+ promote, user int
+ timestamp int64
+ amount string
+ }{{100, 1, 1000, "9999.50"}, {101, 2, 2000000000, "0.50"}, {100, 3, start + 3600, "1000.00"}, {100, 4, start + 3600, "0.01"}, {100, 5, start + 3600, "0.00"}} {
+ if err := db.Exec("INSERT INTO tab_spend VALUES('lx',?,?,1,?,?)", row.promote, row.user, row.timestamp, row.amount).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ provider := businessHTTPData{db}
+ channels := businessdata.NewChannelRepo(provider)
+ accounts := businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider))
+ prefs := businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider))
+ uc := businessbiz.NewChannelPortraitUsecase(businessdata.NewChannelPortraitRepo(provider), channels, businessdata.NewBusinessPortraitRepo(provider, channelHTTPDatabases{}), accounts, prefs)
+ h := handler.NewChannelPortrait(businessservice.NewChannelPortraitService(uc))
+ engine := gin.New()
+ engine.Use(func(c *gin.Context) {
+ id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64)
+ aid := uint(901)
+ if id == 1 {
+ aid = 888
+ }
+ c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), system.Actor{UserID: uint(id), AuthorityID: aid}))
+ c.Next()
+ })
+ engine.GET("/sw/promote/portrait", h.Portrait)
+ server := httptest.NewServer(engine)
+ defer server.Close()
+ call := func(user int, query string, expected int) map[string]any {
+ t.Helper()
+ req, _ := http.NewRequest("GET", server.URL+"/sw/promote/portrait?"+query, nil)
+ 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 body map[string]any
+ if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
+ t.Fatal(err)
+ }
+ if body["code"] != float64(expected) {
+ t.Fatalf("画像 %s 用户%d响应错误: %+v", query, user, body)
+ }
+ return body["data"].(map[string]any)
+ }
+ data := call(7, "sourceDb=lx&promoteId=100&period=today", 0)
+ basic := data["basic"].(map[string]any)
+ metrics := data["metrics"].(map[string]any)
+ if basic["statusLabel"] != "商务新增" || basic["gameCount"] != float64(2) || basic["parentPromoteId"] != nil || basic["lastLoginTime"] == nil || basic["busierDisplayName"] != "sdk(leaf)" {
+ t.Fatalf("基本信息错误: %+v", basic)
+ }
+ if metrics["register"].(map[string]any)["value"] != "4" || metrics["active"].(map[string]any)["value"] != "4" || metrics["payRate"].(map[string]any)["value"] != "25.0" || metrics["payAmount"].(map[string]any)["value"] != "15.00" {
+ t.Fatalf("本期指标错误: %+v", metrics)
+ }
+ if _, ok := metrics["arpu"]; ok {
+ t.Fatal("渠道第四项是活跃人数,不是商务 ARPU")
+ }
+ tiers := data["payTiers"].([]any)
+ for _, raw := range tiers {
+ tier := raw.(map[string]any)
+ if tier["count"] != float64(1) || tier["ratio"] != "25.0" {
+ t.Fatalf("累计付费分层错误: %+v", tiers)
+ }
+ }
+ if data["gameDistribution"].([]any)[0].(map[string]any)["payAmount"] != "31.00" {
+ t.Fatal("游戏分布应始终累计,不随顶部今日切换")
+ }
+ data = call(7, "sourceDb=lx&promoteId=100&period=total", 0)
+ metrics = data["metrics"].(map[string]any)
+ if metrics["payAmount"].(map[string]any)["value"] != "31.00" || len(data["registerTrend"].([]any)) != 21 {
+ t.Fatal("累计模式没有使用当前去重口径最早日期")
+ }
+ for _, raw := range metrics {
+ metric := raw.(map[string]any)
+ if metric["compareValue"] != nil || metric["changeRate"] != nil || metric["changeDiff"] != nil || metric["compareLabel"] != nil {
+ t.Fatal("累计模式不应有环比字段值")
+ }
+ }
+ if err := db.Exec("INSERT INTO sys_user_preference(user_id,include_sub_promote,idcard_dedup) VALUES(7,1,0)").Error; err != nil {
+ t.Fatal(err)
+ }
+ data = call(7, "sourceDb=lx&promoteId=100&period=total", 0)
+ tiers = data["payTiers"].([]any)
+ if data["metrics"].(map[string]any)["payAmount"].(map[string]any)["value"] != "51.00" || tiers[0].(map[string]any)["count"] != float64(0) || tiers[1].(map[string]any)["count"] != float64(2) {
+ t.Fatal("模式0应按原始付款账号分类,且使用自己的累计起点")
+ }
+ data = call(7, "sourceDb=lx&promoteId=101&period=last7", 0)
+ basic = data["basic"].(map[string]any)
+ if basic["parentPromoteId"] != float64(100) || basic["parentPromoteName"] != "root-account" || basic["lastLoginTime"] != nil {
+ t.Fatal("上级应读同平台当前账号,不能沿用旧快照名")
+ }
+ call(99, "sourceDb=lx&promoteId=100", 7)
+ call(7, "sourceDb=hw&promoteId=100", 7)
+ if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
+ t.Fatal(err)
+ }
+ data = call(7, "sourceDb=lx&promoteId=100", 0)
+ if data["available"] != false || data["metrics"] != nil || data["basic"].(map[string]any)["promoteId"] != float64(100) {
+ t.Fatal("缺数据时应保留已授权基本资料并标明未知")
+ }
+}
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index 28079cb..008413f 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -82,15 +82,19 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
registry := taskRegistry()
+ channelPortraitRepo := business.NewChannelPortraitRepo(dataData)
+ channelRepo := business.NewChannelRepo(dataData)
businessPortraitRepo := business.NewBusinessPortraitRepo(dataData, dataData)
accountRepo := business.NewAccountRepo(dataData)
accountUsecase := business2.NewAccountUsecase(accountRepo)
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
+ channelPortraitUsecase := business2.NewChannelPortraitUsecase(channelPortraitRepo, channelRepo, businessPortraitRepo, accountUsecase, userPreferenceUsecase)
+ channelPortraitService := business3.NewChannelPortraitService(channelPortraitUsecase)
+ channelPortrait := handler.NewChannelPortrait(channelPortraitService)
businessPortraitUsecase := business2.NewBusinessPortraitUsecase(businessPortraitRepo, accountUsecase, userPreferenceUsecase)
businessPortraitService := business3.NewBusinessPortraitService(businessPortraitUsecase)
businessPortrait := handler.NewBusinessPortrait(businessPortraitService)
- channelRepo := business.NewChannelRepo(dataData)
channelWriteRepo := business.NewChannelWriteRepo(dataData)
platformPasswordEncoder := gameplatform.NewPasswordEncoder()
channelUsecase := business2.NewChannelUsecase(channelRepo, channelWriteRepo, accountUsecase, userPreferenceUsecase, platformPasswordEncoder)
@@ -223,6 +227,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
set := &handler.Set{
+ ChannelPortrait: channelPortrait,
BusinessPortrait: businessPortrait,
Channel: channel,
Activation: activation,
diff --git a/docs/sw6-to-kra/channel-page-2026-11-03.json b/docs/sw6-to-kra/channel-page-2026-11-03.json
index 2d26db8..a19668f 100644
--- a/docs/sw6-to-kra/channel-page-2026-11-03.json
+++ b/docs/sw6-to-kra/channel-page-2026-11-03.json
@@ -9,6 +9,6 @@
"zeroGameCountClickable": true,
"missingMetricsVisible": true,
"sourceDatabaseCreatePayload": true,
- "portraitPending": true,
+ "portraitPending": false,
"pageErrors": []
}
diff --git a/docs/sw6-to-kra/channel-portrait-http-2026-11-03.json b/docs/sw6-to-kra/channel-portrait-http-2026-11-03.json
new file mode 100644
index 0000000..f990a29
--- /dev/null
+++ b/docs/sw6-to-kra/channel-portrait-http-2026-11-03.json
@@ -0,0 +1,35 @@
+{
+ "scope": "当前 sw7 登录会话,只读检查;完整累计、分层、日期与权限在隔离 TCP HTTP 中验证。",
+ "checks": [
+ {
+ "path": "/sw/promote/list?pageNum=1&pageSize=1",
+ "http": 200,
+ "code": 0,
+ "msg": "成功",
+ "passed": true,
+ "available": false
+ },
+ {
+ "path": "/sw/promote/portrait?sourceDb=lx&promoteId=1&period=total",
+ "http": 200,
+ "code": 7,
+ "msg": "渠道数据尚未接入",
+ "passed": true
+ },
+ {
+ "path": "/sw/promote/portrait?sourceDb=lx&promoteId=0",
+ "http": 200,
+ "code": 7,
+ "msg": "渠道查询参数不正确",
+ "passed": true
+ },
+ {
+ "path": "/sw/promote/portrait?sourceDb=invalid&promoteId=1",
+ "http": 200,
+ "code": 7,
+ "msg": "所属平台不正确",
+ "passed": true
+ }
+ ],
+ "passed": true
+}
diff --git a/docs/sw6-to-kra/channel-portrait-page-2026-11-03.json b/docs/sw6-to-kra/channel-portrait-page-2026-11-03.json
new file mode 100644
index 0000000..f9ea63e
--- /dev/null
+++ b/docs/sw6-to-kra/channel-portrait-page-2026-11-03.json
@@ -0,0 +1,13 @@
+{
+ "method": "真实 Chrome,画像、游戏与预警数据为受控响应,不改业务数据",
+ "passed": true,
+ "activeMetric": true,
+ "totalNoComparison": true,
+ "canonicalLast7": true,
+ "fourPayTiers": true,
+ "gameDrawer": true,
+ "platformAndChannelAlertFilter": true,
+ "crossPlatformSameID": true,
+ "unavailableIsNotZero": true,
+ "pageErrors": []
+}
diff --git a/docs/sw6-to-kra/channel-portrait-source-notes.md b/docs/sw6-to-kra/channel-portrait-source-notes.md
new file mode 100644
index 0000000..e27c333
--- /dev/null
+++ b/docs/sw6-to-kra/channel-portrait-source-notes.md
@@ -0,0 +1,32 @@
+# 渠道画像迁移核对
+
+已实现 `GET /sw/promote/portrait` 及渠道列表画像入口,来源为旧 `PromotePortraitServiceImpl`、`ChannelProfileDrawer.vue`、日汇总/身份键和支付 Mapper。完整业务库数据仍待最终复制验收。
+
+## 数据范围与基本资料
+
+- 先按当前商务本人/下级的正常绑定校验渠道可见性,平台与渠道 ID 组合定位;超管不限。
+- 统计树与原渠道画像 helper 一致:关闭下级只看自身;二级看自身及直属三级;一级看自身及同平台 top/parent 对应的渠道。
+- 基本信息包含昵称、账号、负责人、级别、审批状态、注册类型、注册/登录时间、归属商务、上级渠道与已通过推广游戏数。一级渠道不显示父级;其他渠道的父级账号从同平台记录重新读取。
+- 画像状态取 status,与列表锁定状态不同:0 待审核、1 正常、-2 驳回、-3 商务新增。第四张指标卡是活跃人数,不能套用商务画像的人均充值。
+
+## 时间与累计
+
+- 支持 today、yesterday、last7、last30、total;累计起点取当前人数去重模式的最早日汇总,缺省上海当天。
+- 累计模式无对照期,compareValue/changeRate/changeDiff/compareLabel 均为空;其他周期保持原对比规则。
+- 游戏分布和付费分层始终按累计身份范围,与顶部指标周期独立。
+- 今日/昨日趋势读取原始主账号和成功支付;其余按日汇总。金额固定模式 0,人数遵守全局去重偏好。
+
+## 付费分层
+
+- 参与者是累计注册身份键集合,重复键只计一次;无键时保留原注册人数回退规则。
+- 分层金额来自统计渠道树内的所有历史成功支付,没有支付时间过滤。因此历史或未来时间戳的支付仍可能影响已注册身份的分层,不擅自改成顶部日期范围。
+- 模式 1 按付款用户的原始 idcard 汇总,再按旧 trim/末尾 X 规则归一并合并;不擅自加主账号过滤或把子账号付款并到父账号。模式 0 按原始付款 user_id 汇总,并使用平台前缀与注册身份对应。
+- 大R ≥10000,中R ≥1000 且 <10000,小R >0 且 <1000,其余免费。先按旧金额规则保留两位再归档,比例独立保留一位,不强制凑成 100%。未出现在注册集合中的付款人不增加分层人数。
+
+## 修复的旧页面参数错误
+
+- 旧“近7天/近30天”发送 7d/30d,而旧后台只识别 last7/last30,导致回退今日;新页面发送后台正式参数。
+- 旧关联预警误将渠道 ID 传入 businessId;新页面使用 targetPromoteId 与 platformCodes,并继续由预警接口执行查看者数据范围隔离。
+- 小R/免费区间文案按实际后端 >0 / ≤0 调整,避免把小于一元的付费玩家误解为免费。
+
+验证覆盖领域规则、真实仓储查询、隔离数据库 TCP HTTP、浏览器完整交互及当前 sw7 只读接口。详见 `channel-portrait-http-2026-11-03.json` 与 `channel-portrait-page-2026-11-03.json`;全量 Go 测试与前端构建通过。
diff --git a/docs/sw6-to-kra/channel-source-notes.md b/docs/sw6-to-kra/channel-source-notes.md
index 793f5cb..2cd3c00 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` 一致。列表、新增、绑定选项、游戏分布及渠道画像已接入并验证;报表构建任务及完整数据验收仍未完成。
## 页面
@@ -52,4 +52,4 @@
游戏分布依赖 `PromotePortraitServiceImpl.buildGameDistribution`、`SwReportDayQueryServiceImpl` 和日汇总/身份键 Mapper。当前实现只列根渠道已通过的游戏,用同渠道统计树读取日汇总:人数对 metric=1 的 identity_key 去重,dedup_mode 随偏好;金额固定 dedup_mode=0。累计起点从当前人数模式的最早日汇总取得,缺失则使用上海当天,即使模式 0 有更早数据也不改写该起点。截止上海当天,排除未来日期。先将金额保留两位再按金额降序、游戏 ID 升序排列。
-完整画像、报表汇总任务和预警生成任务仍属于后续范围。列表没有用无效画像按钮或占位响应冒充画像已迁移。
+完整渠道画像已接入,规则及验证见 `channel-portrait-source-notes.md`。报表汇总任务、预警生成任务和最终实库验收仍属于后续范围。
diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md
index e33b750..46822cf 100644
--- a/docs/sw6-to-kra/implementation.md
+++ b/docs/sw6-to-kra/implementation.md
@@ -24,7 +24,7 @@
| 1 | 商务用户字段、层级与角色、账号资料和审核、偏好、登录及找回密码 | 进行中 |
| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 |
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 |
-| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 进行中;渠道列表/新增、激活、游戏分布已接入,渠道画像待迁移 |
+| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 功能已接入;包含渠道画像,完整业务数据和源平台联调仍待最终验收 |
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 待实现 |
| 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 |
| 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 |
@@ -47,7 +47,7 @@
- 已落地商务用户七个扩展字段的存取及增量补列;登录和既有会话同时检查商务审核状态。
- 已落地全局偏好 GET/PUT、当前用户隔离、缺省开启、显式关闭及幂等写入;Kra 顶栏增加统计口径入口。
- 已落地商务列表、查询资料、新增子商务、部分编辑和审核状态,以及 Kra 风格 `BusinessList` 页面。层级及角色由服务端按原角色 ID 映射分配;账号、角色、偏好在同一事务创建。列表按平台与商务组合键统计直属一级渠道和已通过绑定。
-- 商务与 SDK 商务画像已接入;渠道画像、其余报表及任务继续迁移。后端导出等接口另按源码核对,不能据此给旧页面增加不存在的按钮;最终仍需完整数据源验收。
+- 商务、SDK 商务和渠道画像已接入;其余报表及任务继续迁移。后端导出等接口另按源码核对,不能据此给旧页面增加不存在的按钮;最终仍需完整数据源验收。
- 已接入绑定列表、指定商务已通过绑定详情、提交绑定及解绑四个接口,页面恢复仅三级可见的“绑定列表”页签及只读详情抽屉。绑定流程保持三级限制、平台验密、OA 待审核及驳回重提规则;新增键锁表 `sys_business_binding_locks`,串行处理同一平台商务的并发绑定。
- 已恢复旧登录页的图片、文案、布局及记住账号功能,系统名称统一为“遨游龙翔游戏平台”。找回密码四个匿名接口位于 `/sw/auth/forgot-password/`,保留账号/手机号检查、60 秒发码限制、两分钟验证码、十分钟凭证、6–15 位密码及失败五次吊销等规则。
- 找回密码采用同库事务保存验证码摘要和一次性凭证,并原子执行改密/消费凭证;新增 `sys_business_password_codes`、`sys_business_password_resets`。更新密码版本使旧 JWT 失效。短信复用 Kra 集成配置,`SW_PASSWORD_RESET_SMS_PROVIDER` 默认 `tencent`,也支持 `aliyun`;模板和通道凭据在对应集成配置中维护。
@@ -85,3 +85,5 @@
- 画像日指标与日趋势读取汇总/身份键表,模式 0 人数带平台前缀、模式 1 跨平台身份证去重,金额固定模式 0。付费率分母为活跃人数,人均充值分母为注册人数;上期活跃为零时付费率对照和百分点差为 null。今日/昨日趋势读原始主账号与成功支付、补齐 24 小时;MySQL 保留原 HOUR(FROM_UNIXTIME) 会话时区语义。注册 TOP 5 在渠道树内 COUNT DISTINCT;人数上限在排序截取前应用。
- 画像批次通过领域/存储/双库实际 TCP HTTP 测试、全量 Go 检查和前端 ESLint/构建;验证月末/闰月对比、四舍五入与负零、去重切换、金额稳定、原始小时口径、父级状态、跨商务拒绝及缺表。`portrait-http-2026-11-03.json` 为真实会话检查:无绑定返回零指标及完整趋势,有绑定缺表保留基本资料并明确未知。`portrait-page-2026-11-03.json` 核对三个入口、周期切换、交互图表、两个 ID 范畴和缺数据提示。
- 当前没有执行最终旧数据库复制与迁移验收。
+- 渠道画像 `/sw/promote/portrait` 与列表入口已接入,包含基本资料、累计/当期指标、趋势、四档付费分层、关联预警及推广游戏抽屉。复用画像统计并增加经过校验的单渠道树范围、无对照期分支;渠道第四项保持活跃玩家。分层成员取累计注册身份,金额按历史成功支付及原始付款账号/身份证口径,保持无日期过滤,不能误复用指标周期过滤。
+- 修复旧渠道页面两处参数错配:近7/30天改发 last7/last30,关联预警改发 targetPromoteId 而不是 businessId。领域、存储和隔离 TCP HTTP 覆盖分层边界、子账号与跨平台、模式切换、累计无环比、原始小时趋势和缺数据;浏览器验证入口、正确参数、四档分层及游戏抽屉。全量 Go、ESLint/构建通过;当前真实会话请求记录于 `channel-portrait-http-2026-11-03.json`,仍未复制实际旧业务库。
diff --git a/docs/sw6-to-kra/page-parity.md b/docs/sw6-to-kra/page-parity.md
index e4f2ee8..a61ad48 100644
--- a/docs/sw6-to-kra/page-parity.md
+++ b/docs/sw6-to-kra/page-parity.md
@@ -31,7 +31,7 @@
- `business-page-parity-2026-11-03.json`:真实 Chrome 渲染、受控身份/业务数据,检查四个等级的页签和按钮,以及三种审核状态的行操作;无页面运行时错误。
- `http-business-bindings-2026-11-03.json`:真实运行实例及已有 sw7 测试数据的 HTTP 验证。
- 旗下渠道已通过仓储与实际 HTTP 回归,覆盖已通过绑定、直属一级、跨平台同号和稳定分页;sw7 缺渠道数据时明确返回 `available=false`。
-- 商务/SDK 商务画像已通过接口及浏览器验证;渠道画像仍待迁移,没有使用占位响应冒充完成。
+- 商务、SDK 商务和渠道画像已通过接口及浏览器验证;真实业务库数值仍待最终验收。
## 商务与游戏商务画像
@@ -48,7 +48,7 @@
| 画像“旗下渠道”继续打开直属渠道抽屉 | 已对照父组件处理函数;传入 SW 用户 ID,SDK 场景附带平台和平台商务 ID |
| 缺业务数据与没有业务数据 | 分别显示未接入与真实零,避免未知指标显示为零 |
-验证:`portrait-http-2026-11-03.json`、`portrait-page-2026-11-03.json` 及隔离双库 TCP HTTP 测试。完整平台资料与真实业务数值仍待最终实库联调;渠道画像是下一项独立工作。
+验证:`portrait-http-2026-11-03.json`、`portrait-page-2026-11-03.json` 及隔离双库 TCP HTTP 测试。完整平台资料与真实业务数值仍待最终实库联调。
## 其他页面
@@ -120,7 +120,7 @@
| 平台、商务账号、渠道关键词、层级、类型、锁定状态筛选 | 已实现;修正旧“账号/负责人姓名”占位文案,按实际后端只查询账号 |
| 列表类型个人/公会;新增类型个人/企业 | 按两处实际模板分别保留 |
| 渠道信息、归属商务、上级/负责人、推广游戏、玩家、累计充值、锁定状态 | 已实现;缺失指标明确显示未接入 |
-| 没有编辑入口,行操作为画像 | 未增加编辑;画像随完整画像用例继续迁移,目前没有冒充可用的占位入口 |
+| 没有编辑入口,行操作为画像 | 已对齐,渠道画像入口及完整抽屉已接入 |
| 添加渠道按按钮权限,不额外限制三级 | 已实现并用一级浏览器身份核对,上级可选择下级正常绑定 |
| 切换平台清空游戏商务并重新加载、按平台商务 ID 去重 | 已实现,防止旧请求覆盖新平台选项 |
| 添加成功后关闭抽屉、刷新第一页,允许同步延迟 | 已实现,保持源平台创建及同步延迟提示 |
@@ -128,4 +128,20 @@
| 游戏分布显示累计玩家数、充值金额 | 已实现,实际取日汇总/身份键数据,独立于列表原始累计指标 |
| 全局偏好变化刷新页面 | 复用 Kra 全局 reload,后端每次请求读取当前偏好 |
-验证:`channel-http-2026-11-03.json`、`channel-page-2026-11-03.json`,以及实际 TCP HTTP + 隔离主库/平台库。整菜单仍因画像待迁移而保持未完成。
+验证:`channel-http-2026-11-03.json`、`channel-page-2026-11-03.json`,以及实际 TCP HTTP + 隔离主库/平台库。画像新增验证见下表,完整真实数据仍待最终验收。
+
+## 渠道画像
+
+来源及计算规则见 `channel-portrait-source-notes.md`。
+
+| 旧页面行为 | 新页面状态 |
+| --- | --- |
+| 基本资料、上级渠道、注册与最后登录时间、推广游戏数 | 已实现,一级不显示上级,其余读取同平台当前上级账号 |
+| 今日、昨日、近7天、近30天、累计 | 已实现;修正旧 7d/30d 参数错配,发送 last7/last30 |
+| 注册玩家、充值金额、付费率、活跃玩家 | 已实现;累计模式无环比值 |
+| 两个趋势图与四档付费分层 | 已实现,复用 Kra 主题图表和进度条,分层按累计身份与历史成功支付 |
+| 推广游戏数点击打开游戏分布 | 已实现,携带同一平台和渠道 ID,始终累计口径 |
+| 关联预警分页 | 已实现;修复旧页面误传 businessId,改为平台+targetPromoteId |
+| 缺业务表 | 保留能读取的基本资料,明确提示未接入,不生成假零分层 |
+
+验证:`channel-portrait-http-2026-11-03.json`、`channel-portrait-page-2026-11-03.json`、隔离数据真实 TCP HTTP 和完整 Go/前端构建检查。
diff --git a/internal/biz/biz.go b/internal/biz/biz.go
index 81d4330..b3bed8e 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, business.NewChannelUsecase, business.NewBusinessPortraitUsecase)
+var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase)
diff --git a/internal/biz/business/business_portrait.go b/internal/biz/business/business_portrait.go
index 59cf22a..e4ca59d 100644
--- a/internal/biz/business/business_portrait.go
+++ b/internal/biz/business/business_portrait.go
@@ -101,6 +101,8 @@ type PortraitStatistics struct {
Available bool
}
type PortraitReportScope struct {
+ Root *Channel
+ NoCompare bool
Pairs []PlatformKey
Sources []string
IncludeSub, IDCardDedup bool
diff --git a/internal/biz/business/channel.go b/internal/biz/business/channel.go
index e9dd5ee..6d9092d 100644
--- a/internal/biz/business/channel.go
+++ b/internal/biz/business/channel.go
@@ -40,6 +40,7 @@ type Channel struct {
BindingAccount, BindingUserName string
GameCount, PlayerCount *int64
TotalPayAmount *string
+ CreatedUnix, LastLoginUnix *int64
}
func (c *Channel) BusinessDisplayName() string {
@@ -100,6 +101,7 @@ type ChannelGame struct {
type ChannelGameScope struct {
Root *Channel
IncludeSub, IDCardDedup bool
+ BeginDate string
EndDate string
}
type ChannelRepo interface {
@@ -327,6 +329,10 @@ func (uc *ChannelUsecase) Games(ctx context.Context, key ChannelKey) ([]*Channel
if err != nil {
return nil, err
}
+ return normalizeChannelGames(games)
+}
+
+func normalizeChannelGames(games []*ChannelGame) ([]*ChannelGame, error) {
amounts := map[uint64]*big.Rat{}
for _, game := range games {
game.RegisterCount = min(game.RegisterCount, int64(2147483647))
diff --git a/internal/biz/business/channel_portrait.go b/internal/biz/business/channel_portrait.go
new file mode 100644
index 0000000..69e23a1
--- /dev/null
+++ b/internal/biz/business/channel_portrait.go
@@ -0,0 +1,312 @@
+package business
+
+import (
+ "context"
+ "math/big"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-kratos/kratos/v3/errors"
+)
+
+type ChannelPortraitBasic struct {
+ Channel *Channel
+ Parent *Channel
+ GameCount *int64
+}
+type ChannelPortraitMetrics struct{ Register, PayAmount, PayRate, Active *PortraitMetric }
+type ChannelPayTier struct {
+ Tier, Label string
+ Count int64
+ Ratio string
+}
+type ChannelPortrait struct {
+ Basic *ChannelPortraitBasic
+ Period string
+ Metrics *ChannelPortraitMetrics
+ RegisterTrend, PayTrend []*PortraitTrend
+ GameDistribution []*ChannelGame
+ PayTiers []*ChannelPayTier
+ Available bool
+ UnavailableReason string
+}
+type ChannelIdentityPay struct {
+ UserID *uint64
+ IDCard *string
+ Amount string
+}
+type ChannelTierData struct {
+ RegisterKeys []string
+ FallbackCount int64
+ Payments []*ChannelIdentityPay
+ Available bool
+}
+type ChannelPortraitRepo interface {
+ GameCount(context.Context, ChannelKey) (*int64, error)
+ EarliestDate(context.Context, ChannelGameScope) (string, bool, error)
+ TierData(context.Context, ChannelGameScope) (*ChannelTierData, error)
+}
+type ChannelPortraitUsecase struct {
+ repo ChannelPortraitRepo
+ channels ChannelRepo
+ reports BusinessPortraitRepo
+ accounts *AccountUsecase
+ preferences *UserPreferenceUsecase
+ now func() time.Time
+}
+
+func NewChannelPortraitUsecase(repo ChannelPortraitRepo, channels ChannelRepo, reports BusinessPortraitRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *ChannelPortraitUsecase {
+ return &ChannelPortraitUsecase{repo: repo, channels: channels, reports: reports, accounts: accounts, preferences: preferences, now: time.Now}
+}
+func normalizeChannelPortraitPeriod(period string) string {
+ switch value := strings.ToLower(strings.TrimSpace(period)); value {
+ case "today", "yesterday", "last7", "last30", "total":
+ return value
+ }
+ return "today"
+}
+func ChannelLevelLabel(level *int) string {
+ if level == nil {
+ return ""
+ }
+ return map[int]string{1: "一级渠道", 2: "二级渠道", 3: "三级渠道"}[*level]
+}
+func ChannelStatusLabel(status *int) string {
+ if status == nil {
+ return ""
+ }
+ return map[int]string{0: "待审核", 1: "正常", -2: "驳回", -3: "商务新增"}[*status]
+}
+func ChannelTypeLabel(registerType *int) string {
+ if registerType == nil {
+ return ""
+ }
+ if *registerType == 1 {
+ return "公会"
+ }
+ return "个人"
+}
+func ChannelUnixTime(value *int64) *time.Time {
+ if value == nil || *value <= 0 {
+ return nil
+ }
+ t := time.Unix(*value, 0).In(portraitLocation)
+ return &t
+}
+func unavailableChannelPortrait(p *ChannelPortrait, reason string) *ChannelPortrait {
+ p.Available = false
+ p.UnavailableReason = reason
+ p.Metrics = nil
+ p.RegisterTrend = []*PortraitTrend{}
+ p.PayTrend = []*PortraitTrend{}
+ p.GameDistribution = []*ChannelGame{}
+ p.PayTiers = []*ChannelPayTier{}
+ return p
+}
+func (uc *ChannelPortraitUsecase) Portrait(ctx context.Context, key ChannelKey, period string) (*ChannelPortrait, error) {
+ source, err := NormalizePlatform(key.SourceDB)
+ if err != nil {
+ return nil, err
+ }
+ key.SourceDB = source
+ actor, account, err := uc.accounts.actor(ctx)
+ if err != nil {
+ return nil, err
+ }
+ viewer := portraitUserScope(account)
+ viewer.All = actor.AuthorityID == 888
+ root, err := uc.channels.Find(ctx, viewer, key)
+ if err != nil {
+ return nil, err
+ }
+ period = normalizeChannelPortraitPeriod(period)
+ profile := &ChannelPortrait{Basic: &ChannelPortraitBasic{Channel: root}, Period: period, Available: true, RegisterTrend: []*PortraitTrend{}, PayTrend: []*PortraitTrend{}, GameDistribution: []*ChannelGame{}, PayTiers: []*ChannelPayTier{}}
+ if (root.Level == nil || *root.Level != 1) && root.ParentID > 0 {
+ parent, err := uc.channels.Find(ctx, UserScope{All: true}, ChannelKey{SourceDB: source, ID: root.ParentID})
+ if err != nil && !errors.IsNotFound(err) {
+ return nil, err
+ }
+ profile.Basic.Parent = parent
+ }
+ if root.BusierID > 0 {
+ bindings, err := uc.reports.Bindings(ctx, viewer, []string{source}, &PlatformKey{SourceDB: source, BusierID: root.BusierID})
+ if err != nil {
+ return nil, err
+ }
+ if len(bindings) > 0 {
+ b := bindings[0]
+ root.BusierUserID = &b.UserID
+ root.BindingAccount = b.Account
+ owner, err := uc.accounts.repo.Find(ctx, b.UserID)
+ if err != nil && !errors.IsNotFound(err) {
+ return nil, err
+ }
+ if owner != nil {
+ root.BindingUserName = owner.Username
+ }
+ }
+ }
+ profile.Basic.GameCount, err = uc.repo.GameCount(ctx, key)
+ if err != nil {
+ return nil, err
+ }
+ if profile.Basic.GameCount == nil {
+ return unavailableChannelPortrait(profile, "推广游戏数据尚未接入,渠道画像指标暂不可用"), nil
+ }
+ pref, err := uc.preferences.Current(ctx)
+ if err != nil {
+ return nil, err
+ }
+ now := uc.now()
+ today := now.In(portraitLocation).Format("2006-01-02")
+ cumulative := ChannelGameScope{Root: root, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup, EndDate: today}
+ begin, available, err := uc.repo.EarliestDate(ctx, cumulative)
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ return unavailableChannelPortrait(profile, "日汇总数据尚未接入,渠道画像指标暂不可用"), nil
+ }
+ if begin == "" {
+ begin = today
+ }
+ cumulative.BeginDate = begin
+ ranges := portraitRanges(period, now)
+ if period == "total" {
+ ranges.Begin, err = time.ParseInLocation("2006-01-02", begin, portraitLocation)
+ if err != nil {
+ return nil, ErrStorage
+ }
+ ranges.Hourly = false
+ ranges.CompareLabel = ""
+ }
+ stats, err := uc.reports.Statistics(ctx, PortraitReportScope{Root: root, Sources: []string{source}, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup, Range: ranges, NoCompare: period == "total"})
+ if err != nil {
+ return nil, err
+ }
+ if !stats.Available {
+ return unavailableChannelPortrait(profile, "汇总或趋势数据尚未接入,渠道画像指标暂不可用"), nil
+ }
+ profile.Metrics, err = channelPortraitMetrics(stats, ranges.CompareLabel, period != "total")
+ if err != nil {
+ return nil, err
+ }
+ profile.RegisterTrend, err = portraitTrend(stats.RegisterSeries, ranges, false)
+ if err != nil {
+ return nil, err
+ }
+ profile.PayTrend, err = portraitTrend(stats.PaySeries, ranges, true)
+ if err != nil {
+ return nil, err
+ }
+ games, err := uc.channels.Games(ctx, cumulative)
+ if err != nil {
+ if err == ErrChannelReportsUnavailable {
+ return unavailableChannelPortrait(profile, "推广游戏汇总数据尚未接入"), nil
+ }
+ return nil, err
+ }
+ profile.GameDistribution, err = normalizeChannelGames(games)
+ if err != nil {
+ return nil, err
+ }
+ tiers, err := uc.repo.TierData(ctx, cumulative)
+ if err != nil {
+ return nil, err
+ }
+ if !tiers.Available {
+ return unavailableChannelPortrait(profile, "付费分层所需的注册或支付数据尚未接入"), nil
+ }
+ profile.PayTiers, err = buildChannelPayTiers(source, pref.IDCardDedup, tiers)
+ return profile, err
+}
+func channelPortraitMetrics(stats *PortraitStatistics, label string, compare bool) (*ChannelPortraitMetrics, error) {
+ base, err := portraitMetrics(stats, label)
+ if err != nil {
+ return nil, err
+ }
+ active, err := portraitRateCard(strconv.FormatInt(min(stats.Current.Active, int64(2147483647)), 10), strconv.FormatInt(min(stats.Previous.Active, int64(2147483647)), 10), label)
+ if err != nil {
+ return nil, err
+ }
+ result := &ChannelPortraitMetrics{Register: base.Register, PayAmount: base.PayAmount, PayRate: base.PayRate, Active: active}
+ if !compare {
+ for _, card := range []*PortraitMetric{result.Register, result.PayAmount, result.PayRate, result.Active} {
+ card.CompareValue = nil
+ card.ChangeDiff = nil
+ card.ChangeRate = nil
+ card.CompareLabel = ""
+ }
+ }
+ return result, nil
+}
+func buildChannelPayTiers(source string, dedup bool, data *ChannelTierData) ([]*ChannelPayTier, error) {
+ keys := map[string]bool{}
+ for _, key := range data.RegisterKeys {
+ keys[key] = true
+ }
+ denominator := int64(len(keys))
+ if denominator == 0 {
+ denominator = min(data.FallbackCount, int64(2147483647))
+ }
+ counts := []int64{0, 0, 0, 0}
+ if len(keys) == 0 {
+ counts[3] = denominator
+ } else {
+ payByKey := map[string]*big.Rat{}
+ for _, payment := range data.Payments {
+ var key string
+ if dedup {
+ if payment.IDCard == nil {
+ continue
+ }
+ key = normalizeIDCard(*payment.IDCard)
+ if key == "" {
+ continue
+ }
+ } else {
+ if payment.UserID == nil {
+ continue
+ }
+ key = source + "|" + strconv.FormatUint(*payment.UserID, 10)
+ }
+ amount, err := portraitNumber(payment.Amount)
+ if err != nil {
+ return nil, err
+ }
+ amount, _ = portraitNumber(portraitDecimal(amount, 2))
+ if payByKey[key] == nil {
+ payByKey[key] = new(big.Rat)
+ }
+ payByKey[key].Add(payByKey[key], amount)
+ }
+ for key := range keys {
+ pay := payByKey[key]
+ if pay == nil {
+ pay = new(big.Rat)
+ }
+ switch {
+ case pay.Cmp(big.NewRat(10000, 1)) >= 0:
+ counts[0]++
+ case pay.Cmp(big.NewRat(1000, 1)) >= 0:
+ counts[1]++
+ case pay.Sign() > 0:
+ counts[2]++
+ default:
+ counts[3]++
+ }
+ }
+ }
+ result := []*ChannelPayTier{}
+ codes := []string{"whale", "mid", "small", "free"}
+ labels := []string{"大R", "中R", "小R", "免费"}
+ for i, code := range codes {
+ ratio := "0.0"
+ if denominator > 0 {
+ ratio = portraitDecimal(new(big.Rat).Mul(big.NewRat(counts[i], denominator), big.NewRat(100, 1)), 1)
+ }
+ result = append(result, &ChannelPayTier{Tier: code, Label: labels[i], Count: counts[i], Ratio: ratio})
+ }
+ return result, nil
+}
diff --git a/internal/biz/business/channel_portrait_test.go b/internal/biz/business/channel_portrait_test.go
new file mode 100644
index 0000000..77de2b8
--- /dev/null
+++ b/internal/biz/business/channel_portrait_test.go
@@ -0,0 +1,120 @@
+package business
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+type channelPortraitRepoFake struct {
+ count *int64
+ first string
+ tiers *ChannelTierData
+ tierScope ChannelGameScope
+}
+
+func (r *channelPortraitRepoFake) GameCount(context.Context, ChannelKey) (*int64, error) {
+ return r.count, nil
+}
+func (r *channelPortraitRepoFake) EarliestDate(context.Context, ChannelGameScope) (string, bool, error) {
+ return r.first, true, nil
+}
+func (r *channelPortraitRepoFake) TierData(_ context.Context, s ChannelGameScope) (*ChannelTierData, error) {
+ r.tierScope = s
+ return r.tiers, nil
+}
+
+type portraitChannelReaderFake struct {
+ ChannelRepo
+ rows map[ChannelKey]*Channel
+ gameScope ChannelGameScope
+}
+
+func (r *portraitChannelReaderFake) Find(_ context.Context, scope UserScope, key ChannelKey) (*Channel, error) {
+ if !scope.All && scope.SelfID == 99 {
+ return nil, ErrChannelScope
+ }
+ row := r.rows[key]
+ if row == nil {
+ return nil, ErrChannelNotFound
+ }
+ copy := *row
+ return ©, nil
+}
+func (r *portraitChannelReaderFake) Games(_ context.Context, s ChannelGameScope) ([]*ChannelGame, error) {
+ r.gameScope = s
+ return []*ChannelGame{{ID: 1, Name: "游戏", RegisterCount: 1, PayAmount: "12.00"}}, nil
+}
+func channelPortraitFixture() (*ChannelPortraitUsecase, *channelPortraitRepoFake, *portraitChannelReaderFake, *portraitRepoFake) {
+ base, reports := portraitFixture()
+ key := ChannelKey{SourceDB: "lx", ID: 101}
+ parent := ChannelKey{SourceDB: "lx", ID: 100}
+ count := int64(1)
+ channels := &portraitChannelReaderFake{rows: map[ChannelKey]*Channel{key: {ChannelKey: key, Account: "child", Nickname: "子渠道", Level: intPointer(2), Status: intPointer(-3), ParentID: 100, BusierID: 14}, parent: {ChannelKey: parent, Account: "parent", Level: intPointer(1)}}}
+ repo := &channelPortraitRepoFake{count: &count, first: "2026-11-01", tiers: &ChannelTierData{Available: true, RegisterKeys: []string{"A"}}}
+ uc := NewChannelPortraitUsecase(repo, channels, reports, base.accounts, base.preferences)
+ uc.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, portraitLocation) }
+ return uc, repo, channels, reports
+}
+func TestChannelPortraitTotalHasNoComparisonAndKeepsCumulativeParts(t *testing.T) {
+ uc, repo, channels, reports := channelPortraitFixture()
+ p, err := uc.Portrait(accountContext(7, 901), ChannelKey{SourceDB: "lx", ID: 101}, "total")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.Period != "total" || !reports.scope.NoCompare || reports.scope.Root.ID != 101 || len(reports.scope.Pairs) != 0 {
+ t.Fatal("累计模式或渠道范围错误")
+ }
+ if reports.scope.Range.Begin.Format("2006-01-02") != "2026-11-01" || len(p.RegisterTrend) != 3 || p.Metrics.Active.Value != "3" || p.Metrics.Active.CompareValue != nil || p.Metrics.PayAmount.ChangeRate != nil {
+ t.Fatal("累计卡片不应带环比,趋势应从最早汇总开始")
+ }
+ if p.Basic.Parent == nil || p.Basic.Parent.Account != "parent" || p.Basic.Channel.BusinessDisplayName() != "leaf" {
+ t.Fatal("上级渠道或归属商务资料未正确补齐")
+ }
+ p, err = uc.Portrait(accountContext(7, 901), ChannelKey{SourceDB: "lx", ID: 101}, "last7")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(p.RegisterTrend) != 7 || channels.gameScope.BeginDate != "2026-11-01" || repo.tierScope.BeginDate != "2026-11-01" || reports.scope.Range.Begin.Format("2006-01-02") != "2026-10-28" {
+ t.Fatal("日期切换不应把游戏分布或分层改成当期口径")
+ }
+}
+func TestChannelPortraitMissingDataAndVisibility(t *testing.T) {
+ uc, repo, _, _ := channelPortraitFixture()
+ repo.count = nil
+ p, err := uc.Portrait(accountContext(7, 901), ChannelKey{SourceDB: "lx", ID: 101}, "today")
+ if err != nil || p.Available || p.Metrics != nil || p.Basic.Channel.Account != "child" {
+ t.Fatal("缺业务数据时应保留已读取资料,不造零指标")
+ }
+ if _, err := uc.Portrait(accountContext(99, 901), ChannelKey{SourceDB: "lx", ID: 101}, "today"); err != ErrChannelScope {
+ t.Fatal("越权渠道画像被放行")
+ }
+ if normalizeChannelPortraitPeriod("thismonth") != "today" || normalizeChannelPortraitPeriod("TOTAL") != "total" {
+ t.Fatal("渠道周期范围与商务画像混用")
+ }
+}
+func TestChannelPayTiersUseRegisteredIdentitiesAndExactThresholds(t *testing.T) {
+ data := &ChannelTierData{RegisterKeys: []string{"A-x", "A-x", "B", "C", "D", "E", "F"}, Payments: []*ChannelIdentityPay{{IDCard: stringPointer(" A-X "), Amount: "9999.50"}, {IDCard: stringPointer("A-x"), Amount: "0.50"}, {IDCard: stringPointer("B"), Amount: "1000.00"}, {IDCard: stringPointer("C"), Amount: "0.01"}, {IDCard: stringPointer("D"), Amount: "0"}, {IDCard: stringPointer("E"), Amount: "-1"}, {IDCard: stringPointer("outside"), Amount: "999999"}}}
+ tiers, err := buildChannelPayTiers("lx", true, data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []int64{1, 1, 1, 3}
+ for i, tier := range tiers {
+ if tier.Count != want[i] {
+ t.Fatalf("分层边界、归一或成员范围错误: %+v", tiers)
+ }
+ }
+ if tiers[0].Ratio != "16.7" || tiers[3].Ratio != "50.0" {
+ t.Fatal("分层比例应独立保留一位,不强行凑整")
+ }
+ id1, id2, id4 := uint64(1), uint64(2), uint64(4)
+ tiers, err = buildChannelPayTiers("lx", false, &ChannelTierData{RegisterKeys: []string{"lx|1", "lx|2", "lx|3"}, Payments: []*ChannelIdentityPay{{UserID: &id1, Amount: "1000"}, {UserID: &id2, Amount: "999.99"}, {UserID: &id4, Amount: "100000"}}})
+ if err != nil || tiers[0].Count != 0 || tiers[1].Count != 1 || tiers[2].Count != 1 || tiers[3].Count != 1 {
+ t.Fatal("模式0必须使用带平台前缀的原始用户 ID,未注册身份不参与分层")
+ }
+ tiers, err = buildChannelPayTiers("lx", false, &ChannelTierData{FallbackCount: 5})
+ if err != nil || tiers[3].Count != 5 || tiers[3].Ratio != "100.0" {
+ t.Fatal("无身份列表时应保留原回退的免费人数")
+ }
+}
diff --git a/internal/data/business/business_portrait.go b/internal/data/business/business_portrait.go
index 9b2d01d..7e9a3f7 100644
--- a/internal/data/business/business_portrait.go
+++ b/internal/data/business/business_portrait.go
@@ -97,6 +97,9 @@ func (r *businessPortraitRepo) PlatformBusiness(ctx context.Context, key busines
}
func portraitPromoteScope(db *gorm.DB, scope business.PortraitReportScope, sourceColumn, promoteColumn string) *gorm.DB {
+ if scope.Root != nil {
+ return channelGameTree(db, business.ChannelGameScope{Root: scope.Root, IncludeSub: scope.IncludeSub}).Select("1").Where("p.source_db = " + sourceColumn + " AND p.id = " + promoteColumn)
+ }
q := db.Table("tab_promote scope_promote").Select("1").Where("scope_promote.source_db = " + sourceColumn + " AND scope_promote.id = " + promoteColumn)
if len(scope.Pairs) == 0 {
return q.Where("1=0")
@@ -148,7 +151,7 @@ func toPortraitCounts(rows []portraitCountPO) business.PortraitCounts {
func (r *businessPortraitRepo) Statistics(ctx context.Context, scope business.PortraitReportScope) (*business.PortraitStatistics, error) {
result := &business.PortraitStatistics{CurrentPay: "0", PreviousPay: "0", RegisterSeries: map[string]string{}, PaySeries: map[string]string{}, RegisterRanks: []*business.PortraitStatRank{}, PayRanks: []*business.PortraitStatRank{}, Available: true}
- if len(scope.Pairs) == 0 {
+ if len(scope.Pairs) == 0 && scope.Root == nil {
return result, nil
}
db := r.data.DB().WithContext(ctx)
@@ -172,11 +175,19 @@ func (r *businessPortraitRepo) Statistics(ctx context.Context, scope business.Po
}
begin, end := scope.Range.Begin.Format("2006-01-02"), scope.Range.End.Format("2006-01-02")
previousBegin, previousEnd := scope.Range.PreviousBegin.Format("2006-01-02"), scope.Range.PreviousEnd.Format("2006-01-02")
- for _, period := range []struct {
+ periods := []struct {
begin, end string
counts *business.PortraitCounts
pay *string
- }{{begin, end, &result.Current, &result.CurrentPay}, {previousBegin, previousEnd, &result.Previous, &result.PreviousPay}} {
+ }{{begin, end, &result.Current, &result.CurrentPay}}
+ if !scope.NoCompare {
+ periods = append(periods, struct {
+ begin, end string
+ counts *business.PortraitCounts
+ pay *string
+ }{previousBegin, previousEnd, &result.Previous, &result.PreviousPay})
+ }
+ for _, period := range periods {
var rows []portraitCountPO
q := portraitReportQuery(db, scope, "tab_sw_report_day_key", period.begin, period.end, mode)
if err := q.Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&rows).Error; err != nil {
@@ -196,16 +207,18 @@ func (r *businessPortraitRepo) Statistics(ctx context.Context, scope business.Po
return nil, err
}
}
- register, err := portraitRankQuery(db, scope, mode, begin, end, false)
- if err != nil {
- return nil, err
+ if scope.RankLimit > 0 {
+ register, err := portraitRankQuery(db, scope, mode, begin, end, false)
+ if err != nil {
+ return nil, err
+ }
+ result.RegisterRanks = register
+ pay, err := portraitRankQuery(db, scope, 0, begin, end, true)
+ if err != nil {
+ return nil, err
+ }
+ result.PayRanks = pay
}
- result.RegisterRanks = register
- pay, err := portraitRankQuery(db, scope, 0, begin, end, true)
- if err != nil {
- return nil, err
- }
- result.PayRanks = pay
return result, nil
}
func portraitDaily(db *gorm.DB, scope business.PortraitReportScope, mode int, begin, end string, result *business.PortraitStatistics) error {
diff --git a/internal/data/business/channel.go b/internal/data/business/channel.go
index 33cb316..fd384d9 100644
--- a/internal/data/business/channel.go
+++ b/internal/data/business/channel.go
@@ -16,11 +16,12 @@ type channelPO struct {
PromoteLevel, RegisterType, Status, LockStatus *int
ParentID, TopPromoteID, BusierID uint64
CreateTime *int64
+ LastLoginTime *int64 `gorm:"column:last_login_time"`
}
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}
+ 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, CreatedUnix: p.CreateTime, LastLoginUnix: p.LastLoginTime}
}
type channelRepo struct{ data Provider }
diff --git a/internal/data/business/channel_metrics.go b/internal/data/business/channel_metrics.go
index 74519c2..404aa1b 100644
--- a/internal/data/business/channel_metrics.go
+++ b/internal/data/business/channel_metrics.go
@@ -128,13 +128,16 @@ func (r *channelRepo) Games(ctx context.Context, scope business.ChannelGameScope
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
+ begin := scope.BeginDate
+ if begin == "" {
+ 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 {
diff --git a/internal/data/business/channel_portrait.go b/internal/data/business/channel_portrait.go
new file mode 100644
index 0000000..d193ca0
--- /dev/null
+++ b/internal/data/business/channel_portrait.go
@@ -0,0 +1,122 @@
+package business
+
+import (
+ "context"
+
+ "gorm.io/gorm"
+ "kra/internal/biz/business"
+)
+
+type channelPortraitRepo struct{ data Provider }
+
+func NewChannelPortraitRepo(data Provider) business.ChannelPortraitRepo {
+ return &channelPortraitRepo{data: data}
+}
+
+func (r *channelPortraitRepo) GameCount(ctx context.Context, key business.ChannelKey) (*int64, error) {
+ db := r.data.DB().WithContext(ctx)
+ available, err := businessTableAvailable(db, "tab_promote_apply")
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ return nil, nil
+ }
+ var count int64
+ if err := db.Table("tab_promote_apply").Where("source_db = ? AND promote_id = ? AND status = 1", key.SourceDB, key.ID).Distinct("game_id").Count(&count).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ return &count, nil
+}
+func (r *channelPortraitRepo) EarliestDate(ctx context.Context, scope business.ChannelGameScope) (string, bool, error) {
+ db := r.data.DB().WithContext(ctx)
+ available, err := businessTableAvailable(db, "tab_sw_report_day")
+ if err != nil {
+ return "", false, err
+ }
+ if !available {
+ return "", false, nil
+ }
+ mode := 0
+ if scope.IDCardDedup {
+ mode = 1
+ }
+ var result struct{ FirstDate *string }
+ q := db.Table("tab_sw_report_day r").Where("r.source_db = ? AND r.dedup_mode = ? AND r.game_id > 0", scope.Root.SourceDB, mode).Where("EXISTS (?)", channelGameTree(db, scope).Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
+ if err := q.Select("CAST(MIN(r.stat_date) AS CHAR) AS first_date").Scan(&result).Error; err != nil {
+ return "", false, business.ErrStorage
+ }
+ if result.FirstDate == nil {
+ return "", true, nil
+ }
+ return *result.FirstDate, true, nil
+}
+func channelTierKeys(db *gorm.DB, scope business.ChannelGameScope) *gorm.DB {
+ mode := 0
+ if scope.IDCardDedup {
+ mode = 1
+ }
+ return db.Table("tab_sw_report_day_key r").Where("r.source_db = ? AND r.dedup_mode = ? AND r.game_id > 0 AND r.metric = 1 AND r.stat_date >= ? AND r.stat_date <= ? AND r.identity_key IS NOT NULL", scope.Root.SourceDB, mode, scope.BeginDate, scope.EndDate).Where("EXISTS (?)", channelGameTree(db, scope).Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
+}
+func (r *channelPortraitRepo) TierData(ctx context.Context, scope business.ChannelGameScope) (*business.ChannelTierData, error) {
+ result := &business.ChannelTierData{Available: true, RegisterKeys: []string{}, Payments: []*business.ChannelIdentityPay{}}
+ db := r.data.DB().WithContext(ctx)
+ available, err := businessTableAvailable(db, "tab_sw_report_day_key")
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ result.Available = false
+ return result, nil
+ }
+ identity := portraitIdentity(db, scope.IDCardDedup)
+ if err := channelTierKeys(db, scope).Select("DISTINCT " + identity).Scan(&result.RegisterKeys).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ if len(result.RegisterKeys) == 0 {
+ if err := channelTierKeys(db, scope).Select("COUNT(DISTINCT " + identity + ")").Scan(&result.FallbackCount).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ return result, nil
+ }
+ tables := []string{"tab_spend"}
+ if scope.IDCardDedup {
+ tables = append(tables, "tab_user")
+ }
+ for _, table := range tables {
+ available, err := businessTableAvailable(db, table)
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ result.Available = false
+ return result, nil
+ }
+ }
+ // 分层金额按历史成功支付累计,不受画像周期或注册日期范围限制。
+ q := db.Table("tab_spend s").Where("s.source_db = ? AND s.pay_status = 1", scope.Root.SourceDB).Where("EXISTS (?)", channelGameTree(db, scope).Select("1").Where("p.source_db=s.source_db AND p.id=s.promote_id"))
+ if scope.IDCardDedup {
+ var rows []struct {
+ IDCard *string `gorm:"column:idcard"`
+ Amount string
+ }
+ if err := q.Joins("JOIN tab_user u ON u.id=s.user_id AND u.source_db=s.source_db").Where("u.idcard IS NOT NULL AND TRIM(u.idcard) <> ''").Select("u.idcard AS idcard,COALESCE(SUM(s.pay_amount),0) AS amount").Group("u.idcard").Scan(&rows).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, row := range rows {
+ result.Payments = append(result.Payments, &business.ChannelIdentityPay{IDCard: row.IDCard, Amount: row.Amount})
+ }
+ } else {
+ var rows []struct {
+ UserID *uint64
+ Amount string
+ }
+ if err := q.Select("s.user_id,COALESCE(SUM(s.pay_amount),0) AS amount").Group("s.user_id").Scan(&rows).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, row := range rows {
+ result.Payments = append(result.Payments, &business.ChannelIdentityPay{UserID: row.UserID, Amount: row.Amount})
+ }
+ }
+ return result, nil
+}
diff --git a/internal/data/business/channel_portrait_test.go b/internal/data/business/channel_portrait_test.go
new file mode 100644
index 0000000..3e24dde
--- /dev/null
+++ b/internal/data/business/channel_portrait_test.go
@@ -0,0 +1,104 @@
+package business
+
+import (
+ "context"
+ "testing"
+
+ "gorm.io/gorm"
+ "kra/internal/biz/business"
+)
+
+func prepareChannelTiers(t *testing.T) (*gorm.DB, business.ChannelPortraitRepo, business.ChannelGameScope) {
+ t.Helper()
+ db, _ := prepareChannels(t)
+ for _, sql := range []string{
+ "ALTER TABLE tab_spend ADD COLUMN user_id INTEGER",
+ "ALTER TABLE tab_spend ADD COLUMN pay_time INTEGER",
+ "DELETE FROM tab_spend",
+ "DELETE FROM tab_sw_report_day_key",
+ "INSERT INTO tab_user VALUES('lx',6,100,0,'subaccount'),('lx',7,100,0,'unpaid')",
+ "INSERT INTO tab_sw_report_day_key VALUES('lx',100,1,1,'2026-11-01',1,'abcx'),('lx',101,1,1,'2026-11-02',1,'abcx'),('lx',100,1,1,'2026-11-01',1,'subaccount'),('lx',100,1,1,'2026-11-01',1,'unpaid'),('lx',100,1,1,'2026-11-04',1,'future'),('lx',103,1,1,'2026-11-01',1,'outside-tree'),('hw',100,1,1,'2026-11-01',1,'outside-platform'),('lx',100,1,0,'2026-11-01',1,'1'),('lx',100,1,0,'2026-11-01',1,'2'),('lx',100,1,0,'2026-11-01',1,'6')",
+ "INSERT INTO tab_spend VALUES('lx',100,1,10.10,1,1000),('lx',101,1,20.20,2,1793577600),('lx',100,1,9969.70,1,2000000000),('lx',101,1,5000,4,1793577600),('lx',102,1,100000,3,1793577600),('lx',103,1,999999,5,1793577600),('hw',100,1,999999,1,1793577600),('lx',100,0,999999,1,1793577600)",
+ } {
+ if err := db.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ level := 1
+ scope := business.ChannelGameScope{Root: &business.Channel{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 100}, Level: &level}, IncludeSub: true, IDCardDedup: true, BeginDate: "2026-11-01", EndDate: "2026-11-03"}
+ return db, NewChannelPortraitRepo(testData{db}), scope
+}
+func TestChannelTierStorageUsesAllHistoricalPayAndRawPayer(t *testing.T) {
+ db, repo, scope := prepareChannelTiers(t)
+ data, err := repo.TierData(context.Background(), scope)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !data.Available || len(data.RegisterKeys) != 3 || len(data.Payments) != 3 {
+ t.Fatalf("分层成员或支付范围错误: %+v", data)
+ }
+ amounts := map[string]string{}
+ for _, pay := range data.Payments {
+ if pay.IDCard != nil {
+ amounts[*pay.IDCard] = portraitTestMoney(pay.Amount)
+ }
+ }
+ if amounts[" abcX "] != "9979.80" || amounts["abcx"] != "20.20" || amounts["subaccount"] != "5000.00" {
+ t.Fatalf("历史/未来成功支付被日期过滤、合并到错误用户或混入其他平台: %+v", amounts)
+ }
+ if err := db.Exec("DROP TABLE tab_user").Error; err != nil {
+ t.Fatal(err)
+ }
+ scope.IDCardDedup = false
+ data, err = repo.TierData(context.Background(), scope)
+ if err != nil || !data.Available {
+ t.Fatalf("模式0不应依赖身份证用户表: %v", err)
+ }
+ keys := map[string]bool{}
+ for _, key := range data.RegisterKeys {
+ keys[key] = true
+ }
+ if len(keys) != 3 || !keys["lx|1"] || !keys["lx|2"] || !keys["lx|6"] {
+ t.Fatalf("模式0身份前缀错误: %+v", keys)
+ }
+ payers := map[uint64]string{}
+ for _, pay := range data.Payments {
+ if pay.UserID != nil {
+ payers[*pay.UserID] = portraitTestMoney(pay.Amount)
+ }
+ }
+ if payers[2] != "20.20" || payers[4] != "5000.00" {
+ t.Fatal("旧分层使用实际付款账号,不能擅自把子账号金额合到父账号")
+ }
+ scope.IDCardDedup = true
+ data, err = repo.TierData(context.Background(), scope)
+ if err != nil || data.Available {
+ t.Fatal("身份证模式缺用户表时不能伪造分层")
+ }
+}
+func TestChannelPortraitEarliestDateAndEmptyMembership(t *testing.T) {
+ db, repo, scope := prepareChannelTiers(t)
+ date, available, err := repo.EarliestDate(context.Background(), scope)
+ if err != nil || !available || date != "2026-11-01" {
+ t.Fatalf("累计起点未遵循当前去重模式: %s %v", date, err)
+ }
+ scope.IDCardDedup = false
+ date, _, err = repo.EarliestDate(context.Background(), scope)
+ if err != nil || date != "2026-10-01" {
+ t.Fatal("模式0累计起点错误")
+ }
+ count, err := repo.GameCount(context.Background(), scope.Root.ChannelKey)
+ if err != nil || count == nil || *count != 2 {
+ t.Fatal("游戏数应只统计当前渠道正常申请并去重")
+ }
+ if err := db.Exec("DELETE FROM tab_sw_report_day_key").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Exec("DROP TABLE tab_spend").Error; err != nil {
+ t.Fatal(err)
+ }
+ data, err := repo.TierData(context.Background(), scope)
+ if err != nil || !data.Available || len(data.RegisterKeys) != 0 || data.FallbackCount != 0 {
+ t.Fatal("无注册身份是已知零,不应继续查询支付")
+ }
+}
diff --git a/internal/data/data.go b/internal/data/data.go
index 30fff41..d0e716d 100644
--- a/internal/data/data.go
+++ b/internal/data/data.go
@@ -40,6 +40,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewChannelRepo,
databusiness.NewChannelWriteRepo,
databusiness.NewBusinessPortraitRepo,
+ databusiness.NewChannelPortraitRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go
index e488711..5a6e788 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 != 11 || apis != 52 {
- t.Fatalf("admin surface = %d menus/%d APIs, want 11/52", menus, apis)
+ if menus != 11 || apis != 53 {
+ t.Fatalf("admin surface = %d menus/%d APIs, want 11/53", 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 685d229..3977133 100644
--- a/internal/routecatalog/catalog.go
+++ b/internal/routecatalog/catalog.go
@@ -41,6 +41,7 @@ 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/portrait": {group: "渠道画像", description: "查询渠道画像和玩家付费分层"},
"GET /sw/business/:userId/portrait": {group: "商务画像", description: "查询商务画像"},
"GET /sw/business/bindPlatform/portrait": {group: "商务画像", description: "查询 SDK 商务画像"},
"GET /sw/promote/list": {group: "渠道管理", description: "查询渠道列表及累计指标"},
diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go
index 551b9c7..7d9a1be 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{
+ ChannelPortrait: &handler.ChannelPortrait{},
BusinessPortrait: &handler.BusinessPortrait{},
Channel: &handler.Channel{},
Activation: &handler.Activation{},
@@ -104,7 +105,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":235`) {
+ if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":236`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
diff --git a/internal/server/handler/channel_portrait.go b/internal/server/handler/channel_portrait.go
new file mode 100644
index 0000000..6a4ea16
--- /dev/null
+++ b/internal/server/handler/channel_portrait.go
@@ -0,0 +1,29 @@
+package handler
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/go-kratos/kratos/v3/errors"
+ "kra/internal/service/business"
+ "kra/internal/service/dto"
+)
+
+type ChannelPortrait struct {
+ service *business.ChannelPortraitService
+}
+
+func NewChannelPortrait(service *business.ChannelPortraitService) *ChannelPortrait {
+ return &ChannelPortrait{service: service}
+}
+func (h *ChannelPortrait) Portrait(c *gin.Context) {
+ var req dto.ChannelPortraitQuery
+ if c.ShouldBindQuery(&req) != nil {
+ Fail(c, "渠道画像查询参数不正确")
+ return
+ }
+ result, err := h.service.Portrait(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 86a7823..60a76fa 100644
--- a/internal/server/handler/provider.go
+++ b/internal/server/handler/provider.go
@@ -13,6 +13,7 @@ var ProviderSet = wire.NewSet(
NewActivation,
NewChannel,
NewBusinessPortrait,
+ NewChannelPortrait,
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 f3f9846..f9db7b0 100644
--- a/internal/server/handler/set.go
+++ b/internal/server/handler/set.go
@@ -1,6 +1,7 @@
package handler
type Set struct {
+ ChannelPortrait *ChannelPortrait
BusinessPortrait *BusinessPortrait
Channel *Channel
Activation *Activation
diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go
index 5bfd3c8..d6f6527 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() { private.GET("/sw/promote/portrait", r.handlers.ChannelPortrait.Portrait) },
func() {
private.GET("/sw/business/:userId/portrait", r.handlers.BusinessPortrait.ForUser)
private.GET("/sw/business/bindPlatform/portrait", r.handlers.BusinessPortrait.ForBinding)
diff --git a/internal/service/business/business_portrait.go b/internal/service/business/business_portrait.go
index 7395cdf..061d169 100644
--- a/internal/service/business/business_portrait.go
+++ b/internal/service/business/business_portrait.go
@@ -19,7 +19,12 @@ func portraitMetricResponse(m *business.PortraitMetric) *dto.PortraitMetric {
if m == nil {
return nil
}
- return &dto.PortraitMetric{Value: m.Value, CompareValue: m.CompareValue, ChangeRate: m.ChangeRate, ChangeDiff: m.ChangeDiff, CompareLabel: m.CompareLabel}
+ var label *string
+ if m.CompareLabel != "" {
+ value := m.CompareLabel
+ label = &value
+ }
+ return &dto.PortraitMetric{Value: m.Value, CompareValue: m.CompareValue, ChangeRate: m.ChangeRate, ChangeDiff: m.ChangeDiff, CompareLabel: label}
}
func portraitResponse(p *business.BusinessPortrait) *dto.BusinessPortraitResponse {
b := p.Basic
diff --git a/internal/service/business/channel_portrait.go b/internal/service/business/channel_portrait.go
new file mode 100644
index 0000000..dc7d75d
--- /dev/null
+++ b/internal/service/business/channel_portrait.go
@@ -0,0 +1,49 @@
+package business
+
+import (
+ "context"
+ "kra/internal/biz/business"
+ "kra/internal/service/dto"
+)
+
+type ChannelPortraitUsecase interface {
+ Portrait(context.Context, business.ChannelKey, string) (*business.ChannelPortrait, error)
+}
+type ChannelPortraitService struct{ uc ChannelPortraitUsecase }
+
+func NewChannelPortraitService(uc *business.ChannelPortraitUsecase) *ChannelPortraitService {
+ return &ChannelPortraitService{uc: uc}
+}
+func (s *ChannelPortraitService) Portrait(ctx context.Context, req *dto.ChannelPortraitQuery) (*dto.ChannelPortraitResponse, error) {
+ if req == nil || req.PromoteID == 0 {
+ return nil, business.ErrChannelInput
+ }
+ p, err := s.uc.Portrait(ctx, business.ChannelKey{SourceDB: req.SourceDB, ID: req.PromoteID}, req.Period)
+ if err != nil {
+ return nil, err
+ }
+ c := p.Basic.Channel
+ basic := &dto.ChannelPortraitBasic{PromoteID: c.ID, SourceDB: c.SourceDB, SourceDBLabel: business.PlatformName(c.SourceDB), Account: c.Account, Nickname: c.Nickname, RealName: c.RealName, PromoteLevel: c.Level, PromoteLevelLabel: business.ChannelLevelLabel(c.Level), Status: c.Status, StatusLabel: business.ChannelStatusLabel(c.Status), RegisterType: c.RegisterType, RegisterTypeLabel: business.ChannelTypeLabel(c.RegisterType), CreateTime: localTime(business.ChannelUnixTime(c.CreatedUnix)), LastLoginTime: localTime(business.ChannelUnixTime(c.LastLoginUnix)), BusierUserID: c.BusierUserID, BusierDisplayName: c.BusinessDisplayName(), GameCount: p.Basic.GameCount}
+ if parent := p.Basic.Parent; parent != nil {
+ basic.ParentPromoteID = &parent.ID
+ basic.ParentPromoteName = &parent.Account
+ basic.ParentPromoteAccount = &parent.Account
+ }
+ out := &dto.ChannelPortraitResponse{Basic: basic, Period: p.Period, Available: p.Available, UnavailableReason: p.UnavailableReason, RegisterTrend: []*dto.PortraitTrend{}, PayTrend: []*dto.PortraitTrend{}, GameDistribution: []*dto.ChannelGameResponse{}, PayTiers: []*dto.ChannelPayTier{}}
+ if m := p.Metrics; m != nil {
+ out.Metrics = &dto.ChannelPortraitMetrics{Register: portraitMetricResponse(m.Register), PayAmount: portraitMetricResponse(m.PayAmount), PayRate: portraitMetricResponse(m.PayRate), Active: portraitMetricResponse(m.Active)}
+ }
+ for _, point := range p.RegisterTrend {
+ out.RegisterTrend = append(out.RegisterTrend, &dto.PortraitTrend{Label: point.Label, Value: point.Value})
+ }
+ for _, point := range p.PayTrend {
+ out.PayTrend = append(out.PayTrend, &dto.PortraitTrend{Label: point.Label, Value: point.Value})
+ }
+ for _, game := range p.GameDistribution {
+ out.GameDistribution = append(out.GameDistribution, &dto.ChannelGameResponse{GameID: game.ID, GameName: game.Name, RegisterCount: game.RegisterCount, PayAmount: game.PayAmount})
+ }
+ for _, tier := range p.PayTiers {
+ out.PayTiers = append(out.PayTiers, &dto.ChannelPayTier{Tier: tier.Tier, TierLabel: tier.Label, Count: tier.Count, Ratio: tier.Ratio})
+ }
+ return out, nil
+}
diff --git a/internal/service/dto/business_portrait.go b/internal/service/dto/business_portrait.go
index b64ad3d..7316d84 100644
--- a/internal/service/dto/business_portrait.go
+++ b/internal/service/dto/business_portrait.go
@@ -29,7 +29,7 @@ type PortraitMetric struct {
CompareValue *string `json:"compareValue"`
ChangeRate *string `json:"changeRate"`
ChangeDiff *string `json:"changeDiff"`
- CompareLabel string `json:"compareLabel"`
+ CompareLabel *string `json:"compareLabel"`
}
type PortraitMetrics struct {
Register *PortraitMetric `json:"register"`
diff --git a/internal/service/dto/channel_portrait.go b/internal/service/dto/channel_portrait.go
new file mode 100644
index 0000000..f59f42c
--- /dev/null
+++ b/internal/service/dto/channel_portrait.go
@@ -0,0 +1,52 @@
+package dto
+
+type ChannelPortraitQuery struct {
+ SourceDB string `form:"sourceDb"`
+ PromoteID uint64 `form:"promoteId"`
+ Period string `form:"period"`
+}
+type ChannelPortraitBasic struct {
+ PromoteID uint64 `json:"promoteId"`
+ SourceDB string `json:"sourceDb"`
+ SourceDBLabel string `json:"sourceDbLabel"`
+ Account string `json:"account"`
+ Nickname string `json:"nickname"`
+ RealName string `json:"realName"`
+ PromoteLevel *int `json:"promoteLevel"`
+ PromoteLevelLabel string `json:"promoteLevelLabel"`
+ Status *int `json:"status"`
+ StatusLabel string `json:"statusLabel"`
+ RegisterType *int `json:"registerType"`
+ RegisterTypeLabel string `json:"registerTypeLabel"`
+ CreateTime *string `json:"createTime"`
+ LastLoginTime *string `json:"lastLoginTime"`
+ BusierUserID *uint `json:"busierUserId"`
+ BusierDisplayName string `json:"busierDisplayName"`
+ ParentPromoteID *uint64 `json:"parentPromoteId"`
+ ParentPromoteName *string `json:"parentPromoteName"`
+ ParentPromoteAccount *string `json:"parentPromoteAccount"`
+ GameCount *int64 `json:"gameCount"`
+}
+type ChannelPortraitMetrics struct {
+ Register *PortraitMetric `json:"register"`
+ PayAmount *PortraitMetric `json:"payAmount"`
+ PayRate *PortraitMetric `json:"payRate"`
+ Active *PortraitMetric `json:"active"`
+}
+type ChannelPayTier struct {
+ Tier string `json:"tier"`
+ TierLabel string `json:"tierLabel"`
+ Count int64 `json:"count"`
+ Ratio string `json:"ratio"`
+}
+type ChannelPortraitResponse struct {
+ Basic *ChannelPortraitBasic `json:"basic"`
+ Period string `json:"period"`
+ Metrics *ChannelPortraitMetrics `json:"metrics"`
+ RegisterTrend []*PortraitTrend `json:"registerTrend"`
+ PayTrend []*PortraitTrend `json:"payTrend"`
+ GameDistribution []*ChannelGameResponse `json:"gameDistribution"`
+ PayTiers []*ChannelPayTier `json:"payTiers"`
+ Available bool `json:"available"`
+ UnavailableReason string `json:"unavailableReason"`
+}
diff --git a/internal/service/service.go b/internal/service/service.go
index 8ab4297..15f9a86 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -22,6 +22,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewActivationService,
businessservice.NewChannelService,
businessservice.NewBusinessPortraitService,
+ businessservice.NewChannelPortraitService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
diff --git a/scripts/sw_channel_page_smoke.cjs b/scripts/sw_channel_page_smoke.cjs
index 6b8bdb9..e2b3700 100644
--- a/scripts/sw_channel_page_smoke.cjs
+++ b/scripts/sw_channel_page_smoke.cjs
@@ -86,7 +86,7 @@ const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright')
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 }
+ const result = { passed: true, level: 1, noEditEntry: true, createAllowedForUpperBusiness: true, platformSwitchClearsBinding: true, crossPlatformSameID: true, zeroGameCountClickable: true, missingMetricsVisible: true, sourceDatabaseCreatePayload: true, portraitPending: false, 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()
diff --git a/scripts/sw_channel_portrait_http_smoke.py b/scripts/sw_channel_portrait_http_smoke.py
new file mode 100644
index 0000000..2f149b1
--- /dev/null
+++ b/scripts/sw_channel_portrait_http_smoke.py
@@ -0,0 +1,47 @@
+"""只读验证渠道画像路由及现有数据的缺表处理,不修改业务记录。"""
+import argparse
+import json
+import urllib.parse
+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 get(path, expected=0):
+ req = urllib.request.Request(args.base_url + path, headers={"x-token": token})
+ with urllib.request.urlopen(req, timeout=30) as response:
+ result = json.load(response)
+ item = {"path": path, "http": response.status, "code": result.get("code"), "msg": result.get("msg"),
+ "passed": response.status == 200 and result.get("code") == expected}
+ data = result.get("data", {})
+ if isinstance(data, dict) and "available" in data:
+ item["available"] = data["available"]
+ checks.append(item)
+ return data
+
+ channels = get("/sw/promote/list?pageNum=1&pageSize=1")
+ if channels.get("available") is False:
+ get("/sw/promote/portrait?sourceDb=lx&promoteId=1&period=total", expected=7)
+ elif channels.get("rows"):
+ row = channels["rows"][0]
+ get("/sw/promote/portrait?" + urllib.parse.urlencode({"sourceDb": row["sourceDb"], "promoteId": row["id"], "period": "today"}))
+ get("/sw/promote/portrait?sourceDb=lx&promoteId=0", expected=7)
+ get("/sw/promote/portrait?sourceDb=invalid&promoteId=1", expected=7)
+ report = {"scope": "当前 sw7 登录会话,只读检查;完整累计、分层、日期与权限在隔离 TCP HTTP 中验证。",
+ "checks": checks, "passed": all(item["passed"] for item in checks)}
+ args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ print(json.dumps(report, ensure_ascii=False))
+ if not report["passed"]:
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/sw_channel_portrait_page_smoke.cjs b/scripts/sw_channel_portrait_page_smoke.cjs
new file mode 100644
index 0000000..fdd3a26
--- /dev/null
+++ b/scripts/sw_channel_portrait_page_smoke.cjs
@@ -0,0 +1,75 @@
+// 真实 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: 1500, height: 1050 } })
+ await context.addInitScript(value => localStorage.setItem('token', value), token)
+ const page = await context.newPage()
+ const errors = [], requests = [], alerts = [], games = []
+ let unavailable = false
+ 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') return ok({ available: true, total: 2, rows: ['lx', 'hw'].map(sourceDb => ({ id: 100, sourceDb, platformName: sourceDb === 'lx' ? '龙翔' : '汇玩', account: `${sourceDb}-channel`, nickname: `${sourceDb}测试渠道`, promoteLevel: 1, gameCount: 2, playerCount: 4, totalPayAmount: '15.00', lockStatus: 0 })) })
+ if (api === '/sw/promote/portrait') {
+ requests.push(Object.fromEntries(url.searchParams))
+ const period = url.searchParams.get('period'), source = url.searchParams.get('sourceDb')
+ const length = period === 'total' ? 21 : period === 'last7' ? 7 : 24
+ const trend = Array.from({ length }, (_, i) => ({ label: length === 24 ? `${String(i).padStart(2, '0')}:00` : new Date(Date.UTC(2026, 10, 3 - length + 1 + i)).toISOString().slice(5, 10), value: i === 1 ? '4' : '0' }))
+ const metric = value => ({ value, compareValue: period === 'total' ? null : '1', changeRate: period === 'total' ? null : '100.0', changeDiff: null, compareLabel: period === 'total' ? null : '较上一周期' })
+ return ok({ available: !unavailable, unavailableReason: unavailable ? '付费分层所需数据尚未接入' : '', period,
+ basic: { promoteId: 100, nickname: `${source}测试渠道`, account: `${source}-channel`, busierDisplayName: 'SDK(商务)', promoteLevelLabel: '一级渠道', registerTypeLabel: '公会', status: -3, statusLabel: '商务新增', gameCount: 2, createTime: '2026-11-01 08:00:00', lastLoginTime: '2026-11-03 08:00:00' },
+ metrics: unavailable ? null : { register: metric('4'), payAmount: metric('15.00'), payRate: metric('25.0'), active: metric('4') },
+ registerTrend: unavailable ? [] : trend, payTrend: unavailable ? [] : trend.map(point => ({ ...point, value: point.value === '4' ? '15.00' : '0.00' })),
+ payTiers: unavailable ? [] : ['whale', 'mid', 'small', 'free'].map((tier, index) => ({ tier, tierLabel: ['大R', '中R', '小R', '免费'][index], count: 1, ratio: '25.0' })), gameDistribution: [] })
+ }
+ if (api === '/sw/alertRecord/list') { alerts.push(Object.fromEntries(url.searchParams)); return ok({ available: true, pageData: { rows: [{ id: '1', createdAt: '2026-11-03 10:00:00', ruleType: 'register_anomaly', alertContent: '对应渠道预警', status: 1 }], total: 1 }, statusCount: { 1: 1 } }) }
+ if (api === '/sw/promote/game-distribution') { games.push(Object.fromEntries(url.searchParams)); return ok([{ gameId: 1, gameName: '累计推广游戏', registerCount: 4, payAmount: '31.00' }]) }
+ const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } })
+ return route.fulfill({ response })
+ })
+ await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/ChannelList')
+ const row = source => page.locator('.el-table__body-wrapper tr').filter({ hasText: `${source}-channel` })
+ await row('lx').getByRole('button', { name: '画像', exact: true }).click()
+ const dialog = page.getByRole('dialog', { name: '渠道画像 — lx测试渠道', exact: true })
+ await dialog.getByText('活跃玩家', { exact: true }).waitFor()
+ await dialog.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
+ assert.equal(await dialog.getByText('人均充值', { exact: true }).count(), 0)
+ assert.equal(await dialog.locator('.pay-tier-item').count(), 4)
+ await dialog.getByText('对应渠道预警', { exact: true }).waitFor()
+ assert.equal(alerts.at(-1).targetPromoteId, '100'); assert.equal(alerts.at(-1).platformCodes, 'lx'); assert.equal(alerts.at(-1).businessId, undefined)
+ await Promise.all([page.waitForResponse(response => response.url().includes('/sw/promote/portrait') && response.url().includes('period=last7')), dialog.getByText('近7天', { exact: true }).click()])
+ assert.equal(requests.at(-1).period, 'last7')
+ await dialog.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
+ await Promise.all([page.waitForResponse(response => response.url().includes('/sw/promote/portrait') && response.url().includes('period=total')), dialog.getByText('累计', { exact: true }).click()])
+ await dialog.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
+ assert.equal(await dialog.locator('.metric-compare').count(), 0)
+ await page.screenshot({ path: path.join(process.env.TEMP, 'sw-channel-portrait.png'), animations: 'disabled' })
+ await dialog.locator('.profile-info').getByRole('button', { name: '2 款', exact: true }).click()
+ const gameDialog = page.getByRole('dialog', { name: '推广游戏数据 — lx测试渠道', exact: true })
+ await gameDialog.getByText('累计推广游戏', { exact: true }).waitFor()
+ assert.equal(games.at(-1).sourceDb, 'lx'); assert.equal(games.at(-1).promoteId, '100')
+ await gameDialog.locator('.el-drawer__close-btn').click(); await gameDialog.waitFor({ state: 'hidden' })
+ await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await dialog.waitFor({ state: 'hidden' })
+ await row('hw').getByRole('button', { name: '画像', exact: true }).click()
+ const other = page.getByRole('dialog', { name: '渠道画像 — hw测试渠道', exact: true })
+ await other.getByText('对应渠道预警', { exact: true }).waitFor()
+ assert.equal(requests.at(-1).sourceDb, 'hw'); assert.equal(alerts.at(-1).platformCodes, 'hw')
+ await other.getByRole('button', { name: '关闭', exact: true }).click(); await other.waitFor({ state: 'hidden' })
+ unavailable = true
+ await row('lx').getByRole('button', { name: '画像', exact: true }).click()
+ await dialog.getByText('付费分层所需数据尚未接入', { exact: true }).waitFor()
+ assert.equal(await dialog.locator('.pay-tier-item').count(), 0)
+ assert.deepEqual(errors, [])
+ const report = { method: '真实 Chrome,画像、游戏与预警数据为受控响应,不改业务数据', passed: true, activeMetric: true, totalNoComparison: true, canonicalLast7: true, fourPayTiers: true, gameDrawer: true, platformAndChannelAlertFilter: true, crossPlatformSameID: true, unavailableIsNotZero: true, pageErrors: errors }
+ await fs.writeFile('docs/sw6-to-kra/channel-portrait-page-2026-11-03.json', JSON.stringify(report, null, 2) + '\n')
+ console.log(JSON.stringify(report)); 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
index 88058ad..e7cdb60 100644
--- a/web/src/api/channel.js
+++ b/web/src/api/channel.js
@@ -4,3 +4,4 @@ export const listChannels = params => service({ url: '/sw/promote/list', method:
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 })
+export const channelPortrait = params => service({ url: '/sw/promote/portrait', method: 'get', params })
diff --git a/web/src/view/sw/ChannelList.vue b/web/src/view/sw/ChannelList.vue
index 3a0b415..bd210a4 100644
--- a/web/src/view/sw/ChannelList.vue
+++ b/web/src/view/sw/ChannelList.vue
@@ -27,11 +27,13 @@