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