feat: 迁移渠道激活列表和绑定期间流水
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Generated
+6
-1
@@ -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": []
|
||||
}
|
||||
@@ -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 映射,不能把隔离替身测试当成外部审核验收。
|
||||
- 当前没有执行最终旧数据库复制与迁移验收。
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("未审核绑定不应查询流水")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("确定没有渠道时应返回真实空流水")
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
Activation *Activation
|
||||
AlertRecord *AlertRecord
|
||||
AlertRule *AlertRule
|
||||
BusinessPromote *BusinessPromote
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -19,6 +19,7 @@ var ProviderSet = wire.NewSet(
|
||||
businessservice.NewBusinessPromoteService,
|
||||
businessservice.NewAlertRuleService,
|
||||
businessservice.NewAlertRecordService,
|
||||
businessservice.NewActivationService,
|
||||
systemservice.ProviderSet,
|
||||
paymentservice.ProviderSet,
|
||||
integrationservice.ProviderSet,
|
||||
|
||||
@@ -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()
|
||||
@@ -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 })
|
||||
@@ -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 } })
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user