From 8908430a89eb98fbab23ef67570dfcf3ad50f4ea Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Tue, 3 Nov 2026 20:53:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=E5=88=97=E8=A1=A8=E5=92=8C=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E6=9C=9F=E9=97=B4=E6=B5=81=E6=B0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/activation_http_test.go | 137 +++++++++++++ cmd/wire_gen.go | 7 +- .../activation-http-2026-11-03.json | 44 ++++ .../activation-page-2026-11-03.json | 11 + docs/sw6-to-kra/implementation.md | 10 +- docs/sw6-to-kra/page-parity.md | 19 +- internal/biz/biz.go | 2 +- internal/biz/business/activation.go | 193 ++++++++++++++++++ internal/biz/business/activation_test.go | 80 ++++++++ internal/data/business/activation.go | 160 +++++++++++++++ internal/data/business/activation_test.go | 115 +++++++++++ internal/data/data.go | 1 + internal/modules/business/definition.go | 1 + internal/modules/catalog_test.go | 4 +- internal/routecatalog/catalog.go | 2 + internal/server/gin_test.go | 3 +- internal/server/handler/activation.go | 42 ++++ internal/server/handler/provider.go | 1 + internal/server/handler/set.go | 1 + internal/server/router/routes.go | 4 + internal/service/business/activation.go | 78 +++++++ internal/service/dto/activation.go | 42 ++++ internal/service/service.go | 1 + scripts/sw_activation_http_smoke.py | 42 ++++ scripts/sw_activation_smoke.cjs | 75 +++++++ web/src/api/activation.js | 4 + web/src/pathInfo.json | 1 + web/src/view/sw/ChannelActivationList.vue | 109 ++++++++++ 28 files changed, 1180 insertions(+), 9 deletions(-) create mode 100644 cmd/activation_http_test.go create mode 100644 docs/sw6-to-kra/activation-http-2026-11-03.json create mode 100644 docs/sw6-to-kra/activation-page-2026-11-03.json create mode 100644 internal/biz/business/activation.go create mode 100644 internal/biz/business/activation_test.go create mode 100644 internal/data/business/activation.go create mode 100644 internal/data/business/activation_test.go create mode 100644 internal/server/handler/activation.go create mode 100644 internal/service/business/activation.go create mode 100644 internal/service/dto/activation.go create mode 100644 scripts/sw_activation_http_smoke.py create mode 100644 scripts/sw_activation_smoke.cjs create mode 100644 web/src/api/activation.js create mode 100644 web/src/view/sw/ChannelActivationList.vue diff --git a/cmd/activation_http_test.go b/cmd/activation_http_test.go new file mode 100644 index 0000000..0da0f7a --- /dev/null +++ b/cmd/activation_http_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "testing" + + "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" +) + +type activationHTTPDatabases struct{ oa *gorm.DB } + +func (p *activationHTTPDatabases) Database(name string) (*gorm.DB, error) { + if name == "oa" && p.oa != nil { + return p.oa, nil + } + return nil, fmt.Errorf("数据源未配置") +} + +func TestActivationHTTPNamedDatabaseAndSpend(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, oa := open("main.db"), open("oa.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,level,parent_id,id_path,enable) VALUES(1,'root','admin',0,0,'0',1),(7,'parent','parent',2,0,'0',1),(8,'leaf','leaf',3,7,'0,7',1)", + "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(8,'lx',14,'Own','0')", + "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,parent_id INTEGER,top_promote_id INTEGER)", + "INSERT INTO tab_promote VALUES(100,'lx',0,0),(101,'lx',100,0),(100,'hw',0,0)", + "CREATE TABLE tab_spend(source_db TEXT,promote_id INTEGER,pay_status INTEGER,pay_time INTEGER,game_id INTEGER,game_name TEXT,pay_amount DECIMAL(20,2))", + "INSERT INTO tab_spend VALUES('lx',100,1,1000,1,'游戏甲',10.10),('lx',101,1,2000,2,'游戏乙',20.20),('lx',100,1,2001,1,'游戏甲',999),('hw',100,1,1000,1,'其他平台',999)", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for _, sql := range []string{ + "CREATE TABLE sys_promote_active_pool_bind(id INTEGER PRIMARY KEY,promote_id INTEGER,platform_name TEXT,promote_account TEXT,busier_account TEXT,applicant_oa TEXT,bind_at DATETIME,reach_at DATETIME,unbind_at DATETIME,accum_amount TEXT,status TEXT,deleted_at DATETIME)", + "INSERT INTO sys_promote_active_pool_bind VALUES(9007199254740993,100,'lx','主渠道','OWN','test-oa','1970-01-01 00:16:40','1970-01-01 00:33:20','1970-01-01 01:00:00','30.30','reached',NULL),(2,100,'lx','待审渠道','Own','test-oa',NULL,NULL,NULL,'0','pending',NULL),(3,100,'hw','其他商务渠道','other','test-oa',NULL,NULL,NULL,'0','pending',NULL)", + } { + if err := oa.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + provider := businessHTTPData{db} + databases := &activationHTTPDatabases{oa: oa} + uc := businessbiz.NewActivationUsecase(businessdata.NewActivationRepo(provider, databases), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider))) + h := handler.NewActivation(businessservice.NewActivationService(uc)) + engine := gin.New() + engine.Use(func(c *gin.Context) { + id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64) + aid := uint(903) + 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/promoteActivePool/bindList", h.List) + engine.GET("/sw/promoteActivePool/bindSpendDetail", h.SpendDetail) + 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: %+v", path, body) + } + return body["data"].(map[string]any) + } + const list = "/sw/promoteActivePool/bindList" + for _, tc := range []struct { + user, total int + query string + }{{1, 3, ""}, {8, 2, ""}, {7, 0, ""}, {8, 1, "?platformNames=lx,hw&statuses=%E5%B7%B2%E8%BE%BE%E6%A0%87"}, {8, 0, "?busierAccount=other"}, {1, 1, "?busierAccount=other"}} { + data := call(tc.user, list+tc.query, 0) + if data["total"] != float64(tc.total) { + t.Fatalf("范围或筛选错误: %+v", data) + } + } + const detail = "/sw/promoteActivePool/bindSpendDetail?id=9007199254740993" + data := call(8, detail, 0) + if data["totalAmount"] != "30.30" || len(data["list"].([]any)) != 2 || data["endAt"] != "1970-01-01 00:33:20" { + t.Fatalf("流水金额、树范围或达标优先截止错误: %+v", data) + } + call(7, detail, 7) + call(8, "/sw/promoteActivePool/bindSpendDetail?id=2", 7) + call(8, "/sw/promoteActivePool/bindSpendDetail?id=3", 7) + call(1, "/sw/promoteActivePool/bindSpendDetail?id=0", 7) + if err := db.Exec("DROP TABLE tab_spend").Error; err != nil { + t.Fatal(err) + } + call(8, detail, 7) + databases.oa = nil + data = call(1, list, 0) + if data["available"] != false { + t.Fatal("缺 OA 数据源应明确不可用") + } + data = call(7, list, 0) + if data["available"] != true || data["total"] != float64(0) { + t.Fatal("无本人绑定仍为确定空集") + } + call(1, detail, 7) +} diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 0892c2f..86dff01 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -82,9 +82,13 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo) auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase) registry := taskRegistry() - alertRecordRepo := business.NewAlertRecordRepo(dataData) + activationRepo := business.NewActivationRepo(dataData, dataData) accountRepo := business.NewAccountRepo(dataData) accountUsecase := business2.NewAccountUsecase(accountRepo) + activationUsecase := business2.NewActivationUsecase(activationRepo, accountUsecase) + activationService := business3.NewActivationService(activationUsecase) + activation := handler.NewActivation(activationService) + alertRecordRepo := business.NewAlertRecordRepo(dataData) alertRecordUsecase := business2.NewAlertRecordUsecase(alertRecordRepo, accountUsecase) alertRecordService := business3.NewAlertRecordService(alertRecordUsecase) alertRecord := handler.NewAlertRecord(alertRecordService) @@ -209,6 +213,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase) integrationConfig := handler.NewIntegrationConfig(integrationConfigService) set := &handler.Set{ + Activation: activation, AlertRecord: alertRecord, AlertRule: alertRule, BusinessPromote: businessPromote, diff --git a/docs/sw6-to-kra/activation-http-2026-11-03.json b/docs/sw6-to-kra/activation-http-2026-11-03.json new file mode 100644 index 0000000..15e5f3f --- /dev/null +++ b/docs/sw6-to-kra/activation-http-2026-11-03.json @@ -0,0 +1,44 @@ +{ + "scope": "当前 sw7 运行实例,只读请求;OA 未接入场景明确返回 available=false。完整 OA 与流水数据通过两个隔离数据库的 TCP HTTP 测试。", + "checks": [ + { + "method": "GET", + "path": "/sw/promoteActivePool/bindList?pageNum=1&pageSize=10", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true, + "total": 0, + "available": false + }, + { + "method": "GET", + "path": "/sw/promoteActivePool/bindList?platformNames=lx,hw&statuses=reached", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true, + "total": 0, + "available": false + }, + { + "method": "GET", + "path": "/sw/promoteActivePool/bindList?statuses=%E5%B7%B2%E8%BE%BE%E6%A0%87&busierAccount=does-not-exist", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true, + "total": 0, + "available": false + }, + { + "method": "GET", + "path": "/sw/promoteActivePool/bindSpendDetail?id=0", + "http": 200, + "code": 7, + "msg": "绑定记录不存在", + "passed": true + } + ], + "passed": true +} diff --git a/docs/sw6-to-kra/activation-page-2026-11-03.json b/docs/sw6-to-kra/activation-page-2026-11-03.json new file mode 100644 index 0000000..6954812 --- /dev/null +++ b/docs/sw6-to-kra/activation-page-2026-11-03.json @@ -0,0 +1,11 @@ +{ + "method": "真实 Chrome,受控 OA 和流水响应,无写入", + "passed": true, + "rejectedHidesFlow": true, + "pendingShowsReasonWithoutFakeTotal": true, + "reachedCutoffDisplayed": true, + "exactLargeAmount": true, + "filtersAndReset": true, + "unavailableSourceNotice": true, + "pageErrors": [] +} diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md index c99ef30..874bdad 100644 --- a/docs/sw6-to-kra/implementation.md +++ b/docs/sw6-to-kra/implementation.md @@ -22,9 +22,9 @@ | 阶段 | 范围 | 状态 | | --- | --- | --- | | 1 | 商务用户字段、层级与角色、账号资料和审核、偏好、登录及找回密码 | 进行中 | -| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定和旗下渠道已接入,画像及 OA 同步待迁移 | -| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入,生成任务及消息待迁移 | -| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 待实现 | +| 2 | 平台绑定、商务/渠道可见范围、外部平台与 OA 数据源、商务列表及抽屉 | 进行中;绑定、旗下渠道、OA 激活只读接口已接入;画像与外部审核联调待完成 | +| 3 | 预警规则、记录与状态机、操作日志、附件、消息、任务注册与调度 | 进行中;规则、记录和工作流已接入;任务待迁移,消息盒子已确认属于未改动框架代码 | +| 4 | 渠道列表及新增、渠道激活、推广游戏等业务接口与页面 | 进行中;渠道激活已接入,渠道列表/新增及推广游戏待迁移 | | 5 | 日汇总构建、充值/注册明细、渠道/游戏/区服报表、排行、画像与仪表盘 | 待实现 | | 6 | 静态接口核对、后端测试、前端构建和页面验证 | 随阶段推进,最终全量复核 | | 7 | 用户确认源数据库,复制至隔离库,执行迁移,启动并逐接口核对数据 | 未开始,必须等待用户确认 | @@ -69,4 +69,8 @@ - 补充 `202611030007_business_alert_attachments`,针对原 SQL 遗留的日志数字附件做文件 ID 映射。只转换与 `bak_sw2kra_20261103_sys_alert_operation_log` 备份相符的旧日志,新日志、URL 或已变化引用不改动;缺失旧文件不解释为同号新文件,原引用保留在备份。详见 `alert-attachment-migration.md`。 - 预警记录批次全量 `go test ./...`、前端 ESLint 与生产构建通过。隔离 SQLite + 实际 TCP HTTP 覆盖全部处理动作、驳回重提、跨商务/平台拒绝、大 ID、附件读取与缺表场景;仓储测试覆盖日志失败回滚与重复请求。MySQL 行锁的真实并发验证留待最终隔离旧库验收。`alert-records-http-2026-11-03.json` 记录当前 sw7 的真实会话请求;当前预警零条,未创建真实预警或上传文件。原商务 45 条、绑定 58 条的 HTTP 回归继续通过。 - `alert-records-page-2026-11-03.json` 记录真实 Chrome 下四个等级、五种状态的入口检查,以及完整抽屉流程、附件延迟上传、状态卡片筛选、缺渠道数据提示。浏览器业务数据、处理写入和上传使用受控替身;正向服务端行为另由前述 TCP HTTP 测试覆盖。 +- 渠道激活 `/sw/promoteActivePool/bindList` 和 `/sw/promoteActivePool/bindSpendDetail` 及旧同名页面已接入。OA 连接复用 `data.database_list` 中别名 `oa` 的共享客户端;只读 `sys_promote_active_pool_bind`,不创建或修改 OA 表。普通商务按本人已通过绑定的账号集合匹配 OA 认领,不包含下级,超管不限;保持旧实现按账号匹配的规则。 +- 激活流水从主库 `tab_spend` 按游戏汇总,成功支付、同平台、绑定开始(含)到达标/解绑截止(含);渠道树包含本级、直属子级及 `top_promote_id` 匹配项,不依赖全局下级/身份证偏好。达标时间优先于解绑时间,进行中没有结束上限。使用 EXISTS 避免重复渠道行导致金额翻倍,金额及大 ID 以字符串返回,计算和页面格式化避免浮点精度丢失。 +- 激活批次通过全量 `go test ./...`、ESLint 和生产构建,以及主库/OA 两个隔离数据库的实际 TCP HTTP 测试。`activation-http-2026-11-03.json` 记录当前真实会话请求(OA 未接入,明确 available=false);`activation-page-2026-11-03.json` 核对旧页面列、五种状态入口、筛选重置、达标截止与大金额。待审核可以打开流水入口但显示原接口“审核通过后可查看”,已驳回不显示入口;明细读取失败不展示伪造零流水。 +- 已对框架基线至 main 的 Git 差异核实:`SysMessageController` 与 `SysMessageServiceImpl` 未变更,旧导航栏消息入口也被注释,因此不新增商务消息 UI。SW 主库写入待审绑定后由外部 OA 审核,并没有需要照搬的 SW 内部 OA 同步任务;最终联调仍须核对 OA 连接目标及商务用户 ID 映射,不能把隔离替身测试当成外部审核验收。 - 当前没有执行最终旧数据库复制与迁移验收。 diff --git a/docs/sw6-to-kra/page-parity.md b/docs/sw6-to-kra/page-parity.md index 7c17d76..e0951cd 100644 --- a/docs/sw6-to-kra/page-parity.md +++ b/docs/sw6-to-kra/page-parity.md @@ -35,7 +35,7 @@ ## 其他页面 -渠道列表、渠道激活、渠道统计、充值明细、注册明细、游戏数据、区服分析和排行榜,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。 +渠道列表、渠道统计、充值明细、注册明细、游戏数据、区服分析和排行榜,均需在各自实现前逐项提取旧模板的筛选项、列、按钮、抽屉、权限和跳转。数据库菜单清单或后端接口清单只能用于交叉核对,不能替代页面核对。 ## 预警规则 @@ -76,3 +76,20 @@ ## 登录页 已从旧 `login.vue` 复用登录模板和样式,复制原品牌图片,适配 Kra 登录接口;保持记住账号而不存密码、验证码、三步找回密码和原文案。系统标题、侧栏标识及页脚已同步旧商务名称。`login-page-verification-2026-11-03.json` 记录 Chrome 的流程验证,短信接口为测试替身,不向真实手机发码。 + +## 渠道激活 + +来源:`sw/web/src/views/BusMgmt/ChannelActivationList.vue`,最终 `PromoteActivePoolBindServiceImpl` 与独立 OA 查询适配器。 + +| 旧页面行为 | 新页面状态 | +| --- | --- | +| 平台默认全选,渠道账号与绑定商务筛选,查询/重置 | 已实现;没有额外增加旧模板不存在的状态筛选 | +| 列为平台、渠道名称、绑定商务、绑定日期、解绑时间、累计流水金额、状态、操作 | 已实现,日期按年月日显示,金额保留两位小数 | +| 已驳回无流水明细按钮,其余状态均显示 | 已实现;待审核按原接口提示“审核通过后可查看” | +| 流水使用居中弹窗,显示平台、渠道、绑定期间、游戏汇总和总额 | 已实现 | +| 普通商务仅看本人认领,超管看全部 | 已实现,和预警的下级树范围分开;在隔离双库 HTTP 测试中验证 | +| 达标/解绑后的流水截止时间 | 保持最终后端计算,并修正旧弹窗达标状态仍显示“起”的文案,显示实际截止日期 | +| 明细加载失败 | 明确展示原因,不保留预填金额冒充成功读取的明细 | +| OA 未接入 | 明确显示未接入;不回退到主库同名表 | + +验证:`activation-http-2026-11-03.json`、`activation-page-2026-11-03.json`,以及独立主库/OA 两个 SQLite 数据库的真实 TCP HTTP 测试。未修改实际 OA 数据。 diff --git a/internal/biz/biz.go b/internal/biz/biz.go index 8d82136..2e92b83 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -11,4 +11,4 @@ import ( ) // ProviderSet is biz providers. -var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase) +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) diff --git a/internal/biz/business/activation.go b/internal/biz/business/activation.go new file mode 100644 index 0000000..83822c7 --- /dev/null +++ b/internal/biz/business/activation.go @@ -0,0 +1,193 @@ +package business + +import ( + "context" + "math/big" + "strings" + "time" + + "github.com/go-kratos/kratos/v3/errors" +) + +var ( + ErrActivationNotFound = errors.NotFound("SW_ACTIVATION_NOT_FOUND", "绑定记录不存在") + ErrActivationScope = errors.Forbidden("SW_ACTIVATION_SCOPE", "无权查看该绑定记录") + ErrActivationPending = errors.BadRequest("SW_ACTIVATION_PENDING", "审核通过后可查看") + ErrActivationSource = errors.BadRequest("SW_ACTIVATION_SOURCE", "OA 渠道激活数据源未接入或暂不可用") + ErrActivationMetrics = errors.BadRequest("SW_ACTIVATION_METRICS", "渠道或流水数据尚未接入,暂时无法查询流水明细") + ErrActivationInput = errors.BadRequest("SW_ACTIVATION_INPUT", "渠道激活查询参数不正确") +) + +type Activation struct { + ID uint64 + PromoteID *uint64 + PlatformName, PromoteAccount, BusierAccount, ApplicantOA, Status, AccumAmount string + BindAt, UnbindAt, ReachAt *time.Time +} + +func (a *Activation) EndAt() *time.Time { + if a.Status == "reached" && a.ReachAt != nil { + return a.ReachAt + } + return a.UnbindAt +} + +type ActivationScope struct { + All bool + Accounts []string +} +type ActivationFilter struct { + PlatformNames, Statuses []string + PromoteAccount, BusierAccount string +} +type ActivationPage struct { + Rows []*Activation + Total int64 + Available bool +} +type ActivationSpendQuery struct { + PlatformName string + PromoteID uint64 + Start int64 + End *int64 +} +type ActivationSpendItem struct{ GameName, Amount string } +type ActivationSpendDetail struct { + Activation *Activation + Items []*ActivationSpendItem + TotalAmount string +} + +type ActivationRepo interface { + OwnAccounts(context.Context, uint) ([]string, error) + List(context.Context, ActivationScope, ActivationFilter, ...ListOption) (*ActivationPage, error) + Find(context.Context, uint64) (*Activation, error) + Spend(context.Context, ActivationSpendQuery) ([]*ActivationSpendItem, error) +} + +type ActivationUsecase struct { + repo ActivationRepo + accounts *AccountUsecase +} + +func NewActivationUsecase(repo ActivationRepo, accounts *AccountUsecase) *ActivationUsecase { + return &ActivationUsecase{repo: repo, accounts: accounts} +} + +func (uc *ActivationUsecase) scope(ctx context.Context) (ActivationScope, error) { + actor, _, err := uc.accounts.actor(ctx) + if err != nil { + return ActivationScope{}, err + } + if actor.AuthorityID == 888 { + return ActivationScope{All: true}, nil + } + accounts, err := uc.repo.OwnAccounts(ctx, actor.UserID) + if err != nil { + return ActivationScope{}, err + } + scope := ActivationScope{} + seen := map[string]bool{} + for _, account := range accounts { + account = strings.TrimSpace(account) + key := strings.ToLower(account) + if key != "" && !seen[key] { + scope.Accounts = append(scope.Accounts, account) + seen[key] = true + } + } + return scope, nil +} + +func (uc *ActivationUsecase) List(ctx context.Context, filter ActivationFilter, options ...ListOption) (*ActivationPage, error) { + scope, err := uc.scope(ctx) + if err != nil { + return nil, err + } + if !scope.All && filter.BusierAccount != "" { + matched := []string{} + keyword := strings.ToLower(strings.TrimSpace(filter.BusierAccount)) + for _, account := range scope.Accounts { + if strings.Contains(strings.ToLower(account), keyword) { + matched = append(matched, account) + } + } + scope.Accounts = matched + filter.BusierAccount = "" + } + if !scope.All && len(scope.Accounts) == 0 { + return &ActivationPage{Rows: []*Activation{}, Available: true}, nil + } + for i, status := range filter.Statuses { + filter.Statuses[i] = NormalizeActivationStatus(status) + } + return uc.repo.List(ctx, scope, filter, options...) +} + +func (uc *ActivationUsecase) SpendDetail(ctx context.Context, id uint64) (*ActivationSpendDetail, error) { + scope, err := uc.scope(ctx) + if err != nil { + return nil, err + } + if !scope.All && len(scope.Accounts) == 0 { + return nil, ErrActivationScope + } + activation, err := uc.repo.Find(ctx, id) + if err != nil { + return nil, err + } + if !scope.All { + found := false + for _, account := range scope.Accounts { + if strings.EqualFold(account, strings.TrimSpace(activation.BusierAccount)) { + found = true + break + } + } + if !found { + return nil, ErrActivationScope + } + } + if activation.BindAt == nil { + return nil, ErrActivationPending + } + detail := &ActivationSpendDetail{Activation: activation, Items: []*ActivationSpendItem{}, TotalAmount: "0.00"} + if strings.TrimSpace(activation.PlatformName) == "" || activation.PromoteID == nil || *activation.PromoteID == 0 { + return detail, nil + } + query := ActivationSpendQuery{PlatformName: activation.PlatformName, PromoteID: *activation.PromoteID, Start: activation.BindAt.Unix()} + if end := activation.EndAt(); end != nil { + n := end.Unix() + query.End = &n + } + detail.Items, err = uc.repo.Spend(ctx, query) + if err != nil { + return nil, err + } + // 金额合计不用浮点数,避免多个游戏相加产生误差或大金额精度损失。 + total := new(big.Rat) + for _, item := range detail.Items { + amount, ok := new(big.Rat).SetString(item.Amount) + if !ok { + return nil, ErrStorage + } + item.Amount = amount.FloatString(2) + total.Add(total, amount) + } + detail.TotalAmount = total.FloatString(2) + return detail, nil +} + +func NormalizeActivationStatus(status string) string { + status = strings.TrimSpace(status) + if code, ok := map[string]string{"待审核": "pending", "进行中": "in_progress", "已达标": "reached", "已驳回": "rejected", "已解绑": "unbound"}[status]; ok { + return code + } + return status +} +func ActivationStatusLabel(status string) string { + if label, ok := map[string]string{"pending": "待审核", "in_progress": "进行中", "reached": "已达标", "rejected": "已驳回", "unbound": "已解绑"}[status]; ok { + return label + } + return status +} diff --git a/internal/biz/business/activation_test.go b/internal/biz/business/activation_test.go new file mode 100644 index 0000000..713be5c --- /dev/null +++ b/internal/biz/business/activation_test.go @@ -0,0 +1,80 @@ +package business + +import ( + "context" + "testing" + "time" +) + +type activationRepoFake struct { + ActivationRepo + accounts []string + requested uint + scope ActivationScope + filter ActivationFilter + record *Activation + query ActivationSpendQuery + items []*ActivationSpendItem + listCalls int +} + +func (r *activationRepoFake) OwnAccounts(_ context.Context, id uint) ([]string, error) { + r.requested = id + return r.accounts, nil +} +func (r *activationRepoFake) List(_ context.Context, scope ActivationScope, filter ActivationFilter, _ ...ListOption) (*ActivationPage, error) { + r.listCalls++ + r.scope = scope + r.filter = filter + return &ActivationPage{Rows: []*Activation{}, Available: true}, nil +} +func (r *activationRepoFake) Find(context.Context, uint64) (*Activation, error) { return r.record, nil } +func (r *activationRepoFake) Spend(_ context.Context, q ActivationSpendQuery) ([]*ActivationSpendItem, error) { + r.query = q + return r.items, nil +} +func activationFixture() (*ActivationUsecase, *activationRepoFake) { + a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1, Level: 0}, 7: {ID: 7, Level: 1}, 8: {ID: 8, Level: 2, IDPath: "0,7"}}} + r := &activationRepoFake{accounts: []string{" Own ", "own", "Other", ""}} + return NewActivationUsecase(r, NewAccountUsecase(a)), r +} +func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) { + uc, r := activationFixture() + if _, err := uc.List(accountContext(8, 902), ActivationFilter{BusierAccount: "oWn", Statuses: []string{"已达标"}}); err != nil { + t.Fatal(err) + } + if r.requested != 8 || r.scope.All || len(r.scope.Accounts) != 1 || r.scope.Accounts[0] != "Own" || r.filter.BusierAccount != "" || r.filter.Statuses[0] != "reached" { + t.Fatalf("本人账号或中文状态筛选错误: %+v", r) + } + page, err := uc.List(accountContext(7, 901), ActivationFilter{BusierAccount: "下级账号"}) + if err != nil || page.Total != 0 || r.listCalls != 1 { + t.Fatal("无匹配绑定应直接返回空集") + } + if _, err := uc.List(accountContext(1, 888), ActivationFilter{BusierAccount: "any"}); err != nil || !r.scope.All || r.filter.BusierAccount != "any" { + t.Fatal("超管可筛全部 OA 绑定") + } +} +func TestActivationSpendScopeCutoffAndExactMoney(t *testing.T) { + uc, r := activationFixture() + start := time.Date(2026, 11, 3, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)) + reach, unbind := start.Add(time.Hour), start.Add(2*time.Hour) + id := uint64(100) + r.record = &Activation{ID: 10, PromoteID: &id, PlatformName: "lx", BusierAccount: "OWN", BindAt: &start, ReachAt: &reach, UnbindAt: &unbind, Status: "reached"} + r.items = []*ActivationSpendItem{{Amount: "9007199254740993.01"}, {Amount: "0.09"}} + detail, err := uc.SpendDetail(accountContext(8, 902), 10) + if err != nil || detail.TotalAmount != "9007199254740993.10" || r.query.Start != start.Unix() || r.query.End == nil || *r.query.End != reach.Unix() { + t.Fatalf("达标截止或精确金额错误: %+v %+v %v", detail, r.query, err) + } + r.record.Status = "unbound" + if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != nil || *r.query.End != unbind.Unix() { + t.Fatal("解绑截止错误") + } + r.record.BusierAccount = "not-owned" + if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != ErrActivationScope { + t.Fatal("非本人绑定可读取流水") + } + r.record.BindAt = nil + if _, err := uc.SpendDetail(accountContext(1, 888), 10); err != ErrActivationPending { + t.Fatal("未审核绑定不应查询流水") + } +} diff --git a/internal/data/business/activation.go b/internal/data/business/activation.go new file mode 100644 index 0000000..c4a9678 --- /dev/null +++ b/internal/data/business/activation.go @@ -0,0 +1,160 @@ +package business + +import ( + "context" + "errors" + "strings" + "time" + + "gorm.io/gorm" + "kra/internal/biz/business" +) + +// OA 表只读,不由新商务的系统迁移创建或改写。 +type activationPO struct { + ID uint64 `gorm:"primaryKey"` + PromoteID *uint64 + PlatformName, PromoteAccount, BusierAccount, ApplicantOA, Status string + AccumAmount *string + BindAt, UnbindAt, ReachAt, DeletedAt *time.Time +} + +func (activationPO) TableName() string { return "sys_promote_active_pool_bind" } +func toBizActivation(po activationPO) *business.Activation { + amount := "0.00" + if po.AccumAmount != nil { + amount = *po.AccumAmount + } + return &business.Activation{ID: po.ID, PromoteID: po.PromoteID, PlatformName: po.PlatformName, PromoteAccount: po.PromoteAccount, BusierAccount: po.BusierAccount, ApplicantOA: po.ApplicantOA, Status: po.Status, AccumAmount: amount, BindAt: po.BindAt, UnbindAt: po.UnbindAt, ReachAt: po.ReachAt} +} + +type activationRepo struct { + data Provider + databases PlatformProvider +} + +func NewActivationRepo(data Provider, databases PlatformProvider) business.ActivationRepo { + return &activationRepo{data: data, databases: databases} +} + +func (r *activationRepo) OwnAccounts(ctx context.Context, id uint) ([]string, error) { + accounts := []string{} + err := r.data.DB().WithContext(ctx).Table("sys_user_bind_platform").Where("user_id = ? AND status = ? AND platform_busier_account IS NOT NULL", id, "0").Distinct("platform_busier_account").Pluck("platform_busier_account", &accounts).Error + return accounts, storageError(err) +} + +func (r *activationRepo) oa(ctx context.Context) (*gorm.DB, error) { + db, err := r.databases.Database("oa") + if err != nil || db == nil { + return nil, business.ErrActivationSource + } + db = db.WithContext(ctx) + available, err := businessTableAvailable(db, "sys_promote_active_pool_bind") + if err != nil || !available { + return nil, business.ErrActivationSource + } + return db, nil +} + +func (r *activationRepo) List(ctx context.Context, scope business.ActivationScope, filter business.ActivationFilter, options ...business.ListOption) (*business.ActivationPage, error) { + page := &business.ActivationPage{Rows: []*business.Activation{}, Available: true} + if !scope.All && len(scope.Accounts) == 0 { + return page, nil + } + db, err := r.oa(ctx) + if err != nil { + page.Available = false + return page, nil + } + q := db.Model(&activationPO{}).Where("deleted_at IS NULL") + if !scope.All { + accounts := make([]string, 0, len(scope.Accounts)) + for _, account := range scope.Accounts { + accounts = append(accounts, strings.ToLower(account)) + } + q = q.Where("LOWER(busier_account) IN ?", accounts) + } + if len(filter.PlatformNames) > 0 { + q = q.Where("platform_name IN ?", filter.PlatformNames) + } + if len(filter.Statuses) > 0 { + q = q.Where("status IN ?", filter.Statuses) + } + if filter.PromoteAccount != "" { + q = q.Where("promote_account LIKE ?", "%"+filter.PromoteAccount+"%") + } + if filter.BusierAccount != "" { + q = q.Where("busier_account LIKE ?", "%"+filter.BusierAccount+"%") + } + if err := q.Count(&page.Total).Error; err != nil { + return nil, business.ErrActivationSource + } + o := business.NewListOptions(options...) + var pos []activationPO + if err := q.Order("id DESC").Offset(o.Offset).Limit(o.Limit).Find(&pos).Error; err != nil { + return nil, business.ErrActivationSource + } + for _, po := range pos { + page.Rows = append(page.Rows, toBizActivation(po)) + } + return page, nil +} + +func (r *activationRepo) Find(ctx context.Context, id uint64) (*business.Activation, error) { + db, err := r.oa(ctx) + if err != nil { + return nil, err + } + var po activationPO + if err := db.Where("id = ? AND deleted_at IS NULL", id).Take(&po).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, business.ErrActivationNotFound + } + return nil, business.ErrActivationSource + } + return toBizActivation(po), nil +} + +func (r *activationRepo) Spend(ctx context.Context, query business.ActivationSpendQuery) ([]*business.ActivationSpendItem, error) { + db := r.data.DB().WithContext(ctx) + available, err := businessTableAvailable(db, "tab_promote") + if err != nil { + return nil, err + } + if !available { + return nil, business.ErrActivationMetrics + } + tree := func() *gorm.DB { + return db.Table("tab_promote p").Where("p.source_db = ? AND (p.id = ? OR p.top_promote_id = ? OR p.parent_id = ?)", query.PlatformName, query.PromoteID, query.PromoteID, query.PromoteID) + } + var count int64 + if err := tree().Count(&count).Error; err != nil { + return nil, business.ErrStorage + } + items := []*business.ActivationSpendItem{} + if count == 0 { + return items, nil + } + available, err = businessTableAvailable(db, "tab_spend") + if err != nil { + return nil, err + } + if !available { + return nil, business.ErrActivationMetrics + } + q := db.Table("tab_spend s").Where("s.source_db = ? AND s.pay_status = ? AND s.pay_time >= ?", query.PlatformName, 1, query.Start).Where("EXISTS (?)", tree().Select("1").Where("p.id=s.promote_id")) + if query.End != nil { + q = q.Where("s.pay_time <= ?", *query.End) + } + var rows []struct { + GameID uint64 + GameName, Amount string + } + if err := q.Select("s.game_id,COALESCE(MAX(s.game_name),'') AS game_name,COALESCE(SUM(s.pay_amount),0) AS amount").Group("s.game_id").Order("amount DESC,s.game_id").Scan(&rows).Error; err != nil { + return nil, business.ErrStorage + } + for _, row := range rows { + items = append(items, &business.ActivationSpendItem{GameName: row.GameName, Amount: row.Amount}) + } + return items, nil +} diff --git a/internal/data/business/activation_test.go b/internal/data/business/activation_test.go new file mode 100644 index 0000000..0ec8252 --- /dev/null +++ b/internal/data/business/activation_test.go @@ -0,0 +1,115 @@ +package business + +import ( + "context" + "errors" + "math/big" + "path/filepath" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "kra/internal/biz/business" +) + +type activationDatabases struct{ oa *gorm.DB } + +func (d activationDatabases) Database(name string) (*gorm.DB, error) { + if name != "oa" || d.oa == nil { + return nil, errors.New("未配置") + } + return d.oa, nil +} +func prepareActivations(t *testing.T) (*gorm.DB, *gorm.DB, business.ActivationRepo) { + t.Helper() + db := prepareAccounts(t) + oa, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "oa.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := oa.DB() + t.Cleanup(func() { _ = pool.Close() }) + if err := oa.AutoMigrate(&activationPO{}); err != nil { + t.Fatal(err) + } + start := time.Unix(1000, 0).UTC() + end := time.Unix(2000, 0).UTC() + channel := uint64(100) + amount := "30.30" + pos := []activationPO{{ID: 1, PromoteID: &channel, PlatformName: "lx", PromoteAccount: "渠道一", BusierAccount: "Own", BindAt: &start, ReachAt: &end, Status: "reached", AccumAmount: &amount}, {ID: 2, PlatformName: "hw", BusierAccount: "own", Status: "pending"}, {ID: 3, PlatformName: "lx", BusierAccount: "Other", Status: "in_progress"}, {ID: 4, PlatformName: "lx", BusierAccount: "Own", Status: "rejected", DeletedAt: &end}} + if err := oa.Create(&pos).Error; err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(7,'lx',1,'Own','0'),(8,'lx',2,'Child','0'),(7,'hw',3,'Pending','3')", + "ALTER TABLE tab_promote ADD COLUMN top_promote_id INTEGER", + "ALTER TABLE tab_promote ADD COLUMN parent_id INTEGER", + "INSERT INTO tab_promote(id,source_db,top_promote_id,parent_id) VALUES(100,'lx',0,0),(101,'lx',0,100),(101,'lx',0,100),(102,'lx',100,101),(103,'lx',200,200),(100,'hw',0,0)", + "CREATE TABLE tab_spend(source_db TEXT,promote_id INTEGER,pay_status INTEGER,pay_time INTEGER,game_id INTEGER,game_name TEXT,pay_amount DECIMAL(20,2))", + "INSERT INTO tab_spend VALUES('lx',100,1,1000,1,'A',10.10),('lx',101,1,2000,1,'Z',20.20),('lx',102,1,1500,2,'B',3.30),('lx',103,1,1500,1,'X',900),('hw',100,1,1500,1,'X',999),('lx',100,1,999,1,'X',800),('lx',100,1,2001,1,'X',700),('lx',100,0,1500,1,'X',600)", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + return db, oa, NewActivationRepo(testData{db}, activationDatabases{oa}) +} +func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) { + db, _, repo := prepareActivations(t) + ctx := context.Background() + accounts, err := repo.OwnAccounts(ctx, 7) + if err != nil || len(accounts) != 1 || accounts[0] != "Own" { + t.Fatalf("本人已通过账号范围错误: %+v %v", accounts, err) + } + page, err := repo.List(ctx, business.ActivationScope{Accounts: []string{"OWN"}}, business.ActivationFilter{}) + if err != nil || page.Total != 2 || page.Rows[0].ID != 2 { + t.Fatalf("OA 列表或排序错误: %+v %v", page, err) + } + page, err = repo.List(ctx, business.ActivationScope{All: true}, business.ActivationFilter{PlatformNames: []string{"lx"}, Statuses: []string{"reached"}}) + if err != nil || page.Total != 1 || page.Rows[0].AccumAmount != "30.30" { + t.Fatalf("平台或状态筛选错误: %+v %v", page, err) + } + if _, err := repo.Find(ctx, 4); err != business.ErrActivationNotFound { + t.Fatal("软删除 OA 记录仍可访问") + } + // 即使主库有同名表,也不能在 OA 未配置时回退到主库。 + if err := db.AutoMigrate(&activationPO{}); err != nil { + t.Fatal(err) + } + missing := NewActivationRepo(testData{db}, activationDatabases{}) + page, err = missing.List(ctx, business.ActivationScope{All: true}, business.ActivationFilter{}) + if err != nil || page.Available { + t.Fatal("OA 缺配置必须明确不可用") + } + if _, err := missing.Find(ctx, 1); err != business.ErrActivationSource { + t.Fatal("OA 缺配置不应读取主库") + } +} +func TestActivationSpendTreePlatformAndInclusiveBounds(t *testing.T) { + db, _, repo := prepareActivations(t) + end := int64(2000) + query := business.ActivationSpendQuery{PlatformName: "lx", PromoteID: 100, Start: 1000, End: &end} + items, err := repo.Spend(context.Background(), query) + amount := func(value string) string { + n, ok := new(big.Rat).SetString(value) + if !ok { + return "无效金额" + } + return n.FloatString(2) + } + if err != nil || len(items) != 2 || items[0].GameName != "Z" || amount(items[0].Amount) != "30.30" || amount(items[1].Amount) != "3.30" { + t.Fatalf("渠道树、同号平台、成功支付、起止边界或重复渠道导致金额错误: %+v %v", items, err) + } + if err := db.Exec("DROP TABLE tab_spend").Error; err != nil { + t.Fatal(err) + } + if _, err := repo.Spend(context.Background(), query); err != business.ErrActivationMetrics { + t.Fatal("缺流水表未报告不可用") + } + query.PromoteID = 404 + items, err = repo.Spend(context.Background(), query) + if err != nil || len(items) != 0 { + t.Fatal("确定没有渠道时应返回真实空流水") + } +} diff --git a/internal/data/data.go b/internal/data/data.go index c84aae3..5e41550 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -36,6 +36,7 @@ var ProviderSet = wire.NewSet( databusiness.NewBusinessPromoteRepo, databusiness.NewAlertRuleRepo, databusiness.NewAlertRecordRepo, + databusiness.NewActivationRepo, NewIntegrationRuntime, wire.Bind(new(datasystem.Provider), new(*Data)), wire.Bind(new(datasystem.DatabaseProvider), new(*Data)), diff --git a/internal/modules/business/definition.go b/internal/modules/business/definition.go index a9756d9..3081afb 100644 --- a/internal/modules/business/definition.go +++ b/internal/modules/business/definition.go @@ -9,6 +9,7 @@ import ( func Definition() module.Definition { return module.Definition{Name: "business", Migrations: databusiness.Migrations(), Surface: module.Surface{ Menus: []module.Menu{ + {Name: "sw_ChannelActivationList", Path: "ChannelActivationList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelActivationList.vue", Title: "渠道激活", Icon: "company", Sort: 3}, {Name: "sw_AlertList", Path: "AlertList", ParentName: "sw_AlertCenter", Component: "view/sw/AlertList.vue", Title: "预警列表", Icon: "warn", Sort: 1}, {Name: "sw_AlertCenter", Path: "sw-AlertCenter", Component: "view/routerHolder.vue", Title: "预警中心", Icon: "warn", Sort: 4}, {Name: "sw_AlertRules", Path: "AlertRules", ParentName: "sw_AlertCenter", Component: "view/sw/AlertRules.vue", Title: "预警规则", Icon: "warn", Sort: 2}, diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 284e256..c3d9303 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) { menus += len(definition.Surface.Menus) apis += len(definition.Surface.APIs) } - if menus != 9 || apis != 44 { - t.Fatalf("admin surface = %d menus/%d APIs, want 9/44", menus, apis) + if menus != 10 || apis != 46 { + t.Fatalf("admin surface = %d menus/%d APIs, want 10/46", menus, apis) } if got := catalog.DefaultTimedTasks(); len(got) != 2 { t.Fatalf("default timed tasks = %d, want 2", len(got)) diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index eec5cc9..2be5fce 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -41,6 +41,8 @@ type routeValue struct { // Gin router files still bind handlers; tests keep those registrations aligned // with this catalog while public, audit and API metadata live only here. var routes = map[string]routeValue{ + "GET /sw/promoteActivePool/bindList": {group: "渠道激活", description: "查询 OA 渠道激活绑定"}, + "GET /sw/promoteActivePool/bindSpendDetail": {group: "渠道激活", description: "查询绑定期间游戏流水"}, "GET /sw/alertRecord/list": {group: "预警记录", description: "查询预警列表及状态统计"}, "GET /sw/alertRecord/detail/:id": {group: "预警记录", description: "查询预警详情及操作记录"}, "POST /sw/alertRecord/startProcess": {group: "预警记录", description: "开始处理预警", audit: true}, diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index 5b18854..dec419b 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -26,6 +26,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ func emptyHandlers() *handler.Set { return &handler.Set{ + Activation: &handler.Activation{}, AlertRecord: &handler.AlertRecord{}, AlertRule: &handler.AlertRule{}, BusinessPromote: &handler.BusinessPromote{}, @@ -101,7 +102,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":227`) { + if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":229`) { t.Fatalf("startup route summary is missing: %s", text) } } diff --git a/internal/server/handler/activation.go b/internal/server/handler/activation.go new file mode 100644 index 0000000..7deca27 --- /dev/null +++ b/internal/server/handler/activation.go @@ -0,0 +1,42 @@ +package handler + +import ( + "strconv" + + "github.com/gin-gonic/gin" + "github.com/go-kratos/kratos/v3/errors" + "kra/internal/service/business" + "kra/internal/service/dto" +) + +type Activation struct{ service *business.ActivationService } + +func NewActivation(service *business.ActivationService) *Activation { + return &Activation{service: service} +} +func (h *Activation) List(c *gin.Context) { + var req dto.ActivationQuery + if c.ShouldBindQuery(&req) != nil { + Fail(c, "渠道激活查询参数不正确") + return + } + result, err := h.service.List(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, result) +} +func (h *Activation) SpendDetail(c *gin.Context) { + id, err := strconv.ParseUint(c.Query("id"), 10, 64) + if err != nil { + Fail(c, "绑定记录不存在") + return + } + result, err := h.service.SpendDetail(c.Request.Context(), id) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, result) +} diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go index 95e1c26..0e6a579 100644 --- a/internal/server/handler/provider.go +++ b/internal/server/handler/provider.go @@ -10,6 +10,7 @@ var ProviderSet = wire.NewSet( NewBusinessPromote, NewAlertRule, NewAlertRecord, + NewActivation, NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization, NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit, NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken, diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go index bfffbef..0933186 100644 --- a/internal/server/handler/set.go +++ b/internal/server/handler/set.go @@ -1,6 +1,7 @@ package handler type Set struct { + Activation *Activation AlertRecord *AlertRecord AlertRule *AlertRule BusinessPromote *BusinessPromote diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go index 796c809..052ad86 100644 --- a/internal/server/router/routes.go +++ b/internal/server/router/routes.go @@ -20,6 +20,10 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En return } registrations := []func(){ + func() { + private.GET("/sw/promoteActivePool/bindList", r.handlers.Activation.List) + private.GET("/sw/promoteActivePool/bindSpendDetail", r.handlers.Activation.SpendDetail) + }, func() { RegisterAlertRecord(private, r.handlers.AlertRecord) }, func() { RegisterAlertRule(private, r.handlers.AlertRule) }, func() { private.GET("/sw/business/:userId/promotes", r.handlers.BusinessPromote.List) }, diff --git a/internal/service/business/activation.go b/internal/service/business/activation.go new file mode 100644 index 0000000..6db6ed1 --- /dev/null +++ b/internal/service/business/activation.go @@ -0,0 +1,78 @@ +package business + +import ( + "context" + "strings" + + "kra/internal/biz/business" + "kra/internal/service/dto" +) + +type ActivationUsecase interface { + List(context.Context, business.ActivationFilter, ...business.ListOption) (*business.ActivationPage, error) + SpendDetail(context.Context, uint64) (*business.ActivationSpendDetail, error) +} +type ActivationService struct{ uc ActivationUsecase } + +func NewActivationService(uc *business.ActivationUsecase) *ActivationService { + return &ActivationService{uc: uc} +} + +func splitQueryValues(values []string) []string { + result := []string{} + seen := map[string]bool{} + for _, value := range values { + for _, part := range strings.Split(value, ",") { + part = strings.TrimSpace(part) + if part != "" && !seen[part] { + result = append(result, part) + seen[part] = true + } + } + } + return result +} + +func (s *ActivationService) List(ctx context.Context, req *dto.ActivationQuery) (*dto.ActivationPage, error) { + if req == nil { + return nil, business.ErrActivationInput + } + page, size := req.Page, req.PageSize + if page < 1 { + page = 1 + } + if size < 1 { + size = 10 + } + if size > 500 { + size = 500 + } + if page > int(^uint(0)>>1)/size { + return nil, business.ErrActivationInput + } + filter := business.ActivationFilter{PlatformNames: splitQueryValues(req.PlatformNames), Statuses: splitQueryValues(req.Statuses), PromoteAccount: strings.TrimSpace(req.PromoteAccount), BusierAccount: strings.TrimSpace(req.BusierAccount)} + result, err := s.uc.List(ctx, filter, business.ListOffset((page-1)*size), business.ListLimit(size)) + if err != nil { + return nil, err + } + out := &dto.ActivationPage{Rows: []*dto.ActivationResponse{}, Total: result.Total, Available: result.Available} + for _, r := range result.Rows { + out.Rows = append(out.Rows, &dto.ActivationResponse{ID: r.ID, PromoteAccount: r.PromoteAccount, PlatformName: r.PlatformName, PlatformTitle: business.PlatformName(r.PlatformName), BusierAccount: r.BusierAccount, ApplicantOA: r.ApplicantOA, BindAt: localTime(r.BindAt), UnbindAt: localTime(r.UnbindAt), ReachAt: localTime(r.ReachAt), EndAt: localTime(r.EndAt()), AccumAmount: r.AccumAmount, Status: r.Status, StatusText: business.ActivationStatusLabel(r.Status)}) + } + return out, nil +} +func (s *ActivationService) SpendDetail(ctx context.Context, id uint64) (*dto.ActivationSpendDetail, error) { + if id == 0 { + return nil, business.ErrActivationNotFound + } + result, err := s.uc.SpendDetail(ctx, id) + if err != nil { + return nil, err + } + a := result.Activation + out := &dto.ActivationSpendDetail{PromoteAccount: a.PromoteAccount, BindAt: localTime(a.BindAt), EndAt: localTime(a.EndAt()), List: []*dto.ActivationSpendItem{}, TotalAmount: result.TotalAmount} + for _, item := range result.Items { + out.List = append(out.List, &dto.ActivationSpendItem{PlatformTitle: business.PlatformName(a.PlatformName), GameName: item.GameName, TotalAmount: item.Amount}) + } + return out, nil +} diff --git a/internal/service/dto/activation.go b/internal/service/dto/activation.go new file mode 100644 index 0000000..af5121e --- /dev/null +++ b/internal/service/dto/activation.go @@ -0,0 +1,42 @@ +package dto + +type ActivationQuery struct { + Page int `form:"pageNum"` + PageSize int `form:"pageSize"` + PlatformNames []string `form:"platformNames"` + Statuses []string `form:"statuses"` + PromoteAccount string `form:"promoteAccount"` + BusierAccount string `form:"busierAccount"` +} +type ActivationResponse struct { + ID uint64 `json:"id,string"` + PromoteAccount string `json:"promoteAccount"` + PlatformName string `json:"platformName"` + PlatformTitle string `json:"platformTitle"` + BusierAccount string `json:"busierAccount"` + ApplicantOA string `json:"applicantOa"` + BindAt *string `json:"bindAt"` + UnbindAt *string `json:"unbindAt"` + ReachAt *string `json:"reachAt"` + EndAt *string `json:"endAt"` + AccumAmount string `json:"accumAmount"` + Status string `json:"status"` + StatusText string `json:"statusText"` +} +type ActivationPage struct { + Rows []*ActivationResponse `json:"rows"` + Total int64 `json:"total"` + Available bool `json:"available"` +} +type ActivationSpendItem struct { + PlatformTitle string `json:"platformTitle"` + GameName string `json:"gameName"` + TotalAmount string `json:"totalAmount"` +} +type ActivationSpendDetail struct { + PromoteAccount string `json:"promoteAccount"` + BindAt *string `json:"bindAt"` + EndAt *string `json:"endAt"` + List []*ActivationSpendItem `json:"list"` + TotalAmount string `json:"totalAmount"` +} diff --git a/internal/service/service.go b/internal/service/service.go index 8ba8cf2..2ae8b55 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -19,6 +19,7 @@ var ProviderSet = wire.NewSet( businessservice.NewBusinessPromoteService, businessservice.NewAlertRuleService, businessservice.NewAlertRecordService, + businessservice.NewActivationService, systemservice.ProviderSet, paymentservice.ProviderSet, integrationservice.ProviderSet, diff --git a/scripts/sw_activation_http_smoke.py b/scripts/sw_activation_http_smoke.py new file mode 100644 index 0000000..1c6b4d7 --- /dev/null +++ b/scripts/sw_activation_http_smoke.py @@ -0,0 +1,42 @@ +"""查询运行实例的渠道激活接口,仅执行只读请求。""" + +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 = [] + paths = [ + ("/sw/promoteActivePool/bindList?pageNum=1&pageSize=10", 0), + ("/sw/promoteActivePool/bindList?platformNames=lx,hw&statuses=reached", 0), + ("/sw/promoteActivePool/bindList?" + urllib.parse.urlencode({"statuses": "已达标", "busierAccount": "does-not-exist"}), 0), + ("/sw/promoteActivePool/bindSpendDetail?id=0", 7), + ] + for path, expected in paths: + request = urllib.request.Request(args.base_url + path, headers={"x-token": token}) + with urllib.request.urlopen(request, timeout=30) as response: + body = json.load(response) + item = {"method": "GET", "path": path, "http": response.status, "code": body.get("code"), + "msg": body.get("msg"), "passed": response.status == 200 and body.get("code") == expected} + if isinstance(body.get("data"), dict) and "rows" in body["data"]: + item.update(total=body["data"]["total"], available=body["data"].get("available")) + checks.append(item) + print(json.dumps(item, ensure_ascii=False)) + report = {"scope": "当前 sw7 运行实例,只读请求;OA 未接入场景明确返回 available=false。完整 OA 与流水数据通过两个隔离数据库的 TCP HTTP 测试。", + "checks": checks, "passed": all(item["passed"] for item in checks)} + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if not report["passed"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/sw_activation_smoke.cjs b/scripts/sw_activation_smoke.cjs new file mode 100644 index 0000000..6b770e0 --- /dev/null +++ b/scripts/sw_activation_smoke.cjs @@ -0,0 +1,75 @@ +// 核对渠道激活页面的真实渲染;OA 数据与流水查询使用受控响应。 +const fs = require('node:fs/promises') +const path = require('node:path') +const assert = require('node:assert/strict') +const { chromium } = require(process.env.SW_PLAYWRIGHT_MODULE || 'playwright') + +;(async () => { + const token = (await fs.readFile(path.join(process.env.TEMP, 'sw-kra-http-test-token'), 'utf8')).trim() + const browser = await chromium.launch({ channel: 'chrome', headless: true }) + try { + const context = await browser.newContext({ viewport: { width: 1500, height: 1000 } }) + await context.addInitScript(value => localStorage.setItem('token', value), token) + const page = await context.newPage() + const errors = [] + const queries = [] + let unavailable = false + const rows = ['pending', 'in_progress', 'reached', 'rejected', 'unbound'].map((status, i) => ({ + id: String(i + 1), promoteAccount: `测试渠道-${i + 1}`, busierAccount: '测试商务', platformName: 'lx', platformTitle: '龙翔', + bindAt: ['pending', 'rejected'].includes(status) ? null : '2026-11-01 00:00:00', + unbindAt: status === 'unbound' ? '2026-11-03 12:00:00' : null, + endAt: ['reached', 'unbound'].includes(status) ? '2026-11-03 12:00:00' : null, + accumAmount: '30.30', status, statusText: { pending: '待审核', in_progress: '进行中', reached: '已达标', rejected: '已驳回', unbound: '已解绑' }[status] + })) + page.on('pageerror', error => errors.push(error.message)) + await page.route('http://127.0.0.1:8080/api/**', async route => { + const request = route.request() + const url = new URL(request.url()) + const api = url.pathname.slice(4) + if (api === '/sw/promoteActivePool/bindList') { + queries.push(url.search) + return route.fulfill({ json: { code: 0, data: { rows: unavailable ? [] : rows, total: unavailable ? 0 : rows.length, available: !unavailable } } }) + } + if (api === '/sw/promoteActivePool/bindSpendDetail') { + if (url.searchParams.get('id') === '1') return route.fulfill({ json: { code: 7, msg: '审核通过后可查看', data: {} } }) + const row = rows.find(item => item.id === url.searchParams.get('id')) + return route.fulfill({ json: { code: 0, data: { promoteAccount: row.promoteAccount, bindAt: row.bindAt, endAt: row.endAt, list: [{ platformTitle: '龙翔', gameName: '测试游戏', totalAmount: '9007199254740993.10' }], totalAmount: '9007199254740993.10' } } }) + } + const response = await route.fetch({ url: 'http://127.0.0.1:18000' + api + url.search, headers: { ...request.headers(), 'x-token': token } }) + return route.fulfill({ response }) + }) + await page.goto('http://127.0.0.1:8080/#/layout/sw-BusMgmt/ChannelActivationList') + await page.getByText('测试渠道-1', { exact: true }).waitFor() + const row = id => page.locator('.el-table__body-wrapper tr').filter({ hasText: `测试渠道-${id}` }) + assert.equal(await row(4).getByRole('button', { name: '流水明细', exact: true }).count(), 0) + for (const id of [1, 2, 3, 5]) assert.equal(await row(id).getByRole('button', { name: '流水明细', exact: true }).count(), 1) + const dialog = page.getByRole('dialog', { name: '流水明细', exact: true }) + await row(3).getByRole('button', { name: '流水明细', exact: true }).click() + await dialog.getByText('测试游戏', { exact: true }).waitFor() + await dialog.getByText('2026-11-01 至 2026-11-03', { exact: true }).waitFor() + assert.equal(await dialog.getByText('¥9,007,199,254,740,993.10', { exact: true }).count(), 2) + await dialog.locator('.el-loading-mask').waitFor({ state: 'hidden' }) + await page.screenshot({ path: path.join(process.env.TEMP, 'sw-activation-flow.png'), animations: 'disabled' }) + await dialog.getByRole('button', { name: '关闭', exact: true }).click() + await dialog.waitFor({ state: 'hidden' }) + await row(1).getByRole('button', { name: '流水明细', exact: true }).click() + await dialog.getByText('审核通过后可查看', { exact: true }).waitFor() + assert.equal(await dialog.getByText('绑定期间累计流水:', { exact: false }).count(), 0) + await dialog.getByRole('button', { name: '关闭', exact: true }).click() + await dialog.waitFor({ state: 'hidden' }) + const form = page.locator('.kra-search-box') + await form.getByPlaceholder('渠道账号', { exact: true }).fill(' 渠道 ') + await form.getByRole('button', { name: '查询', exact: true }).click() + await page.waitForResponse(response => response.url().includes('/sw/promoteActivePool/bindList') && response.url().includes('promoteAccount=')) + assert.equal(new URLSearchParams(queries.at(-1)).get('promoteAccount'), '渠道') + unavailable = true + await form.getByRole('button', { name: '重置', exact: true }).click() + await page.getByText('OA 渠道激活数据源尚未接入或暂不可用。', { exact: true }).waitFor() + assert.equal(new URLSearchParams(queries.at(-1)).get('promoteAccount'), '') + assert.deepEqual(errors, []) + const result = { passed: true, rejectedHidesFlow: true, pendingShowsReasonWithoutFakeTotal: true, reachedCutoffDisplayed: true, exactLargeAmount: true, filtersAndReset: true, unavailableSourceNotice: true, pageErrors: errors } + await fs.writeFile('docs/sw6-to-kra/activation-page-2026-11-03.json', JSON.stringify({ method: '真实 Chrome,受控 OA 和流水响应,无写入', ...result }, null, 2) + '\n') + console.log(JSON.stringify(result)) + await context.close() + } finally { await browser.close() } +})().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/web/src/api/activation.js b/web/src/api/activation.js new file mode 100644 index 0000000..37b0c71 --- /dev/null +++ b/web/src/api/activation.js @@ -0,0 +1,4 @@ +import service from '@/utils/request' + +export const listChannelActivations = (params) => service({ url: '/sw/promoteActivePool/bindList', method: 'get', params }) +export const getActivationSpend = (id) => service({ url: '/sw/promoteActivePool/bindSpendDetail', method: 'get', params: { id } }) diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index 4e73d7c..c06781d 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -55,6 +55,7 @@ "/src/view/sw/AlertList.vue": "SwAlertList", "/src/view/sw/AlertRules.vue": "SwAlertRules", "/src/view/sw/BusinessList.vue": "SwBusinessList", + "/src/view/sw/ChannelActivationList.vue": "SwChannelActivationList", "/src/view/sw/components/BusinessBindings.vue": "SwBusinessBindings", "/src/view/sw/components/BusinessChannels.vue": "SwBusinessChannels", "/src/view/system/security/forceChangePassword.vue": "ForceChangePassword", diff --git a/web/src/view/sw/ChannelActivationList.vue b/web/src/view/sw/ChannelActivationList.vue new file mode 100644 index 0000000..465f88f --- /dev/null +++ b/web/src/view/sw/ChannelActivationList.vue @@ -0,0 +1,109 @@ + + +