feat: 迁移渠道激活列表和绑定期间流水

This commit is contained in:
Yvan
2026-11-03 20:53:25 +08:00
parent 838c2c496f
commit 8908430a89
28 changed files with 1180 additions and 9 deletions
+137
View File
@@ -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)
}
+6 -1
View File
@@ -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,
@@ -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
}
@@ -0,0 +1,11 @@
{
"method": "真实 Chrome,受控 OA 和流水响应,无写入",
"passed": true,
"rejectedHidesFlow": true,
"pendingShowsReasonWithoutFakeTotal": true,
"reachedCutoffDisplayed": true,
"exactLargeAmount": true,
"filtersAndReset": true,
"unavailableSourceNotice": true,
"pageErrors": []
}
+7 -3
View File
@@ -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 映射,不能把隔离替身测试当成外部审核验收。
- 当前没有执行最终旧数据库复制与迁移验收。
+18 -1
View File
@@ -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 数据。
+1 -1
View File
@@ -11,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase)
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)
+193
View File
@@ -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
}
+80
View File
@@ -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("未审核绑定不应查询流水")
}
}
+160
View File
@@ -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
}
+115
View File
@@ -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("确定没有渠道时应返回真实空流水")
}
}
+1
View File
@@ -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)),
+1
View File
@@ -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},
+2 -2
View File
@@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs)
}
if menus != 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))
+2
View File
@@ -41,6 +41,8 @@ type routeValue struct {
// Gin router files still bind handlers; tests keep those registrations aligned
// with this catalog while public, audit and API metadata live only here.
var routes = map[string]routeValue{
"GET /sw/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},
+2 -1
View File
@@ -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)
}
}
+42
View File
@@ -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)
}
+1
View File
@@ -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,
+1
View File
@@ -1,6 +1,7 @@
package handler
type Set struct {
Activation *Activation
AlertRecord *AlertRecord
AlertRule *AlertRule
BusinessPromote *BusinessPromote
+4
View File
@@ -20,6 +20,10 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
return
}
registrations := []func(){
func() {
private.GET("/sw/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) },
+78
View File
@@ -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
}
+42
View File
@@ -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"`
}
+1
View File
@@ -19,6 +19,7 @@ var ProviderSet = wire.NewSet(
businessservice.NewBusinessPromoteService,
businessservice.NewAlertRuleService,
businessservice.NewAlertRecordService,
businessservice.NewActivationService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,
+42
View File
@@ -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()
+75
View File
@@ -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 })
+4
View File
@@ -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 } })
+1
View File
@@ -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",
+109
View File
@@ -0,0 +1,109 @@
<template>
<div>
<div class="kra-search-box">
<el-form :model="query" :inline="true" @submit.prevent="search">
<el-form-item label="所属平台"><el-select v-model="query.platformNames" multiple collapse-tags clearable placeholder="选择平台" style="width: 210px"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="渠道账号"><el-input v-model="query.promoteAccount" clearable placeholder="渠道账号" /></el-form-item>
<el-form-item label="绑定商务"><el-input v-model="query.busierAccount" clearable placeholder="绑定商务" /></el-form-item>
<el-form-item><el-button icon="refresh" @click="reset">重置</el-button><el-button type="primary" icon="search" native-type="submit">查询</el-button></el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<el-alert v-if="!available" class="mb-4" type="warning" :closable="false" title="OA 渠道激活数据源尚未接入或暂不可用。" />
<el-table v-loading="loading" :data="rows" row-key="id">
<el-table-column prop="platformTitle" label="所属平台" min-width="100" />
<el-table-column prop="promoteAccount" label="渠道名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="busierAccount" label="绑定商务" min-width="130" show-overflow-tooltip />
<el-table-column label="绑定日期" min-width="120"><template #default="{ row }">{{ date(row.bindAt) }}</template></el-table-column>
<el-table-column label="解绑时间" min-width="120"><template #default="{ row }">{{ date(row.unbindAt) }}</template></el-table-column>
<el-table-column label="累计流水金额" min-width="160"><template #default="{ row }">{{ currency(row.accumAmount) }}</template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag effect="plain" :type="statusTone(row.status)">{{ row.statusText || row.status || '—' }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="120" fixed="right"><template #default="{ row }"><el-button v-if="row.status !== 'rejected' && row.statusText !== '已驳回'" link type="primary" @click="openFlow(row)">流水明细</el-button><span v-else>—</span></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]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
<el-dialog v-model="dialog" title="流水明细" width="min(640px, 95vw)" append-to-body destroy-on-close @closed="clearDetail">
<div v-loading="flowLoading">
<el-descriptions :column="1">
<el-descriptions-item label="所属平台">{{ selected?.platformTitle || '—' }}</el-descriptions-item>
<el-descriptions-item label="渠道名称">{{ selected?.promoteAccount || '—' }}</el-descriptions-item>
<el-descriptions-item label="绑定期间">{{ period }}</el-descriptions-item>
</el-descriptions>
<el-alert v-if="flowError" type="warning" :title="flowError" :closable="false" />
<template v-else-if="detail">
<el-alert class="mb-4" type="info" title="仅统计绑定期间产生的流水。" :closable="false" />
<el-table :data="detail.list" max-height="320">
<el-table-column prop="platformTitle" label="所属平台" min-width="100" />
<el-table-column prop="gameName" label="游戏名称" min-width="160" show-overflow-tooltip />
<el-table-column label="累计金额" min-width="150" align="right"><template #default="{ row }">{{ currency(row.totalAmount) }}</template></el-table-column>
</el-table>
<p class="mt-4 text-right">绑定期间累计流水:<strong>{{ currency(detail.totalAmount) }}</strong></p>
</template>
</div>
<template #footer><el-button @click="dialog = false">关闭</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { listChannelActivations, getActivationSpend } from '@/api/activation'
defineOptions({ name: 'SwChannelActivationList' })
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const query = reactive({ pageNum: 1, pageSize: 10, platformNames: Object.keys(platforms), promoteAccount: '', busierAccount: '' })
const rows = ref([])
const total = ref(0)
const available = ref(true)
const loading = ref(false)
const dialog = ref(false)
const flowLoading = ref(false)
const flowError = ref('')
const selected = ref(null)
const detail = ref(null)
let listVersion = 0
let detailVersion = 0
const date = value => value ? value.slice(0, 10) : '—'
// 金额按字符串分组,保留超出 JavaScript 精确整数范围的金额。
const currency = value => {
const text = String(value ?? '0').trim()
if (!/^-?\d+(\.\d+)?$/.test(text)) return '—'
const [whole, fraction = ''] = text.split('.')
return `¥${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${fraction.padEnd(2, '0').slice(0, 2)}`
}
const statusTone = status => ({ pending: 'info', in_progress: 'warning', reached: 'success', rejected: 'danger', unbound: 'info' })[status] || 'info'
const period = computed(() => {
const start = detail.value?.bindAt || selected.value?.bindAt
const end = detail.value?.endAt || selected.value?.endAt
if (!start) return '—'
return end ? `${date(start)} 至 ${date(end)}` : `${date(start)} 起`
})
const load = async () => {
const version = ++listVersion
if (!query.platformNames.length) { rows.value = []; total.value = 0; available.value = true; loading.value = false; return }
loading.value = true
try {
const res = await listChannelActivations({ ...query, platformNames: query.platformNames.join(','), promoteAccount: query.promoteAccount.trim(), busierAccount: query.busierAccount.trim() })
if (version !== listVersion) return
if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total; available.value = res.data.available !== false }
else { rows.value = []; total.value = 0 }
} catch { if (version === listVersion) { rows.value = []; total.value = 0 } } finally { if (version === listVersion) loading.value = false }
}
const search = () => { query.pageNum = 1; return load() }
const reset = () => { Object.assign(query, { platformNames: Object.keys(platforms), promoteAccount: '', busierAccount: '' }); return search() }
const clearDetail = () => { ++detailVersion; detail.value = null; selected.value = null; flowError.value = ''; flowLoading.value = false }
const openFlow = async row => {
clearDetail()
const version = ++detailVersion
selected.value = row
dialog.value = true
flowLoading.value = true
try {
const res = await getActivationSpend(row.id)
if (version !== detailVersion) return
if (res.code === 0) detail.value = res.data
else flowError.value = res.msg || '流水明细读取失败'
} catch { if (version === detailVersion) flowError.value = '流水明细读取失败,请重试' } finally { if (version === detailVersion) flowLoading.value = false }
}
onMounted(load)
</script>