feat: 迁移商务与游戏商务画像及交互看板
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Generated
+7
-2
@@ -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,
|
||||
|
||||
@@ -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 范畴和缺数据提示。
|
||||
- 当前没有执行最终旧数据库复制与迁移验收。
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
|
||||
| 旧页面行为 | 新页面状态 |
|
||||
| --- | --- |
|
||||
| 商务列表没有编辑按钮;操作列为画像 | 已移除错误增加的编辑入口;画像待报表与画像用例迁移,不能视为完成 |
|
||||
| 商务列表没有编辑按钮;操作列为画像 | 已对齐,商务画像及对应抽屉已接入 |
|
||||
| 第二个页签名为“绑定列表”,仅 `level=3` 显示 | 已对齐;超管、一级、二级均隐藏且不会加载绑定列表 |
|
||||
| 商务列表筛选:商务名称、商务账号、等级、状态 | 已实现 |
|
||||
| 添加子商务:账号、密码、姓名、手机号、QQ;展示等级/所属商务 | 已实现;已移除原页面没有的邮箱和编辑审核表单 |
|
||||
| SDK商务数量可点击,零数量也能打开详情 | 已对齐 |
|
||||
| SDK商务绑定详情是只读抽屉,上级可以查看;列为平台、ID、游戏商务账号、审核状态、绑定日期 | 已对齐基础列表与字段;平台点击进入画像待实现 |
|
||||
| SDK商务绑定详情是只读抽屉,上级可以查看;列为平台、ID、游戏商务账号、审核状态、绑定日期 | 已对齐,平台点击可进入游戏商务画像 |
|
||||
| 商务列表列名“旗下渠道数”,点击进入旗下渠道抽屉 | 已对齐,抽屉显示序号、渠道名称、所属平台及分页 |
|
||||
| 注册日期、绑定日期显示年月日 | 已对齐 |
|
||||
| 绑定状态正常:画像、解绑;驳回:查看驳回;待审核:无操作 | 解绑/查看驳回/待审核无操作已对齐;画像待实现 |
|
||||
| 绑定状态正常:画像、解绑;驳回:查看驳回;待审核:无操作 | 已对齐,画像只出现在正常绑定行 |
|
||||
| 查看驳回打开审核详情,展示申请商务、平台、账号、状态、申请/审核时间、审核人和审核意见 | 已实现 |
|
||||
| 添加绑定、解除绑定确认使用右侧抽屉 | 已对齐 |
|
||||
| 绑定列表点击旗下渠道数时,仅正常状态可查看并携带平台/平台商务筛选 | 已实现并通过浏览器参数核对 |
|
||||
@@ -31,7 +31,24 @@
|
||||
- `business-page-parity-2026-11-03.json`:真实 Chrome 渲染、受控身份/业务数据,检查四个等级的页签和按钮,以及三种审核状态的行操作;无页面运行时错误。
|
||||
- `http-business-bindings-2026-11-03.json`:真实运行实例及已有 sw7 测试数据的 HTTP 验证。
|
||||
- 旗下渠道已通过仓储与实际 HTTP 回归,覆盖已通过绑定、直属一级、跨平台同号和稳定分页;sw7 缺渠道数据时明确返回 `available=false`。
|
||||
- 画像等未完成项没有用占位响应或假数据冒充已迁移功能。
|
||||
- 商务/SDK 商务画像已通过接口及浏览器验证;渠道画像仍待迁移,没有使用占位响应冒充完成。
|
||||
|
||||
## 商务与游戏商务画像
|
||||
|
||||
来源:旧 `BusinessProfileDrawer.vue` 及 `BusinessList.vue` 的实际入口、`handleProfileOpenChannels` 参数传递。
|
||||
|
||||
| 旧页面行为 | 新页面状态 |
|
||||
| --- | --- |
|
||||
| 商务列表画像、正常绑定行画像、只读 SDK 详情平台入口 | 三个入口已接入,并分别验证一级与三级身份 |
|
||||
| 基本资料、上级商务、注册时间、状态、一级渠道数、本月新增 | 已实现;SDK 父级来自 commerce1/commerce2,SDK 状态 0 展示待审核 |
|
||||
| 今日、昨日、近7天、近30天、本月 | 已实现,周期和对照区间由后端统一计算 |
|
||||
| 注册玩家、充值金额、付费率、人均充值及环比 | 已实现,付费率使用百分点差并保留无对照期时的 null |
|
||||
| 注册、充值趋势及 TOP 5 | 已实现;使用 Kra 主题样式,图表支持鼠标与键盘查看数据点 |
|
||||
| 关联预警独立分页,不随指标周期改变查询范围 | 已实现,按 SW businessId 与平台过滤 |
|
||||
| 画像“旗下渠道”继续打开直属渠道抽屉 | 已对照父组件处理函数;传入 SW 用户 ID,SDK 场景附带平台和平台商务 ID |
|
||||
| 缺业务数据与没有业务数据 | 分别显示未接入与真实零,避免未知指标显示为零 |
|
||||
|
||||
验证:`portrait-http-2026-11-03.json`、`portrait-page-2026-11-03.json` 及隔离双库 TCP HTTP 测试。完整平台资料与真实业务数值仍待最终实库联调;渠道画像是下一项独立工作。
|
||||
|
||||
## 其他页面
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"scope": "真实 sw7 登录会话,只读请求;有绑定但业务表未接入时不伪造零,无绑定时验证真实空画像。完整指标和 SDK 基本信息通过隔离双库 TCP HTTP 测试。",
|
||||
"checks": [
|
||||
{
|
||||
"path": "/sw/business/list?pageNum=1&pageSize=500",
|
||||
"http": 200,
|
||||
"code": 0,
|
||||
"msg": "成功",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"path": "/sw/business/10001/portrait?sourceDbList=lx,hw,yq&period=today",
|
||||
"http": 200,
|
||||
"code": 0,
|
||||
"msg": "成功",
|
||||
"passed": true,
|
||||
"available": true,
|
||||
"promote_count": 0,
|
||||
"register_points": 24,
|
||||
"pay_points": 24
|
||||
},
|
||||
{
|
||||
"path": "/sw/business/10001/portrait?sourceDbList=lx&period=thisMonth",
|
||||
"http": 200,
|
||||
"code": 0,
|
||||
"msg": "成功",
|
||||
"passed": true,
|
||||
"available": true,
|
||||
"promote_count": 0,
|
||||
"register_points": 3,
|
||||
"pay_points": 3
|
||||
},
|
||||
{
|
||||
"path": "/sw/business/10004/portrait?sourceDbList=lx,hw,yq&period=last7",
|
||||
"http": 200,
|
||||
"code": 0,
|
||||
"msg": "成功",
|
||||
"passed": true,
|
||||
"available": false,
|
||||
"promote_count": null,
|
||||
"register_points": 0,
|
||||
"pay_points": 0
|
||||
},
|
||||
{
|
||||
"path": "/sw/business/10001/portrait",
|
||||
"http": 200,
|
||||
"code": 7,
|
||||
"msg": "请选择所属平台后查看数据",
|
||||
"passed": true
|
||||
},
|
||||
{
|
||||
"path": "/sw/business/bindPlatform/portrait?sourceDb=lx&platformBusierId=0",
|
||||
"http": 200,
|
||||
"code": 7,
|
||||
"msg": "商务账号参数不正确",
|
||||
"passed": true
|
||||
}
|
||||
],
|
||||
"passed": true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"method": "真实 Chrome 渲染;业务接口使用受控数据。验证三个入口、时间切换、交互图表、SDK 状态、SW 用户 ID 与平台 ID 分离、缺数据提示。",
|
||||
"results": [
|
||||
{
|
||||
"level": 1,
|
||||
"passed": true,
|
||||
"entry": "商务列表与只读 SDK 详情",
|
||||
"pageErrors": []
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"passed": true,
|
||||
"entry": "三级绑定列表正常行",
|
||||
"pageErrors": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# 下一批:商务与 SDK 商务画像
|
||||
# 商务与 SDK 商务画像源码核对
|
||||
|
||||
状态:已核对以下源码,尚未实现两种画像接口和页面入口。账号状态联动已在 `fc17b38` 完成,不要重复迁移。下一步直接实现画像及其报表查询依赖。
|
||||
状态:两种画像接口、三个页面入口及报表读取已实现并通过隔离 HTTP、真实会话和浏览器验证。完整平台数据源与最终旧库数据验收仍未完成。账号状态联动已在 `fc17b38` 完成。
|
||||
|
||||
## 范围与入口
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
- 注册玩家来自日汇总身份键的 REGISTER=1;付费率为 PAY=3 人数 / ACTIVE=2 人数 ×100,保留 1 位;人均充值为金额 / 注册人数,保留 2 位。
|
||||
- 当前活跃为零时付费率为 0.0;对照期活跃为零时 compareValue 为 null,百分点差也为 null。
|
||||
- 注册、金额、ARPU 的相对变化率为 `(current-previous)*100/previous`,保留 1 位;对照为零/null 时 changeRate 为 null。付费率用 changeDiff 百分点差。
|
||||
- today/yesterday 的趋势读取原始主账号注册与成功支付,补齐 24 个小时;小时注册趋势不做身份证去重。还需核对 Mapper 的具体小时桶时区表达式。
|
||||
- today/yesterday 的趋势读取原始主账号注册与成功支付,补齐 24 个小时;小时注册趋势不做身份证去重。MySQL 与原 Mapper 一致使用 `HOUR(FROM_UNIXTIME(...))`,原服务与新服务的数据库会话时区在最终实库验收时核对;SQLite 测试按传入上海日边界计算小时。
|
||||
- 其他周期趋势来自日汇总/身份键,按天补零。金额查询固定 dedup_mode=0;人数随当前登录人的偏好。
|
||||
- 跨平台不去重人数需要给身份键加 source_db 前缀;身份证去重模式按身份键跨平台去重。具体 SQL 在 `TabSwReportDayKeyMapper.xml` 的 summary / summaryByDate 中。
|
||||
|
||||
## 排行和关联预警
|
||||
|
||||
- 注册 TOP 5 经 `ChannelTreeMetricsSupport.countRegisterByDisplayChannel` 汇总;实现前继续读取这个 helper 和对应 Mapper,不能把子渠道人数直接求和替代整棵树去重。
|
||||
- 注册 TOP 5 经 `ChannelTreeMetricsSupport.countRegisterByDisplayChannel` 汇总:含下级时按源平台和一级渠道树 COUNT DISTINCT;一级取自身 ID,其他渠道 top_promote_id>0 时取 top,否则取自身 ID。不能把子渠道人数简单求和。人数先按旧 int 上限归一,再排序和截取 TOP 5。
|
||||
- 充值 TOP 5 先按渠道取金额,再按 `resolveRootPromoteId` 归根;同平台分组,过滤 <=0,只取前五。按值降序、渠道 ID 升序,返回 rank、平台、渠道 ID/账号、value(保留两位)。
|
||||
- 关联预警不在画像 DTO 中,由前端独立请求 `/sw/alertRecord/list`,参数为 `platformCodes`、SW `businessId`、分页。
|
||||
- 旧 SDK 画像的预警仍按 SW 商务及平台筛选,没有单独按 platformBusierId 收窄;需按实际模板核对,不从基本资料 ID 推断范围。
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPortraitPlatforms = errors.BadRequest("SW_PORTRAIT_PLATFORMS", "请选择所属平台后查看数据")
|
||||
ErrPortraitPlatformMissing = errors.NotFound("SW_PORTRAIT_PLATFORM_BUSINESS_NOT_FOUND", "平台商务不存在")
|
||||
)
|
||||
|
||||
var portraitLocation = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
|
||||
type PortraitBasic struct {
|
||||
UserID uint64
|
||||
UserName, RealName string
|
||||
Level *int
|
||||
ParentUserID *uint64
|
||||
ParentUserName, ParentRealName string
|
||||
CreatedAt *time.Time
|
||||
Status *string
|
||||
StatusLabel string
|
||||
PromoteCount, MonthNewPromote *int64
|
||||
}
|
||||
type PortraitPlatformBusiness struct {
|
||||
ID uint64
|
||||
Account, RealName string
|
||||
Level, Status *int
|
||||
Commerce1, Commerce2 *int64
|
||||
CreatedUnix *int64
|
||||
}
|
||||
|
||||
func (p *PortraitPlatformBusiness) ParentID() *uint64 {
|
||||
if p.Level == nil {
|
||||
return nil
|
||||
}
|
||||
var id *int64
|
||||
switch *p.Level {
|
||||
case 2:
|
||||
id = p.Commerce1
|
||||
case 3:
|
||||
id = p.Commerce2
|
||||
}
|
||||
if id == nil || *id <= 0 {
|
||||
return nil
|
||||
}
|
||||
value := uint64(*id)
|
||||
return &value
|
||||
}
|
||||
|
||||
type PortraitChannel struct {
|
||||
ChannelKey
|
||||
Level *int
|
||||
TopPromoteID uint64
|
||||
Account string
|
||||
CreatedUnix *int64
|
||||
}
|
||||
type PortraitMetric struct {
|
||||
Value string
|
||||
CompareValue, ChangeRate, ChangeDiff *string
|
||||
CompareLabel string
|
||||
}
|
||||
type PortraitMetrics struct{ Register, PayAmount, PayRate, ARPU *PortraitMetric }
|
||||
type PortraitTrend struct{ Label, Value string }
|
||||
type PortraitRank struct {
|
||||
Rank int
|
||||
ChannelKey
|
||||
PromoteName, Value string
|
||||
}
|
||||
type BusinessPortrait struct {
|
||||
Basic *PortraitBasic
|
||||
Period string
|
||||
Metrics *PortraitMetrics
|
||||
RegisterTrend, PayTrend []*PortraitTrend
|
||||
RegisterTop5, PayTop5 []*PortraitRank
|
||||
Available bool
|
||||
UnavailableReason string
|
||||
}
|
||||
type PortraitRange struct {
|
||||
Begin, End, PreviousBegin, PreviousEnd time.Time
|
||||
CompareLabel string
|
||||
Hourly bool
|
||||
}
|
||||
type PortraitCounts struct{ Register, Active, Pay, NewPay int64 }
|
||||
type PortraitStatRank struct {
|
||||
ChannelKey
|
||||
Value string
|
||||
}
|
||||
type PortraitStatistics struct {
|
||||
Current, Previous PortraitCounts
|
||||
CurrentPay, PreviousPay string
|
||||
RegisterSeries, PaySeries map[string]string
|
||||
RegisterRanks, PayRanks []*PortraitStatRank
|
||||
Available bool
|
||||
}
|
||||
type PortraitReportScope struct {
|
||||
Pairs []PlatformKey
|
||||
Sources []string
|
||||
IncludeSub, IDCardDedup bool
|
||||
Range PortraitRange
|
||||
RankLimit int
|
||||
CountLimit int64
|
||||
}
|
||||
type BusinessPortraitRepo interface {
|
||||
Bindings(context.Context, UserScope, []string, *PlatformKey) ([]*Binding, error)
|
||||
Channels(context.Context, []PlatformKey, bool) ([]*PortraitChannel, bool, error)
|
||||
PlatformBusiness(context.Context, PlatformKey) (*PortraitPlatformBusiness, error)
|
||||
Statistics(context.Context, PortraitReportScope) (*PortraitStatistics, error)
|
||||
}
|
||||
type BusinessPortraitUsecase struct {
|
||||
repo BusinessPortraitRepo
|
||||
accounts *AccountUsecase
|
||||
preferences *UserPreferenceUsecase
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewBusinessPortraitUsecase(repo BusinessPortraitRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *BusinessPortraitUsecase {
|
||||
return &BusinessPortraitUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now}
|
||||
}
|
||||
|
||||
func normalizePortraitPeriod(period string) string {
|
||||
switch value := strings.ToLower(strings.TrimSpace(period)); value {
|
||||
case "today", "yesterday", "last7", "last30", "thismonth":
|
||||
return value
|
||||
}
|
||||
return "today"
|
||||
}
|
||||
func portraitRanges(period string, now time.Time) PortraitRange {
|
||||
now = now.In(portraitLocation)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation)
|
||||
r := PortraitRange{Begin: today, End: today, PreviousBegin: today.AddDate(0, 0, -1), PreviousEnd: today.AddDate(0, 0, -1), CompareLabel: "较昨日", Hourly: true}
|
||||
switch period {
|
||||
case "yesterday":
|
||||
r.Begin = today.AddDate(0, 0, -1)
|
||||
r.End = r.Begin
|
||||
r.PreviousBegin = today.AddDate(0, 0, -2)
|
||||
r.PreviousEnd = r.PreviousBegin
|
||||
r.CompareLabel = "较前日"
|
||||
case "last7", "last30":
|
||||
days := 7
|
||||
if period == "last30" {
|
||||
days = 30
|
||||
}
|
||||
r.Begin = today.AddDate(0, 0, 1-days)
|
||||
r.PreviousEnd = r.Begin.AddDate(0, 0, -1)
|
||||
r.PreviousBegin = r.PreviousEnd.AddDate(0, 0, 1-days)
|
||||
r.CompareLabel = "较上一周期"
|
||||
r.Hourly = false
|
||||
case "thismonth":
|
||||
r.Begin = time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, portraitLocation)
|
||||
r.PreviousBegin = r.Begin.AddDate(0, -1, 0)
|
||||
lastDay := r.Begin.AddDate(0, 0, -1).Day()
|
||||
r.PreviousEnd = r.PreviousBegin.AddDate(0, 0, min(today.Day(), lastDay)-1)
|
||||
r.CompareLabel = "较上月同期"
|
||||
r.Hourly = false
|
||||
}
|
||||
return r
|
||||
}
|
||||
func portraitUserScope(account *Account) UserScope {
|
||||
return UserScope{SelfID: account.ID, IncludeSelf: true, OnlySelf: account.Level >= 3, DescendantPath: descendantPath(account)}
|
||||
}
|
||||
func portraitPairs(bindings []*Binding) []PlatformKey {
|
||||
pairs := []PlatformKey{}
|
||||
seen := map[PlatformKey]bool{}
|
||||
for _, b := range bindings {
|
||||
if !seen[b.PlatformKey] {
|
||||
pairs = append(pairs, b.PlatformKey)
|
||||
seen[b.PlatformKey] = true
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
func businessStatusLabel(status int) string {
|
||||
return map[int]string{0: "正常", 1: "停用", 2: "已驳回", 3: "待审核"}[status]
|
||||
}
|
||||
func BusinessLevelLabel(level *int) string {
|
||||
if level == nil {
|
||||
return ""
|
||||
}
|
||||
return map[int]string{1: "一级商务", 2: "二级商务", 3: "三级商务"}[*level]
|
||||
}
|
||||
|
||||
func (uc *BusinessPortraitUsecase) ForUser(ctx context.Context, id uint, sources []string, period string) (*BusinessPortrait, error) {
|
||||
if len(sources) == 0 {
|
||||
return nil, ErrPortraitPlatforms
|
||||
}
|
||||
account, err := uc.accounts.Find(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := strconv.Itoa(account.Status)
|
||||
parentID := uint64(account.ParentID)
|
||||
level := account.Level
|
||||
basic := &PortraitBasic{UserID: uint64(account.ID), UserName: account.Username, RealName: account.RealName, Level: &level, ParentUserID: &parentID, CreatedAt: &account.CreatedAt, Status: &status, StatusLabel: businessStatusLabel(account.Status)}
|
||||
if account.ParentID > 0 {
|
||||
parent, err := uc.accounts.repo.Find(ctx, account.ParentID)
|
||||
if err != nil && !errors.IsNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
if parent != nil {
|
||||
basic.ParentUserName = parent.Username
|
||||
basic.ParentRealName = parent.RealName
|
||||
}
|
||||
}
|
||||
bindings, err := uc.repo.Bindings(ctx, portraitUserScope(account), sources, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.assemble(ctx, basic, bindings, sources, period)
|
||||
}
|
||||
func (uc *BusinessPortraitUsecase) ForBinding(ctx context.Context, key PlatformKey, period string) (*BusinessPortrait, error) {
|
||||
source, err := NormalizePlatform(key.SourceDB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key.SourceDB = source
|
||||
actor, current, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all, err := uc.repo.Bindings(ctx, UserScope{All: true}, []string{source}, &key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return nil, ErrBindingNotFound
|
||||
}
|
||||
scope := portraitUserScope(current)
|
||||
scope.All = actor.AuthorityID == 888
|
||||
bindings, err := uc.repo.Bindings(ctx, scope, []string{source}, &key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
return nil, ErrAccountScope
|
||||
}
|
||||
platform, err := uc.repo.PlatformBusiness(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if platform == nil {
|
||||
return nil, ErrPortraitPlatformMissing
|
||||
}
|
||||
basic := &PortraitBasic{UserID: platform.ID, UserName: platform.Account, RealName: platform.RealName, Level: platform.Level}
|
||||
if parentID := platform.ParentID(); parentID != nil {
|
||||
parent, err := uc.repo.PlatformBusiness(ctx, PlatformKey{SourceDB: source, BusierID: *parentID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parent != nil {
|
||||
basic.ParentUserID = parentID
|
||||
basic.ParentUserName = parent.Account
|
||||
basic.ParentRealName = parent.RealName
|
||||
}
|
||||
}
|
||||
if platform.CreatedUnix != nil && *platform.CreatedUnix > 0 {
|
||||
created := time.Unix(*platform.CreatedUnix, 0).In(portraitLocation)
|
||||
basic.CreatedAt = &created
|
||||
}
|
||||
if platform.Status != nil {
|
||||
status := strconv.Itoa(*platform.Status)
|
||||
basic.Status = &status
|
||||
switch *platform.Status {
|
||||
case 1:
|
||||
basic.StatusLabel = "正常"
|
||||
case 0:
|
||||
basic.StatusLabel = "待审核"
|
||||
default:
|
||||
basic.StatusLabel = "停用"
|
||||
}
|
||||
}
|
||||
return uc.assemble(ctx, basic, bindings, []string{source}, period)
|
||||
}
|
||||
func (uc *BusinessPortraitUsecase) assemble(ctx context.Context, basic *PortraitBasic, bindings []*Binding, sources []string, period string) (*BusinessPortrait, error) {
|
||||
pref, err := uc.preferences.Current(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
period = normalizePortraitPeriod(period)
|
||||
now := uc.now()
|
||||
ranges := portraitRanges(period, now)
|
||||
out := &BusinessPortrait{Basic: basic, Period: period, RegisterTrend: []*PortraitTrend{}, PayTrend: []*PortraitTrend{}, RegisterTop5: []*PortraitRank{}, PayTop5: []*PortraitRank{}, Available: true}
|
||||
pairs := portraitPairs(bindings)
|
||||
channels, available, err := uc.repo.Channels(ctx, pairs, pref.IncludeSubPromote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
out.Available = false
|
||||
out.UnavailableReason = "渠道数据尚未接入,画像指标暂不可用"
|
||||
return out, nil
|
||||
}
|
||||
count, monthCount := int64(0), int64(0)
|
||||
today := now.In(portraitLocation)
|
||||
monthBegin := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, portraitLocation).Unix()
|
||||
tomorrow := time.Date(today.Year(), today.Month(), today.Day()+1, 0, 0, 0, 0, portraitLocation).Unix()
|
||||
for _, channel := range channels {
|
||||
if channel.Level != nil && *channel.Level == 1 {
|
||||
count++
|
||||
if channel.CreatedUnix != nil && *channel.CreatedUnix >= monthBegin && *channel.CreatedUnix < tomorrow {
|
||||
monthCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
basic.PromoteCount = &count
|
||||
basic.MonthNewPromote = &monthCount
|
||||
stats := &PortraitStatistics{Available: true, CurrentPay: "0", PreviousPay: "0", RegisterSeries: map[string]string{}, PaySeries: map[string]string{}}
|
||||
if len(channels) > 0 {
|
||||
stats, err = uc.repo.Statistics(ctx, PortraitReportScope{Pairs: pairs, Sources: sources, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup, Range: ranges, RankLimit: 5, CountLimit: 2147483647})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !stats.Available {
|
||||
out.Available = false
|
||||
out.UnavailableReason = "日汇总或趋势数据尚未接入,画像指标暂不可用"
|
||||
return out, nil
|
||||
}
|
||||
out.Metrics, err = portraitMetrics(stats, ranges.CompareLabel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.RegisterTrend, err = portraitTrend(stats.RegisterSeries, ranges, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.PayTrend, err = portraitTrend(stats.PaySeries, ranges, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.RegisterTop5, err = portraitRanks(stats.RegisterRanks, channels, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.PayTop5, err = portraitRanks(stats.PayRanks, channels, false)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func portraitNumber(value string) (*big.Rat, error) {
|
||||
if value == "" {
|
||||
value = "0"
|
||||
}
|
||||
n, ok := new(big.Rat).SetString(value)
|
||||
if !ok {
|
||||
return nil, ErrStorage
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
func stringPointer(value string) *string { return &value }
|
||||
func portraitDecimal(n *big.Rat, digits int) string {
|
||||
value := n.FloatString(digits)
|
||||
// 与 BigDecimal 一致:四舍五入为零后不携带负号。
|
||||
if strings.HasPrefix(value, "-") && strings.Trim(value[1:], "0.") == "" {
|
||||
return value[1:]
|
||||
}
|
||||
return value
|
||||
}
|
||||
func portraitRateCard(current, previous string, label string) (*PortraitMetric, error) {
|
||||
c, err := portraitNumber(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := portraitNumber(previous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
card := &PortraitMetric{Value: current, CompareValue: stringPointer(previous), CompareLabel: label}
|
||||
if p.Sign() != 0 {
|
||||
diff := new(big.Rat).Sub(c, p)
|
||||
diff.Mul(diff, big.NewRat(100, 1))
|
||||
diff.Quo(diff, p)
|
||||
card.ChangeRate = stringPointer(portraitDecimal(diff, 1))
|
||||
}
|
||||
return card, nil
|
||||
}
|
||||
func portraitMetrics(stats *PortraitStatistics, label string) (*PortraitMetrics, error) {
|
||||
pay, err := portraitNumber(stats.CurrentPay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previousPay, err := portraitNumber(stats.PreviousPay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pay, _ = portraitNumber(portraitDecimal(pay, 2))
|
||||
previousPay, _ = portraitNumber(portraitDecimal(previousPay, 2))
|
||||
currentRegister, previousRegister := min(stats.Current.Register, int64(2147483647)), min(stats.Previous.Register, int64(2147483647))
|
||||
register, _ := portraitRateCard(strconv.FormatInt(currentRegister, 10), strconv.FormatInt(previousRegister, 10), label)
|
||||
payCard, _ := portraitRateCard(portraitDecimal(pay, 2), portraitDecimal(previousPay, 2), label)
|
||||
arpu := func(amount *big.Rat, count int64) string {
|
||||
if count == 0 {
|
||||
return "0.00"
|
||||
}
|
||||
return portraitDecimal(new(big.Rat).Quo(amount, big.NewRat(count, 1)), 2)
|
||||
}
|
||||
arpuCard, _ := portraitRateCard(arpu(pay, currentRegister), arpu(previousPay, previousRegister), label)
|
||||
payRate := func(c PortraitCounts) string {
|
||||
active := min(c.Active, int64(2147483647))
|
||||
if active == 0 {
|
||||
return "0.0"
|
||||
}
|
||||
return portraitDecimal(new(big.Rat).Mul(big.NewRat(min(c.Pay, int64(2147483647)), active), big.NewRat(100, 1)), 1)
|
||||
}
|
||||
rate := &PortraitMetric{Value: payRate(stats.Current), CompareLabel: label}
|
||||
if stats.Previous.Active != 0 {
|
||||
value := payRate(stats.Previous)
|
||||
rate.CompareValue = &value
|
||||
c, _ := portraitNumber(rate.Value)
|
||||
p, _ := portraitNumber(value)
|
||||
rate.ChangeDiff = stringPointer(portraitDecimal(new(big.Rat).Sub(c, p), 1))
|
||||
}
|
||||
return &PortraitMetrics{Register: register, PayAmount: payCard, PayRate: rate, ARPU: arpuCard}, nil
|
||||
}
|
||||
func portraitTrend(series map[string]string, ranges PortraitRange, money bool) ([]*PortraitTrend, error) {
|
||||
result := []*PortraitTrend{}
|
||||
add := func(key, label string) error {
|
||||
n, err := portraitNumber(series[key])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digits := 0
|
||||
if money {
|
||||
digits = 2
|
||||
} else if !ranges.Hourly && n.Cmp(big.NewRat(2147483647, 1)) > 0 {
|
||||
n = big.NewRat(2147483647, 1)
|
||||
}
|
||||
result = append(result, &PortraitTrend{Label: label, Value: portraitDecimal(n, digits)})
|
||||
return nil
|
||||
}
|
||||
if ranges.Hourly {
|
||||
for hour := 0; hour < 24; hour++ {
|
||||
label := time.Date(2000, 1, 1, hour, 0, 0, 0, time.UTC).Format("15:04")
|
||||
if err := add(strconv.Itoa(hour), label); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for day := ranges.Begin; !day.After(ranges.End); day = day.AddDate(0, 0, 1) {
|
||||
if err := add(day.Format("2006-01-02"), day.Format("01-02")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func portraitRanks(raw []*PortraitStatRank, channels []*PortraitChannel, count bool) ([]*PortraitRank, error) {
|
||||
names := map[ChannelKey]string{}
|
||||
for _, channel := range channels {
|
||||
names[channel.ChannelKey] = channel.Account
|
||||
}
|
||||
values := map[ChannelKey]*big.Rat{}
|
||||
rows := []*PortraitRank{}
|
||||
for _, item := range raw {
|
||||
n, err := portraitNumber(item.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count && n.Cmp(big.NewRat(2147483647, 1)) > 0 {
|
||||
n = big.NewRat(2147483647, 1)
|
||||
}
|
||||
if n.Sign() <= 0 {
|
||||
continue
|
||||
}
|
||||
values[item.ChannelKey] = n
|
||||
rows = append(rows, &PortraitRank{ChannelKey: item.ChannelKey, PromoteName: names[item.ChannelKey], Value: portraitDecimal(n, 2)})
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
cmp := values[rows[i].ChannelKey].Cmp(values[rows[j].ChannelKey])
|
||||
if cmp != 0 {
|
||||
return cmp > 0
|
||||
}
|
||||
if rows[i].ID != rows[j].ID {
|
||||
return rows[i].ID < rows[j].ID
|
||||
}
|
||||
return rows[i].SourceDB < rows[j].SourceDB
|
||||
})
|
||||
if len(rows) > 5 {
|
||||
rows = rows[:5]
|
||||
}
|
||||
for i, row := range rows {
|
||||
row.Rank = i + 1
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type portraitRepoFake struct {
|
||||
BusinessPortraitRepo
|
||||
bindings []*Binding
|
||||
channels []*PortraitChannel
|
||||
channelsAvailable bool
|
||||
stats *PortraitStatistics
|
||||
scope PortraitReportScope
|
||||
platforms map[uint64]*PortraitPlatformBusiness
|
||||
}
|
||||
|
||||
func (r *portraitRepoFake) Bindings(_ context.Context, scope UserScope, _ []string, _ *PlatformKey) ([]*Binding, error) {
|
||||
if !scope.All && scope.SelfID == 99 {
|
||||
return []*Binding{}, nil
|
||||
}
|
||||
return r.bindings, nil
|
||||
}
|
||||
func (r *portraitRepoFake) Channels(_ context.Context, pairs []PlatformKey, _ bool) ([]*PortraitChannel, bool, error) {
|
||||
if len(pairs) == 0 {
|
||||
return []*PortraitChannel{}, true, nil
|
||||
}
|
||||
return r.channels, r.channelsAvailable, nil
|
||||
}
|
||||
func (r *portraitRepoFake) Statistics(_ context.Context, scope PortraitReportScope) (*PortraitStatistics, error) {
|
||||
r.scope = scope
|
||||
return r.stats, nil
|
||||
}
|
||||
func (r *portraitRepoFake) PlatformBusiness(_ context.Context, key PlatformKey) (*PortraitPlatformBusiness, error) {
|
||||
return r.platforms[key.BusierID], nil
|
||||
}
|
||||
func portraitFixture() (*BusinessPortraitUsecase, *portraitRepoFake) {
|
||||
accounts := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Username: "owner", RealName: "商务甲", Level: 1, IDPath: "0"}, 9: {ID: 9, Username: "leaf", Level: 3, ParentID: 7, IDPath: "0,7"}, 99: {ID: 99, Username: "other", Level: 1, IDPath: "0"}}}
|
||||
created := time.Date(2026, 11, 1, 0, 0, 0, 0, portraitLocation).Unix()
|
||||
r := &portraitRepoFake{bindings: []*Binding{{UserID: 9, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 14}, Status: "0"}}, channelsAvailable: true, channels: []*PortraitChannel{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 100}, Level: intPointer(1), Account: "一级渠道", CreatedUnix: &created}}, platforms: map[uint64]*PortraitPlatformBusiness{14: {ID: 14, Account: "platform", RealName: "平台商务", Level: intPointer(3), Status: intPointer(0), Commerce2: portraitUint(12), CreatedUnix: &created}, 12: {ID: 12, Account: "platform-parent", RealName: "平台上级"}}}
|
||||
r.stats = &PortraitStatistics{Available: true, Current: PortraitCounts{Register: 2, Active: 3, Pay: 2}, Previous: PortraitCounts{Register: 1, Active: 4, Pay: 1}, CurrentPay: "12.00", PreviousPay: "6.00", RegisterSeries: map[string]string{"2026-11-01": "2", "0": "3"}, PaySeries: map[string]string{"0": "1.25"}, RegisterRanks: []*PortraitStatRank{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 100}, Value: "2"}}, PayRanks: []*PortraitStatRank{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 100}, Value: "12.00"}}}
|
||||
uc := NewBusinessPortraitUsecase(r, NewAccountUsecase(accounts), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}))
|
||||
uc.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, portraitLocation) }
|
||||
return uc, r
|
||||
}
|
||||
func portraitUint(n int64) *int64 { return &n }
|
||||
func TestPortraitCalendarRangesClampPreviousMonth(t *testing.T) {
|
||||
today := time.Date(2024, 3, 31, 10, 0, 0, 0, portraitLocation)
|
||||
cases := []struct{ period, begin, end, prevBegin, prevEnd string }{{"today", "2024-03-31", "2024-03-31", "2024-03-30", "2024-03-30"}, {"yesterday", "2024-03-30", "2024-03-30", "2024-03-29", "2024-03-29"}, {"last7", "2024-03-25", "2024-03-31", "2024-03-18", "2024-03-24"}, {"last30", "2024-03-02", "2024-03-31", "2024-02-01", "2024-03-01"}, {"thismonth", "2024-03-01", "2024-03-31", "2024-02-01", "2024-02-29"}}
|
||||
for _, tc := range cases {
|
||||
r := portraitRanges(tc.period, today)
|
||||
if r.Begin.Format("2006-01-02") != tc.begin || r.End.Format("2006-01-02") != tc.end || r.PreviousBegin.Format("2006-01-02") != tc.prevBegin || r.PreviousEnd.Format("2006-01-02") != tc.prevEnd {
|
||||
t.Fatalf("日期范围错误: %+v %+v", tc, r)
|
||||
}
|
||||
}
|
||||
if normalizePortraitPeriod(" thisMonth ") != "thismonth" || normalizePortraitPeriod("total") != "today" {
|
||||
t.Fatal("商务画像与渠道累计模式混用")
|
||||
}
|
||||
}
|
||||
func TestPortraitMetricsUseActiveDenominatorAndPercentagePoints(t *testing.T) {
|
||||
uc, r := portraitFixture()
|
||||
portrait, err := uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "today")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := portrait.Metrics
|
||||
if m.Register.Value != "2" || *m.Register.ChangeRate != "100.0" || m.PayRate.Value != "66.7" || *m.PayRate.CompareValue != "25.0" || *m.PayRate.ChangeDiff != "41.7" || m.ARPU.Value != "6.00" || *m.ARPU.ChangeRate != "0.0" {
|
||||
t.Fatalf("画像卡片计算错误: %+v", m)
|
||||
}
|
||||
if *portrait.Basic.PromoteCount != 1 || *portrait.Basic.MonthNewPromote != 1 || len(portrait.RegisterTrend) != 24 || portrait.RegisterTrend[0].Value != "3" || portrait.PayTrend[0].Value != "1.25" {
|
||||
t.Fatal("基本渠道计数或小时趋势错误")
|
||||
}
|
||||
if r.scope.RankLimit != 5 || !r.scope.IDCardDedup || !r.scope.IncludeSub {
|
||||
t.Fatal("画像没有使用查看人的全局偏好")
|
||||
}
|
||||
r.stats.Previous.Active = 0
|
||||
portrait, err = uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "last7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if portrait.Metrics.PayRate.CompareValue != nil || portrait.Metrics.PayRate.ChangeDiff != nil || len(portrait.RegisterTrend) != 7 {
|
||||
t.Fatal("无上期活跃时不应伪造付费率环比")
|
||||
}
|
||||
}
|
||||
func TestPortraitEmptyScopeAndUnavailableDataAreDifferent(t *testing.T) {
|
||||
uc, r := portraitFixture()
|
||||
r.channelsAvailable = false
|
||||
p, err := uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "today")
|
||||
if err != nil || p.Available || p.Basic.UserName != "owner" || p.Basic.PromoteCount != nil || p.Metrics != nil {
|
||||
t.Fatal("缺表时应保留基本资料并标记未知指标")
|
||||
}
|
||||
r.bindings = nil
|
||||
p, err = uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "today")
|
||||
if err != nil || !p.Available || *p.Basic.PromoteCount != 0 || p.Metrics.Register.Value != "0" || len(p.RegisterTrend) != 24 {
|
||||
t.Fatal("无绑定属于确定空数据")
|
||||
}
|
||||
if _, err := uc.ForUser(accountContext(99, 901), 7, []string{"lx"}, "today"); err != ErrAccountScope {
|
||||
t.Fatal("不能查询其他一级商务画像")
|
||||
}
|
||||
}
|
||||
func TestBindingPortraitUsesPlatformHierarchyAndStatus(t *testing.T) {
|
||||
uc, _ := portraitFixture()
|
||||
p, err := uc.ForBinding(accountContext(9, 903), PlatformKey{SourceDB: "lx", BusierID: 14}, "today")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Basic.UserID != 14 || p.Basic.UserName != "platform" || p.Basic.ParentUserID == nil || *p.Basic.ParentUserID != 12 || p.Basic.ParentUserName != "platform-parent" || *p.Basic.Status != "0" || p.Basic.StatusLabel != "待审核" {
|
||||
t.Fatalf("混淆平台与 SW 的 ID、上级或状态: %+v", p.Basic)
|
||||
}
|
||||
if _, err := uc.ForBinding(accountContext(99, 901), PlatformKey{SourceDB: "lx", BusierID: 14}, "today"); err != ErrAccountScope {
|
||||
t.Fatal("范围外 SDK 画像被放行")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformPortraitNonpositiveParentIsAbsent(t *testing.T) {
|
||||
negative := int64(-1)
|
||||
if parent := (&PortraitPlatformBusiness{Level: intPointer(3), Commerce2: &negative}).ParentID(); parent != nil {
|
||||
t.Fatal("平台的负数父级占位值不能变成无符号 ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortraitDecimalRoundingMatchesBigDecimal(t *testing.T) {
|
||||
card, err := portraitRateCard("1000.00", "1000.01", "较昨日")
|
||||
if err != nil || card.ChangeRate == nil || *card.ChangeRate != "0.0" {
|
||||
t.Fatal("极小负环比应舍入为不带负号的零")
|
||||
}
|
||||
metrics, err := portraitMetrics(&PortraitStatistics{CurrentPay: "8.005", PreviousPay: "0", Current: PortraitCounts{Register: 2}}, "较昨日")
|
||||
if err != nil || metrics.PayAmount.Value != "8.01" || metrics.ARPU.Value != "4.01" {
|
||||
t.Fatal("应先将充值金额保留两位再计算人均充值")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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: "查询推广游戏累计分布"},
|
||||
|
||||
@@ -26,13 +26,14 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
|
||||
|
||||
func emptyHandlers() *handler.Set {
|
||||
return &handler.Set{
|
||||
Channel: &handler.Channel{},
|
||||
Activation: &handler.Activation{},
|
||||
AlertRecord: &handler.AlertRecord{},
|
||||
AlertRule: &handler.AlertRule{},
|
||||
BusinessPromote: &handler.BusinessPromote{},
|
||||
Business: &handler.Business{},
|
||||
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
|
||||
BusinessPortrait: &handler.BusinessPortrait{},
|
||||
Channel: &handler.Channel{},
|
||||
Activation: &handler.Activation{},
|
||||
AlertRecord: &handler.AlertRecord{},
|
||||
AlertRule: &handler.AlertRule{},
|
||||
BusinessPromote: &handler.BusinessPromote{},
|
||||
Business: &handler.Business{},
|
||||
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
|
||||
Permission: &handler.Permission{}, Organization: &handler.Organization{},
|
||||
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Payment: &handler.Payment{}, Task: &handler.Task{},
|
||||
Media: &handler.Media{}, Audit: &handler.Audit{}, Export: &handler.Export{},
|
||||
@@ -103,7 +104,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||
}
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":233`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":235`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"kra/internal/service/business"
|
||||
"kra/internal/service/dto"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type BusinessPortrait struct {
|
||||
service *business.BusinessPortraitService
|
||||
}
|
||||
|
||||
func NewBusinessPortrait(service *business.BusinessPortraitService) *BusinessPortrait {
|
||||
return &BusinessPortrait{service: service}
|
||||
}
|
||||
func (h *BusinessPortrait) ForUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("userId"), 10, 64)
|
||||
if err != nil {
|
||||
Fail(c, "商务用户 ID 不正确")
|
||||
return
|
||||
}
|
||||
var req dto.BusinessPortraitQuery
|
||||
if c.ShouldBindQuery(&req) != nil {
|
||||
Fail(c, "商务画像查询参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.ForUser(c.Request.Context(), uint(id), &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
func (h *BusinessPortrait) ForBinding(c *gin.Context) {
|
||||
var req dto.BindingPortraitQuery
|
||||
if c.ShouldBindQuery(&req) != nil {
|
||||
Fail(c, "商务画像查询参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.ForBinding(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewAlertRecord,
|
||||
NewActivation,
|
||||
NewChannel,
|
||||
NewBusinessPortrait,
|
||||
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
|
||||
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
BusinessPortrait *BusinessPortrait
|
||||
Channel *Channel
|
||||
Activation *Activation
|
||||
AlertRecord *AlertRecord
|
||||
|
||||
@@ -20,6 +20,10 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
return
|
||||
}
|
||||
registrations := []func(){
|
||||
func() {
|
||||
private.GET("/sw/business/:userId/portrait", r.handlers.BusinessPortrait.ForUser)
|
||||
private.GET("/sw/business/bindPlatform/portrait", r.handlers.BusinessPortrait.ForBinding)
|
||||
},
|
||||
func() { RegisterChannel(private, r.handlers.Channel) },
|
||||
func() {
|
||||
private.GET("/sw/promoteActivePool/bindList", r.handlers.Activation.List)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type BusinessPortraitUsecase interface {
|
||||
ForUser(context.Context, uint, []string, string) (*business.BusinessPortrait, error)
|
||||
ForBinding(context.Context, business.PlatformKey, string) (*business.BusinessPortrait, error)
|
||||
}
|
||||
type BusinessPortraitService struct{ uc BusinessPortraitUsecase }
|
||||
|
||||
func NewBusinessPortraitService(uc *business.BusinessPortraitUsecase) *BusinessPortraitService {
|
||||
return &BusinessPortraitService{uc: uc}
|
||||
}
|
||||
func portraitMetricResponse(m *business.PortraitMetric) *dto.PortraitMetric {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.PortraitMetric{Value: m.Value, CompareValue: m.CompareValue, ChangeRate: m.ChangeRate, ChangeDiff: m.ChangeDiff, CompareLabel: m.CompareLabel}
|
||||
}
|
||||
func portraitResponse(p *business.BusinessPortrait) *dto.BusinessPortraitResponse {
|
||||
b := p.Basic
|
||||
out := &dto.BusinessPortraitResponse{Basic: &dto.PortraitBasic{UserID: b.UserID, UserName: b.UserName, RealName: b.RealName, Level: b.Level, LevelLabel: business.BusinessLevelLabel(b.Level), ParentUserID: b.ParentUserID, ParentUserName: b.ParentUserName, ParentRealName: b.ParentRealName, CreateTime: localTime(b.CreatedAt), Status: b.Status, StatusLabel: b.StatusLabel, PromoteCount: b.PromoteCount, MonthNewPromote: b.MonthNewPromote}, Period: p.Period, RegisterTrend: []*dto.PortraitTrend{}, PayTrend: []*dto.PortraitTrend{}, RegisterTop5: []*dto.PortraitRank{}, PayTop5: []*dto.PortraitRank{}, Available: p.Available, UnavailableReason: p.UnavailableReason}
|
||||
if m := p.Metrics; m != nil {
|
||||
out.Metrics = &dto.PortraitMetrics{Register: portraitMetricResponse(m.Register), PayAmount: portraitMetricResponse(m.PayAmount), PayRate: portraitMetricResponse(m.PayRate), ARPU: portraitMetricResponse(m.ARPU)}
|
||||
}
|
||||
for _, point := range p.RegisterTrend {
|
||||
out.RegisterTrend = append(out.RegisterTrend, &dto.PortraitTrend{Label: point.Label, Value: point.Value})
|
||||
}
|
||||
for _, point := range p.PayTrend {
|
||||
out.PayTrend = append(out.PayTrend, &dto.PortraitTrend{Label: point.Label, Value: point.Value})
|
||||
}
|
||||
ranks := func(rows []*business.PortraitRank) []*dto.PortraitRank {
|
||||
result := make([]*dto.PortraitRank, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, &dto.PortraitRank{Rank: row.Rank, SourceDB: row.SourceDB, SourceDBLabel: business.PlatformName(row.SourceDB), PromoteID: row.ID, PromoteName: row.PromoteName, Value: row.Value})
|
||||
}
|
||||
return result
|
||||
}
|
||||
out.RegisterTop5 = ranks(p.RegisterTop5)
|
||||
out.PayTop5 = ranks(p.PayTop5)
|
||||
return out
|
||||
}
|
||||
func (s *BusinessPortraitService) ForUser(ctx context.Context, id uint, req *dto.BusinessPortraitQuery) (*dto.BusinessPortraitResponse, error) {
|
||||
if id == 0 || req == nil {
|
||||
return nil, business.ErrAccountInput
|
||||
}
|
||||
sources, err := normalizePlatforms(req.Sources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
return nil, business.ErrPortraitPlatforms
|
||||
}
|
||||
portrait, err := s.uc.ForUser(ctx, id, sources, req.Period)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return portraitResponse(portrait), nil
|
||||
}
|
||||
func (s *BusinessPortraitService) ForBinding(ctx context.Context, req *dto.BindingPortraitQuery) (*dto.BusinessPortraitResponse, error) {
|
||||
if req == nil || req.PlatformBusierID == 0 {
|
||||
return nil, business.ErrAccountInput
|
||||
}
|
||||
portrait, err := s.uc.ForBinding(ctx, business.PlatformKey{SourceDB: req.SourceDB, BusierID: req.PlatformBusierID}, req.Period)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return portraitResponse(portrait), nil
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -21,6 +21,7 @@ var ProviderSet = wire.NewSet(
|
||||
businessservice.NewAlertRecordService,
|
||||
businessservice.NewActivationService,
|
||||
businessservice.NewChannelService,
|
||||
businessservice.NewBusinessPortraitService,
|
||||
systemservice.ProviderSet,
|
||||
paymentservice.ProviderSet,
|
||||
integrationservice.ProviderSet,
|
||||
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
@@ -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 })
|
||||
@@ -38,6 +38,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="注册时间" min-width="130"><template #default="{ row }">{{ row.createTime?.slice(0, 10) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statuses[row.status] || row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button v-if="canQuery" type="primary" link @click="showPortrait(row)">画像</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination">
|
||||
<el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" />
|
||||
@@ -45,6 +46,7 @@
|
||||
</div>
|
||||
<el-drawer append-to-body v-model="channelsDrawer" title="旗下渠道" size="520px" destroy-on-close><business-channels :user-id="channelUserId" /></el-drawer>
|
||||
<el-drawer append-to-body v-model="bindingsDrawer" title="SDK商务绑定详情" size="85%" destroy-on-close><business-bindings :user-id="bindingUserId" /></el-drawer>
|
||||
<business-profile-drawer v-model="portraitOpen" :user-id="portraitUser?.userId || 0" :name="portraitUser?.realName || portraitUser?.userName || ''" />
|
||||
<el-drawer append-to-body v-model="drawer" title="添加子商务" :size="480" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="商务账号" prop="userName"><el-input v-model="form.userName" maxlength="30" /></el-form-item>
|
||||
@@ -68,6 +70,7 @@ import { useBtnAuth } from '@/utils/btnAuth'
|
||||
import { listBusiness, createBusiness } from '@/api/business'
|
||||
import BusinessBindings from './components/BusinessBindings.vue'
|
||||
import BusinessChannels from './components/BusinessChannels.vue'
|
||||
import BusinessProfileDrawer from './components/BusinessProfileDrawer.vue'
|
||||
|
||||
defineOptions({ name: 'SwBusinessList' })
|
||||
const user = useUserStore()
|
||||
@@ -79,9 +82,13 @@ const channelUserId = ref(0)
|
||||
const showChannels = (row) => { channelUserId.value = row.userId; channelsDrawer.value = true }
|
||||
const showBindings = (row) => { bindingUserId.value = row.userId; bindingsDrawer.value = true }
|
||||
const buttons = useBtnAuth()
|
||||
const portraitOpen = ref(false)
|
||||
const portraitUser = ref(null)
|
||||
const showPortrait = row => { portraitUser.value = row; portraitOpen.value = true }
|
||||
const levels = { 1: '一级商务', 2: '二级商务', 3: '三级商务' }
|
||||
const statuses = { 0: '正常', 1: '停用', 2: '已驳回', 3: '待审核' }
|
||||
const isAdmin = computed(() => Number(user.userInfo.authorityId) === 888)
|
||||
const canQuery = computed(() => isAdmin.value || !!buttons['sw:business:query'])
|
||||
const canAdd = computed(() => (isAdmin.value || buttons['sw:business:add']) && (isAdmin.value || Number(user.userInfo.level) < 3))
|
||||
const isLevel3 = computed(() => Number(user.userInfo.level) === 3)
|
||||
const childLevel = computed(() => levels[isAdmin.value ? 1 : Number(user.userInfo.level || 1) + 1])
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
<div class="kra-table-box">
|
||||
<div v-if="!userId && canBind" class="kra-btn-list"><el-button type="primary" icon="plus" @click="openBind">绑定游戏商务</el-button></div>
|
||||
<el-table v-loading="loading" :data="rows" :row-key="rowKey">
|
||||
<el-table-column prop="platformName" label="所属平台" min-width="80" />
|
||||
<el-table-column label="所属平台" min-width="80"><template #default="{ row }"><el-button v-if="userId && canPortrait && row.status === '0'" type="primary" link @click="showPortrait(row)">{{ row.platformName }}</el-button><span v-else>{{ row.platformName }}</span></template></el-table-column>
|
||||
<el-table-column v-if="userId" prop="platformBusierId" label="ID" min-width="90" />
|
||||
<el-table-column prop="platformBusierAccount" label="游戏商务账号" min-width="140" />
|
||||
<el-table-column v-if="!userId" prop="bindUserName" label="绑定商务" min-width="140" />
|
||||
<el-table-column v-if="!userId" label="旗下渠道数" min-width="110"><template #default="{ row }"><span v-if="row.promoteCount === null" class="text-gray-400">未接入</span><el-button v-else type="primary" link aria-label="查看旗下渠道" :disabled="row.status !== '0'" @click="showChannels(row)">{{ row.promoteCount }}</el-button></template></el-table-column>
|
||||
<el-table-column label="审核状态" width="110"><template #default="{ row }"><el-tag :type="row.status === '0' ? 'success' : row.status === '2' ? 'danger' : 'warning'">{{ statuses[row.status] || row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="createTime" label="绑定时间" min-width="130"><template #default="{ row }">{{ row.createTime?.slice(0, 10) }}</template></el-table-column>
|
||||
<el-table-column v-if="!userId" label="操作" width="110" fixed="right">
|
||||
<el-table-column v-if="!userId" label="操作" width="145" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canBind && row.status === '0' && row.userId === user.userInfo.ID" link type="danger" @click="remove(row)">解绑</el-button>
|
||||
<template v-if="row.status === '0'"><el-button v-if="canPortrait" link type="primary" @click="showPortrait(row)">画像</el-button><el-button v-if="canBind && row.userId === user.userInfo.ID" link type="danger" @click="remove(row)">解绑</el-button></template>
|
||||
<el-button v-else-if="row.status === '2'" link type="primary" @click="showReject(row)">查看驳回</el-button>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
@@ -29,6 +29,7 @@
|
||||
<div v-if="!userId" class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
|
||||
</div>
|
||||
<el-drawer append-to-body v-model="channelsDrawer" title="旗下渠道" size="520px" destroy-on-close><business-channels v-if="selectedChannel" :user-id="selectedChannel.userId" :source-db="selectedChannel.sourceDb" :platform-busier-id="selectedChannel.platformBusierId" /></el-drawer>
|
||||
<business-profile-drawer v-model="portraitOpen" :user-id="portraitRow?.userId || userId" :source-db="portraitRow?.sourceDb || ''" :platform-busier-id="portraitRow?.platformBusierId || 0" :name="portraitRow?.platformBusierAccount || ''" />
|
||||
<el-drawer append-to-body v-model="dialog" title="绑定游戏商务" size="460px" destroy-on-close>
|
||||
<el-alert title="验证成功后提交 OA 审核,审核通过后可查看对应渠道数据。" type="info" :closable="false" class="mb-4" />
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
@@ -66,12 +67,17 @@ import { useUserStore } from '@/pinia/modules/user'
|
||||
import { useBtnAuth } from '@/utils/btnAuth'
|
||||
import { listBusinessBindings, listUserBindings, bindPlatform, unbindPlatform } from '@/api/business'
|
||||
import BusinessChannels from './BusinessChannels.vue'
|
||||
import BusinessProfileDrawer from './BusinessProfileDrawer.vue'
|
||||
|
||||
defineOptions({ name: 'SwBusinessBindings' })
|
||||
const props = defineProps({ userId: { type: Number, default: 0 } })
|
||||
const emit = defineEmits(['changed'])
|
||||
const user = useUserStore()
|
||||
const buttons = useBtnAuth()
|
||||
const canPortrait = computed(() => Number(user.userInfo.authorityId) === 888 || !!buttons['sw:business:query'])
|
||||
const portraitOpen = ref(false)
|
||||
const portraitRow = ref(null)
|
||||
const showPortrait = row => { portraitRow.value = row; portraitOpen.value = true }
|
||||
const canBind = computed(() => !props.userId && Number(user.userInfo.level) === 3 && Boolean(buttons['sw:business:bind']))
|
||||
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||
const statuses = { 0: '正常', 2: '已驳回', 3: '待审核' }
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" append-to-body destroy-on-close :title="title" size="min(960px, 100vw)" @closed="reset">
|
||||
<div v-loading="loading">
|
||||
<el-alert v-if="error" type="warning" :closable="false" :title="error" />
|
||||
<template v-if="profile">
|
||||
<h3 class="profile-heading">基本信息</h3>
|
||||
<dl class="profile-info">
|
||||
<div><dt>商务账号</dt><dd>{{ basic.userName || '—' }}</dd></div>
|
||||
<div><dt>姓名</dt><dd>{{ basic.realName || '—' }}</dd></div>
|
||||
<div><dt>等级</dt><dd>{{ basic.levelLabel || (basic.level ?? '—') }}</dd></div>
|
||||
<div><dt>上级商务</dt><dd>{{ basic.parentUserName || basic.parentRealName || '—' }}</dd></div>
|
||||
<div><dt>注册时间</dt><dd>{{ basic.createTime || '—' }}</dd></div>
|
||||
<div><dt>状态</dt><dd><el-tag :type="basic.statusLabel === '正常' ? 'success' : 'info'" effect="plain">{{ basic.statusLabel || '—' }}</el-tag></dd></div>
|
||||
<div><dt>旗下渠道数</dt><dd><span v-if="basic.promoteCount == null">未接入</span><el-button v-else link type="primary" @click="channelsOpen = true">{{ channelCount(basic.promoteCount) }}</el-button></dd></div>
|
||||
<div><dt>本月新增</dt><dd>{{ basic.monthNewPromote == null ? '未接入' : `+${channelCount(basic.monthNewPromote)}` }}</dd></div>
|
||||
</dl>
|
||||
<div class="profile-section-title"><h3 class="profile-heading">核心指标</h3><el-radio-group v-model="period" size="small" :disabled="loading" @change="loadProfile"><el-radio-button v-for="item in periods" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></div>
|
||||
<el-alert v-if="profile.available === false" class="mb-4" type="warning" :closable="false" :title="profile.unavailableReason || '画像数据尚未接入'" />
|
||||
<template v-else-if="profile.metrics">
|
||||
<div class="profile-cards">
|
||||
<el-card v-for="card in cards" :key="card.key" shadow="never">
|
||||
<div class="metric-label">{{ card.label }}</div><strong class="metric-value">{{ card.value }}</strong>
|
||||
<div class="metric-compare" :class="card.tone">{{ card.comparison }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<h3 class="profile-heading">数据趋势</h3>
|
||||
<div class="profile-two-columns">
|
||||
<el-card shadow="never"><template #header>{{ rangeLabel }}注册量{{ hourly ? '(按小时)' : '' }}</template><business-trend :series="profile.registerTrend" title="注册量趋势" /></el-card>
|
||||
<el-card shadow="never"><template #header>{{ rangeLabel }}充值金额{{ hourly ? '(按小时)' : '' }}</template><business-trend :series="profile.payTrend" title="充值金额趋势" money color="var(--el-color-success)" /></el-card>
|
||||
</div>
|
||||
<h3 class="profile-heading">旗下渠道排行</h3>
|
||||
<div class="profile-two-columns">
|
||||
<el-card shadow="never"><template #header>注册量 TOP 5</template><el-table :data="profile.registerTop5" size="small"><el-table-column label="渠道" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.promoteName || '—' }} <el-text type="info" size="small">{{ row.sourceDbLabel }}</el-text></template></el-table-column><el-table-column label="注册量" min-width="100"><template #default="{ row }">{{ number(row.value) }}</template></el-table-column></el-table></el-card>
|
||||
<el-card shadow="never"><template #header>充值金额 TOP 5</template><el-table :data="profile.payTop5" size="small"><el-table-column label="渠道" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.promoteName || '—' }} <el-text type="info" size="small">{{ row.sourceDbLabel }}</el-text></template></el-table-column><el-table-column label="充值金额" min-width="120"><template #default="{ row }">{{ channelMoney(row.value) }}</template></el-table-column></el-table></el-card>
|
||||
</div>
|
||||
</template>
|
||||
<h3 class="profile-heading">关联预警</h3>
|
||||
<el-alert v-if="alertError" type="warning" :closable="false" :title="alertError" />
|
||||
<template v-else>
|
||||
<el-table v-loading="alertsLoading" :data="alerts" size="small">
|
||||
<el-table-column label="时间" width="120"><template #default="{ row }">{{ row.createdAt?.slice(5, 16) || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="targetPromoteName" label="渠道" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="预警类型" min-width="130"><template #default="{ row }">{{ alertType(row) }}</template></el-table-column>
|
||||
<el-table-column prop="alertContent" label="详情" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 5 ? 'success' : 'warning'" effect="plain">{{ row.statusLabel || alertStatuses[row.status] }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="alertTotal" class="kra-pagination"><el-pagination v-model:current-page="alertPage" :page-size="10" :total="alertTotal" layout="total, prev, pager, next" @current-change="loadAlerts" /></div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="visible = false">关闭</el-button></template>
|
||||
</el-drawer>
|
||||
<el-drawer v-model="channelsOpen" append-to-body destroy-on-close title="旗下渠道" size="min(520px, 100vw)"><business-channels :user-id="userId" :source-db="sourceDb" :platform-busier-id="platformBusierId" /></el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getBusinessPortrait, getBindingPortrait } from '@/api/businessPortrait'
|
||||
import { listAlertRecords } from '@/api/alert'
|
||||
import { channelCount, channelMoney } from '../channel-display'
|
||||
import { alertStatuses } from '../alert-display'
|
||||
import BusinessTrend from './BusinessTrend.vue'
|
||||
import BusinessChannels from './BusinessChannels.vue'
|
||||
const props = defineProps({ modelValue: Boolean, userId: { type: Number, default: 0 }, sourceDb: { type: String, default: '' }, platformBusierId: { type: Number, default: 0 }, name: { type: String, default: '' } })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const visible = computed({ get: () => props.modelValue, set: value => emit('update:modelValue', value) })
|
||||
const periods = [{ label: '今日', value: 'today' }, { label: '昨日', value: 'yesterday' }, { label: '近7天', value: 'last7' }, { label: '近30天', value: 'last30' }, { label: '本月', value: 'thismonth' }]
|
||||
const period = ref('today')
|
||||
const profile = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const channelsOpen = ref(false)
|
||||
const alerts = ref([])
|
||||
const alertPage = ref(1)
|
||||
const alertTotal = ref(0)
|
||||
const alertsLoading = ref(false)
|
||||
const alertError = ref('')
|
||||
let profileVersion = 0, alertVersion = 0
|
||||
const basic = computed(() => profile.value?.basic || {})
|
||||
const sources = computed(() => props.sourceDb || 'lx,hw,yq')
|
||||
const title = computed(() => `${props.platformBusierId ? '游戏商务画像' : '商务画像'}${props.name || basic.value.userName ? ` — ${props.name || basic.value.userName}` : ''}`)
|
||||
const rangeLabel = computed(() => periods.find(item => item.value === period.value)?.label || '今日')
|
||||
const hourly = computed(() => ['today', 'yesterday'].includes(period.value))
|
||||
const number = value => value == null ? '—' : channelCount(String(value).replace(/\.0+$/, ''))
|
||||
const cards = computed(() => [
|
||||
{ key: 'register', label: '注册玩家', format: number }, { key: 'payAmount', label: '充值金额', format: channelMoney },
|
||||
{ key: 'payRate', label: '付费率', format: value => value == null ? '—' : `${value}%` }, { key: 'arpu', label: '人均充值', format: channelMoney }
|
||||
].map(card => {
|
||||
const metric = profile.value?.metrics?.[card.key]
|
||||
const diff = metric?.changeRate ?? metric?.changeDiff
|
||||
const numeric = diff == null ? null : Number(diff)
|
||||
const suffix = metric?.changeRate != null ? '%' : ' 个百分点'
|
||||
return { ...card, value: card.format(metric?.value), tone: numeric == null || numeric === 0 ? '' : numeric > 0 ? 'positive' : 'negative', comparison: diff == null ? `${metric?.compareLabel || ''} ${metric?.compareValue == null ? '—' : card.format(metric.compareValue)}` : `${metric?.compareLabel || ''} ${numeric > 0 ? '↑ ' : numeric < 0 ? '↓ ' : ''}${Math.abs(numeric).toFixed(1)}${suffix}` }
|
||||
}))
|
||||
const alertType = row => ({ register_anomaly: '注册显著暴跌', recharge_anomaly: '充值额暴涨', login_anomaly: '渠道长期未登录' })[row.ruleType] || row.ruleName || row.ruleType || '—'
|
||||
const reset = () => { ++profileVersion; ++alertVersion; profile.value = null; error.value = ''; alerts.value = []; alertTotal.value = 0; alertPage.value = 1; alertError.value = ''; loading.value = false; alertsLoading.value = false; channelsOpen.value = false }
|
||||
const loadProfile = async () => {
|
||||
const version = ++profileVersion
|
||||
loading.value = true; error.value = ''
|
||||
try {
|
||||
const res = props.platformBusierId ? await getBindingPortrait({ sourceDb: props.sourceDb, platformBusierId: props.platformBusierId, period: period.value }) : await getBusinessPortrait(props.userId, { sourceDbList: sources.value, period: period.value })
|
||||
if (version !== profileVersion) return
|
||||
if (res.code === 0) profile.value = res.data
|
||||
else { profile.value = null; error.value = res.msg || '画像读取失败' }
|
||||
} catch { if (version === profileVersion) { profile.value = null; error.value = '画像读取失败,请重试' } } finally { if (version === profileVersion) loading.value = false }
|
||||
}
|
||||
const loadAlerts = async () => {
|
||||
const version = ++alertVersion
|
||||
alertsLoading.value = true; alertError.value = ''
|
||||
try {
|
||||
const res = await listAlertRecords({ platformCodes: sources.value, businessId: props.userId, pageNum: alertPage.value, pageSize: 10 })
|
||||
if (version !== alertVersion) return
|
||||
if (res.code === 0 && res.data.available !== false) { alerts.value = res.data.pageData.rows; alertTotal.value = res.data.pageData.total }
|
||||
else { alerts.value = []; alertTotal.value = 0; alertError.value = res.code === 0 ? '渠道数据尚未接入,暂时无法核对关联预警。' : res.msg || '关联预警读取失败' }
|
||||
} catch { if (version === alertVersion) { alerts.value = []; alertError.value = '关联预警读取失败,请重试' } } finally { if (version === alertVersion) alertsLoading.value = false }
|
||||
}
|
||||
watch(() => [props.modelValue, props.userId, props.sourceDb, props.platformBusierId], ([open]) => {
|
||||
if (open && props.userId) { reset(); period.value = 'today'; void loadProfile(); void loadAlerts() }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-heading { margin: 20px 0 14px; font-size: 16px; font-weight: 600; }
|
||||
.profile-info { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin: 0; }
|
||||
.profile-info dt, .metric-label, .metric-compare { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.profile-info dd { margin: 6px 0 0; overflow-wrap: anywhere; }
|
||||
.profile-section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.profile-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.metric-value { display: block; font-size: 23px; margin: 12px 0; overflow-wrap: anywhere; }
|
||||
.metric-compare.positive { color: var(--el-color-success); }
|
||||
.metric-compare.negative { color: var(--el-color-danger); }
|
||||
.profile-two-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
@media (max-width: 700px) { .profile-info, .profile-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .profile-two-columns { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="business-trend">
|
||||
<svg viewBox="0 0 600 260" role="img" :aria-label="title" tabindex="0" @mousemove="hover" @mouseleave="active = null" @focus="active = 0" @blur="active = null" @keydown.left.prevent="move(-1)" @keydown.right.prevent="move(1)">
|
||||
<g v-for="tick in ticks" :key="tick.y">
|
||||
<line x1="52" :y1="tick.y" x2="582" :y2="tick.y" class="grid-line" />
|
||||
<text x="44" :y="tick.y + 4" text-anchor="end" class="axis-text">{{ compact(tick.value) }}</text>
|
||||
</g>
|
||||
<polygon v-if="points.length" :points="area" :fill="color" opacity="0.08" />
|
||||
<polyline :points="line" fill="none" :stroke="color" stroke-width="2.5" stroke-linejoin="round" />
|
||||
<text v-for="index in labelIndexes" :key="index" :x="pointX(index)" y="244" text-anchor="middle" class="axis-text">{{ series[index]?.label }}</text>
|
||||
<template v-if="selected">
|
||||
<line :x1="selected.x" y1="18" :x2="selected.x" y2="220" class="cursor-line" />
|
||||
<circle :cx="selected.x" :cy="selected.y" r="4" :fill="color" />
|
||||
</template>
|
||||
</svg>
|
||||
<div v-if="selected" role="status" aria-live="polite" class="trend-tooltip" :style="{ left: `${Math.max(15, Math.min(85, selected.x / 6))}%` }">
|
||||
<span>{{ selected.label }}</span><strong>{{ formatted(selected.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { channelCount, channelMoney } from '../channel-display'
|
||||
const props = defineProps({ series: { type: Array, default: () => [] }, title: { type: String, default: '' }, money: Boolean, color: { type: String, default: 'var(--el-color-primary)' } })
|
||||
const active = ref(null)
|
||||
const values = computed(() => props.series.map(point => Number.isFinite(Number(point.value)) ? Number(point.value) : 0))
|
||||
const maximum = computed(() => Math.max(1, ...values.value))
|
||||
const minimum = computed(() => Math.min(0, ...values.value))
|
||||
const pointY = value => 220 - (value - minimum.value) / (maximum.value - minimum.value) * 202
|
||||
const pointX = index => 52 + index * 530 / Math.max(1, props.series.length - 1)
|
||||
const points = computed(() => props.series.map((point, index) => ({ ...point, x: pointX(index), y: pointY(values.value[index]) })))
|
||||
const line = computed(() => points.value.map(point => `${point.x},${point.y}`).join(' '))
|
||||
const area = computed(() => `52,${pointY(0)} ${line.value} ${points.value.at(-1)?.x || 52},${pointY(0)}`)
|
||||
const ticks = computed(() => [0, 1, 2, 3, 4].map(index => ({ y: 220 - index * 50.5, value: minimum.value + (maximum.value - minimum.value) * index / 4 })))
|
||||
const labelIndexes = computed(() => [...new Set([0, 1, 2, 3, 4].map(index => Math.round(Math.max(0, props.series.length - 1) * index / 4)))])
|
||||
const selected = computed(() => active.value == null ? null : points.value[active.value])
|
||||
const compact = value => new Intl.NumberFormat('zh-CN', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
|
||||
const formatted = value => props.money ? channelMoney(value) : channelCount(String(value).replace(/\.0+$/, ''))
|
||||
const hover = event => {
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
const x = (event.clientX - bounds.left) / Math.max(1, bounds.width) * 600
|
||||
active.value = Math.max(0, Math.min(props.series.length - 1, Math.round((x - 52) / 530 * Math.max(0, props.series.length - 1))))
|
||||
}
|
||||
const move = step => { active.value = Math.max(0, Math.min(props.series.length - 1, (active.value ?? 0) + step)) }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.business-trend { position: relative; }
|
||||
svg { display: block; width: 100%; outline: none; }
|
||||
svg:focus-visible { outline: 2px solid var(--el-color-primary-light-5); border-radius: 6px; }
|
||||
.grid-line { stroke: var(--el-border-color-lighter); stroke-dasharray: 3 4; }
|
||||
.axis-text { fill: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.cursor-line { stroke: var(--el-border-color); stroke-dasharray: 3 3; }
|
||||
.trend-tooltip { position: absolute; top: 8px; transform: translateX(-50%); display: flex; gap: 12px; padding: 8px 12px; border: 1px solid var(--el-border-color-light); border-radius: 6px; background: var(--el-bg-color-overlay); box-shadow: var(--el-box-shadow-light); pointer-events: none; font-size: 12px; white-space: nowrap; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user