feat: 迁移商务与游戏商务画像及交互看板

This commit is contained in:
Yvan
2026-11-03 23:11:46 +08:00
parent 7f8105f6fb
commit 0de822f121
30 changed files with 1959 additions and 26 deletions
+185
View File
@@ -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)
}
+7 -2
View File
@@ -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,
+6 -2
View File
@@ -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 范畴和缺数据提示。
- 当前没有执行最终旧数据库复制与迁移验收。
+21 -4
View File
@@ -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 测试。完整平台资料与真实业务数值仍待最终实库联调;渠道画像是下一项独立工作。
## 其他页面
@@ -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
}
@@ -0,0 +1,17 @@
{
"method": "真实 Chrome 渲染;业务接口使用受控数据。验证三个入口、时间切换、交互图表、SDK 状态、SW 用户 ID 与平台 ID 分离、缺数据提示。",
"results": [
{
"level": 1,
"passed": true,
"entry": "商务列表与只读 SDK 详情",
"pageErrors": []
},
{
"level": 3,
"passed": true,
"entry": "三级绑定列表正常行",
"pageErrors": []
}
]
}
+4 -4
View File
@@ -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 推断范围。
+1 -1
View File
@@ -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)
+491
View File
@@ -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
}
@@ -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("应先将充值金额保留两位再计算人均充值")
}
}
+299
View File
@@ -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(&registers).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
}
@@ -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)
}
}
+1
View File
@@ -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)),
+2 -2
View File
@@ -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))
+2
View File
@@ -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: "查询推广游戏累计分布"},
+9 -8
View File
@@ -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)
}
}
@@ -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)
}
+1
View File
@@ -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,
+1
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
BusinessPortrait *BusinessPortrait
Channel *Channel
Activation *Activation
AlertRecord *AlertRecord
+4
View File
@@ -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)
@@ -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
}
+62
View File
@@ -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"`
}
+1
View File
@@ -21,6 +21,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewAlertRecordService,
businessservice.NewActivationService,
businessservice.NewChannelService,
businessservice.NewBusinessPortraitService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
+68
View File
@@ -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()
+101
View File
@@ -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 })
+4
View File
@@ -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 })
+7
View File
@@ -38,6 +38,7 @@
</el-table-column>
<el-table-column prop="createTime" label="注册时间" min-width="130"><template #default="{ row }">{{ row.createTime?.slice(0, 10) }}</template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statuses[row.status] || row.status }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button v-if="canQuery" type="primary" link @click="showPortrait(row)">画像</el-button></template></el-table-column>
</el-table>
<div class="kra-pagination">
<el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" />
@@ -45,6 +46,7 @@
</div>
<el-drawer append-to-body v-model="channelsDrawer" title="旗下渠道" size="520px" destroy-on-close><business-channels :user-id="channelUserId" /></el-drawer>
<el-drawer append-to-body v-model="bindingsDrawer" title="SDK商务绑定详情" size="85%" destroy-on-close><business-bindings :user-id="bindingUserId" /></el-drawer>
<business-profile-drawer v-model="portraitOpen" :user-id="portraitUser?.userId || 0" :name="portraitUser?.realName || portraitUser?.userName || ''" />
<el-drawer append-to-body v-model="drawer" title="添加子商务" :size="480" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="商务账号" prop="userName"><el-input v-model="form.userName" maxlength="30" /></el-form-item>
@@ -68,6 +70,7 @@ import { useBtnAuth } from '@/utils/btnAuth'
import { listBusiness, createBusiness } from '@/api/business'
import BusinessBindings from './components/BusinessBindings.vue'
import BusinessChannels from './components/BusinessChannels.vue'
import BusinessProfileDrawer from './components/BusinessProfileDrawer.vue'
defineOptions({ name: 'SwBusinessList' })
const user = useUserStore()
@@ -79,9 +82,13 @@ const channelUserId = ref(0)
const showChannels = (row) => { channelUserId.value = row.userId; channelsDrawer.value = true }
const showBindings = (row) => { bindingUserId.value = row.userId; bindingsDrawer.value = true }
const buttons = useBtnAuth()
const portraitOpen = ref(false)
const portraitUser = ref(null)
const showPortrait = row => { portraitUser.value = row; portraitOpen.value = true }
const levels = { 1: '一级商务', 2: '二级商务', 3: '三级商务' }
const statuses = { 0: '正常', 1: '停用', 2: '已驳回', 3: '待审核' }
const isAdmin = computed(() => Number(user.userInfo.authorityId) === 888)
const canQuery = computed(() => isAdmin.value || !!buttons['sw:business:query'])
const canAdd = computed(() => (isAdmin.value || buttons['sw:business:add']) && (isAdmin.value || Number(user.userInfo.level) < 3))
const isLevel3 = computed(() => Number(user.userInfo.level) === 3)
const childLevel = computed(() => levels[isAdmin.value ? 1 : Number(user.userInfo.level || 1) + 1])
@@ -11,16 +11,16 @@
<div class="kra-table-box">
<div v-if="!userId && canBind" class="kra-btn-list"><el-button type="primary" icon="plus" @click="openBind">绑定游戏商务</el-button></div>
<el-table v-loading="loading" :data="rows" :row-key="rowKey">
<el-table-column prop="platformName" label="所属平台" min-width="80" />
<el-table-column label="所属平台" min-width="80"><template #default="{ row }"><el-button v-if="userId && canPortrait && row.status === '0'" type="primary" link @click="showPortrait(row)">{{ row.platformName }}</el-button><span v-else>{{ row.platformName }}</span></template></el-table-column>
<el-table-column v-if="userId" prop="platformBusierId" label="ID" min-width="90" />
<el-table-column prop="platformBusierAccount" label="游戏商务账号" min-width="140" />
<el-table-column v-if="!userId" prop="bindUserName" label="绑定商务" min-width="140" />
<el-table-column v-if="!userId" label="旗下渠道数" min-width="110"><template #default="{ row }"><span v-if="row.promoteCount === null" class="text-gray-400">未接入</span><el-button v-else type="primary" link aria-label="查看旗下渠道" :disabled="row.status !== '0'" @click="showChannels(row)">{{ row.promoteCount }}</el-button></template></el-table-column>
<el-table-column label="审核状态" width="110"><template #default="{ row }"><el-tag :type="row.status === '0' ? 'success' : row.status === '2' ? 'danger' : 'warning'">{{ statuses[row.status] || row.status }}</el-tag></template></el-table-column>
<el-table-column prop="createTime" label="绑定时间" min-width="130"><template #default="{ row }">{{ row.createTime?.slice(0, 10) }}</template></el-table-column>
<el-table-column v-if="!userId" label="操作" width="110" fixed="right">
<el-table-column v-if="!userId" label="操作" width="145" fixed="right">
<template #default="{ row }">
<el-button v-if="canBind && row.status === '0' && row.userId === user.userInfo.ID" link type="danger" @click="remove(row)">解绑</el-button>
<template v-if="row.status === '0'"><el-button v-if="canPortrait" link type="primary" @click="showPortrait(row)">画像</el-button><el-button v-if="canBind && row.userId === user.userInfo.ID" link type="danger" @click="remove(row)">解绑</el-button></template>
<el-button v-else-if="row.status === '2'" link type="primary" @click="showReject(row)">查看驳回</el-button>
<span v-else>—</span>
</template>
@@ -29,6 +29,7 @@
<div v-if="!userId" class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
<el-drawer append-to-body v-model="channelsDrawer" title="旗下渠道" size="520px" destroy-on-close><business-channels v-if="selectedChannel" :user-id="selectedChannel.userId" :source-db="selectedChannel.sourceDb" :platform-busier-id="selectedChannel.platformBusierId" /></el-drawer>
<business-profile-drawer v-model="portraitOpen" :user-id="portraitRow?.userId || userId" :source-db="portraitRow?.sourceDb || ''" :platform-busier-id="portraitRow?.platformBusierId || 0" :name="portraitRow?.platformBusierAccount || ''" />
<el-drawer append-to-body v-model="dialog" title="绑定游戏商务" size="460px" destroy-on-close>
<el-alert title="验证成功后提交 OA 审核,审核通过后可查看对应渠道数据。" type="info" :closable="false" class="mb-4" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
@@ -66,12 +67,17 @@ import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
import { listBusinessBindings, listUserBindings, bindPlatform, unbindPlatform } from '@/api/business'
import BusinessChannels from './BusinessChannels.vue'
import BusinessProfileDrawer from './BusinessProfileDrawer.vue'
defineOptions({ name: 'SwBusinessBindings' })
const props = defineProps({ userId: { type: Number, default: 0 } })
const emit = defineEmits(['changed'])
const user = useUserStore()
const buttons = useBtnAuth()
const canPortrait = computed(() => Number(user.userInfo.authorityId) === 888 || !!buttons['sw:business:query'])
const portraitOpen = ref(false)
const portraitRow = ref(null)
const showPortrait = row => { portraitRow.value = row; portraitOpen.value = true }
const canBind = computed(() => !props.userId && Number(user.userInfo.level) === 3 && Boolean(buttons['sw:business:bind']))
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const statuses = { 0: '正常', 2: '已驳回', 3: '待审核' }
@@ -0,0 +1,134 @@
<template>
<el-drawer v-model="visible" append-to-body destroy-on-close :title="title" size="min(960px, 100vw)" @closed="reset">
<div v-loading="loading">
<el-alert v-if="error" type="warning" :closable="false" :title="error" />
<template v-if="profile">
<h3 class="profile-heading">基本信息</h3>
<dl class="profile-info">
<div><dt>商务账号</dt><dd>{{ basic.userName || '—' }}</dd></div>
<div><dt>姓名</dt><dd>{{ basic.realName || '—' }}</dd></div>
<div><dt>等级</dt><dd>{{ basic.levelLabel || (basic.level ?? '—') }}</dd></div>
<div><dt>上级商务</dt><dd>{{ basic.parentUserName || basic.parentRealName || '—' }}</dd></div>
<div><dt>注册时间</dt><dd>{{ basic.createTime || '—' }}</dd></div>
<div><dt>状态</dt><dd><el-tag :type="basic.statusLabel === '正常' ? 'success' : 'info'" effect="plain">{{ basic.statusLabel || '—' }}</el-tag></dd></div>
<div><dt>旗下渠道数</dt><dd><span v-if="basic.promoteCount == null">未接入</span><el-button v-else link type="primary" @click="channelsOpen = true">{{ channelCount(basic.promoteCount) }}</el-button></dd></div>
<div><dt>本月新增</dt><dd>{{ basic.monthNewPromote == null ? '未接入' : `+${channelCount(basic.monthNewPromote)}` }}</dd></div>
</dl>
<div class="profile-section-title"><h3 class="profile-heading">核心指标</h3><el-radio-group v-model="period" size="small" :disabled="loading" @change="loadProfile"><el-radio-button v-for="item in periods" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></div>
<el-alert v-if="profile.available === false" class="mb-4" type="warning" :closable="false" :title="profile.unavailableReason || '画像数据尚未接入'" />
<template v-else-if="profile.metrics">
<div class="profile-cards">
<el-card v-for="card in cards" :key="card.key" shadow="never">
<div class="metric-label">{{ card.label }}</div><strong class="metric-value">{{ card.value }}</strong>
<div class="metric-compare" :class="card.tone">{{ card.comparison }}</div>
</el-card>
</div>
<h3 class="profile-heading">数据趋势</h3>
<div class="profile-two-columns">
<el-card shadow="never"><template #header>{{ rangeLabel }}注册量{{ hourly ? '(按小时)' : '' }}</template><business-trend :series="profile.registerTrend" title="注册量趋势" /></el-card>
<el-card shadow="never"><template #header>{{ rangeLabel }}充值金额{{ hourly ? '(按小时)' : '' }}</template><business-trend :series="profile.payTrend" title="充值金额趋势" money color="var(--el-color-success)" /></el-card>
</div>
<h3 class="profile-heading">旗下渠道排行</h3>
<div class="profile-two-columns">
<el-card shadow="never"><template #header>注册量 TOP 5</template><el-table :data="profile.registerTop5" size="small"><el-table-column label="渠道" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.promoteName || '—' }} <el-text type="info" size="small">{{ row.sourceDbLabel }}</el-text></template></el-table-column><el-table-column label="注册量" min-width="100"><template #default="{ row }">{{ number(row.value) }}</template></el-table-column></el-table></el-card>
<el-card shadow="never"><template #header>充值金额 TOP 5</template><el-table :data="profile.payTop5" size="small"><el-table-column label="渠道" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.promoteName || '—' }} <el-text type="info" size="small">{{ row.sourceDbLabel }}</el-text></template></el-table-column><el-table-column label="充值金额" min-width="120"><template #default="{ row }">{{ channelMoney(row.value) }}</template></el-table-column></el-table></el-card>
</div>
</template>
<h3 class="profile-heading">关联预警</h3>
<el-alert v-if="alertError" type="warning" :closable="false" :title="alertError" />
<template v-else>
<el-table v-loading="alertsLoading" :data="alerts" size="small">
<el-table-column label="时间" width="120"><template #default="{ row }">{{ row.createdAt?.slice(5, 16) || '—' }}</template></el-table-column>
<el-table-column prop="targetPromoteName" label="渠道" min-width="140" show-overflow-tooltip />
<el-table-column label="预警类型" min-width="130"><template #default="{ row }">{{ alertType(row) }}</template></el-table-column>
<el-table-column prop="alertContent" label="详情" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 5 ? 'success' : 'warning'" effect="plain">{{ row.statusLabel || alertStatuses[row.status] }}</el-tag></template></el-table-column>
</el-table>
<div v-if="alertTotal" class="kra-pagination"><el-pagination v-model:current-page="alertPage" :page-size="10" :total="alertTotal" layout="total, prev, pager, next" @current-change="loadAlerts" /></div>
</template>
</template>
</div>
<template #footer><el-button @click="visible = false">关闭</el-button></template>
</el-drawer>
<el-drawer v-model="channelsOpen" append-to-body destroy-on-close title="旗下渠道" size="min(520px, 100vw)"><business-channels :user-id="userId" :source-db="sourceDb" :platform-busier-id="platformBusierId" /></el-drawer>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { getBusinessPortrait, getBindingPortrait } from '@/api/businessPortrait'
import { listAlertRecords } from '@/api/alert'
import { channelCount, channelMoney } from '../channel-display'
import { alertStatuses } from '../alert-display'
import BusinessTrend from './BusinessTrend.vue'
import BusinessChannels from './BusinessChannels.vue'
const props = defineProps({ modelValue: Boolean, userId: { type: Number, default: 0 }, sourceDb: { type: String, default: '' }, platformBusierId: { type: Number, default: 0 }, name: { type: String, default: '' } })
const emit = defineEmits(['update:modelValue'])
const visible = computed({ get: () => props.modelValue, set: value => emit('update:modelValue', value) })
const periods = [{ label: '今日', value: 'today' }, { label: '昨日', value: 'yesterday' }, { label: '近7天', value: 'last7' }, { label: '近30天', value: 'last30' }, { label: '本月', value: 'thismonth' }]
const period = ref('today')
const profile = ref(null)
const loading = ref(false)
const error = ref('')
const channelsOpen = ref(false)
const alerts = ref([])
const alertPage = ref(1)
const alertTotal = ref(0)
const alertsLoading = ref(false)
const alertError = ref('')
let profileVersion = 0, alertVersion = 0
const basic = computed(() => profile.value?.basic || {})
const sources = computed(() => props.sourceDb || 'lx,hw,yq')
const title = computed(() => `${props.platformBusierId ? '游戏商务画像' : '商务画像'}${props.name || basic.value.userName ? ` — ${props.name || basic.value.userName}` : ''}`)
const rangeLabel = computed(() => periods.find(item => item.value === period.value)?.label || '今日')
const hourly = computed(() => ['today', 'yesterday'].includes(period.value))
const number = value => value == null ? '—' : channelCount(String(value).replace(/\.0+$/, ''))
const cards = computed(() => [
{ key: 'register', label: '注册玩家', format: number }, { key: 'payAmount', label: '充值金额', format: channelMoney },
{ key: 'payRate', label: '付费率', format: value => value == null ? '—' : `${value}%` }, { key: 'arpu', label: '人均充值', format: channelMoney }
].map(card => {
const metric = profile.value?.metrics?.[card.key]
const diff = metric?.changeRate ?? metric?.changeDiff
const numeric = diff == null ? null : Number(diff)
const suffix = metric?.changeRate != null ? '%' : ' 个百分点'
return { ...card, value: card.format(metric?.value), tone: numeric == null || numeric === 0 ? '' : numeric > 0 ? 'positive' : 'negative', comparison: diff == null ? `${metric?.compareLabel || ''} ${metric?.compareValue == null ? '—' : card.format(metric.compareValue)}` : `${metric?.compareLabel || ''} ${numeric > 0 ? '↑ ' : numeric < 0 ? '↓ ' : ''}${Math.abs(numeric).toFixed(1)}${suffix}` }
}))
const alertType = row => ({ register_anomaly: '注册显著暴跌', recharge_anomaly: '充值额暴涨', login_anomaly: '渠道长期未登录' })[row.ruleType] || row.ruleName || row.ruleType || '—'
const reset = () => { ++profileVersion; ++alertVersion; profile.value = null; error.value = ''; alerts.value = []; alertTotal.value = 0; alertPage.value = 1; alertError.value = ''; loading.value = false; alertsLoading.value = false; channelsOpen.value = false }
const loadProfile = async () => {
const version = ++profileVersion
loading.value = true; error.value = ''
try {
const res = props.platformBusierId ? await getBindingPortrait({ sourceDb: props.sourceDb, platformBusierId: props.platformBusierId, period: period.value }) : await getBusinessPortrait(props.userId, { sourceDbList: sources.value, period: period.value })
if (version !== profileVersion) return
if (res.code === 0) profile.value = res.data
else { profile.value = null; error.value = res.msg || '画像读取失败' }
} catch { if (version === profileVersion) { profile.value = null; error.value = '画像读取失败,请重试' } } finally { if (version === profileVersion) loading.value = false }
}
const loadAlerts = async () => {
const version = ++alertVersion
alertsLoading.value = true; alertError.value = ''
try {
const res = await listAlertRecords({ platformCodes: sources.value, businessId: props.userId, pageNum: alertPage.value, pageSize: 10 })
if (version !== alertVersion) return
if (res.code === 0 && res.data.available !== false) { alerts.value = res.data.pageData.rows; alertTotal.value = res.data.pageData.total }
else { alerts.value = []; alertTotal.value = 0; alertError.value = res.code === 0 ? '渠道数据尚未接入,暂时无法核对关联预警。' : res.msg || '关联预警读取失败' }
} catch { if (version === alertVersion) { alerts.value = []; alertError.value = '关联预警读取失败,请重试' } } finally { if (version === alertVersion) alertsLoading.value = false }
}
watch(() => [props.modelValue, props.userId, props.sourceDb, props.platformBusierId], ([open]) => {
if (open && props.userId) { reset(); period.value = 'today'; void loadProfile(); void loadAlerts() }
})
</script>
<style scoped>
.profile-heading { margin: 20px 0 14px; font-size: 16px; font-weight: 600; }
.profile-info { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin: 0; }
.profile-info dt, .metric-label, .metric-compare { color: var(--el-text-color-secondary); font-size: 12px; }
.profile-info dd { margin: 6px 0 0; overflow-wrap: anywhere; }
.profile-section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 10px; }
.profile-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.metric-value { display: block; font-size: 23px; margin: 12px 0; overflow-wrap: anywhere; }
.metric-compare.positive { color: var(--el-color-success); }
.metric-compare.negative { color: var(--el-color-danger); }
.profile-two-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
@media (max-width: 700px) { .profile-info, .profile-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .profile-two-columns { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,56 @@
<template>
<div class="business-trend">
<svg viewBox="0 0 600 260" role="img" :aria-label="title" tabindex="0" @mousemove="hover" @mouseleave="active = null" @focus="active = 0" @blur="active = null" @keydown.left.prevent="move(-1)" @keydown.right.prevent="move(1)">
<g v-for="tick in ticks" :key="tick.y">
<line x1="52" :y1="tick.y" x2="582" :y2="tick.y" class="grid-line" />
<text x="44" :y="tick.y + 4" text-anchor="end" class="axis-text">{{ compact(tick.value) }}</text>
</g>
<polygon v-if="points.length" :points="area" :fill="color" opacity="0.08" />
<polyline :points="line" fill="none" :stroke="color" stroke-width="2.5" stroke-linejoin="round" />
<text v-for="index in labelIndexes" :key="index" :x="pointX(index)" y="244" text-anchor="middle" class="axis-text">{{ series[index]?.label }}</text>
<template v-if="selected">
<line :x1="selected.x" y1="18" :x2="selected.x" y2="220" class="cursor-line" />
<circle :cx="selected.x" :cy="selected.y" r="4" :fill="color" />
</template>
</svg>
<div v-if="selected" role="status" aria-live="polite" class="trend-tooltip" :style="{ left: `${Math.max(15, Math.min(85, selected.x / 6))}%` }">
<span>{{ selected.label }}</span><strong>{{ formatted(selected.value) }}</strong>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { channelCount, channelMoney } from '../channel-display'
const props = defineProps({ series: { type: Array, default: () => [] }, title: { type: String, default: '' }, money: Boolean, color: { type: String, default: 'var(--el-color-primary)' } })
const active = ref(null)
const values = computed(() => props.series.map(point => Number.isFinite(Number(point.value)) ? Number(point.value) : 0))
const maximum = computed(() => Math.max(1, ...values.value))
const minimum = computed(() => Math.min(0, ...values.value))
const pointY = value => 220 - (value - minimum.value) / (maximum.value - minimum.value) * 202
const pointX = index => 52 + index * 530 / Math.max(1, props.series.length - 1)
const points = computed(() => props.series.map((point, index) => ({ ...point, x: pointX(index), y: pointY(values.value[index]) })))
const line = computed(() => points.value.map(point => `${point.x},${point.y}`).join(' '))
const area = computed(() => `52,${pointY(0)} ${line.value} ${points.value.at(-1)?.x || 52},${pointY(0)}`)
const ticks = computed(() => [0, 1, 2, 3, 4].map(index => ({ y: 220 - index * 50.5, value: minimum.value + (maximum.value - minimum.value) * index / 4 })))
const labelIndexes = computed(() => [...new Set([0, 1, 2, 3, 4].map(index => Math.round(Math.max(0, props.series.length - 1) * index / 4)))])
const selected = computed(() => active.value == null ? null : points.value[active.value])
const compact = value => new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
const formatted = value => props.money ? channelMoney(value) : channelCount(String(value).replace(/\.0+$/, ''))
const hover = event => {
const bounds = event.currentTarget.getBoundingClientRect()
const x = (event.clientX - bounds.left) / Math.max(1, bounds.width) * 600
active.value = Math.max(0, Math.min(props.series.length - 1, Math.round((x - 52) / 530 * Math.max(0, props.series.length - 1))))
}
const move = step => { active.value = Math.max(0, Math.min(props.series.length - 1, (active.value ?? 0) + step)) }
</script>
<style scoped>
.business-trend { position: relative; }
svg { display: block; width: 100%; outline: none; }
svg:focus-visible { outline: 2px solid var(--el-color-primary-light-5); border-radius: 6px; }
.grid-line { stroke: var(--el-border-color-lighter); stroke-dasharray: 3 4; }
.axis-text { fill: var(--el-text-color-secondary); font-size: 12px; }
.cursor-line { stroke: var(--el-border-color); stroke-dasharray: 3 3; }
.trend-tooltip { position: absolute; top: 8px; transform: translateX(-50%); display: flex; gap: 12px; padding: 8px 12px; border: 1px solid var(--el-border-color-light); border-radius: 6px; background: var(--el-bg-color-overlay); box-shadow: var(--el-box-shadow-light); pointer-events: none; font-size: 12px; white-space: nowrap; }
</style>