From 0de822f121acb98ae24e378eb7c420dc7bd1611a Mon Sep 17 00:00:00 2001
From: Yvan <8574526@qq,com>
Date: Tue, 3 Nov 2026 23:11:46 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E5=95=86=E5=8A=A1?=
=?UTF-8?q?=E4=B8=8E=E6=B8=B8=E6=88=8F=E5=95=86=E5=8A=A1=E7=94=BB=E5=83=8F?=
=?UTF-8?q?=E5=8F=8A=E4=BA=A4=E4=BA=92=E7=9C=8B=E6=9D=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
cmd/business_portrait_http_test.go | 185 +++++++
cmd/wire_gen.go | 9 +-
docs/sw6-to-kra/implementation.md | 8 +-
docs/sw6-to-kra/page-parity.md | 25 +-
docs/sw6-to-kra/portrait-http-2026-11-03.json | 60 +++
docs/sw6-to-kra/portrait-page-2026-11-03.json | 17 +
docs/sw6-to-kra/portrait-source-notes.md | 8 +-
internal/biz/biz.go | 2 +-
internal/biz/business/business_portrait.go | 491 ++++++++++++++++++
.../biz/business/business_portrait_test.go | 132 +++++
internal/data/business/business_portrait.go | 299 +++++++++++
.../data/business/business_portrait_test.go | 153 ++++++
internal/data/data.go | 1 +
internal/modules/catalog_test.go | 4 +-
internal/routecatalog/catalog.go | 2 +
internal/server/gin_test.go | 17 +-
internal/server/handler/business_portrait.go | 48 ++
internal/server/handler/provider.go | 1 +
internal/server/handler/set.go | 1 +
internal/server/router/routes.go | 4 +
.../service/business/business_portrait.go | 73 +++
internal/service/dto/business_portrait.go | 62 +++
internal/service/service.go | 1 +
scripts/sw_portrait_http_smoke.py | 68 +++
scripts/sw_portrait_page_smoke.cjs | 101 ++++
web/src/api/businessPortrait.js | 4 +
web/src/view/sw/BusinessList.vue | 7 +
.../view/sw/components/BusinessBindings.vue | 12 +-
.../sw/components/BusinessProfileDrawer.vue | 134 +++++
web/src/view/sw/components/BusinessTrend.vue | 56 ++
30 files changed, 1959 insertions(+), 26 deletions(-)
create mode 100644 cmd/business_portrait_http_test.go
create mode 100644 docs/sw6-to-kra/portrait-http-2026-11-03.json
create mode 100644 docs/sw6-to-kra/portrait-page-2026-11-03.json
create mode 100644 internal/biz/business/business_portrait.go
create mode 100644 internal/biz/business/business_portrait_test.go
create mode 100644 internal/data/business/business_portrait.go
create mode 100644 internal/data/business/business_portrait_test.go
create mode 100644 internal/server/handler/business_portrait.go
create mode 100644 internal/service/business/business_portrait.go
create mode 100644 internal/service/dto/business_portrait.go
create mode 100644 scripts/sw_portrait_http_smoke.py
create mode 100644 scripts/sw_portrait_page_smoke.cjs
create mode 100644 web/src/api/businessPortrait.js
create mode 100644 web/src/view/sw/components/BusinessProfileDrawer.vue
create mode 100644 web/src/view/sw/components/BusinessTrend.vue
diff --git a/cmd/business_portrait_http_test.go b/cmd/business_portrait_http_test.go
new file mode 100644
index 0000000..09f1269
--- /dev/null
+++ b/cmd/business_portrait_http_test.go
@@ -0,0 +1,185 @@
+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 TestBusinessAndPlatformPortraitOverHTTP(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("portrait-main.db"), open("portrait-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,real_name,level,parent_id,id_path,enable,business_status) VALUES(1,'admin','admin','管理员',0,0,'0',1,0),(7,'owner','owner','商务甲',1,0,'0',1,0),(9,'leaf','leaf','下级商务',3,7,'0,7',1,0),(50,'empty','empty','无绑定商务',1,0,'0',1,0),(99,'other','other','其他商务',1,0,'0',1,0)",
+ "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(9,'lx',14,'sdk-child','0'),(99,'hw',14,'outside','0')",
+ "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,account TEXT,promote_level INTEGER,top_promote_id INTEGER,busier_id INTEGER,create_time INTEGER)",
+ "INSERT INTO tab_promote VALUES(100,'lx','主渠道',1,0,14,1000),(101,'lx','子渠道',2,100,14,1000),(100,'hw','其他平台渠道',1,0,14,1000)",
+ "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,promote_id INTEGER,puid INTEGER,register_time INTEGER)",
+ "CREATE TABLE tab_spend(source_db TEXT,promote_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")
+ previous := now.AddDate(0, 0, -7).Format("2006-01-02")
+ for _, row := range []struct {
+ source string
+ promote, mode int
+ date, amount string
+ }{{"lx", 100, 0, today, "5.00"}, {"lx", 101, 0, today, "7.00"}, {"lx", 100, 1, today, "1200.00"}, {"lx", 100, 0, previous, "6.00"}, {"hw", 100, 0, today, "999.00"}} {
+ if err := db.Exec("INSERT INTO tab_sw_report_day VALUES(?,?,1,?,?,?)", row.source, row.promote, row.mode, row.date, row.amount).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, mode := range []int{0, 1} {
+ second := "A"
+ if mode == 0 {
+ second = "B"
+ }
+ for _, row := range []struct {
+ promote, metric int
+ key, date string
+ }{{100, 1, "A", today}, {101, 1, second, today}, {100, 2, "A", today}, {101, 2, "B", today}, {100, 3, "A", today}, {100, 1, "P", previous}, {100, 2, "P1", previous}, {100, 2, "P2", previous}, {100, 2, "P3", previous}, {100, 2, "P4", previous}, {100, 3, "P1", previous}} {
+ if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES('lx',?,1,?,?,?,?)", row.promote, mode, row.date, row.metric, row.key).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES('hw',100,1,1,?,1,'outside')", today).Error; err != nil {
+ t.Fatal(err)
+ }
+ start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
+ if err := db.Exec("INSERT INTO tab_user VALUES('lx',100,0,?),('lx',101,0,?),('lx',101,100,?)", start+3600, start+3600, start+3600).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Exec("INSERT INTO tab_spend VALUES('lx',100,1,?,5),('lx',101,1,?,7),('lx',100,0,?,999)", start+3600, start+3600, start+3600).Error; err != nil {
+ t.Fatal(err)
+ }
+ for _, sql := range []string{"CREATE TABLE tab_promote_business(id INTEGER,account TEXT,real_name TEXT,level INTEGER,status INTEGER,commerce1 INTEGER,commerce2 INTEGER,create_time INTEGER,password TEXT)", "INSERT INTO tab_promote_business VALUES(14,'sdk-child','平台商务',3,0,11,12,1000,'not-for-response'),(12,'sdk-parent','平台上级',2,1,11,0,1000,'not-for-response')"} {
+ if err := platform.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ provider := businessHTTPData{db}
+ platforms := &channelHTTPDatabases{platform: platform}
+ accounts := businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider))
+ preferences := businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider))
+ h := handler.NewBusinessPortrait(businessservice.NewBusinessPortraitService(businessbiz.NewBusinessPortraitUsecase(businessdata.NewBusinessPortraitRepo(provider, platforms), accounts, preferences)))
+ 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/business/:userId/portrait", h.ForUser)
+ engine.GET("/sw/business/bindPlatform/portrait", h.ForBinding)
+ server := httptest.NewServer(engine)
+ defer server.Close()
+ call := func(user int, path string, expected int) map[string]any {
+ t.Helper()
+ req, _ := http.NewRequest("GET", server.URL+path, 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", path, user, body)
+ }
+ return body["data"].(map[string]any)
+ }
+ const profilePath = "/sw/business/7/portrait?sourceDbList=lx,hw&period=last7"
+ data := call(7, profilePath, 0)
+ basic := data["basic"].(map[string]any)
+ metrics := data["metrics"].(map[string]any)
+ if basic["userId"] != float64(7) || basic["promoteCount"] != float64(1) || metrics["register"].(map[string]any)["value"] != "1" || metrics["payAmount"].(map[string]any)["value"] != "12.00" || metrics["payRate"].(map[string]any)["value"] != "50.0" || metrics["payRate"].(map[string]any)["changeDiff"] != "25.0" || len(data["registerTrend"].([]any)) != 7 {
+ t.Fatalf("商务画像口径或响应错误: %+v", data)
+ }
+ ranks := data["registerTop5"].([]any)
+ if len(ranks) != 1 || ranks[0].(map[string]any)["value"] != "1.00" || ranks[0].(map[string]any)["promoteName"] != "主渠道" {
+ t.Fatal("注册排行没有在渠道树内去重")
+ }
+ data = call(7, "/sw/business/7/portrait?sourceDbList=lx&period=today", 0)
+ if len(data["registerTrend"].([]any)) != 24 || data["registerTrend"].([]any)[1].(map[string]any)["value"] != "2" {
+ 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, profilePath, 0)
+ if data["metrics"].(map[string]any)["register"].(map[string]any)["value"] != "2" {
+ t.Fatal("未读取查看者的去重偏好")
+ }
+ data = call(1, profilePath, 0)
+ if data["metrics"].(map[string]any)["register"].(map[string]any)["value"] != "1" {
+ t.Fatal("误用了目标商务的偏好")
+ }
+ if err := db.Exec("UPDATE sys_user_preference SET include_sub_promote=0 WHERE user_id=7").Error; err != nil {
+ t.Fatal(err)
+ }
+ data = call(7, profilePath, 0)
+ if data["metrics"].(map[string]any)["payAmount"].(map[string]any)["value"] != "5.00" {
+ t.Fatal("关闭下级仍汇总子渠道金额")
+ }
+ data = call(9, "/sw/business/bindPlatform/portrait?sourceDb=lx&platformBusierId=14&period=last7", 0)
+ basic = data["basic"].(map[string]any)
+ if basic["userId"] != float64(14) || basic["userName"] != "sdk-child" || basic["parentUserId"] != float64(12) || basic["parentUserName"] != "sdk-parent" || basic["statusLabel"] != "待审核" {
+ t.Fatalf("SDK 基本信息混入 SW 资料: %+v", basic)
+ }
+ call(99, profilePath, 7)
+ call(7, "/sw/business/bindPlatform/portrait?sourceDb=hw&platformBusierId=14", 7)
+ call(7, "/sw/business/7/portrait", 7)
+ if err := db.Exec("DROP TABLE tab_promote").Error; err != nil {
+ t.Fatal(err)
+ }
+ data = call(7, profilePath, 0)
+ if data["available"] != false || data["metrics"] != nil || data["basic"].(map[string]any)["promoteCount"] != nil {
+ t.Fatal("缺渠道表时不能伪造零画像")
+ }
+ data = call(1, "/sw/business/50/portrait?sourceDbList=lx&period=today", 0)
+ if data["available"] != true || data["metrics"].(map[string]any)["register"].(map[string]any)["value"] != "0" {
+ t.Fatal("无绑定商务仍应返回真实空画像")
+ }
+ platforms.platform = nil
+ call(9, "/sw/business/bindPlatform/portrait?sourceDb=lx&platformBusierId=14", 7)
+}
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index 4037252..28079cb 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -82,12 +82,16 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
registry := taskRegistry()
- channelRepo := business.NewChannelRepo(dataData)
- channelWriteRepo := business.NewChannelWriteRepo(dataData)
+ businessPortraitRepo := business.NewBusinessPortraitRepo(dataData, dataData)
accountRepo := business.NewAccountRepo(dataData)
accountUsecase := business2.NewAccountUsecase(accountRepo)
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
+ 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)
channelService := business3.NewChannelService(channelUsecase)
@@ -219,6 +223,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase)
integrationConfig := handler.NewIntegrationConfig(integrationConfigService)
set := &handler.Set{
+ BusinessPortrait: businessPortrait,
Channel: channel,
Activation: activation,
AlertRecord: alertRecord,
diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md
index d2cb624..e33b750 100644
--- a/docs/sw6-to-kra/implementation.md
+++ b/docs/sw6-to-kra/implementation.md
@@ -22,7 +22,7 @@
| 阶段 | 范围 | 状态 |
| --- | --- | --- |
| 1 | 商务用户字段、层级与角色、账号资料和审核、偏好、登录及找回密码 | 进行中 |
-| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、OA 激活只读接口已接入;画像与外部审核联调待完成 |
+| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、商务/SDK 画像、OA 激活只读接口已接入;外部数据源及审核实库联调待完成 |
| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 |
| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 进行中;渠道列表/新增、激活、游戏分布已接入,渠道画像待迁移 |
| 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 待实现 |
@@ -47,7 +47,7 @@
- 已落地商务用户七个扩展字段的存取及增量补列;登录和既有会话同时检查商务审核状态。
- 已落地全局偏好 GET/PUT、当前用户隔离、缺省开启、显式关闭及幂等写入;Kra 顶栏增加统计口径入口。
- 已落地商务列表、查询资料、新增子商务、部分编辑和审核状态,以及 Kra 风格 `BusinessList` 页面。层级及角色由服务端按原角色 ID 映射分配;账号、角色、偏好在同一事务创建。列表按平台与商务组合键统计直属一级渠道和已通过绑定。
-- 商务画像及其关联报表仍在后续范围内;后端导出等接口另按源码核对,不能据此给旧页面增加不存在的按钮。当前不将整个商务菜单标为完成。
+- 商务与 SDK 商务画像已接入;渠道画像、其余报表及任务继续迁移。后端导出等接口另按源码核对,不能据此给旧页面增加不存在的按钮;最终仍需完整数据源验收。
- 已接入绑定列表、指定商务已通过绑定详情、提交绑定及解绑四个接口,页面恢复仅三级可见的“绑定列表”页签及只读详情抽屉。绑定流程保持三级限制、平台验密、OA 待审核及驳回重提规则;新增键锁表 `sys_business_binding_locks`,串行处理同一平台商务的并发绑定。
- 已恢复旧登录页的图片、文案、布局及记住账号功能,系统名称统一为“遨游龙翔游戏平台”。找回密码四个匿名接口位于 `/sw/auth/forgot-password/`,保留账号/手机号检查、60 秒发码限制、两分钟验证码、十分钟凭证、6–15 位密码及失败五次吊销等规则。
- 找回密码采用同库事务保存验证码摘要和一次性凭证,并原子执行改密/消费凭证;新增 `sys_business_password_codes`、`sys_business_password_resets`。更新密码版本使旧 JWT 失效。短信复用 Kra 集成配置,`SW_PASSWORD_RESET_SMS_PROVIDER` 默认 `tencent`,也支持 `aliyun`;模板和通道凭据在对应集成配置中维护。
@@ -80,4 +80,8 @@
- 渠道批次通过全量 Go 检查、前端 ESLint/生产构建、独立主库和源平台 SQLite 的 TCP HTTP 验证。`channel-http-2026-11-03.json` 记录实际 sw7 会话请求,当前主库渠道数据未接入,lx/hw/yq 正常绑定选项分别为 17/20/20 条。`channel-page-2026-11-03.json` 核对一级商务新增入口、平台切换清空绑定、跨平台同号、零游戏数入口、缺失指标提示及新增提交参数。画像入口继续随画像用例迁移,当前不将整个渠道菜单标为完成。
- 核对剩余用户接口的基线差异及 Kra 原生能力,记录于 `system-api-audit.md`;保留历史别名未注册、Excel 协议未兼容等差异,不把能力对应表当成接口兼容完成证明。商务页面仍不增加原页面没有的编辑/删除/导入/导出入口。
- 修复原生用户启停只更新 enable、商务状态不同步的问题:由 biz 决定状态联动,在 data 行锁事务中一起写入;未启用时保留待审/驳回,个人资料请求不能自行解除管理员限制。当前 45 个真实用户只读检查未发现已有冲突,没有批量更新真实账号;正常启停、审核状态保留、失败回滚和越权通过隔离 HTTP/仓储/领域测试。全量 Go 测试通过,实际服务检查记录为 `user-status-http-2026-11-03.json`。
+- 新增 `/sw/business/:userId/portrait` 与 `/sw/business/bindPlatform/portrait`。补齐商务列表画像、正常绑定画像和只读 SDK 详情平台入口,共用 Kra 风格抽屉,包含基本资料、五个时间周期、四项指标、可交互趋势、注册/充值 TOP 5 及独立分页的关联预警。SVG 图表使用 Kra 主题变量,无新增图表依赖,支持键盘数据点和负金额。
+- 普通商务基本资料来自新系统用户,SDK 资料来自对应平台库,父级分别按 SW parent_id 和平台 commerce1/commerce2 解析。SDK 状态 0 仍是待审核,不能当作 SW 正常;关联预警/旗下渠道使用打开时的 SW 用户 ID,平台商务 ID 只作为绑定筛选。保留旧 SDK 画像关联预警按商务及平台的实际范围。
+- 画像日指标与日趋势读取汇总/身份键表,模式 0 人数带平台前缀、模式 1 跨平台身份证去重,金额固定模式 0。付费率分母为活跃人数,人均充值分母为注册人数;上期活跃为零时付费率对照和百分点差为 null。今日/昨日趋势读原始主账号与成功支付、补齐 24 小时;MySQL 保留原 HOUR(FROM_UNIXTIME) 会话时区语义。注册 TOP 5 在渠道树内 COUNT DISTINCT;人数上限在排序截取前应用。
+- 画像批次通过领域/存储/双库实际 TCP HTTP 测试、全量 Go 检查和前端 ESLint/构建;验证月末/闰月对比、四舍五入与负零、去重切换、金额稳定、原始小时口径、父级状态、跨商务拒绝及缺表。`portrait-http-2026-11-03.json` 为真实会话检查:无绑定返回零指标及完整趋势,有绑定缺表保留基本资料并明确未知。`portrait-page-2026-11-03.json` 核对三个入口、周期切换、交互图表、两个 ID 范畴和缺数据提示。
- 当前没有执行最终旧数据库复制与迁移验收。
diff --git a/docs/sw6-to-kra/page-parity.md b/docs/sw6-to-kra/page-parity.md
index 9912626..e4f2ee8 100644
--- a/docs/sw6-to-kra/page-parity.md
+++ b/docs/sw6-to-kra/page-parity.md
@@ -8,15 +8,15 @@
| 旧页面行为 | 新页面状态 |
| --- | --- |
-| 商务列表没有编辑按钮;操作列为画像 | 已移除错误增加的编辑入口;画像待报表与画像用例迁移,不能视为完成 |
+| 商务列表没有编辑按钮;操作列为画像 | 已对齐,商务画像及对应抽屉已接入 |
| 第二个页签名为“绑定列表”,仅 `level=3` 显示 | 已对齐;超管、一级、二级均隐藏且不会加载绑定列表 |
| 商务列表筛选:商务名称、商务账号、等级、状态 | 已实现 |
| 添加子商务:账号、密码、姓名、手机号、QQ;展示等级/所属商务 | 已实现;已移除原页面没有的邮箱和编辑审核表单 |
| SDK商务数量可点击,零数量也能打开详情 | 已对齐 |
-| SDK商务绑定详情是只读抽屉,上级可以查看;列为平台、ID、游戏商务账号、审核状态、绑定日期 | 已对齐基础列表与字段;平台点击进入画像待实现 |
+| SDK商务绑定详情是只读抽屉,上级可以查看;列为平台、ID、游戏商务账号、审核状态、绑定日期 | 已对齐,平台点击可进入游戏商务画像 |
| 商务列表列名“旗下渠道数”,点击进入旗下渠道抽屉 | 已对齐,抽屉显示序号、渠道名称、所属平台及分页 |
| 注册日期、绑定日期显示年月日 | 已对齐 |
-| 绑定状态正常:画像、解绑;驳回:查看驳回;待审核:无操作 | 解绑/查看驳回/待审核无操作已对齐;画像待实现 |
+| 绑定状态正常:画像、解绑;驳回:查看驳回;待审核:无操作 | 已对齐,画像只出现在正常绑定行 |
| 查看驳回打开审核详情,展示申请商务、平台、账号、状态、申请/审核时间、审核人和审核意见 | 已实现 |
| 添加绑定、解除绑定确认使用右侧抽屉 | 已对齐 |
| 绑定列表点击旗下渠道数时,仅正常状态可查看并携带平台/平台商务筛选 | 已实现并通过浏览器参数核对 |
@@ -31,7 +31,24 @@
- `business-page-parity-2026-11-03.json`:真实 Chrome 渲染、受控身份/业务数据,检查四个等级的页签和按钮,以及三种审核状态的行操作;无页面运行时错误。
- `http-business-bindings-2026-11-03.json`:真实运行实例及已有 sw7 测试数据的 HTTP 验证。
- 旗下渠道已通过仓储与实际 HTTP 回归,覆盖已通过绑定、直属一级、跨平台同号和稳定分页;sw7 缺渠道数据时明确返回 `available=false`。
-- 画像等未完成项没有用占位响应或假数据冒充已迁移功能。
+- 商务/SDK 商务画像已通过接口及浏览器验证;渠道画像仍待迁移,没有使用占位响应冒充完成。
+
+## 商务与游戏商务画像
+
+来源:旧 `BusinessProfileDrawer.vue` 及 `BusinessList.vue` 的实际入口、`handleProfileOpenChannels` 参数传递。
+
+| 旧页面行为 | 新页面状态 |
+| --- | --- |
+| 商务列表画像、正常绑定行画像、只读 SDK 详情平台入口 | 三个入口已接入,并分别验证一级与三级身份 |
+| 基本资料、上级商务、注册时间、状态、一级渠道数、本月新增 | 已实现;SDK 父级来自 commerce1/commerce2,SDK 状态 0 展示待审核 |
+| 今日、昨日、近7天、近30天、本月 | 已实现,周期和对照区间由后端统一计算 |
+| 注册玩家、充值金额、付费率、人均充值及环比 | 已实现,付费率使用百分点差并保留无对照期时的 null |
+| 注册、充值趋势及 TOP 5 | 已实现;使用 Kra 主题样式,图表支持鼠标与键盘查看数据点 |
+| 关联预警独立分页,不随指标周期改变查询范围 | 已实现,按 SW businessId 与平台过滤 |
+| 画像“旗下渠道”继续打开直属渠道抽屉 | 已对照父组件处理函数;传入 SW 用户 ID,SDK 场景附带平台和平台商务 ID |
+| 缺业务数据与没有业务数据 | 分别显示未接入与真实零,避免未知指标显示为零 |
+
+验证:`portrait-http-2026-11-03.json`、`portrait-page-2026-11-03.json` 及隔离双库 TCP HTTP 测试。完整平台资料与真实业务数值仍待最终实库联调;渠道画像是下一项独立工作。
## 其他页面
diff --git a/docs/sw6-to-kra/portrait-http-2026-11-03.json b/docs/sw6-to-kra/portrait-http-2026-11-03.json
new file mode 100644
index 0000000..3ff6b7f
--- /dev/null
+++ b/docs/sw6-to-kra/portrait-http-2026-11-03.json
@@ -0,0 +1,60 @@
+{
+ "scope": "真实 sw7 登录会话,只读请求;有绑定但业务表未接入时不伪造零,无绑定时验证真实空画像。完整指标和 SDK 基本信息通过隔离双库 TCP HTTP 测试。",
+ "checks": [
+ {
+ "path": "/sw/business/list?pageNum=1&pageSize=500",
+ "http": 200,
+ "code": 0,
+ "msg": "成功",
+ "passed": true
+ },
+ {
+ "path": "/sw/business/10001/portrait?sourceDbList=lx,hw,yq&period=today",
+ "http": 200,
+ "code": 0,
+ "msg": "成功",
+ "passed": true,
+ "available": true,
+ "promote_count": 0,
+ "register_points": 24,
+ "pay_points": 24
+ },
+ {
+ "path": "/sw/business/10001/portrait?sourceDbList=lx&period=thisMonth",
+ "http": 200,
+ "code": 0,
+ "msg": "成功",
+ "passed": true,
+ "available": true,
+ "promote_count": 0,
+ "register_points": 3,
+ "pay_points": 3
+ },
+ {
+ "path": "/sw/business/10004/portrait?sourceDbList=lx,hw,yq&period=last7",
+ "http": 200,
+ "code": 0,
+ "msg": "成功",
+ "passed": true,
+ "available": false,
+ "promote_count": null,
+ "register_points": 0,
+ "pay_points": 0
+ },
+ {
+ "path": "/sw/business/10001/portrait",
+ "http": 200,
+ "code": 7,
+ "msg": "请选择所属平台后查看数据",
+ "passed": true
+ },
+ {
+ "path": "/sw/business/bindPlatform/portrait?sourceDb=lx&platformBusierId=0",
+ "http": 200,
+ "code": 7,
+ "msg": "商务账号参数不正确",
+ "passed": true
+ }
+ ],
+ "passed": true
+}
diff --git a/docs/sw6-to-kra/portrait-page-2026-11-03.json b/docs/sw6-to-kra/portrait-page-2026-11-03.json
new file mode 100644
index 0000000..98c43a9
--- /dev/null
+++ b/docs/sw6-to-kra/portrait-page-2026-11-03.json
@@ -0,0 +1,17 @@
+{
+ "method": "真实 Chrome 渲染;业务接口使用受控数据。验证三个入口、时间切换、交互图表、SDK 状态、SW 用户 ID 与平台 ID 分离、缺数据提示。",
+ "results": [
+ {
+ "level": 1,
+ "passed": true,
+ "entry": "商务列表与只读 SDK 详情",
+ "pageErrors": []
+ },
+ {
+ "level": 3,
+ "passed": true,
+ "entry": "三级绑定列表正常行",
+ "pageErrors": []
+ }
+ ]
+}
diff --git a/docs/sw6-to-kra/portrait-source-notes.md b/docs/sw6-to-kra/portrait-source-notes.md
index 92abbfc..14eeeae 100644
--- a/docs/sw6-to-kra/portrait-source-notes.md
+++ b/docs/sw6-to-kra/portrait-source-notes.md
@@ -1,6 +1,6 @@
-# 下一批:商务与 SDK 商务画像
+# 商务与 SDK 商务画像源码核对
-状态:已核对以下源码,尚未实现两种画像接口和页面入口。账号状态联动已在 `fc17b38` 完成,不要重复迁移。下一步直接实现画像及其报表查询依赖。
+状态:两种画像接口、三个页面入口及报表读取已实现并通过隔离 HTTP、真实会话和浏览器验证。完整平台数据源与最终旧库数据验收仍未完成。账号状态联动已在 `fc17b38` 完成。
## 范围与入口
@@ -25,13 +25,13 @@
- 注册玩家来自日汇总身份键的 REGISTER=1;付费率为 PAY=3 人数 / ACTIVE=2 人数 ×100,保留 1 位;人均充值为金额 / 注册人数,保留 2 位。
- 当前活跃为零时付费率为 0.0;对照期活跃为零时 compareValue 为 null,百分点差也为 null。
- 注册、金额、ARPU 的相对变化率为 `(current-previous)*100/previous`,保留 1 位;对照为零/null 时 changeRate 为 null。付费率用 changeDiff 百分点差。
-- today/yesterday 的趋势读取原始主账号注册与成功支付,补齐 24 个小时;小时注册趋势不做身份证去重。还需核对 Mapper 的具体小时桶时区表达式。
+- today/yesterday 的趋势读取原始主账号注册与成功支付,补齐 24 个小时;小时注册趋势不做身份证去重。MySQL 与原 Mapper 一致使用 `HOUR(FROM_UNIXTIME(...))`,原服务与新服务的数据库会话时区在最终实库验收时核对;SQLite 测试按传入上海日边界计算小时。
- 其他周期趋势来自日汇总/身份键,按天补零。金额查询固定 dedup_mode=0;人数随当前登录人的偏好。
- 跨平台不去重人数需要给身份键加 source_db 前缀;身份证去重模式按身份键跨平台去重。具体 SQL 在 `TabSwReportDayKeyMapper.xml` 的 summary / summaryByDate 中。
## 排行和关联预警
-- 注册 TOP 5 经 `ChannelTreeMetricsSupport.countRegisterByDisplayChannel` 汇总;实现前继续读取这个 helper 和对应 Mapper,不能把子渠道人数直接求和替代整棵树去重。
+- 注册 TOP 5 经 `ChannelTreeMetricsSupport.countRegisterByDisplayChannel` 汇总:含下级时按源平台和一级渠道树 COUNT DISTINCT;一级取自身 ID,其他渠道 top_promote_id>0 时取 top,否则取自身 ID。不能把子渠道人数简单求和。人数先按旧 int 上限归一,再排序和截取 TOP 5。
- 充值 TOP 5 先按渠道取金额,再按 `resolveRootPromoteId` 归根;同平台分组,过滤 <=0,只取前五。按值降序、渠道 ID 升序,返回 rank、平台、渠道 ID/账号、value(保留两位)。
- 关联预警不在画像 DTO 中,由前端独立请求 `/sw/alertRecord/list`,参数为 `platformCodes`、SW `businessId`、分页。
- 旧 SDK 画像的预警仍按 SW 商务及平台筛选,没有单独按 platformBusierId 收窄;需按实际模板核对,不从基本资料 ID 推断范围。
diff --git a/internal/biz/biz.go b/internal/biz/biz.go
index 340fe17..81d4330 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)
+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)
diff --git a/internal/biz/business/business_portrait.go b/internal/biz/business/business_portrait.go
new file mode 100644
index 0000000..59cf22a
--- /dev/null
+++ b/internal/biz/business/business_portrait.go
@@ -0,0 +1,491 @@
+package business
+
+import (
+ "context"
+ "math/big"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-kratos/kratos/v3/errors"
+)
+
+var (
+ ErrPortraitPlatforms = errors.BadRequest("SW_PORTRAIT_PLATFORMS", "请选择所属平台后查看数据")
+ ErrPortraitPlatformMissing = errors.NotFound("SW_PORTRAIT_PLATFORM_BUSINESS_NOT_FOUND", "平台商务不存在")
+)
+
+var portraitLocation = time.FixedZone("Asia/Shanghai", 8*3600)
+
+type PortraitBasic struct {
+ UserID uint64
+ UserName, RealName string
+ Level *int
+ ParentUserID *uint64
+ ParentUserName, ParentRealName string
+ CreatedAt *time.Time
+ Status *string
+ StatusLabel string
+ PromoteCount, MonthNewPromote *int64
+}
+type PortraitPlatformBusiness struct {
+ ID uint64
+ Account, RealName string
+ Level, Status *int
+ Commerce1, Commerce2 *int64
+ CreatedUnix *int64
+}
+
+func (p *PortraitPlatformBusiness) ParentID() *uint64 {
+ if p.Level == nil {
+ return nil
+ }
+ var id *int64
+ switch *p.Level {
+ case 2:
+ id = p.Commerce1
+ case 3:
+ id = p.Commerce2
+ }
+ if id == nil || *id <= 0 {
+ return nil
+ }
+ value := uint64(*id)
+ return &value
+}
+
+type PortraitChannel struct {
+ ChannelKey
+ Level *int
+ TopPromoteID uint64
+ Account string
+ CreatedUnix *int64
+}
+type PortraitMetric struct {
+ Value string
+ CompareValue, ChangeRate, ChangeDiff *string
+ CompareLabel string
+}
+type PortraitMetrics struct{ Register, PayAmount, PayRate, ARPU *PortraitMetric }
+type PortraitTrend struct{ Label, Value string }
+type PortraitRank struct {
+ Rank int
+ ChannelKey
+ PromoteName, Value string
+}
+type BusinessPortrait struct {
+ Basic *PortraitBasic
+ Period string
+ Metrics *PortraitMetrics
+ RegisterTrend, PayTrend []*PortraitTrend
+ RegisterTop5, PayTop5 []*PortraitRank
+ Available bool
+ UnavailableReason string
+}
+type PortraitRange struct {
+ Begin, End, PreviousBegin, PreviousEnd time.Time
+ CompareLabel string
+ Hourly bool
+}
+type PortraitCounts struct{ Register, Active, Pay, NewPay int64 }
+type PortraitStatRank struct {
+ ChannelKey
+ Value string
+}
+type PortraitStatistics struct {
+ Current, Previous PortraitCounts
+ CurrentPay, PreviousPay string
+ RegisterSeries, PaySeries map[string]string
+ RegisterRanks, PayRanks []*PortraitStatRank
+ Available bool
+}
+type PortraitReportScope struct {
+ Pairs []PlatformKey
+ Sources []string
+ IncludeSub, IDCardDedup bool
+ Range PortraitRange
+ RankLimit int
+ CountLimit int64
+}
+type BusinessPortraitRepo interface {
+ Bindings(context.Context, UserScope, []string, *PlatformKey) ([]*Binding, error)
+ Channels(context.Context, []PlatformKey, bool) ([]*PortraitChannel, bool, error)
+ PlatformBusiness(context.Context, PlatformKey) (*PortraitPlatformBusiness, error)
+ Statistics(context.Context, PortraitReportScope) (*PortraitStatistics, error)
+}
+type BusinessPortraitUsecase struct {
+ repo BusinessPortraitRepo
+ accounts *AccountUsecase
+ preferences *UserPreferenceUsecase
+ now func() time.Time
+}
+
+func NewBusinessPortraitUsecase(repo BusinessPortraitRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *BusinessPortraitUsecase {
+ return &BusinessPortraitUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
+}
+
+func normalizePortraitPeriod(period string) string {
+ switch value := strings.ToLower(strings.TrimSpace(period)); value {
+ case "today", "yesterday", "last7", "last30", "thismonth":
+ return value
+ }
+ return "today"
+}
+func portraitRanges(period string, now time.Time) PortraitRange {
+ now = now.In(portraitLocation)
+ today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation)
+ r := PortraitRange{Begin: today, End: today, PreviousBegin: today.AddDate(0, 0, -1), PreviousEnd: today.AddDate(0, 0, -1), CompareLabel: "较昨日", Hourly: true}
+ switch period {
+ case "yesterday":
+ r.Begin = today.AddDate(0, 0, -1)
+ r.End = r.Begin
+ r.PreviousBegin = today.AddDate(0, 0, -2)
+ r.PreviousEnd = r.PreviousBegin
+ r.CompareLabel = "较前日"
+ case "last7", "last30":
+ days := 7
+ if period == "last30" {
+ days = 30
+ }
+ r.Begin = today.AddDate(0, 0, 1-days)
+ r.PreviousEnd = r.Begin.AddDate(0, 0, -1)
+ r.PreviousBegin = r.PreviousEnd.AddDate(0, 0, 1-days)
+ r.CompareLabel = "较上一周期"
+ r.Hourly = false
+ case "thismonth":
+ r.Begin = time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, portraitLocation)
+ r.PreviousBegin = r.Begin.AddDate(0, -1, 0)
+ lastDay := r.Begin.AddDate(0, 0, -1).Day()
+ r.PreviousEnd = r.PreviousBegin.AddDate(0, 0, min(today.Day(), lastDay)-1)
+ r.CompareLabel = "较上月同期"
+ r.Hourly = false
+ }
+ return r
+}
+func portraitUserScope(account *Account) UserScope {
+ return UserScope{SelfID: account.ID, IncludeSelf: true, OnlySelf: account.Level >= 3, DescendantPath: descendantPath(account)}
+}
+func portraitPairs(bindings []*Binding) []PlatformKey {
+ pairs := []PlatformKey{}
+ seen := map[PlatformKey]bool{}
+ for _, b := range bindings {
+ if !seen[b.PlatformKey] {
+ pairs = append(pairs, b.PlatformKey)
+ seen[b.PlatformKey] = true
+ }
+ }
+ return pairs
+}
+func businessStatusLabel(status int) string {
+ return map[int]string{0: "正常", 1: "停用", 2: "已驳回", 3: "待审核"}[status]
+}
+func BusinessLevelLabel(level *int) string {
+ if level == nil {
+ return ""
+ }
+ return map[int]string{1: "一级商务", 2: "二级商务", 3: "三级商务"}[*level]
+}
+
+func (uc *BusinessPortraitUsecase) ForUser(ctx context.Context, id uint, sources []string, period string) (*BusinessPortrait, error) {
+ if len(sources) == 0 {
+ return nil, ErrPortraitPlatforms
+ }
+ account, err := uc.accounts.Find(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ status := strconv.Itoa(account.Status)
+ parentID := uint64(account.ParentID)
+ level := account.Level
+ basic := &PortraitBasic{UserID: uint64(account.ID), UserName: account.Username, RealName: account.RealName, Level: &level, ParentUserID: &parentID, CreatedAt: &account.CreatedAt, Status: &status, StatusLabel: businessStatusLabel(account.Status)}
+ if account.ParentID > 0 {
+ parent, err := uc.accounts.repo.Find(ctx, account.ParentID)
+ if err != nil && !errors.IsNotFound(err) {
+ return nil, err
+ }
+ if parent != nil {
+ basic.ParentUserName = parent.Username
+ basic.ParentRealName = parent.RealName
+ }
+ }
+ bindings, err := uc.repo.Bindings(ctx, portraitUserScope(account), sources, nil)
+ if err != nil {
+ return nil, err
+ }
+ return uc.assemble(ctx, basic, bindings, sources, period)
+}
+func (uc *BusinessPortraitUsecase) ForBinding(ctx context.Context, key PlatformKey, period string) (*BusinessPortrait, error) {
+ source, err := NormalizePlatform(key.SourceDB)
+ if err != nil {
+ return nil, err
+ }
+ key.SourceDB = source
+ actor, current, err := uc.accounts.actor(ctx)
+ if err != nil {
+ return nil, err
+ }
+ all, err := uc.repo.Bindings(ctx, UserScope{All: true}, []string{source}, &key)
+ if err != nil {
+ return nil, err
+ }
+ if len(all) == 0 {
+ return nil, ErrBindingNotFound
+ }
+ scope := portraitUserScope(current)
+ scope.All = actor.AuthorityID == 888
+ bindings, err := uc.repo.Bindings(ctx, scope, []string{source}, &key)
+ if err != nil {
+ return nil, err
+ }
+ if len(bindings) == 0 {
+ return nil, ErrAccountScope
+ }
+ platform, err := uc.repo.PlatformBusiness(ctx, key)
+ if err != nil {
+ return nil, err
+ }
+ if platform == nil {
+ return nil, ErrPortraitPlatformMissing
+ }
+ basic := &PortraitBasic{UserID: platform.ID, UserName: platform.Account, RealName: platform.RealName, Level: platform.Level}
+ if parentID := platform.ParentID(); parentID != nil {
+ parent, err := uc.repo.PlatformBusiness(ctx, PlatformKey{SourceDB: source, BusierID: *parentID})
+ if err != nil {
+ return nil, err
+ }
+ if parent != nil {
+ basic.ParentUserID = parentID
+ basic.ParentUserName = parent.Account
+ basic.ParentRealName = parent.RealName
+ }
+ }
+ if platform.CreatedUnix != nil && *platform.CreatedUnix > 0 {
+ created := time.Unix(*platform.CreatedUnix, 0).In(portraitLocation)
+ basic.CreatedAt = &created
+ }
+ if platform.Status != nil {
+ status := strconv.Itoa(*platform.Status)
+ basic.Status = &status
+ switch *platform.Status {
+ case 1:
+ basic.StatusLabel = "正常"
+ case 0:
+ basic.StatusLabel = "待审核"
+ default:
+ basic.StatusLabel = "停用"
+ }
+ }
+ return uc.assemble(ctx, basic, bindings, []string{source}, period)
+}
+func (uc *BusinessPortraitUsecase) assemble(ctx context.Context, basic *PortraitBasic, bindings []*Binding, sources []string, period string) (*BusinessPortrait, error) {
+ pref, err := uc.preferences.Current(ctx)
+ if err != nil {
+ return nil, err
+ }
+ period = normalizePortraitPeriod(period)
+ now := uc.now()
+ ranges := portraitRanges(period, now)
+ out := &BusinessPortrait{Basic: basic, Period: period, RegisterTrend: []*PortraitTrend{}, PayTrend: []*PortraitTrend{}, RegisterTop5: []*PortraitRank{}, PayTop5: []*PortraitRank{}, Available: true}
+ pairs := portraitPairs(bindings)
+ channels, available, err := uc.repo.Channels(ctx, pairs, pref.IncludeSubPromote)
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ out.Available = false
+ out.UnavailableReason = "渠道数据尚未接入,画像指标暂不可用"
+ return out, nil
+ }
+ count, monthCount := int64(0), int64(0)
+ today := now.In(portraitLocation)
+ monthBegin := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, portraitLocation).Unix()
+ tomorrow := time.Date(today.Year(), today.Month(), today.Day()+1, 0, 0, 0, 0, portraitLocation).Unix()
+ for _, channel := range channels {
+ if channel.Level != nil && *channel.Level == 1 {
+ count++
+ if channel.CreatedUnix != nil && *channel.CreatedUnix >= monthBegin && *channel.CreatedUnix < tomorrow {
+ monthCount++
+ }
+ }
+ }
+ basic.PromoteCount = &count
+ basic.MonthNewPromote = &monthCount
+ stats := &PortraitStatistics{Available: true, CurrentPay: "0", PreviousPay: "0", RegisterSeries: map[string]string{}, PaySeries: map[string]string{}}
+ if len(channels) > 0 {
+ stats, err = uc.repo.Statistics(ctx, PortraitReportScope{Pairs: pairs, Sources: sources, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup, Range: ranges, RankLimit: 5, CountLimit: 2147483647})
+ if err != nil {
+ return nil, err
+ }
+ }
+ if !stats.Available {
+ out.Available = false
+ out.UnavailableReason = "日汇总或趋势数据尚未接入,画像指标暂不可用"
+ return out, nil
+ }
+ out.Metrics, err = portraitMetrics(stats, ranges.CompareLabel)
+ if err != nil {
+ return nil, err
+ }
+ out.RegisterTrend, err = portraitTrend(stats.RegisterSeries, ranges, false)
+ if err != nil {
+ return nil, err
+ }
+ out.PayTrend, err = portraitTrend(stats.PaySeries, ranges, true)
+ if err != nil {
+ return nil, err
+ }
+ out.RegisterTop5, err = portraitRanks(stats.RegisterRanks, channels, true)
+ if err != nil {
+ return nil, err
+ }
+ out.PayTop5, err = portraitRanks(stats.PayRanks, channels, false)
+ return out, err
+}
+
+func portraitNumber(value string) (*big.Rat, error) {
+ if value == "" {
+ value = "0"
+ }
+ n, ok := new(big.Rat).SetString(value)
+ if !ok {
+ return nil, ErrStorage
+ }
+ return n, nil
+}
+func stringPointer(value string) *string { return &value }
+func portraitDecimal(n *big.Rat, digits int) string {
+ value := n.FloatString(digits)
+ // 与 BigDecimal 一致:四舍五入为零后不携带负号。
+ if strings.HasPrefix(value, "-") && strings.Trim(value[1:], "0.") == "" {
+ return value[1:]
+ }
+ return value
+}
+func portraitRateCard(current, previous string, label string) (*PortraitMetric, error) {
+ c, err := portraitNumber(current)
+ if err != nil {
+ return nil, err
+ }
+ p, err := portraitNumber(previous)
+ if err != nil {
+ return nil, err
+ }
+ card := &PortraitMetric{Value: current, CompareValue: stringPointer(previous), CompareLabel: label}
+ if p.Sign() != 0 {
+ diff := new(big.Rat).Sub(c, p)
+ diff.Mul(diff, big.NewRat(100, 1))
+ diff.Quo(diff, p)
+ card.ChangeRate = stringPointer(portraitDecimal(diff, 1))
+ }
+ return card, nil
+}
+func portraitMetrics(stats *PortraitStatistics, label string) (*PortraitMetrics, error) {
+ pay, err := portraitNumber(stats.CurrentPay)
+ if err != nil {
+ return nil, err
+ }
+ previousPay, err := portraitNumber(stats.PreviousPay)
+ if err != nil {
+ return nil, err
+ }
+ pay, _ = portraitNumber(portraitDecimal(pay, 2))
+ previousPay, _ = portraitNumber(portraitDecimal(previousPay, 2))
+ currentRegister, previousRegister := min(stats.Current.Register, int64(2147483647)), min(stats.Previous.Register, int64(2147483647))
+ register, _ := portraitRateCard(strconv.FormatInt(currentRegister, 10), strconv.FormatInt(previousRegister, 10), label)
+ payCard, _ := portraitRateCard(portraitDecimal(pay, 2), portraitDecimal(previousPay, 2), label)
+ arpu := func(amount *big.Rat, count int64) string {
+ if count == 0 {
+ return "0.00"
+ }
+ return portraitDecimal(new(big.Rat).Quo(amount, big.NewRat(count, 1)), 2)
+ }
+ arpuCard, _ := portraitRateCard(arpu(pay, currentRegister), arpu(previousPay, previousRegister), label)
+ payRate := func(c PortraitCounts) string {
+ active := min(c.Active, int64(2147483647))
+ if active == 0 {
+ return "0.0"
+ }
+ return portraitDecimal(new(big.Rat).Mul(big.NewRat(min(c.Pay, int64(2147483647)), active), big.NewRat(100, 1)), 1)
+ }
+ rate := &PortraitMetric{Value: payRate(stats.Current), CompareLabel: label}
+ if stats.Previous.Active != 0 {
+ value := payRate(stats.Previous)
+ rate.CompareValue = &value
+ c, _ := portraitNumber(rate.Value)
+ p, _ := portraitNumber(value)
+ rate.ChangeDiff = stringPointer(portraitDecimal(new(big.Rat).Sub(c, p), 1))
+ }
+ return &PortraitMetrics{Register: register, PayAmount: payCard, PayRate: rate, ARPU: arpuCard}, nil
+}
+func portraitTrend(series map[string]string, ranges PortraitRange, money bool) ([]*PortraitTrend, error) {
+ result := []*PortraitTrend{}
+ add := func(key, label string) error {
+ n, err := portraitNumber(series[key])
+ if err != nil {
+ return err
+ }
+ digits := 0
+ if money {
+ digits = 2
+ } else if !ranges.Hourly && n.Cmp(big.NewRat(2147483647, 1)) > 0 {
+ n = big.NewRat(2147483647, 1)
+ }
+ result = append(result, &PortraitTrend{Label: label, Value: portraitDecimal(n, digits)})
+ return nil
+ }
+ if ranges.Hourly {
+ for hour := 0; hour < 24; hour++ {
+ label := time.Date(2000, 1, 1, hour, 0, 0, 0, time.UTC).Format("15:04")
+ if err := add(strconv.Itoa(hour), label); err != nil {
+ return nil, err
+ }
+ }
+ } else {
+ for day := ranges.Begin; !day.After(ranges.End); day = day.AddDate(0, 0, 1) {
+ if err := add(day.Format("2006-01-02"), day.Format("01-02")); err != nil {
+ return nil, err
+ }
+ }
+ }
+ return result, nil
+}
+func portraitRanks(raw []*PortraitStatRank, channels []*PortraitChannel, count bool) ([]*PortraitRank, error) {
+ names := map[ChannelKey]string{}
+ for _, channel := range channels {
+ names[channel.ChannelKey] = channel.Account
+ }
+ values := map[ChannelKey]*big.Rat{}
+ rows := []*PortraitRank{}
+ for _, item := range raw {
+ n, err := portraitNumber(item.Value)
+ if err != nil {
+ return nil, err
+ }
+ if count && n.Cmp(big.NewRat(2147483647, 1)) > 0 {
+ n = big.NewRat(2147483647, 1)
+ }
+ if n.Sign() <= 0 {
+ continue
+ }
+ values[item.ChannelKey] = n
+ rows = append(rows, &PortraitRank{ChannelKey: item.ChannelKey, PromoteName: names[item.ChannelKey], Value: portraitDecimal(n, 2)})
+ }
+ sort.SliceStable(rows, func(i, j int) bool {
+ cmp := values[rows[i].ChannelKey].Cmp(values[rows[j].ChannelKey])
+ if cmp != 0 {
+ return cmp > 0
+ }
+ if rows[i].ID != rows[j].ID {
+ return rows[i].ID < rows[j].ID
+ }
+ return rows[i].SourceDB < rows[j].SourceDB
+ })
+ if len(rows) > 5 {
+ rows = rows[:5]
+ }
+ for i, row := range rows {
+ row.Rank = i + 1
+ }
+ return rows, nil
+}
diff --git a/internal/biz/business/business_portrait_test.go b/internal/biz/business/business_portrait_test.go
new file mode 100644
index 0000000..2a61e24
--- /dev/null
+++ b/internal/biz/business/business_portrait_test.go
@@ -0,0 +1,132 @@
+package business
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+type portraitRepoFake struct {
+ BusinessPortraitRepo
+ bindings []*Binding
+ channels []*PortraitChannel
+ channelsAvailable bool
+ stats *PortraitStatistics
+ scope PortraitReportScope
+ platforms map[uint64]*PortraitPlatformBusiness
+}
+
+func (r *portraitRepoFake) Bindings(_ context.Context, scope UserScope, _ []string, _ *PlatformKey) ([]*Binding, error) {
+ if !scope.All && scope.SelfID == 99 {
+ return []*Binding{}, nil
+ }
+ return r.bindings, nil
+}
+func (r *portraitRepoFake) Channels(_ context.Context, pairs []PlatformKey, _ bool) ([]*PortraitChannel, bool, error) {
+ if len(pairs) == 0 {
+ return []*PortraitChannel{}, true, nil
+ }
+ return r.channels, r.channelsAvailable, nil
+}
+func (r *portraitRepoFake) Statistics(_ context.Context, scope PortraitReportScope) (*PortraitStatistics, error) {
+ r.scope = scope
+ return r.stats, nil
+}
+func (r *portraitRepoFake) PlatformBusiness(_ context.Context, key PlatformKey) (*PortraitPlatformBusiness, error) {
+ return r.platforms[key.BusierID], nil
+}
+func portraitFixture() (*BusinessPortraitUsecase, *portraitRepoFake) {
+ accounts := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Username: "owner", RealName: "商务甲", Level: 1, IDPath: "0"}, 9: {ID: 9, Username: "leaf", Level: 3, ParentID: 7, IDPath: "0,7"}, 99: {ID: 99, Username: "other", Level: 1, IDPath: "0"}}}
+ created := time.Date(2026, 11, 1, 0, 0, 0, 0, portraitLocation).Unix()
+ r := &portraitRepoFake{bindings: []*Binding{{UserID: 9, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 14}, Status: "0"}}, channelsAvailable: true, channels: []*PortraitChannel{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 100}, Level: intPointer(1), Account: "一级渠道", CreatedUnix: &created}}, platforms: map[uint64]*PortraitPlatformBusiness{14: {ID: 14, Account: "platform", RealName: "平台商务", Level: intPointer(3), Status: intPointer(0), Commerce2: portraitUint(12), CreatedUnix: &created}, 12: {ID: 12, Account: "platform-parent", RealName: "平台上级"}}}
+ r.stats = &PortraitStatistics{Available: true, Current: PortraitCounts{Register: 2, Active: 3, Pay: 2}, Previous: PortraitCounts{Register: 1, Active: 4, Pay: 1}, CurrentPay: "12.00", PreviousPay: "6.00", RegisterSeries: map[string]string{"2026-11-01": "2", "0": "3"}, PaySeries: map[string]string{"0": "1.25"}, RegisterRanks: []*PortraitStatRank{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 100}, Value: "2"}}, PayRanks: []*PortraitStatRank{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 100}, Value: "12.00"}}}
+ uc := NewBusinessPortraitUsecase(r, NewAccountUsecase(accounts), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}))
+ uc.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, portraitLocation) }
+ return uc, r
+}
+func portraitUint(n int64) *int64 { return &n }
+func TestPortraitCalendarRangesClampPreviousMonth(t *testing.T) {
+ today := time.Date(2024, 3, 31, 10, 0, 0, 0, portraitLocation)
+ cases := []struct{ period, begin, end, prevBegin, prevEnd string }{{"today", "2024-03-31", "2024-03-31", "2024-03-30", "2024-03-30"}, {"yesterday", "2024-03-30", "2024-03-30", "2024-03-29", "2024-03-29"}, {"last7", "2024-03-25", "2024-03-31", "2024-03-18", "2024-03-24"}, {"last30", "2024-03-02", "2024-03-31", "2024-02-01", "2024-03-01"}, {"thismonth", "2024-03-01", "2024-03-31", "2024-02-01", "2024-02-29"}}
+ for _, tc := range cases {
+ r := portraitRanges(tc.period, today)
+ if r.Begin.Format("2006-01-02") != tc.begin || r.End.Format("2006-01-02") != tc.end || r.PreviousBegin.Format("2006-01-02") != tc.prevBegin || r.PreviousEnd.Format("2006-01-02") != tc.prevEnd {
+ t.Fatalf("日期范围错误: %+v %+v", tc, r)
+ }
+ }
+ if normalizePortraitPeriod(" thisMonth ") != "thismonth" || normalizePortraitPeriod("total") != "today" {
+ t.Fatal("商务画像与渠道累计模式混用")
+ }
+}
+func TestPortraitMetricsUseActiveDenominatorAndPercentagePoints(t *testing.T) {
+ uc, r := portraitFixture()
+ portrait, err := uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "today")
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := portrait.Metrics
+ if m.Register.Value != "2" || *m.Register.ChangeRate != "100.0" || m.PayRate.Value != "66.7" || *m.PayRate.CompareValue != "25.0" || *m.PayRate.ChangeDiff != "41.7" || m.ARPU.Value != "6.00" || *m.ARPU.ChangeRate != "0.0" {
+ t.Fatalf("画像卡片计算错误: %+v", m)
+ }
+ if *portrait.Basic.PromoteCount != 1 || *portrait.Basic.MonthNewPromote != 1 || len(portrait.RegisterTrend) != 24 || portrait.RegisterTrend[0].Value != "3" || portrait.PayTrend[0].Value != "1.25" {
+ t.Fatal("基本渠道计数或小时趋势错误")
+ }
+ if r.scope.RankLimit != 5 || !r.scope.IDCardDedup || !r.scope.IncludeSub {
+ t.Fatal("画像没有使用查看人的全局偏好")
+ }
+ r.stats.Previous.Active = 0
+ portrait, err = uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "last7")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if portrait.Metrics.PayRate.CompareValue != nil || portrait.Metrics.PayRate.ChangeDiff != nil || len(portrait.RegisterTrend) != 7 {
+ t.Fatal("无上期活跃时不应伪造付费率环比")
+ }
+}
+func TestPortraitEmptyScopeAndUnavailableDataAreDifferent(t *testing.T) {
+ uc, r := portraitFixture()
+ r.channelsAvailable = false
+ p, err := uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "today")
+ if err != nil || p.Available || p.Basic.UserName != "owner" || p.Basic.PromoteCount != nil || p.Metrics != nil {
+ t.Fatal("缺表时应保留基本资料并标记未知指标")
+ }
+ r.bindings = nil
+ p, err = uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "today")
+ if err != nil || !p.Available || *p.Basic.PromoteCount != 0 || p.Metrics.Register.Value != "0" || len(p.RegisterTrend) != 24 {
+ t.Fatal("无绑定属于确定空数据")
+ }
+ if _, err := uc.ForUser(accountContext(99, 901), 7, []string{"lx"}, "today"); err != ErrAccountScope {
+ t.Fatal("不能查询其他一级商务画像")
+ }
+}
+func TestBindingPortraitUsesPlatformHierarchyAndStatus(t *testing.T) {
+ uc, _ := portraitFixture()
+ p, err := uc.ForBinding(accountContext(9, 903), PlatformKey{SourceDB: "lx", BusierID: 14}, "today")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.Basic.UserID != 14 || p.Basic.UserName != "platform" || p.Basic.ParentUserID == nil || *p.Basic.ParentUserID != 12 || p.Basic.ParentUserName != "platform-parent" || *p.Basic.Status != "0" || p.Basic.StatusLabel != "待审核" {
+ t.Fatalf("混淆平台与 SW 的 ID、上级或状态: %+v", p.Basic)
+ }
+ if _, err := uc.ForBinding(accountContext(99, 901), PlatformKey{SourceDB: "lx", BusierID: 14}, "today"); err != ErrAccountScope {
+ t.Fatal("范围外 SDK 画像被放行")
+ }
+}
+
+func TestPlatformPortraitNonpositiveParentIsAbsent(t *testing.T) {
+ negative := int64(-1)
+ if parent := (&PortraitPlatformBusiness{Level: intPointer(3), Commerce2: &negative}).ParentID(); parent != nil {
+ t.Fatal("平台的负数父级占位值不能变成无符号 ID")
+ }
+}
+
+func TestPortraitDecimalRoundingMatchesBigDecimal(t *testing.T) {
+ card, err := portraitRateCard("1000.00", "1000.01", "较昨日")
+ if err != nil || card.ChangeRate == nil || *card.ChangeRate != "0.0" {
+ t.Fatal("极小负环比应舍入为不带负号的零")
+ }
+ metrics, err := portraitMetrics(&PortraitStatistics{CurrentPay: "8.005", PreviousPay: "0", Current: PortraitCounts{Register: 2}}, "较昨日")
+ if err != nil || metrics.PayAmount.Value != "8.01" || metrics.ARPU.Value != "4.01" {
+ t.Fatal("应先将充值金额保留两位再计算人均充值")
+ }
+}
diff --git a/internal/data/business/business_portrait.go b/internal/data/business/business_portrait.go
new file mode 100644
index 0000000..9b2d01d
--- /dev/null
+++ b/internal/data/business/business_portrait.go
@@ -0,0 +1,299 @@
+package business
+
+import (
+ "context"
+ "errors"
+ "strconv"
+
+ "gorm.io/gorm"
+ "kra/internal/biz/business"
+)
+
+type businessPortraitRepo struct {
+ data Provider
+ platforms PlatformProvider
+}
+
+func NewBusinessPortraitRepo(data Provider, platforms PlatformProvider) business.BusinessPortraitRepo {
+ return &businessPortraitRepo{data: data, platforms: platforms}
+}
+func (r *businessPortraitRepo) Bindings(ctx context.Context, scope business.UserScope, sources []string, key *business.PlatformKey) ([]*business.Binding, error) {
+ q := channelBindingQuery(r.data.DB().WithContext(ctx), scope, business.ChannelFilter{Sources: sources})
+ if key != nil {
+ q = q.Where("b.source_db = ? AND b.platform_busier_id = ?", key.SourceDB, key.BusierID)
+ }
+ var pos []bindingPO
+ if err := q.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 portraitBindingPairs(pairs []business.PlatformKey) [][]any {
+ result := make([][]any, 0, len(pairs))
+ for _, pair := range pairs {
+ result = append(result, []any{pair.SourceDB, pair.BusierID})
+ }
+ return result
+}
+func (r *businessPortraitRepo) Channels(ctx context.Context, pairs []business.PlatformKey, includeSub bool) ([]*business.PortraitChannel, bool, error) {
+ rows := []*business.PortraitChannel{}
+ if len(pairs) == 0 {
+ return rows, true, nil
+ }
+ db := r.data.DB().WithContext(ctx)
+ available, err := businessTableAvailable(db, "tab_promote")
+ if err != nil {
+ return nil, false, err
+ }
+ if !available {
+ return rows, false, nil
+ }
+ seen := map[business.ChannelKey]bool{}
+ for start := 0; start < len(pairs); start += 300 {
+ q := db.Table("tab_promote").Select("id,source_db,account,promote_level,top_promote_id,create_time").Where("(source_db,busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))]))
+ if !includeSub {
+ q = q.Where("promote_level = 1")
+ }
+ var pos []channelPO
+ if err := q.Order("source_db,id").Scan(&pos).Error; err != nil {
+ return nil, false, business.ErrStorage
+ }
+ for _, po := range pos {
+ key := business.ChannelKey{SourceDB: po.SourceDB, ID: po.ID}
+ if !seen[key] {
+ rows = append(rows, &business.PortraitChannel{ChannelKey: key, Account: po.Account, Level: po.PromoteLevel, TopPromoteID: po.TopPromoteID, CreatedUnix: po.CreateTime})
+ seen[key] = true
+ }
+ }
+ }
+ return rows, true, nil
+}
+
+type portraitPlatformPO struct {
+ ID uint64
+ Account, RealName string
+ Level, Status *int
+ Commerce1, Commerce2 *int64
+ CreatedUnix *int64 `gorm:"column:create_time"`
+}
+
+func (r *businessPortraitRepo) PlatformBusiness(ctx context.Context, key business.PlatformKey) (*business.PortraitPlatformBusiness, error) {
+ db, err := r.platforms.Database(key.SourceDB)
+ if err != nil || db == nil {
+ return nil, business.ErrPlatformUnavailable
+ }
+ var po portraitPlatformPO
+ if err := db.WithContext(ctx).Table("tab_promote_business").Select("id,account,real_name,level,status,commerce1,commerce2,create_time").Where("id = ?", key.BusierID).Take(&po).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, nil
+ }
+ return nil, business.ErrPlatformUnavailable
+ }
+ return &business.PortraitPlatformBusiness{ID: po.ID, Account: po.Account, RealName: po.RealName, Level: po.Level, Status: po.Status, Commerce1: po.Commerce1, Commerce2: po.Commerce2, CreatedUnix: po.CreatedUnix}, nil
+}
+
+func portraitPromoteScope(db *gorm.DB, scope business.PortraitReportScope, sourceColumn, promoteColumn string) *gorm.DB {
+ 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")
+ }
+ q = q.Where("(scope_promote.source_db,scope_promote.busier_id) IN ?", portraitBindingPairs(scope.Pairs))
+ if !scope.IncludeSub {
+ q = q.Where("scope_promote.promote_level = 1")
+ }
+ return q
+}
+func portraitReportQuery(db *gorm.DB, scope business.PortraitReportScope, table, begin, end string, mode int) *gorm.DB {
+ q := db.Table(table+" r").Where("r.stat_date >= ? AND r.stat_date <= ? AND r.dedup_mode = ? AND r.game_id > 0", begin, end, mode)
+ if len(scope.Sources) > 0 {
+ q = q.Where("r.source_db IN ?", scope.Sources)
+ }
+ return q.Where("EXISTS (?)", portraitPromoteScope(db, scope, "r.source_db", "r.promote_id"))
+}
+func portraitIdentity(db *gorm.DB, dedup bool) string {
+ if dedup {
+ return "r.identity_key"
+ }
+ if db.Dialector.Name() == "mysql" {
+ return "CONCAT(r.source_db,'|',r.identity_key)"
+ }
+ return "(r.source_db || '|' || r.identity_key)"
+}
+
+type portraitCountPO struct {
+ Metric int
+ Count int64
+}
+
+func toPortraitCounts(rows []portraitCountPO) business.PortraitCounts {
+ result := business.PortraitCounts{}
+ for _, row := range rows {
+ switch row.Metric {
+ case 1:
+ result.Register = row.Count
+ case 2:
+ result.Active = row.Count
+ case 3:
+ result.Pay = row.Count
+ case 4:
+ result.NewPay = row.Count
+ }
+ }
+ return result
+}
+
+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 {
+ return result, nil
+ }
+ db := r.data.DB().WithContext(ctx)
+ tables := []string{"tab_promote", "tab_sw_report_day", "tab_sw_report_day_key"}
+ if scope.Range.Hourly {
+ tables = append(tables, "tab_user", "tab_spend")
+ }
+ for _, table := range tables {
+ available, err := businessTableAvailable(db, table)
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ result.Available = false
+ return result, nil
+ }
+ }
+ mode := 0
+ if scope.IDCardDedup {
+ mode = 1
+ }
+ 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 {
+ begin, end string
+ counts *business.PortraitCounts
+ pay *string
+ }{{begin, end, &result.Current, &result.CurrentPay}, {previousBegin, previousEnd, &result.Previous, &result.PreviousPay}} {
+ 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 {
+ return nil, business.ErrStorage
+ }
+ *period.counts = toPortraitCounts(rows)
+ if err := portraitReportQuery(db, scope, "tab_sw_report_day", period.begin, period.end, 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(period.pay).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ }
+ if scope.Range.Hourly {
+ if err := portraitHourly(db, scope, result); err != nil {
+ return nil, err
+ }
+ } else {
+ if err := portraitDaily(db, scope, mode, begin, end, result); err != nil {
+ return nil, err
+ }
+ }
+ 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
+ return result, nil
+}
+func portraitDaily(db *gorm.DB, scope business.PortraitReportScope, mode int, begin, end string, result *business.PortraitStatistics) error {
+ dateExpression := "SUBSTR(CAST(r.stat_date AS CHAR),1,10)"
+ var registers []struct {
+ Bucket string
+ Count int64
+ }
+ q := portraitReportQuery(db, scope, "tab_sw_report_day_key", begin, end, mode).Where("r.metric = 1")
+ if err := q.Select(dateExpression + " AS bucket,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Group(dateExpression).Scan(®isters).Error; err != nil {
+ return business.ErrStorage
+ }
+ for _, row := range registers {
+ result.RegisterSeries[row.Bucket] = strconv.FormatInt(row.Count, 10)
+ }
+ var payments []struct{ Bucket, Amount string }
+ if err := portraitReportQuery(db, scope, "tab_sw_report_day", begin, end, 0).Select(dateExpression + " AS bucket,COALESCE(SUM(r.total_pay),0) AS amount").Group(dateExpression).Scan(&payments).Error; err != nil {
+ return business.ErrStorage
+ }
+ for _, row := range payments {
+ result.PaySeries[row.Bucket] = row.Amount
+ }
+ return nil
+}
+func portraitHourly(db *gorm.DB, scope business.PortraitReportScope, result *business.PortraitStatistics) error {
+ start, end := scope.Range.Begin.Unix(), scope.Range.End.AddDate(0, 0, 1).Unix()
+ for _, metric := range []struct {
+ table, column, aggregate, condition string
+ series map[string]string
+ }{{"tab_user", "register_time", "COUNT(*)", "(r.puid = 0 OR r.puid IS NULL)", result.RegisterSeries}, {"tab_spend", "pay_time", "COALESCE(SUM(r.pay_amount),0)", "r.pay_status = 1", result.PaySeries}} {
+ q := db.Table(metric.table+" r").Where("r."+metric.column+" >= ? AND r."+metric.column+" < ?", start, end).Where(metric.condition).Where("EXISTS (?)", portraitPromoteScope(db, scope, "r.source_db", "r.promote_id"))
+ if len(scope.Sources) > 0 {
+ q = q.Where("r.source_db IN ?", scope.Sources)
+ }
+ var rows []struct {
+ Hour int
+ Value string
+ }
+ if db.Dialector.Name() == "mysql" {
+ // 保留原 Mapper 的数据库会话时区口径,最终实库验收同时核对时区配置。
+ hour := "HOUR(FROM_UNIXTIME(r." + metric.column + "))"
+ if err := q.Select(hour + " AS hour," + metric.aggregate + " AS value").Group(hour).Scan(&rows).Error; err != nil {
+ return business.ErrStorage
+ }
+ } else {
+ if err := q.Select("CAST((r."+metric.column+" - ?) / 3600 AS INTEGER) AS hour,"+metric.aggregate+" AS value", start).Group("hour").Scan(&rows).Error; err != nil {
+ return business.ErrStorage
+ }
+ }
+ for _, row := range rows {
+ if row.Hour >= 0 && row.Hour <= 23 {
+ metric.series[strconv.Itoa(row.Hour)] = row.Value
+ }
+ }
+ }
+ return nil
+}
+func portraitRankQuery(db *gorm.DB, scope business.PortraitReportScope, mode int, begin, end string, pay bool) ([]*business.PortraitStatRank, error) {
+ table, aggregate := "tab_sw_report_day_key", "COUNT(DISTINCT r.identity_key)"
+ if pay {
+ table, aggregate = "tab_sw_report_day", "COALESCE(SUM(r.total_pay),0)"
+ }
+ var selectArgs []any
+ if !pay && scope.CountLimit > 0 {
+ // 人数上限须先于 TOP N 排序,避免截取后才限制导致并列结果不同。
+ aggregate = "CASE WHEN COUNT(DISTINCT r.identity_key)>? THEN ? ELSE COUNT(DISTINCT r.identity_key) END"
+ selectArgs = []any{scope.CountLimit, scope.CountLimit}
+ }
+ q := portraitReportQuery(db, scope, table, begin, end, mode)
+ if !pay {
+ q = q.Where("r.metric = 1")
+ }
+ root := "r.promote_id"
+ if scope.IncludeSub || pay {
+ q = q.Joins("JOIN tab_promote rank_promote ON rank_promote.source_db=r.source_db AND rank_promote.id=r.promote_id")
+ root = "CASE WHEN rank_promote.promote_level=1 THEN rank_promote.id WHEN rank_promote.top_promote_id>0 THEN rank_promote.top_promote_id ELSE rank_promote.id END"
+ }
+ var rows []struct {
+ SourceDB string `gorm:"column:source_db"`
+ PromoteID uint64
+ Value string
+ }
+ if err := q.Select("r.source_db,"+root+" AS promote_id,"+aggregate+" AS value", selectArgs...).Group("r.source_db," + root).Order("value DESC," + root + ",r.source_db").Limit(scope.RankLimit).Scan(&rows).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ result := make([]*business.PortraitStatRank, 0, len(rows))
+ for _, row := range rows {
+ result = append(result, &business.PortraitStatRank{ChannelKey: business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}, Value: row.Value})
+ }
+ return result, nil
+}
diff --git a/internal/data/business/business_portrait_test.go b/internal/data/business/business_portrait_test.go
new file mode 100644
index 0000000..34d8bbd
--- /dev/null
+++ b/internal/data/business/business_portrait_test.go
@@ -0,0 +1,153 @@
+package business
+
+import (
+ "context"
+ "math/big"
+ "testing"
+ "time"
+
+ "gorm.io/gorm"
+ "kra/internal/biz/business"
+)
+
+func preparePortraitData(t *testing.T) (*gorm.DB, business.BusinessPortraitRepo, business.PortraitReportScope) {
+ t.Helper()
+ db, _ := prepareChannels(t)
+ zone := time.FixedZone("CST", 8*3600)
+ start := time.Date(2026, 11, 3, 0, 0, 0, 0, zone)
+ for _, sql := range []string{
+ "UPDATE tab_sw_report_day_key SET identity_key='A' WHERE source_db='hw' AND metric=1",
+ "INSERT INTO tab_sw_report_day_key VALUES('hw',100,1,1,'2026-11-01',2,'C'),('lx',100,1,1,'2026-11-01',3,'A'),('hw',100,1,1,'2026-11-01',3,'A')",
+ "INSERT INTO tab_sw_report_day_key VALUES('hw',100,1,0,'2026-11-01',1,'u1')",
+ "ALTER TABLE tab_user ADD COLUMN register_time INTEGER",
+ "ALTER TABLE tab_spend ADD COLUMN pay_time INTEGER",
+ } {
+ if err := db.Exec(sql).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := db.Exec("UPDATE tab_user SET register_time = ?", start.Unix()+3600).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Exec("UPDATE tab_spend SET pay_time = ?", start.Unix()+3600).Error; err != nil {
+ t.Fatal(err)
+ }
+ scope := business.PortraitReportScope{Pairs: []business.PlatformKey{{SourceDB: "lx", BusierID: 14}, {SourceDB: "hw", BusierID: 14}}, Sources: []string{"lx", "hw"}, IncludeSub: true, IDCardDedup: true, RankLimit: 5, Range: business.PortraitRange{Begin: time.Date(2026, 11, 1, 0, 0, 0, 0, zone), End: start, PreviousBegin: time.Date(2026, 10, 1, 0, 0, 0, 0, zone), PreviousEnd: time.Date(2026, 10, 3, 0, 0, 0, 0, zone)}}
+ return db, NewBusinessPortraitRepo(testData{db}, channelDatabases{}), scope
+}
+func portraitTestMoney(s string) string {
+ n, ok := new(big.Rat).SetString(s)
+ if !ok {
+ return "invalid"
+ }
+ return n.FloatString(2)
+}
+
+func TestPortraitBindingsAndChannelsFollowBusinessTree(t *testing.T) {
+ _, repo, _ := preparePortraitData(t)
+ ctx := context.Background()
+ bindings, err := repo.Bindings(ctx, channelTestScope(), []string{"lx", "hw"}, nil)
+ if err != nil || len(bindings) != 2 {
+ t.Fatalf("本人及下级绑定范围错误: %+v %v", bindings, err)
+ }
+ for _, b := range bindings {
+ if b.UserID == 10 || b.Status != "0" {
+ t.Fatal("混入平级或未通过绑定")
+ }
+ }
+ pairs := []business.PlatformKey{}
+ for _, b := range bindings {
+ pairs = append(pairs, b.PlatformKey)
+ }
+ channels, available, err := repo.Channels(ctx, pairs, true)
+ if err != nil || !available || len(channels) != 5 {
+ t.Fatalf("渠道范围不正确: %+v %v", channels, err)
+ }
+ channels, _, err = repo.Channels(ctx, pairs, false)
+ if err != nil || len(channels) != 2 {
+ t.Fatal("关闭下级时只能取一级渠道")
+ }
+ outside := business.PlatformKey{SourceDB: "lx", BusierID: 16}
+ bindings, err = repo.Bindings(ctx, channelTestScope(), []string{"lx"}, &outside)
+ if err != nil || len(bindings) != 0 {
+ t.Fatal("绑定过滤绕过商务树")
+ }
+}
+func TestPortraitSummaryDedupsAcrossPlatformsAndRanksWithinTrees(t *testing.T) {
+ _, repo, scope := preparePortraitData(t)
+ stats, err := repo.Statistics(context.Background(), scope)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stats.Current.Register != 3 || stats.Current.Active != 1 || stats.Current.Pay != 1 || portraitTestMoney(stats.CurrentPay) != "2058.60" || portraitTestMoney(stats.PreviousPay) != "1.10" {
+ t.Fatalf("全局去重或金额口径错误: %+v", stats)
+ }
+ if stats.RegisterSeries["2026-11-01"] != "1" || stats.RegisterSeries["2026-11-02"] != "3" {
+ t.Fatalf("日趋势错误: %+v", stats.RegisterSeries)
+ }
+ var lxRoot, hwRoot string
+ for _, rank := range stats.RegisterRanks {
+ if rank.ID == 100 && rank.SourceDB == "lx" {
+ lxRoot = rank.Value
+ }
+ if rank.ID == 100 && rank.SourceDB == "hw" {
+ hwRoot = rank.Value
+ }
+ }
+ if lxRoot != "2" || hwRoot != "1" {
+ t.Fatalf("渠道树注册排行不能直接累加子渠道或混淆同号平台: %+v", stats.RegisterRanks)
+ }
+ scope.IDCardDedup = false
+ stats, err = repo.Statistics(context.Background(), scope)
+ if err != nil || stats.Current.Register != 3 || portraitTestMoney(stats.CurrentPay) != "2058.60" {
+ t.Fatalf("模式0没有区分跨平台同号身份或改变金额: %+v %v", stats, err)
+ }
+ scope.IDCardDedup = true
+ scope.IncludeSub = false
+ stats, err = repo.Statistics(context.Background(), scope)
+ if err != nil || stats.Current.Register != 1 || portraitTestMoney(stats.CurrentPay) != "1009.10" {
+ t.Fatalf("关闭下级没有收窄: %+v %v", stats, err)
+ }
+}
+func TestPortraitHourlyUsesRawMainAccountsAndHalfOpenDay(t *testing.T) {
+ db, repo, scope := preparePortraitData(t)
+ scope.Range.Begin = scope.Range.End
+ scope.Range.Hourly = true
+ stats, err := repo.Statistics(context.Background(), scope)
+ if err != nil || stats.RegisterSeries["1"] != "5" || portraitTestMoney(stats.PaySeries["1"]) != "200.90" {
+ t.Fatalf("小时趋势错误: %+v %v", stats, err)
+ }
+ end := scope.Range.End.AddDate(0, 0, 1).Unix()
+ if err := db.Exec("INSERT INTO tab_user(source_db,id,promote_id,puid,idcard,register_time) VALUES('lx',900,100,0,'boundary',?)", end).Error; err != nil {
+ t.Fatal(err)
+ }
+ stats, err = repo.Statistics(context.Background(), scope)
+ if err != nil || stats.RegisterSeries["0"] != "" || stats.RegisterSeries["1"] != "5" {
+ t.Fatal("次日零点被计入当日")
+ }
+ if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
+ t.Fatal(err)
+ }
+ stats, err = repo.Statistics(context.Background(), scope)
+ if err != nil || stats.Available {
+ t.Fatal("缺汇总键表不应返回假零指标")
+ }
+ channels, available, err := repo.Channels(context.Background(), nil, true)
+ if err != nil || !available || len(channels) != 0 {
+ t.Fatal("无绑定应是已知空范围")
+ }
+}
+
+func TestPortraitCountLimitAppliesBeforeTopN(t *testing.T) {
+ db, repo, scope := preparePortraitData(t)
+ if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES('lx',103,1,1,'2026-11-02',1,'N2'),('lx',103,1,1,'2026-11-02',1,'N3')").Error; err != nil {
+ t.Fatal(err)
+ }
+ scope.CountLimit = 1
+ scope.RankLimit = 1
+ scope.Sources = []string{"lx"}
+ stats, err := repo.Statistics(context.Background(), scope)
+ if err != nil || len(stats.RegisterRanks) != 1 || stats.RegisterRanks[0].ID != 100 || stats.RegisterRanks[0].Value != "1" {
+ t.Fatalf("人数归一上限必须先于排行截取: %+v %v", stats, err)
+ }
+}
diff --git a/internal/data/data.go b/internal/data/data.go
index 7f1c3b4..30fff41 100644
--- a/internal/data/data.go
+++ b/internal/data/data.go
@@ -39,6 +39,7 @@ var ProviderSet = wire.NewSet(
databusiness.NewActivationRepo,
databusiness.NewChannelRepo,
databusiness.NewChannelWriteRepo,
+ databusiness.NewBusinessPortraitRepo,
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 d5a1580..e488711 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 != 50 {
- t.Fatalf("admin surface = %d menus/%d APIs, want 11/50", menus, apis)
+ if menus != 11 || apis != 52 {
+ t.Fatalf("admin surface = %d menus/%d APIs, want 11/52", 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 bbaed47..685d229 100644
--- a/internal/routecatalog/catalog.go
+++ b/internal/routecatalog/catalog.go
@@ -41,6 +41,8 @@ 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/business/:userId/portrait": {group: "商务画像", description: "查询商务画像"},
+ "GET /sw/business/bindPlatform/portrait": {group: "商务画像", description: "查询 SDK 商务画像"},
"GET /sw/promote/list": {group: "渠道管理", description: "查询渠道列表及累计指标"},
"GET /sw/promote/bindOptions": {group: "渠道管理", description: "查询可用游戏商务绑定"},
"GET /sw/promote/game-distribution": {group: "渠道管理", description: "查询推广游戏累计分布"},
diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go
index 51a386d..551b9c7 100644
--- a/internal/server/gin_test.go
+++ b/internal/server/gin_test.go
@@ -26,13 +26,14 @@ 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{},
- BusinessPromote: &handler.BusinessPromote{},
- Business: &handler.Business{},
- Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
+ BusinessPortrait: &handler.BusinessPortrait{},
+ Channel: &handler.Channel{},
+ Activation: &handler.Activation{},
+ AlertRecord: &handler.AlertRecord{},
+ AlertRule: &handler.AlertRule{},
+ BusinessPromote: &handler.BusinessPromote{},
+ Business: &handler.Business{},
+ Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
Permission: &handler.Permission{}, Organization: &handler.Organization{},
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Payment: &handler.Payment{}, Task: &handler.Task{},
Media: &handler.Media{}, Audit: &handler.Audit{}, Export: &handler.Export{},
@@ -103,7 +104,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":233`) {
+ if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":235`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
diff --git a/internal/server/handler/business_portrait.go b/internal/server/handler/business_portrait.go
new file mode 100644
index 0000000..b1c5736
--- /dev/null
+++ b/internal/server/handler/business_portrait.go
@@ -0,0 +1,48 @@
+package handler
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/go-kratos/kratos/v3/errors"
+ "kra/internal/service/business"
+ "kra/internal/service/dto"
+ "strconv"
+)
+
+type BusinessPortrait struct {
+ service *business.BusinessPortraitService
+}
+
+func NewBusinessPortrait(service *business.BusinessPortraitService) *BusinessPortrait {
+ return &BusinessPortrait{service: service}
+}
+func (h *BusinessPortrait) ForUser(c *gin.Context) {
+ id, err := strconv.ParseUint(c.Param("userId"), 10, 64)
+ if err != nil {
+ Fail(c, "商务用户 ID 不正确")
+ return
+ }
+ var req dto.BusinessPortraitQuery
+ if c.ShouldBindQuery(&req) != nil {
+ Fail(c, "商务画像查询参数不正确")
+ return
+ }
+ result, err := h.service.ForUser(c.Request.Context(), uint(id), &req)
+ if err != nil {
+ Fail(c, errors.FromError(err).Message)
+ return
+ }
+ OKWithData(c, result)
+}
+func (h *BusinessPortrait) ForBinding(c *gin.Context) {
+ var req dto.BindingPortraitQuery
+ if c.ShouldBindQuery(&req) != nil {
+ Fail(c, "商务画像查询参数不正确")
+ return
+ }
+ result, err := h.service.ForBinding(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 621cd5a..86a7823 100644
--- a/internal/server/handler/provider.go
+++ b/internal/server/handler/provider.go
@@ -12,6 +12,7 @@ var ProviderSet = wire.NewSet(
NewAlertRecord,
NewActivation,
NewChannel,
+ NewBusinessPortrait,
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 f32d7fc..f3f9846 100644
--- a/internal/server/handler/set.go
+++ b/internal/server/handler/set.go
@@ -1,6 +1,7 @@
package handler
type Set struct {
+ BusinessPortrait *BusinessPortrait
Channel *Channel
Activation *Activation
AlertRecord *AlertRecord
diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go
index 8839aea..5bfd3c8 100644
--- a/internal/server/router/routes.go
+++ b/internal/server/router/routes.go
@@ -20,6 +20,10 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
return
}
registrations := []func(){
+ func() {
+ private.GET("/sw/business/:userId/portrait", r.handlers.BusinessPortrait.ForUser)
+ private.GET("/sw/business/bindPlatform/portrait", r.handlers.BusinessPortrait.ForBinding)
+ },
func() { RegisterChannel(private, r.handlers.Channel) },
func() {
private.GET("/sw/promoteActivePool/bindList", r.handlers.Activation.List)
diff --git a/internal/service/business/business_portrait.go b/internal/service/business/business_portrait.go
new file mode 100644
index 0000000..7395cdf
--- /dev/null
+++ b/internal/service/business/business_portrait.go
@@ -0,0 +1,73 @@
+package business
+
+import (
+ "context"
+ "kra/internal/biz/business"
+ "kra/internal/service/dto"
+)
+
+type BusinessPortraitUsecase interface {
+ ForUser(context.Context, uint, []string, string) (*business.BusinessPortrait, error)
+ ForBinding(context.Context, business.PlatformKey, string) (*business.BusinessPortrait, error)
+}
+type BusinessPortraitService struct{ uc BusinessPortraitUsecase }
+
+func NewBusinessPortraitService(uc *business.BusinessPortraitUsecase) *BusinessPortraitService {
+ return &BusinessPortraitService{uc: uc}
+}
+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}
+}
+func portraitResponse(p *business.BusinessPortrait) *dto.BusinessPortraitResponse {
+ b := p.Basic
+ out := &dto.BusinessPortraitResponse{Basic: &dto.PortraitBasic{UserID: b.UserID, UserName: b.UserName, RealName: b.RealName, Level: b.Level, LevelLabel: business.BusinessLevelLabel(b.Level), ParentUserID: b.ParentUserID, ParentUserName: b.ParentUserName, ParentRealName: b.ParentRealName, CreateTime: localTime(b.CreatedAt), Status: b.Status, StatusLabel: b.StatusLabel, PromoteCount: b.PromoteCount, MonthNewPromote: b.MonthNewPromote}, Period: p.Period, RegisterTrend: []*dto.PortraitTrend{}, PayTrend: []*dto.PortraitTrend{}, RegisterTop5: []*dto.PortraitRank{}, PayTop5: []*dto.PortraitRank{}, Available: p.Available, UnavailableReason: p.UnavailableReason}
+ if m := p.Metrics; m != nil {
+ out.Metrics = &dto.PortraitMetrics{Register: portraitMetricResponse(m.Register), PayAmount: portraitMetricResponse(m.PayAmount), PayRate: portraitMetricResponse(m.PayRate), ARPU: portraitMetricResponse(m.ARPU)}
+ }
+ 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})
+ }
+ ranks := func(rows []*business.PortraitRank) []*dto.PortraitRank {
+ result := make([]*dto.PortraitRank, 0, len(rows))
+ for _, row := range rows {
+ result = append(result, &dto.PortraitRank{Rank: row.Rank, SourceDB: row.SourceDB, SourceDBLabel: business.PlatformName(row.SourceDB), PromoteID: row.ID, PromoteName: row.PromoteName, Value: row.Value})
+ }
+ return result
+ }
+ out.RegisterTop5 = ranks(p.RegisterTop5)
+ out.PayTop5 = ranks(p.PayTop5)
+ return out
+}
+func (s *BusinessPortraitService) ForUser(ctx context.Context, id uint, req *dto.BusinessPortraitQuery) (*dto.BusinessPortraitResponse, error) {
+ if id == 0 || req == nil {
+ return nil, business.ErrAccountInput
+ }
+ sources, err := normalizePlatforms(req.Sources)
+ if err != nil {
+ return nil, err
+ }
+ if len(sources) == 0 {
+ return nil, business.ErrPortraitPlatforms
+ }
+ portrait, err := s.uc.ForUser(ctx, id, sources, req.Period)
+ if err != nil {
+ return nil, err
+ }
+ return portraitResponse(portrait), nil
+}
+func (s *BusinessPortraitService) ForBinding(ctx context.Context, req *dto.BindingPortraitQuery) (*dto.BusinessPortraitResponse, error) {
+ if req == nil || req.PlatformBusierID == 0 {
+ return nil, business.ErrAccountInput
+ }
+ portrait, err := s.uc.ForBinding(ctx, business.PlatformKey{SourceDB: req.SourceDB, BusierID: req.PlatformBusierID}, req.Period)
+ if err != nil {
+ return nil, err
+ }
+ return portraitResponse(portrait), nil
+}
diff --git a/internal/service/dto/business_portrait.go b/internal/service/dto/business_portrait.go
new file mode 100644
index 0000000..b64ad3d
--- /dev/null
+++ b/internal/service/dto/business_portrait.go
@@ -0,0 +1,62 @@
+package dto
+
+type BusinessPortraitQuery struct {
+ Sources []string `form:"sourceDbList"`
+ Period string `form:"period"`
+}
+type BindingPortraitQuery struct {
+ SourceDB string `form:"sourceDb"`
+ PlatformBusierID uint64 `form:"platformBusierId"`
+ Period string `form:"period"`
+}
+type PortraitBasic struct {
+ UserID uint64 `json:"userId"`
+ UserName string `json:"userName"`
+ RealName string `json:"realName"`
+ Level *int `json:"level"`
+ LevelLabel string `json:"levelLabel"`
+ ParentUserID *uint64 `json:"parentUserId"`
+ ParentUserName string `json:"parentUserName"`
+ ParentRealName string `json:"parentRealName"`
+ CreateTime *string `json:"createTime"`
+ Status *string `json:"status"`
+ StatusLabel string `json:"statusLabel"`
+ PromoteCount *int64 `json:"promoteCount"`
+ MonthNewPromote *int64 `json:"monthNewPromote"`
+}
+type PortraitMetric struct {
+ Value string `json:"value"`
+ CompareValue *string `json:"compareValue"`
+ ChangeRate *string `json:"changeRate"`
+ ChangeDiff *string `json:"changeDiff"`
+ CompareLabel string `json:"compareLabel"`
+}
+type PortraitMetrics struct {
+ Register *PortraitMetric `json:"register"`
+ PayAmount *PortraitMetric `json:"payAmount"`
+ PayRate *PortraitMetric `json:"payRate"`
+ ARPU *PortraitMetric `json:"arpu"`
+}
+type PortraitTrend struct {
+ Label string `json:"label"`
+ Value string `json:"value"`
+}
+type PortraitRank struct {
+ Rank int `json:"rank"`
+ SourceDB string `json:"sourceDb"`
+ SourceDBLabel string `json:"sourceDbLabel"`
+ PromoteID uint64 `json:"promoteId"`
+ PromoteName string `json:"promoteName"`
+ Value string `json:"value"`
+}
+type BusinessPortraitResponse struct {
+ Basic *PortraitBasic `json:"basic"`
+ Period string `json:"period"`
+ Metrics *PortraitMetrics `json:"metrics"`
+ RegisterTrend []*PortraitTrend `json:"registerTrend"`
+ PayTrend []*PortraitTrend `json:"payTrend"`
+ RegisterTop5 []*PortraitRank `json:"registerTop5"`
+ PayTop5 []*PortraitRank `json:"payTop5"`
+ Available bool `json:"available"`
+ UnavailableReason string `json:"unavailableReason"`
+}
diff --git a/internal/service/service.go b/internal/service/service.go
index 9182b28..8ab4297 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -21,6 +21,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewAlertRecordService,
businessservice.NewActivationService,
businessservice.NewChannelService,
+ businessservice.NewBusinessPortraitService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
diff --git a/scripts/sw_portrait_http_smoke.py b/scripts/sw_portrait_http_smoke.py
new file mode 100644
index 0000000..5bf0b55
--- /dev/null
+++ b/scripts/sw_portrait_http_smoke.py
@@ -0,0 +1,68 @@
+"""只读验证运行实例的商务画像、缺数据处理与参数校验。"""
+
+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):
+ request = urllib.request.Request(args.base_url + path, headers={"x-token": token})
+ with urllib.request.urlopen(request, timeout=30) as response:
+ payload = json.load(response)
+ check = {"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 "basic" in data:
+ check.update(available=data.get("available"), promote_count=data["basic"].get("promoteCount"),
+ register_points=len(data.get("registerTrend", [])), pay_points=len(data.get("payTrend", [])))
+ checks.append(check)
+ return data
+
+ accounts = get("/sw/business/list?pageNum=1&pageSize=500")["rows"]
+
+ def bound_subtree(account):
+ prefix = f"{account.get('idPath') or '0'},{account['userId']}"
+ ids = [account]
+ if int(account.get("level") or 0) < 3:
+ ids += [row for row in accounts if row["userId"] != account["userId"] and
+ (row.get("parentId") == account["userId"] or row.get("idPath") == prefix or
+ str(row.get("idPath") or "").startswith(prefix + ","))]
+ return sum(row.get("bindCount") or 0 for row in ids)
+
+ empty = next((row for row in accounts if bound_subtree(row) == 0), None)
+ bound = next((row for row in accounts if bound_subtree(row) > 0), None)
+ if empty:
+ data = get(f"/sw/business/{empty['userId']}/portrait?sourceDbList=lx,hw,yq&period=today")
+ checks[-1]["passed"] &= data.get("available") is True and data.get("metrics", {}).get("register", {}).get("value") == "0" and len(data.get("registerTrend", [])) == 24
+ data = get(f"/sw/business/{empty['userId']}/portrait?sourceDbList=lx&period=thisMonth")
+ checks[-1]["passed"] &= data.get("period") == "thismonth" and 1 <= len(data.get("registerTrend", [])) <= 31
+ if bound:
+ data = get(f"/sw/business/{bound['userId']}/portrait?sourceDbList=lx,hw,yq&period=last7")
+ if data.get("available") is False:
+ checks[-1]["passed"] &= data.get("metrics") is None and bool(data.get("unavailableReason"))
+ else:
+ checks[-1]["passed"] &= len(data.get("registerTrend", [])) == 7
+ if accounts:
+ get(f"/sw/business/{accounts[0]['userId']}/portrait", expected=7)
+ get("/sw/business/bindPlatform/portrait?sourceDb=lx&platformBusierId=0", expected=7)
+ report = {"scope": "真实 sw7 登录会话,只读请求;有绑定但业务表未接入时不伪造零,无绑定时验证真实空画像。完整指标和 SDK 基本信息通过隔离双库 TCP HTTP 测试。",
+ "checks": checks, "passed": all(check["passed"] for check 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_portrait_page_smoke.cjs b/scripts/sw_portrait_page_smoke.cjs
new file mode 100644
index 0000000..affed88
--- /dev/null
+++ b/scripts/sw_portrait_page_smoke.cjs
@@ -0,0 +1,101 @@
+// 真实 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 })
+ const results = []
+ try {
+ for (const level of [1, 3]) {
+ const context = await browser.newContext({ viewport: { width: 1600, height: 1080 } })
+ await context.addInitScript(value => localStorage.setItem('token', value), token)
+ const page = await context.newPage()
+ const errors = [], portraits = [], alertQueries = [], channelQueries = []
+ let unavailable = false
+ const normal = { userId: 70009, sourceDb: 'lx', platformName: '龙翔', platformBusierId: 14, platformBusierAccount: 'SDK测试账号', bindUserName: '测试商务', status: '0', createTime: '2026-11-01 10:00:00', promoteCount: 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/business/list') return ok({ rows: level === 3 ? [] : [{ userId: 70009, userName: '测试商务账号', realName: '测试商务', level: 3, status: '0', bindCount: 1, promoteCount: 1, createTime: '2026-11-01 10:00:00' }], total: level === 3 ? 0 : 1 })
+ if (api === '/sw/business/70009/bindPlatform') return ok([normal])
+ if (api === '/sw/business/bindPlatform') return ok({ rows: [normal, { ...normal, platformBusierId: 15, platformBusierAccount: '驳回账号', status: '2' }, { ...normal, platformBusierId: 16, platformBusierAccount: '待审账号', status: '3' }], total: 3 })
+ if (api === '/sw/business/70009/portrait' || api === '/sw/business/bindPlatform/portrait') {
+ const sdk = api.includes('bindPlatform')
+ const period = url.searchParams.get('period') || 'today'
+ portraits.push({ api, period, source: url.searchParams.get(sdk ? 'sourceDb' : 'sourceDbList'), platformBusierId: url.searchParams.get('platformBusierId') })
+ const length = ['today', 'yesterday'].includes(period) ? 24 : 7
+ const trends = Array.from({ length }, (_, i) => ({ label: length === 24 ? String(i).padStart(2, '0') + ':00' : new Date(Date.UTC(2026, 9, 28 + i)).toISOString().slice(5, 10), value: i === 1 ? '2' : '0' }))
+ const compareLabel = period === 'today' ? '较昨日' : period === 'yesterday' ? '较前日' : period === 'thismonth' ? '较上月同期' : '较上一周期'
+ const metric = value => ({ value, compareValue: '1', changeRate: '100.0', changeDiff: null, compareLabel })
+ return ok({ basic: { userId: sdk ? 14 : 70009, userName: sdk ? 'SDK测试账号' : '测试商务账号', realName: '画像姓名', level: 3, levelLabel: '三级商务', parentUserName: '上级账号', status: '0', statusLabel: sdk ? '待审核' : '正常', promoteCount: unavailable ? null : 1, monthNewPromote: unavailable ? null : 1, createTime: '2026-11-01 10:00:00' }, period,
+ available: !unavailable, unavailableReason: unavailable ? '渠道数据尚未接入,画像指标暂不可用' : '', metrics: unavailable ? null : { register: metric('2'), payAmount: metric('12.00'), payRate: { ...metric('50.0'), changeRate: null, changeDiff: '25.0', compareValue: '25.0' }, arpu: metric('6.00') },
+ registerTrend: unavailable ? [] : trends, payTrend: unavailable ? [] : trends.map(point => ({ ...point, value: point.value === '2' ? '12.00' : '0.00' })), registerTop5: unavailable ? [] : [{ rank: 1, sourceDbLabel: '龙翔', promoteId: 100, promoteName: '测试主渠道', value: '2.00' }], payTop5: unavailable ? [] : [{ rank: 1, sourceDbLabel: '龙翔', promoteId: 100, promoteName: '测试主渠道', value: '12.00' }] })
+ }
+ if (api === '/sw/alertRecord/list') { alertQueries.push(Object.fromEntries(url.searchParams)); return ok({ available: true, pageData: { rows: [{ id: '1', createdAt: '2026-11-03 10:00:00', targetPromoteName: '测试预警渠道', ruleType: 'register_anomaly', alertContent: '测试关联预警', status: 1, statusLabel: '未处理' }], total: 1 }, statusCount: { 1: 1 } }) }
+ if (api === '/sw/business/70009/promotes') { channelQueries.push({ api, ...Object.fromEntries(url.searchParams) }); return ok({ rows: [{ promoteId: 100, promoteName: '测试直属渠道', sourceDb: 'lx', platformName: '龙翔' }], total: 1, available: true }) }
+ 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: level === 3 ? 70009 : 70001, level, authorityId: 900 + level, 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/BusinessList')
+ const close = async dialog => { await dialog.getByRole('button', { name: '关闭', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }) }
+ if (level === 1) {
+ await page.getByRole('button', { name: '画像', exact: true }).click()
+ const portrait = page.getByRole('dialog', { name: '商务画像 — 测试商务', exact: true })
+ await portrait.getByText('画像姓名', { exact: true }).waitFor()
+ await portrait.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
+ await portrait.getByText('测试关联预警', { exact: true }).waitFor()
+ assert.equal(portraits.at(-1).source, 'lx,hw,yq')
+ await portrait.getByRole('img', { name: '注册量趋势', exact: true }).focus()
+ await portrait.getByRole('img', { name: '注册量趋势', exact: true }).press('ArrowRight')
+ await portrait.locator('.trend-tooltip').getByText('01:00', { exact: true }).waitFor()
+ await portrait.getByText('近7天', { exact: true }).click()
+ await page.waitForFunction(() => [...document.querySelectorAll('.profile-two-columns .el-card__header')].some(node => node.textContent.includes('近7天注册量')))
+ assert.equal(portraits.at(-1).period, 'last7')
+ await portrait.locator('.el-loading-mask').first().waitFor({ state: 'hidden' })
+ await page.screenshot({ path: path.join(process.env.TEMP, 'sw-business-portrait.png'), animations: 'disabled' })
+ await close(portrait)
+ await page.getByRole('button', { name: '查看SDK商务绑定详情', exact: true }).click()
+ const bindings = page.getByRole('dialog', { name: 'SDK商务绑定详情', exact: true })
+ await bindings.getByRole('button', { name: '龙翔', exact: true }).click()
+ const sdk = page.getByRole('dialog', { name: '游戏商务画像 — SDK测试账号', exact: true })
+ await sdk.getByText('画像姓名', { exact: true }).waitFor()
+ assert.equal(portraits.at(-1).platformBusierId, '14')
+ assert.equal(alertQueries.at(-1).businessId, '70009')
+ assert.equal(alertQueries.at(-1).platformCodes, 'lx')
+ await sdk.locator('.profile-info').getByText('待审核', { exact: true }).waitFor()
+ assert.equal(await sdk.locator('.profile-info .el-tag--success').count(), 0)
+ await sdk.locator('.profile-info').getByRole('button', { name: '1', exact: true }).click()
+ const channels = page.getByRole('dialog', { name: '旗下渠道', exact: true })
+ await channels.waitFor(); await channels.locator('.el-loading-mask').waitFor({ state: 'hidden' })
+ assert.equal(channelQueries.at(-1).api, '/sw/business/70009/promotes')
+ assert.equal(channelQueries.at(-1).platformBusierId, '14')
+ await channels.locator('.el-drawer__close-btn').click(); await channels.waitFor({ state: 'hidden' })
+ await close(sdk)
+ await bindings.locator('.el-drawer__close-btn').click(); await bindings.waitFor({ state: 'hidden' })
+ unavailable = true
+ await page.getByRole('button', { name: '画像', exact: true }).click()
+ await portrait.getByText('渠道数据尚未接入,画像指标暂不可用', { exact: true }).waitFor()
+ assert.equal(await portrait.getByRole('img', { name: '注册量趋势', exact: true }).count(), 0)
+ await close(portrait)
+ } else {
+ await page.getByRole('tab', { name: '绑定列表', exact: true }).click()
+ const normalRow = page.locator('.el-table__body-wrapper tr').filter({ hasText: 'SDK测试账号' })
+ await normalRow.getByRole('button', { name: '画像', exact: true }).click()
+ const sdk = page.getByRole('dialog', { name: '游戏商务画像 — SDK测试账号', exact: true })
+ await sdk.getByText('画像姓名', { exact: true }).waitFor()
+ await close(sdk)
+ assert.equal(await page.locator('.el-table__body-wrapper tr').filter({ hasText: '驳回账号' }).getByRole('button', { name: '画像', exact: true }).count(), 0)
+ assert.equal(await page.locator('.el-table__body-wrapper tr').filter({ hasText: '待审账号' }).getByRole('button', { name: '画像', exact: true }).count(), 0)
+ }
+ assert.deepEqual(errors, [])
+ const result = { level, passed: true, entry: level === 1 ? '商务列表与只读 SDK 详情' : '三级绑定列表正常行', pageErrors: errors }
+ results.push(result); console.log(JSON.stringify(result)); await context.close()
+ }
+ await fs.writeFile('docs/sw6-to-kra/portrait-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome 渲染;业务接口使用受控数据。验证三个入口、时间切换、交互图表、SDK 状态、SW 用户 ID 与平台 ID 分离、缺数据提示。', results }, null, 2) + '\n')
+ } finally { await browser.close() }
+})().catch(error => { console.error(error); process.exitCode = 1 })
diff --git a/web/src/api/businessPortrait.js b/web/src/api/businessPortrait.js
new file mode 100644
index 0000000..87ecd53
--- /dev/null
+++ b/web/src/api/businessPortrait.js
@@ -0,0 +1,4 @@
+import service from '@/utils/request'
+
+export const getBusinessPortrait = (userId, params) => service({ url: `/sw/business/${userId}/portrait`, method: 'get', params })
+export const getBindingPortrait = params => service({ url: '/sw/business/bindPlatform/portrait', method: 'get', params })
diff --git a/web/src/view/sw/BusinessList.vue b/web/src/view/sw/BusinessList.vue
index 3883a5a..9cc0f57 100644
--- a/web/src/view/sw/BusinessList.vue
+++ b/web/src/view/sw/BusinessList.vue
@@ -38,6 +38,7 @@