feat: integrate OA activation workflows and business migration
This commit is contained in:
@@ -20,9 +20,15 @@ import (
|
|||||||
businessservice "kra/internal/service/business"
|
businessservice "kra/internal/service/business"
|
||||||
)
|
)
|
||||||
|
|
||||||
type activationHTTPDatabases struct{ oa *gorm.DB }
|
type activationHTTPDatabases struct {
|
||||||
|
oa *gorm.DB
|
||||||
|
sdk *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
func (p *activationHTTPDatabases) Database(name string) (*gorm.DB, error) {
|
func (p *activationHTTPDatabases) Database(name string) (*gorm.DB, error) {
|
||||||
|
if name == "lx" && p.sdk != nil {
|
||||||
|
return p.sdk, nil
|
||||||
|
}
|
||||||
if name == "oa" && p.oa != nil {
|
if name == "oa" && p.oa != nil {
|
||||||
return p.oa, nil
|
return p.oa, nil
|
||||||
}
|
}
|
||||||
@@ -66,9 +72,27 @@ func TestActivationHTTPNamedDatabaseAndSpend(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
provider := businessHTTPData{db}
|
provider := businessHTTPData{db}
|
||||||
databases := &activationHTTPDatabases{oa: oa}
|
sdk := open("sdk.db")
|
||||||
uc := businessbiz.NewActivationUsecase(businessdata.NewActivationRepo(provider, databases), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)))
|
pool, _ := sdk.DB()
|
||||||
h := handler.NewActivation(businessservice.NewActivationService(uc))
|
pool.SetMaxOpenConns(1)
|
||||||
|
var files []struct{ Name, File string }
|
||||||
|
db.Raw("PRAGMA database_list").Scan(&files)
|
||||||
|
for _, file := range files {
|
||||||
|
if file.Name == "main" {
|
||||||
|
if err := sdk.Exec("ATTACH DATABASE ? AS sample", file.File).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, table := range []string{"tab_promote", "tab_spend"} {
|
||||||
|
if err := sdk.Exec("CREATE TABLE " + table + " AS SELECT * FROM sample." + table + " WHERE source_db='lx'").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sdk.Exec("DETACH DATABASE sample")
|
||||||
|
databases := &activationHTTPDatabases{oa: oa, sdk: sdk}
|
||||||
|
uc := businessbiz.NewActivationUsecase(businessdata.NewActivationRepo(provider, databases), businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)), businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider)))
|
||||||
|
h := handler.NewActivation(businessservice.NewActivationService(uc, nil))
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
engine.Use(func(c *gin.Context) {
|
engine.Use(func(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64)
|
id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64)
|
||||||
@@ -113,14 +137,14 @@ func TestActivationHTTPNamedDatabaseAndSpend(t *testing.T) {
|
|||||||
}
|
}
|
||||||
const detail = "/sw/promoteActivePool/bindSpendDetail?id=9007199254740993"
|
const detail = "/sw/promoteActivePool/bindSpendDetail?id=9007199254740993"
|
||||||
data := call(8, detail, 0)
|
data := call(8, detail, 0)
|
||||||
if data["totalAmount"] != "30.30" || len(data["list"].([]any)) != 2 || data["endAt"] != "1970-01-01 00:33:20" {
|
if data["totalAmount"] != "1029.30" || len(data["list"].([]any)) != 2 || data["endAt"] != "1970-01-01 01:00:00" {
|
||||||
t.Fatalf("流水金额、树范围或达标优先截止错误: %+v", data)
|
t.Fatalf("流水金额、树范围或实际解绑截止错误: %+v", data)
|
||||||
}
|
}
|
||||||
call(7, detail, 7)
|
call(7, detail, 7)
|
||||||
call(8, "/sw/promoteActivePool/bindSpendDetail?id=2", 7)
|
call(8, "/sw/promoteActivePool/bindSpendDetail?id=2", 7)
|
||||||
call(8, "/sw/promoteActivePool/bindSpendDetail?id=3", 7)
|
call(8, "/sw/promoteActivePool/bindSpendDetail?id=3", 7)
|
||||||
call(1, "/sw/promoteActivePool/bindSpendDetail?id=0", 7)
|
call(1, "/sw/promoteActivePool/bindSpendDetail?id=0", 7)
|
||||||
if err := db.Exec("DROP TABLE tab_spend").Error; err != nil {
|
if err := sdk.Exec("DROP TABLE tab_spend").Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
call(8, detail, 7)
|
call(8, detail, 7)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func TestAlertRecordHTTPCompleteWorkflowAndVisibility(t *testing.T) {
|
|||||||
c.Next()
|
c.Next()
|
||||||
})
|
})
|
||||||
router.RegisterAlertRecord(engine.Group(""), handler.NewAlertRecord(service))
|
router.RegisterAlertRecord(engine.Group(""), handler.NewAlertRecord(service))
|
||||||
router.RegisterAlertGovernance(engine.Group(""), handler.NewAlertGovernance(businessservice.NewAlertGovernanceService(businessbiz.NewAlertGovernanceUsecase(businessdata.NewAlertGovernanceRepo(provider), accounts))))
|
router.RegisterAlertGovernance(engine.Group(""), handler.NewAlertGovernance(businessservice.NewAlertGovernanceService(businessbiz.NewAlertGovernanceUsecase(businessdata.NewAlertGovernanceRepo(provider, &activationHTTPDatabases{}), accounts))))
|
||||||
server := httptest.NewServer(engine)
|
server := httptest.NewServer(engine)
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
call := func(user int, method, path, body string, expected int) map[string]any {
|
call := func(user int, method, path, body string, expected int) map[string]any {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func TestBusinessManagementHTTPGroupsGoalsAndEffectivenessAgree(t *testing.T) {
|
|||||||
goalRepo := data.NewGoalRepo(provider)
|
goalRepo := data.NewGoalRepo(provider)
|
||||||
performance := biz.NewPerformanceUsecase(data.NewPerformanceRepo(provider), teams, effective, goalRepo)
|
performance := biz.NewPerformanceUsecase(data.NewPerformanceRepo(provider), teams, effective, goalRepo)
|
||||||
goals := biz.NewGoalUsecase(goalRepo, performance)
|
goals := biz.NewGoalUsecase(goalRepo, performance)
|
||||||
dashboard := biz.NewDashboardUsecase(data.NewDashboardRepo(provider), biz.NewWorkbenchUsecase(data.NewWorkbenchRepo(provider), accounts, preferences), effective)
|
dashboard := biz.NewDashboardUsecase(data.NewDashboardRepo(provider, &activationHTTPDatabases{}), biz.NewWorkbenchUsecase(data.NewWorkbenchRepo(provider), accounts, preferences), effective)
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
engine.Use(func(c *gin.Context) {
|
engine.Use(func(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64)
|
id, _ := strconv.ParseUint(c.GetHeader("X-Test-User"), 10, 64)
|
||||||
|
|||||||
Generated
+8
-4
@@ -26,6 +26,7 @@ import (
|
|||||||
"kra/internal/integration/email"
|
"kra/internal/integration/email"
|
||||||
"kra/internal/integration/gameplatform"
|
"kra/internal/integration/gameplatform"
|
||||||
"kra/internal/integration/mq"
|
"kra/internal/integration/mq"
|
||||||
|
"kra/internal/integration/oa"
|
||||||
"kra/internal/integration/payment"
|
"kra/internal/integration/payment"
|
||||||
"kra/internal/integration/sms"
|
"kra/internal/integration/sms"
|
||||||
"kra/internal/integration/storage"
|
"kra/internal/integration/storage"
|
||||||
@@ -82,7 +83,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
|||||||
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
|
auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo)
|
||||||
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
|
auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase)
|
||||||
registry := taskRegistry()
|
registry := taskRegistry()
|
||||||
alertGovernanceRepo := business.NewAlertGovernanceRepo(dataData)
|
alertGovernanceRepo := business.NewAlertGovernanceRepo(dataData, dataData)
|
||||||
accountRepo := business.NewAccountRepo(dataData)
|
accountRepo := business.NewAccountRepo(dataData)
|
||||||
accountUsecase := business2.NewAccountUsecase(accountRepo)
|
accountUsecase := business2.NewAccountUsecase(accountRepo)
|
||||||
alertGovernanceUsecase := business2.NewAlertGovernanceUsecase(alertGovernanceRepo, accountUsecase)
|
alertGovernanceUsecase := business2.NewAlertGovernanceUsecase(alertGovernanceRepo, accountUsecase)
|
||||||
@@ -97,7 +98,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
|||||||
goalRepo := business.NewGoalRepo(dataData)
|
goalRepo := business.NewGoalRepo(dataData)
|
||||||
performanceUsecase := business2.NewPerformanceUsecase(performanceRepo, teamUsecase, effectivenessUsecase, goalRepo)
|
performanceUsecase := business2.NewPerformanceUsecase(performanceRepo, teamUsecase, effectivenessUsecase, goalRepo)
|
||||||
goalUsecase := business2.NewGoalUsecase(goalRepo, performanceUsecase)
|
goalUsecase := business2.NewGoalUsecase(goalRepo, performanceUsecase)
|
||||||
dashboardRepo := business.NewDashboardRepo(dataData)
|
dashboardRepo := business.NewDashboardRepo(dataData, dataData)
|
||||||
workbenchRepo := business.NewWorkbenchRepo(dataData)
|
workbenchRepo := business.NewWorkbenchRepo(dataData)
|
||||||
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
|
userPreferenceRepo := business.NewUserPreferenceRepo(dataData)
|
||||||
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
|
userPreferenceUsecase := business2.NewUserPreferenceUsecase(userPreferenceRepo)
|
||||||
@@ -141,8 +142,11 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg
|
|||||||
channelService := business3.NewChannelService(channelUsecase)
|
channelService := business3.NewChannelService(channelUsecase)
|
||||||
channel := handler.NewChannel(channelService)
|
channel := handler.NewChannel(channelService)
|
||||||
activationRepo := business.NewActivationRepo(dataData, dataData)
|
activationRepo := business.NewActivationRepo(dataData, dataData)
|
||||||
activationUsecase := business2.NewActivationUsecase(activationRepo, accountUsecase)
|
activationUsecase := business2.NewActivationUsecase(activationRepo, accountUsecase, userPreferenceUsecase)
|
||||||
activationService := business3.NewActivationService(activationUsecase)
|
activationPoolRepo := business.NewActivationPoolRepo(dataData, dataData)
|
||||||
|
activationGateway := oa.NewActivationGateway(store)
|
||||||
|
activationPoolUsecase := business2.NewActivationPoolUsecase(activationPoolRepo, activationGateway, accountUsecase)
|
||||||
|
activationService := business3.NewActivationService(activationUsecase, activationPoolUsecase)
|
||||||
activation := handler.NewActivation(activationService)
|
activation := handler.NewActivation(activationService)
|
||||||
alertRecordRepo := business.NewAlertRecordRepo(dataData)
|
alertRecordRepo := business.NewAlertRecordRepo(dataData)
|
||||||
alertRecordUsecase := business2.NewAlertRecordUsecase(alertRecordRepo, accountUsecase)
|
alertRecordUsecase := business2.NewAlertRecordUsecase(alertRecordRepo, accountUsecase)
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ cleanup_run: BEGIN
|
|||||||
('202611060022_business_promote_id_capacity'),
|
('202611060022_business_promote_id_capacity'),
|
||||||
('202611060023_business_binding_status_index'),
|
('202611060023_business_binding_status_index'),
|
||||||
('202611060024_business_role_identity'),
|
('202611060024_business_role_identity'),
|
||||||
|
('202611060024_business_oa_remark'),
|
||||||
|
('202611060025_business_activation_surface'),
|
||||||
('202611060025_automatic_upgrade_state')
|
('202611060025_automatic_upgrade_state')
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 商务与 OA 对接
|
||||||
|
|
||||||
|
商务应用负责展示和发起申请,OA 负责商务账号管理、SDK 绑定审核、渠道激活审核与渠道操作。OA 使用 `oa/server`,不依赖尚未迁移业务的 `oa-new`。
|
||||||
|
|
||||||
|
## 部署顺序与连接
|
||||||
|
|
||||||
|
1. 先升级新商务,完成自动迁移和原商务数据迁移。新商务用户表为 `sys_users`,角色表为 `sys_authorities`,用户角色关系为 `sys_user_authority`。本次新增用户备注列及激活接口权限迁移。
|
||||||
|
2. OA 的 `db-list` 中保留连接别名 `shangwu`,把该连接的数据库指向**迁移后的新商务主库**。`lx`、`hw`、`yq` 仍指向对应 SDK 数据库。OA 启动会检查 `shangwu.sys_users`,避免误连旧库。
|
||||||
|
3. 新商务 `data.database_list` 的 `oa` 连接指向 OA 主库;`lx`、`hw`、`yq` 指向对应 SDK 数据库。新商务只读取这些外部表,不创建它们。
|
||||||
|
4. 两边设置同一服务间密钥。OA 配置使用 `service-credentials.business-integration-key`;新商务可使用环境变量 `KRA_BUSINESS_OA_KEY`,或配置文件中的 `business_oa.key`。密钥只保存在部署环境,不提交版本库。
|
||||||
|
5. 新商务设置 `KRA_BUSINESS_OA_URL`(或 `business_oa.url`),值为 OA 服务地址,包含 OA 配置的 `router-prefix`,末尾无需添加接口路径。例如 OA 前缀为 `/api` 时,基地址以 `/api` 结尾。
|
||||||
|
6. 升级 OA。启动迁移新增 OA 激活申请字段,并在已配置的三个 SDK 库创建 `tab_promote_business_event`。部署账号需要对应的建表/改表权限。应用不会把旧库用户字段自动改造成新商务结构。
|
||||||
|
|
||||||
|
## 接口与行为
|
||||||
|
|
||||||
|
| 功能 | 新商务接口 | OA 行为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 激活列表 | `GET /sw/promoteActivePool/bindList` | 读取 OA 的绑定记录,按当前用户已通过的 SDK 绑定限定范围 |
|
||||||
|
| 激活流水 | `GET /sw/promoteActivePool/bindSpendDetail` | 绑定起算至实际解绑;达标不中止累计;跨平台关联渠道来自申请时的快照,流水和注册读取 OA 使用的 SDK 库 |
|
||||||
|
| 可申请渠道 | `GET /sw/promoteActivePool/pool` | 仅可认领渠道,状态来自 SDK 库;不返回联系方式、注册详情、充值金额或充值详情 |
|
||||||
|
| 本人商务选项 | `GET /sw/promoteActivePool/options` | 仅本人审核通过的 SDK 商务;申请平台必须匹配 |
|
||||||
|
| 申请 | `POST /sw/promoteActivePool/apply` | 后端转发到 `POST /internal/business/activation/apply`,再次校验申请人及其绑定权限 |
|
||||||
|
| 流失记录 | `GET /sw/promoteActivePool/churn` | 按 SDK 渠道换绑事件展示离开原商务的一侧;从池返回不新增回流记录 |
|
||||||
|
| 工作台 | 原工作台概览接口 | 锁定/解除合作按事件发生时间、发生时归属商务统计渠道数,解锁不抹去历史 |
|
||||||
|
|
||||||
|
服务间申请接口以 HMAC-SHA256 校验方法、完整路径、时间戳和请求体,允许前后五分钟时钟偏差。`X-Business-Timestamp` 为 Unix 秒,`X-Business-Signature` 为十六进制签名。签名串为 `METHOD\nPATH\nTIMESTAMP\nBODY`。业务 ID 使用十进制 JSON 字符串。密钥未配置时拒绝服务间申请。
|
||||||
|
|
||||||
|
OA 商务创建/编辑兼容旧请求的 `roleId` 字段,但忽略该值,按 `sys_authorities.business_level` 唯一解析角色。新增账号使用新商务自增 ID、UUID、密码更新时间和默认偏好;锁定同时设置 `business_status` 与 `enable`。调整等级会校验整棵子树并同步角色,不允许把已有 SDK 绑定的账号变成非三级商务。
|
||||||
|
|
||||||
|
OA SDK 绑定审核继续使用原接口。审批遇到重复 SDK 绑定时返回已有账号提示,调用方确认后需传 `confirmDuplicate: true`。通过、驳回、解绑与新商务申请共用平台商务键锁。
|
||||||
|
|
||||||
|
OA 激活审核接口新增 `bindAt`(`YYYY-MM-DD`,省略时取审核当天)、`reason`(驳回必填)及 `restoreCooperation`(明确确认恢复合作时传 `true`)。审核通过只进入观察期,不改变渠道商务归属;累计流水达标后才变更主渠道及下级的商务归属,并从池中移除关联渠道。`ownershipPending` 表示达标后的跨库归属同步尚待完成;定时扫描会重试,同步成功前关联渠道仍保持认领锁定。观察期内主动解绑或到期未达标,只解除认领,不改渠道归属;已达标记录保持原 OA 规则,不能通过观察期解绑接口退回。
|
||||||
|
|
||||||
|
绑定扫描只在观察期内判定达标,迟到的观察期外流水不能使过期申请达标;达标后的展示统计继续累计。外部换绑会结束原激活关系,新商务读取流水时也会依据换绑事件截断,防止读到新归属期间数据。
|
||||||
|
|
||||||
|
累计注册统计仅包含绑定期间新增的主账号;身份证去重开启时跨平台按非空实名身份证合并,缺实名不计入。流水不受身份证去重影响。
|
||||||
|
|
||||||
|
免预警名单自动移出依据 OA 实际入池记录,避免本地流水不足却被 OA 豁免的渠道被误移出。预警列表的实时流失风险指标仍按其原型定义计算,历史预警快照不更改。
|
||||||
|
|
||||||
|
## 历史与校验
|
||||||
|
|
||||||
|
渠道事件从本版本部署后开始记录。SDK 事件表保存首次启用时间;工作台查询或同比区间早于该时间时显示历史不完整,而不把缺失记录当成真实零值。现有 SDK 表仅保留当前状态,不能可靠还原过去每次锁定/解除合作与人工换绑,因此不以当前状态伪造历史事件。既有激活记录继续可读;没有关联快照的历史申请按原平台渠道统计。
|
||||||
|
|
||||||
|
联调应覆盖:新建商务登录、锁定/解锁、SDK 绑定冲突确认、申请→审核→观察期→达标→归属同步、驳回重提、达标后继续累计、到期解绑、跨库暂时不可用后的同步重试,以及不同商务/平台的访问隔离。单元测试使用临时 SQLite 数据库,真实部署还需按上述连接配置验证实际 OA 和 SDK 数据源。
|
||||||
+1
-1
@@ -11,4 +11,4 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ProviderSet is biz providers.
|
// ProviderSet is biz providers.
|
||||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewTeamUsecase, business.NewGoalUsecase, business.NewEffectivenessUsecase, business.NewPerformanceUsecase, business.NewDashboardUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertGovernanceUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
|
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewTeamUsecase, business.NewGoalUsecase, business.NewEffectivenessUsecase, business.NewPerformanceUsecase, business.NewDashboardUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertGovernanceUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewActivationPoolUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
|
||||||
|
|||||||
@@ -19,17 +19,26 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Activation struct {
|
type Activation struct {
|
||||||
|
BusierID uint64
|
||||||
|
CreatedAt *time.Time
|
||||||
|
RegisteredPlayers *int64
|
||||||
|
Channels []ChannelKey
|
||||||
|
MetricsAvailable bool
|
||||||
ID uint64
|
ID uint64
|
||||||
PromoteID *uint64
|
PromoteID *uint64
|
||||||
PlatformName, PromoteAccount, BusierAccount, ApplicantOA, Status, AccumAmount string
|
PlatformName, PromoteAccount, BusierAccount, ApplicantOA, Status, AccumAmount string
|
||||||
BindAt, UnbindAt, ReachAt *time.Time
|
BindAt, UnbindAt, ReachAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Activation) EndAt() *time.Time {
|
// 达标仍处于绑定关系内,流水和注册统计持续到实际解绑。
|
||||||
if a.Status == "reached" && a.ReachAt != nil {
|
func (a *Activation) EndAt() *time.Time { return a.UnbindAt }
|
||||||
return a.ReachAt
|
|
||||||
|
// 换绑即结束原关系,即使 OA 定时扫描还没执行,也不能读取新归属期间的流水。
|
||||||
|
func (a *Activation) EndOnTransfer(at time.Time) {
|
||||||
|
if a.BindAt != nil && !at.Before(*a.BindAt) && (a.UnbindAt == nil || at.Before(*a.UnbindAt)) {
|
||||||
|
a.UnbindAt = &at
|
||||||
|
a.Status = "unbound"
|
||||||
}
|
}
|
||||||
return a.UnbindAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActivationAccount struct {
|
type ActivationAccount struct {
|
||||||
@@ -41,6 +50,7 @@ type ActivationScope struct {
|
|||||||
Accounts []ActivationAccount
|
Accounts []ActivationAccount
|
||||||
}
|
}
|
||||||
type ActivationFilter struct {
|
type ActivationFilter struct {
|
||||||
|
IDCardDedup bool
|
||||||
PlatformNames, Statuses []string
|
PlatformNames, Statuses []string
|
||||||
PromoteAccount, BusierAccount string
|
PromoteAccount, BusierAccount string
|
||||||
}
|
}
|
||||||
@@ -50,12 +60,13 @@ type ActivationPage struct {
|
|||||||
Available bool
|
Available bool
|
||||||
}
|
}
|
||||||
type ActivationSpendQuery struct {
|
type ActivationSpendQuery struct {
|
||||||
|
Channels []ChannelKey
|
||||||
PlatformName string
|
PlatformName string
|
||||||
PromoteID uint64
|
PromoteID uint64
|
||||||
Start int64
|
Start int64
|
||||||
End *int64
|
End *int64
|
||||||
}
|
}
|
||||||
type ActivationSpendItem struct{ GameName, Amount string }
|
type ActivationSpendItem struct{ PlatformName, GameName, Amount string }
|
||||||
type ActivationSpendDetail struct {
|
type ActivationSpendDetail struct {
|
||||||
Activation *Activation
|
Activation *Activation
|
||||||
Items []*ActivationSpendItem
|
Items []*ActivationSpendItem
|
||||||
@@ -70,12 +81,13 @@ type ActivationRepo interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ActivationUsecase struct {
|
type ActivationUsecase struct {
|
||||||
repo ActivationRepo
|
repo ActivationRepo
|
||||||
accounts *AccountUsecase
|
accounts *AccountUsecase
|
||||||
|
preferences *UserPreferenceUsecase
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewActivationUsecase(repo ActivationRepo, accounts *AccountUsecase) *ActivationUsecase {
|
func NewActivationUsecase(repo ActivationRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *ActivationUsecase {
|
||||||
return &ActivationUsecase{repo: repo, accounts: accounts}
|
return &ActivationUsecase{repo: repo, accounts: accounts, preferences: preferences}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *ActivationUsecase) scope(ctx context.Context) (ActivationScope, error) {
|
func (uc *ActivationUsecase) scope(ctx context.Context) (ActivationScope, error) {
|
||||||
@@ -126,6 +138,11 @@ func (uc *ActivationUsecase) List(ctx context.Context, filter ActivationFilter,
|
|||||||
for i, status := range filter.Statuses {
|
for i, status := range filter.Statuses {
|
||||||
filter.Statuses[i] = NormalizeActivationStatus(status)
|
filter.Statuses[i] = NormalizeActivationStatus(status)
|
||||||
}
|
}
|
||||||
|
pref, err := uc.preferences.Current(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
filter.IDCardDedup = pref.IDCardDedup
|
||||||
return uc.repo.List(ctx, scope, filter, options...)
|
return uc.repo.List(ctx, scope, filter, options...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +177,7 @@ func (uc *ActivationUsecase) SpendDetail(ctx context.Context, id uint64) (*Activ
|
|||||||
if strings.TrimSpace(activation.PlatformName) == "" || activation.PromoteID == nil || *activation.PromoteID == 0 {
|
if strings.TrimSpace(activation.PlatformName) == "" || activation.PromoteID == nil || *activation.PromoteID == 0 {
|
||||||
return detail, nil
|
return detail, nil
|
||||||
}
|
}
|
||||||
query := ActivationSpendQuery{PlatformName: activation.PlatformName, PromoteID: *activation.PromoteID, Start: activation.BindAt.Unix()}
|
query := ActivationSpendQuery{Channels: activation.Channels, PlatformName: activation.PlatformName, PromoteID: *activation.PromoteID, Start: activation.BindAt.Unix()}
|
||||||
if end := activation.EndAt(); end != nil {
|
if end := activation.EndAt(); end != nil {
|
||||||
n := end.Unix()
|
n := end.Unix()
|
||||||
query.End = &n
|
query.End = &n
|
||||||
@@ -185,13 +202,13 @@ func (uc *ActivationUsecase) SpendDetail(ctx context.Context, id uint64) (*Activ
|
|||||||
|
|
||||||
func NormalizeActivationStatus(status string) string {
|
func NormalizeActivationStatus(status string) string {
|
||||||
status = strings.TrimSpace(status)
|
status = strings.TrimSpace(status)
|
||||||
if code, ok := map[string]string{"待审核": "pending", "进行中": "in_progress", "已达标": "reached", "已驳回": "rejected", "已解绑": "unbound"}[status]; ok {
|
if code, ok := map[string]string{"待审核": "pending", "审核中": "pending", "进行中": "in_progress", "已达标": "reached", "已驳回": "rejected", "已解绑": "unbound"}[status]; ok {
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
func ActivationStatusLabel(status string) string {
|
func ActivationStatusLabel(status string) string {
|
||||||
if label, ok := map[string]string{"pending": "待审核", "in_progress": "进行中", "reached": "已达标", "rejected": "已驳回", "unbound": "已解绑"}[status]; ok {
|
if label, ok := map[string]string{"pending": "审核中", "in_progress": "进行中", "reached": "已达标", "rejected": "已驳回", "unbound": "已解绑"}[status]; ok {
|
||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
return status
|
return status
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActivationPoolFilter struct {
|
||||||
|
Sources []string
|
||||||
|
Name, ChannelType, Status, Target string
|
||||||
|
Begin, End *time.Time
|
||||||
|
}
|
||||||
|
type ActivationPoolChannel struct {
|
||||||
|
ID, PromoteID uint64
|
||||||
|
SourceDB, Name, ChannelType, Status string
|
||||||
|
NoLoginDays *int64
|
||||||
|
EnteredAt time.Time
|
||||||
|
Rejected bool
|
||||||
|
RejectReason string
|
||||||
|
RejectedAt *time.Time
|
||||||
|
}
|
||||||
|
type ActivationPoolPage struct {
|
||||||
|
Rows []*ActivationPoolChannel
|
||||||
|
Total int64
|
||||||
|
Available bool
|
||||||
|
}
|
||||||
|
type ActivationBindingOption struct {
|
||||||
|
SourceDB, Account string
|
||||||
|
ID uint64
|
||||||
|
Level int
|
||||||
|
}
|
||||||
|
type ActivationApplication struct {
|
||||||
|
UserID uint
|
||||||
|
PoolID, BusierID uint64
|
||||||
|
SourceDB string
|
||||||
|
}
|
||||||
|
type ActivationChurn struct {
|
||||||
|
ID, PromoteID uint64
|
||||||
|
SourceDB, Name, From, To, Reason, Operator string
|
||||||
|
At time.Time
|
||||||
|
ToPool bool
|
||||||
|
}
|
||||||
|
type ActivationChurnPage struct {
|
||||||
|
Rows []*ActivationChurn
|
||||||
|
Total int64
|
||||||
|
Available bool
|
||||||
|
}
|
||||||
|
type ActivationPoolRepo interface {
|
||||||
|
Pool(context.Context, ActivationPoolFilter, ...ListOption) (*ActivationPoolPage, error)
|
||||||
|
PoolChannel(context.Context, uint64) (*ActivationPoolChannel, error)
|
||||||
|
BindingOptions(context.Context, uint) ([]*ActivationBindingOption, error)
|
||||||
|
Churn(context.Context, ActivationPoolFilter, ...ListOption) (*ActivationChurnPage, error)
|
||||||
|
}
|
||||||
|
type ActivationGateway interface {
|
||||||
|
Apply(context.Context, ActivationApplication) error
|
||||||
|
}
|
||||||
|
type ActivationPoolUsecase struct {
|
||||||
|
repo ActivationPoolRepo
|
||||||
|
gateway ActivationGateway
|
||||||
|
accounts *AccountUsecase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewActivationPoolUsecase(repo ActivationPoolRepo, gateway ActivationGateway, accounts *AccountUsecase) *ActivationPoolUsecase {
|
||||||
|
return &ActivationPoolUsecase{repo: repo, gateway: gateway, accounts: accounts}
|
||||||
|
}
|
||||||
|
func (uc *ActivationPoolUsecase) Pool(ctx context.Context, f ActivationPoolFilter, o ...ListOption) (*ActivationPoolPage, error) {
|
||||||
|
if _, _, err := uc.accounts.actor(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return uc.repo.Pool(ctx, f, o...)
|
||||||
|
}
|
||||||
|
func (uc *ActivationPoolUsecase) Options(ctx context.Context) ([]*ActivationBindingOption, error) {
|
||||||
|
a, _, err := uc.accounts.actor(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return uc.repo.BindingOptions(ctx, a.UserID)
|
||||||
|
}
|
||||||
|
func (uc *ActivationPoolUsecase) Apply(ctx context.Context, poolID, busierID uint64, source string) error {
|
||||||
|
a, _, err := uc.accounts.actor(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
source, err = NormalizePlatform(source)
|
||||||
|
if err != nil || poolID == 0 || busierID == 0 {
|
||||||
|
return ErrActivationInput
|
||||||
|
}
|
||||||
|
options, err := uc.repo.BindingOptions(ctx, a.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
owned := false
|
||||||
|
for _, o := range options {
|
||||||
|
if o.SourceDB == source && o.ID == busierID {
|
||||||
|
owned = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return ErrActivationScope
|
||||||
|
}
|
||||||
|
channel, err := uc.repo.PoolChannel(ctx, poolID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(channel.SourceDB, source) {
|
||||||
|
return ErrActivationInput
|
||||||
|
}
|
||||||
|
return uc.gateway.Apply(ctx, ActivationApplication{UserID: a.UserID, PoolID: poolID, BusierID: busierID, SourceDB: source})
|
||||||
|
}
|
||||||
|
func (uc *ActivationPoolUsecase) Churn(ctx context.Context, f ActivationPoolFilter, o ...ListOption) (*ActivationChurnPage, error) {
|
||||||
|
if _, _, err := uc.accounts.actor(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return uc.repo.Churn(ctx, f, o...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type activationPoolFake struct {
|
||||||
|
ActivationPoolRepo
|
||||||
|
options []*ActivationBindingOption
|
||||||
|
requested uint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *activationPoolFake) BindingOptions(_ context.Context, id uint) ([]*ActivationBindingOption, error) {
|
||||||
|
r.requested = id
|
||||||
|
return r.options, nil
|
||||||
|
}
|
||||||
|
func (r *activationPoolFake) PoolChannel(context.Context, uint64) (*ActivationPoolChannel, error) {
|
||||||
|
return &ActivationPoolChannel{ID: 10, SourceDB: "lx"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type activationGatewayFake struct {
|
||||||
|
calls int
|
||||||
|
request ActivationApplication
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *activationGatewayFake) Apply(_ context.Context, a ActivationApplication) error {
|
||||||
|
g.calls++
|
||||||
|
g.request = a
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivationApplicationUsesAuthenticatedIdentityAndOwnedBinding(t *testing.T) {
|
||||||
|
accountUC, _ := activationFixture()
|
||||||
|
repo := &activationPoolFake{options: []*ActivationBindingOption{{ID: 9, SourceDB: "lx", Account: "own"}}}
|
||||||
|
gateway := &activationGatewayFake{}
|
||||||
|
uc := NewActivationPoolUsecase(repo, gateway, accountUC.accounts)
|
||||||
|
ctx := accountContext(8, 902)
|
||||||
|
if err := uc.Apply(ctx, 10, 9, "lx"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gateway.request.UserID != 8 || repo.requested != 8 || gateway.request.BusierID != 9 {
|
||||||
|
t.Fatal("applicant came from request rather than session")
|
||||||
|
}
|
||||||
|
for _, a := range []ActivationApplication{{PoolID: 10, BusierID: 99, SourceDB: "lx"}, {PoolID: 10, BusierID: 9, SourceDB: "hw"}, {PoolID: 0, BusierID: 9, SourceDB: "lx"}} {
|
||||||
|
if err := uc.Apply(ctx, a.PoolID, a.BusierID, a.SourceDB); err == nil {
|
||||||
|
t.Fatal("unowned application accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := uc.Apply(context.Background(), 10, 9, "lx"); err == nil {
|
||||||
|
t.Fatal("anonymous request accepted")
|
||||||
|
}
|
||||||
|
if gateway.calls != 1 {
|
||||||
|
t.Fatal("invalid requests reached OA")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ func (r *activationRepoFake) Spend(_ context.Context, q ActivationSpendQuery) ([
|
|||||||
func activationFixture() (*ActivationUsecase, *activationRepoFake) {
|
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"}}}
|
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: []ActivationAccount{{"lx", " Own "}, {"LX", "own"}, {"hw", "Other"}, {"lx", ""}, {"unknown", "Own"}}}
|
r := &activationRepoFake{accounts: []ActivationAccount{{"lx", " Own "}, {"LX", "own"}, {"hw", "Other"}, {"lx", ""}, {"unknown", "Own"}}}
|
||||||
return NewActivationUsecase(r, NewAccountUsecase(a)), r
|
return NewActivationUsecase(r, NewAccountUsecase(a), NewUserPreferenceUsecase(&preferenceRepoFake{})), r
|
||||||
}
|
}
|
||||||
func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) {
|
func TestActivationScopeIncludesOwnAccountsOnly(t *testing.T) {
|
||||||
uc, r := activationFixture()
|
uc, r := activationFixture()
|
||||||
@@ -62,8 +62,8 @@ func TestActivationSpendScopeCutoffAndExactMoney(t *testing.T) {
|
|||||||
r.record = &Activation{ID: 10, PromoteID: &id, PlatformName: "lx", BusierAccount: "OWN", BindAt: &start, ReachAt: &reach, UnbindAt: &unbind, Status: "reached"}
|
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"}}
|
r.items = []*ActivationSpendItem{{Amount: "9007199254740993.01"}, {Amount: "0.09"}}
|
||||||
detail, err := uc.SpendDetail(accountContext(8, 902), 10)
|
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() {
|
if err != nil || detail.TotalAmount != "9007199254740993.10" || r.query.Start != start.Unix() || r.query.End == nil || *r.query.End != unbind.Unix() {
|
||||||
t.Fatalf("达标截止或精确金额错误: %+v %+v %v", detail, r.query, err)
|
t.Fatalf("绑定截止或精确金额错误: %+v %+v %v", detail, r.query, err)
|
||||||
}
|
}
|
||||||
r.record.Status = "unbound"
|
r.record.Status = "unbound"
|
||||||
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != nil || *r.query.End != unbind.Unix() {
|
if _, err := uc.SpendDetail(accountContext(8, 902), 10); err != nil || *r.query.End != unbind.Unix() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type DashboardMetrics struct {
|
|||||||
Pay string
|
Pay string
|
||||||
}
|
}
|
||||||
type DashboardData struct {
|
type DashboardData struct {
|
||||||
|
EventsIncomplete bool
|
||||||
Current, Previous DashboardMetrics
|
Current, Previous DashboardMetrics
|
||||||
Available bool
|
Available bool
|
||||||
EventsAvailable bool
|
EventsAvailable bool
|
||||||
@@ -84,8 +85,7 @@ func (uc *DashboardUsecase) Overview(ctx context.Context, sources []string, peri
|
|||||||
}
|
}
|
||||||
r.Cards[card.key] = value
|
r.Cards[card.key] = value
|
||||||
}
|
}
|
||||||
// The imported SDK channel table has only current state; historical event
|
// OA 在渠道更新事务中记录事件;按事件发生时间统计,不读取当前锁定状态。
|
||||||
// counts must stay unavailable until a timestamped event source is connected.
|
|
||||||
for _, card := range []struct {
|
for _, card := range []struct {
|
||||||
key string
|
key string
|
||||||
current, previous int64
|
current, previous int64
|
||||||
@@ -93,6 +93,9 @@ func (uc *DashboardUsecase) Overview(ctx context.Context, sources []string, peri
|
|||||||
label := "较上一同期"
|
label := "较上一同期"
|
||||||
if !data.EventsAvailable {
|
if !data.EventsAvailable {
|
||||||
label = "事件数据未接入"
|
label = "事件数据未接入"
|
||||||
|
if data.EventsIncomplete {
|
||||||
|
label = "所选区间事件历史不完整"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
value, err := workbenchCard(num(card.current), num(card.previous), label, data.Available && data.EventsAvailable)
|
value, err := workbenchCard(num(card.current), num(card.previous), label, data.Available && data.EventsAvailable)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBusinessOAEnvironmentAndRuntimeMerge(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte("server:\n http:\n addr: ':8000'\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("KRA_BUSINESS_OA_URL", "http://oa.test/api")
|
||||||
|
t.Setenv("KRA_BUSINESS_OA_KEY", "test-only-key")
|
||||||
|
value, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value.BusinessOA == nil || value.BusinessOA.URL != "http://oa.test/api" || value.BusinessOA.Key != "test-only-key" {
|
||||||
|
t.Fatal("environment-only OA configuration not loaded")
|
||||||
|
}
|
||||||
|
merged := MergeRuntimeConfig(value, &Config{Admin: &Admin{}})
|
||||||
|
merged.BusinessOA.Key = "changed"
|
||||||
|
if value.BusinessOA.Key != "test-only-key" {
|
||||||
|
t.Fatal("configuration snapshot shared a pointer")
|
||||||
|
}
|
||||||
|
serialized, err := json.Marshal(value)
|
||||||
|
if err != nil || strings.Contains(string(serialized), "test-only-key") {
|
||||||
|
t.Fatal("service credential leaked through public configuration")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ func MergeRuntimeConfig(current, next *Config) *Config {
|
|||||||
}
|
}
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
if merged.BusinessOA == nil {
|
||||||
|
merged.BusinessOA = clonePtr(current.BusinessOA)
|
||||||
|
}
|
||||||
if merged.Server == nil {
|
if merged.Server == nil {
|
||||||
merged.Server = clonePtr(current.Server)
|
merged.Server = clonePtr(current.Server)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,8 @@ func newViper(path string) *viper.Viper {
|
|||||||
v.SetEnvPrefix("KRA")
|
v.SetEnvPrefix("KRA")
|
||||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
|
||||||
v.AutomaticEnv()
|
v.AutomaticEnv()
|
||||||
|
_ = v.BindEnv("business_oa.url")
|
||||||
|
_ = v.BindEnv("business_oa.key")
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +387,7 @@ func cloneConfig(value *Config) *Config {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
copied := *value
|
copied := *value
|
||||||
|
copied.BusinessOA = clonePtr(value.BusinessOA)
|
||||||
copied.Server = clonePtr(value.Server)
|
copied.Server = clonePtr(value.Server)
|
||||||
if copied.Server != nil {
|
if copied.Server != nil {
|
||||||
copied.Server.HTTP = clonePtr(value.Server.HTTP)
|
copied.Server.HTTP = clonePtr(value.Server.HTTP)
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import "time"
|
|||||||
// It intentionally uses ordinary Go types so configuration is independent of
|
// It intentionally uses ordinary Go types so configuration is independent of
|
||||||
// protobuf generation and can be consumed by every internal module.
|
// protobuf generation and can be consumed by every internal module.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server *Server `mapstructure:"server" yaml:"server" json:"server"`
|
BusinessOA *BusinessOA `mapstructure:"business_oa" yaml:"business_oa" json:"-"`
|
||||||
Data *Data `mapstructure:"data" yaml:"data" json:"data"`
|
Server *Server `mapstructure:"server" yaml:"server" json:"server"`
|
||||||
Admin *Admin `mapstructure:"admin" yaml:"admin" json:"admin"`
|
Data *Data `mapstructure:"data" yaml:"data" json:"data"`
|
||||||
|
Admin *Admin `mapstructure:"admin" yaml:"admin" json:"admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -224,3 +225,9 @@ type ObjectStore struct {
|
|||||||
ForcePathStyle bool `mapstructure:"force_path_style" yaml:"force_path_style" json:"force_path_style"`
|
ForcePathStyle bool `mapstructure:"force_path_style" yaml:"force_path_style" json:"force_path_style"`
|
||||||
AccountID string `mapstructure:"account_id" yaml:"account_id" json:"account_id"`
|
AccountID string `mapstructure:"account_id" yaml:"account_id" json:"account_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BusinessOA.URL 包含 OA 的路由前缀;Key 与 OA 服务间密钥一致。
|
||||||
|
type BusinessOA struct {
|
||||||
|
URL string `mapstructure:"url" yaml:"url" json:"url"`
|
||||||
|
Key string `mapstructure:"key" yaml:"key" json:"-"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import (
|
|||||||
|
|
||||||
// accountPO 是 sys_users 的商务投影,表结构仍由 system 模块维护。
|
// accountPO 是 sys_users 的商务投影,表结构仍由 system 模块维护。
|
||||||
type accountPO struct {
|
type accountPO struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
Remark string `gorm:"size:500"`
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
CreatedAt, UpdatedAt time.Time
|
CreatedAt, UpdatedAt time.Time
|
||||||
DeletedAt gorm.DeletedAt
|
DeletedAt gorm.DeletedAt
|
||||||
UUID, Username, Password, NickName, RealName, Phone, Email string
|
UUID, Username, Password, NickName, RealName, Phone, Email string
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package business
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,6 +13,9 @@ import (
|
|||||||
|
|
||||||
// OA 表只读,不由新商务的系统迁移创建或改写。
|
// OA 表只读,不由新商务的系统迁移创建或改写。
|
||||||
type activationPO struct {
|
type activationPO struct {
|
||||||
|
BusierID uint64
|
||||||
|
CreatedAt *time.Time
|
||||||
|
RelatedChannels string
|
||||||
ID uint64 `gorm:"primaryKey"`
|
ID uint64 `gorm:"primaryKey"`
|
||||||
PromoteID *uint64
|
PromoteID *uint64
|
||||||
PlatformName, PromoteAccount, BusierAccount, ApplicantOA, Status string
|
PlatformName, PromoteAccount, BusierAccount, ApplicantOA, Status string
|
||||||
@@ -25,7 +29,20 @@ func toBizActivation(po activationPO) *business.Activation {
|
|||||||
if po.AccumAmount != nil {
|
if po.AccumAmount != nil {
|
||||||
amount = *po.AccumAmount
|
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}
|
row := &business.Activation{BusierID: po.BusierID, CreatedAt: po.CreatedAt, 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}
|
||||||
|
var channels []struct {
|
||||||
|
PlatformName string `json:"platformName"`
|
||||||
|
PromoteID uint64 `json:"promoteId"`
|
||||||
|
}
|
||||||
|
if po.RelatedChannels != "" {
|
||||||
|
if json.Unmarshal([]byte(po.RelatedChannels), &channels) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ch := range channels {
|
||||||
|
row.Channels = append(row.Channels, business.ChannelKey{SourceDB: ch.PlatformName, ID: ch.PromoteID})
|
||||||
|
}
|
||||||
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
type activationRepo struct {
|
type activationRepo struct {
|
||||||
@@ -99,7 +116,41 @@ func (r *activationRepo) List(ctx context.Context, scope business.ActivationScop
|
|||||||
return nil, business.ErrActivationSource
|
return nil, business.ErrActivationSource
|
||||||
}
|
}
|
||||||
for _, po := range pos {
|
for _, po := range pos {
|
||||||
page.Rows = append(page.Rows, toBizActivation(po))
|
row := toBizActivation(po)
|
||||||
|
if row == nil {
|
||||||
|
return nil, business.ErrActivationSource
|
||||||
|
}
|
||||||
|
if row.BindAt != nil {
|
||||||
|
if err := r.applyTransferEnd(ctx, row); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
query := activationMetricQuery(row)
|
||||||
|
available, err := r.activationMetricsAvailable(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if available {
|
||||||
|
items, err := r.Spend(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
amounts := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
amounts = append(amounts, item.Amount)
|
||||||
|
}
|
||||||
|
total, err := sumActivationMoney(amounts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row.AccumAmount = total
|
||||||
|
row.MetricsAvailable = true
|
||||||
|
}
|
||||||
|
row.RegisteredPlayers, err = r.registered(ctx, query, filter.IDCardDedup)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page.Rows = append(page.Rows, row)
|
||||||
}
|
}
|
||||||
return page, nil
|
return page, nil
|
||||||
}
|
}
|
||||||
@@ -116,49 +167,12 @@ func (r *activationRepo) Find(ctx context.Context, id uint64) (*business.Activat
|
|||||||
}
|
}
|
||||||
return nil, business.ErrActivationSource
|
return nil, business.ErrActivationSource
|
||||||
}
|
}
|
||||||
return toBizActivation(po), nil
|
row := toBizActivation(po)
|
||||||
}
|
if row == nil {
|
||||||
|
return nil, business.ErrActivationSource
|
||||||
func (r *activationRepo) Spend(ctx context.Context, query business.ActivationSpendQuery) ([]*business.ActivationSpendItem, error) {
|
}
|
||||||
db := r.data.DB().WithContext(ctx)
|
if err := r.applyTransferEnd(ctx, row); err != nil {
|
||||||
available, err := businessTableAvailable(db, "tab_promote")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !available {
|
return row, nil
|
||||||
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,159 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type activationTestSources map[string]*gorm.DB
|
||||||
|
|
||||||
|
func (s activationTestSources) Database(name string) (*gorm.DB, error) {
|
||||||
|
db := s[name]
|
||||||
|
if db == nil {
|
||||||
|
return nil, errors.New("missing source")
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivationCrossPlatformMetricsAndIdentityDedup(t *testing.T) {
|
||||||
|
db, oa, repo := prepareActivations(t)
|
||||||
|
for _, sql := range []string{
|
||||||
|
"CREATE TABLE tab_user(id INTEGER,source_db TEXT,promote_id INTEGER,puid INTEGER,register_time TEXT,idcard TEXT)",
|
||||||
|
"INSERT INTO tab_user VALUES(1,'lx',100,0,'1000','same'),(2,'lx',101,0,'1500','same'),(1,'hw',100,0,'1500','same'),(3,'lx',102,0,'1600',''),(4,'lx',103,0,'1600','outside'),(5,'lx',100,1,'1600','child-account'),(6,'lx',100,0,'999','before'),(7,'lx',100,0,'2001','after')",
|
||||||
|
} {
|
||||||
|
if err := db.Exec(sql).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := oa.Model(&activationPO{}).Where("id=1").Update("related_channels", `[{"platformName":"lx","promoteId":100},{"platformName":"hw","promoteId":100},{"platformName":"lx","promoteId":101}]`).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repo.(*activationRepo).databases = activationSDKFixtures(t, db, oa)
|
||||||
|
page, err := repo.List(context.Background(), business.ActivationScope{All: true}, business.ActivationFilter{Statuses: []string{"reached"}, IDCardDedup: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
row := page.Rows[0]
|
||||||
|
if row.RegisteredPlayers == nil || *row.RegisteredPlayers != 1 || row.AccumAmount != "1032.60" {
|
||||||
|
t.Fatalf("cross platform dedup/tree overlap: %+v", row)
|
||||||
|
}
|
||||||
|
page, err = repo.List(context.Background(), business.ActivationScope{All: true}, business.ActivationFilter{Statuses: []string{"reached"}})
|
||||||
|
if err != nil || *page.Rows[0].RegisteredPlayers != 4 {
|
||||||
|
t.Fatalf("account count: %+v %v", page, err)
|
||||||
|
}
|
||||||
|
if err := oa.Model(&activationPO{}).Where("id=1").Update("unbind_at", nil).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
page, err = repo.List(context.Background(), business.ActivationScope{All: true}, business.ActivationFilter{Statuses: []string{"reached"}})
|
||||||
|
if err != nil || page.Rows[0].AccumAmount != "1732.60" || *page.Rows[0].RegisteredPlayers != 5 {
|
||||||
|
t.Fatalf("reached should keep accumulating: %+v %v", page, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivationPoolFiltersAndRedactedProjection(t *testing.T) {
|
||||||
|
db, oa, _ := prepareActivations(t)
|
||||||
|
if err := oa.AutoMigrate(&activationPoolPO{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, sql := range []string{"ALTER TABLE tab_promote ADD COLUMN lock_status INTEGER", "ALTER TABLE tab_promote ADD COLUMN last_login_time INTEGER", "ALTER TABLE tab_promote ADD COLUMN create_time INTEGER", "UPDATE tab_promote SET status=1,lock_status=0,create_time=1000"} {
|
||||||
|
if err := db.Exec(sql).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sql := range []string{"ALTER TABLE sys_promote_active_pool_bind ADD COLUMN pool_record_id INTEGER", "ALTER TABLE sys_promote_active_pool_bind ADD COLUMN reject_reason TEXT", "ALTER TABLE sys_promote_active_pool_bind ADD COLUMN review_at DATETIME"} {
|
||||||
|
if err := oa.Exec(sql).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
rows := []activationPoolPO{{ID: 1, PlatformName: "lx", PromoteID: 100, PromoteAccount: "Open", RegisterType: 1, CreatedAt: now}, {ID: 2, PlatformName: "hw", PromoteID: 100, PromoteAccount: "Claimed", ClaimFlag: 1, CreatedAt: now}, {ID: 3, PlatformName: "lx", PromoteID: 103, PromoteAccount: "Removed", DeletedAt: &now, CreatedAt: now}}
|
||||||
|
if err := oa.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := NewActivationPoolRepo(testData{db}, activationSDKFixtures(t, db, oa))
|
||||||
|
page, err := r.Pool(context.Background(), business.ActivationPoolFilter{Name: "Open", ChannelType: "公会", Status: "正常"}, business.ListLimit(10))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if page.Total != 1 || page.Rows[0].Name != "Open" || page.Rows[0].Status != "正常" {
|
||||||
|
t.Fatalf("pool filters: %+v", page)
|
||||||
|
}
|
||||||
|
if _, err := r.PoolChannel(context.Background(), 2); err != business.ErrActivationNotFound {
|
||||||
|
t.Fatal("claimed pool may not be applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardEventsUseTimeAndHistoricalOwner(t *testing.T) {
|
||||||
|
db := prepareAccounts(t)
|
||||||
|
sdk := openTestDB(t)
|
||||||
|
if err := sdk.AutoMigrate(&channelEventPO{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Exec("INSERT INTO sys_users(id,username,level,id_path) VALUES(7,'owner',3,'0'); INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(7,'lx',10,'0')").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
begin := time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)
|
||||||
|
w := business.DashboardWindow{Begin: begin, End: begin, PreviousBegin: begin.AddDate(0, 0, -1), PreviousEnd: begin.AddDate(0, 0, -1)}
|
||||||
|
events := []channelEventPO{{ID: 1, PromoteID: 1, PromoteLevel: 1, Kind: "locked", OldBusierID: 10, OccurredAt: begin}, {ID: 2, PromoteID: 1, PromoteLevel: 1, Kind: "locked", OldBusierID: 10, OccurredAt: begin.Add(time.Hour)}, {ID: 3, PromoteID: 2, PromoteLevel: 2, Kind: "terminated", OldBusierID: 10, OccurredAt: begin}, {ID: 4, PromoteID: 3, PromoteLevel: 1, Kind: "locked", OldBusierID: 20, OccurredAt: begin}, {ID: 5, PromoteID: 1, PromoteLevel: 1, Kind: "locked", OldBusierID: 10, OccurredAt: begin.Add(-time.Hour)}, {ID: 6, PromoteID: 1, PromoteLevel: 1, Kind: "terminated", OldBusierID: 10, OccurredAt: begin.AddDate(0, 0, 1)}}
|
||||||
|
if err := sdk.Create(&events).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := &dashboardRepo{data: testData{db}, databases: activationTestSources{"lx": sdk}}
|
||||||
|
out := &business.DashboardData{}
|
||||||
|
if err := r.events(context.Background(), business.AnalyticsScope{Viewer: business.UserScope{OnlySelf: true, SelfID: 7}, IncludeSub: true}, []string{"lx"}, w, out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !out.EventsAvailable || out.Current.LockedChannels != 1 || out.Previous.LockedChannels != 1 || out.Current.TerminatedChannels != 1 {
|
||||||
|
t.Fatalf("events: %+v", out)
|
||||||
|
}
|
||||||
|
if err := sdk.Create(&channelEventPO{Kind: "coverage", OccurredAt: begin}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
incomplete := &business.DashboardData{}
|
||||||
|
if err := r.events(context.Background(), business.AnalyticsScope{Viewer: business.UserScope{All: true}}, []string{"lx"}, w, incomplete); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if incomplete.EventsAvailable || !incomplete.EventsIncomplete {
|
||||||
|
t.Fatal("missing historical coverage was reported as real zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActivationStopsAtExternalTransferBeforeOAScan(t *testing.T) {
|
||||||
|
db, oa, _ := prepareActivations(t)
|
||||||
|
sources := activationSDKFixtures(t, db, oa)
|
||||||
|
sdk := sources["lx"]
|
||||||
|
if err := sdk.AutoMigrate(&channelEventPO{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
end := time.Unix(1600, 0).UTC()
|
||||||
|
if err := oa.Model(&activationPO{}).Where("id=1").Updates(map[string]any{"reach_at": time.Unix(1200, 0).UTC(), "unbind_at": nil}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := sdk.Create(&channelEventPO{Kind: "transfer", PromoteID: 100, OldBusierAccount: "Own", OccurredAt: end}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repo := NewActivationRepo(testData{db}, sources)
|
||||||
|
row, err := repo.Find(context.Background(), 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if row.EndAt() == nil || !row.EndAt().Equal(end) {
|
||||||
|
t.Fatal("old owner can read past transfer")
|
||||||
|
}
|
||||||
|
items, err := repo.Spend(context.Background(), activationMetricQuery(row))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
values := []string{}
|
||||||
|
for _, item := range items {
|
||||||
|
values = append(values, item.Amount)
|
||||||
|
}
|
||||||
|
sum, err := sumActivationMoney(values)
|
||||||
|
if err != nil || sum != "13.40" {
|
||||||
|
t.Fatalf("post-transfer spend leaked: %s %v", sum, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
"math/big"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *activationRepo) applyTransferEnd(ctx context.Context, row *business.Activation) error {
|
||||||
|
if row.BindAt == nil || row.PromoteID == nil || (row.Status != "reached" && row.ReachAt == nil) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db, err := r.databases.Database(row.PlatformName)
|
||||||
|
if err != nil || db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db = db.WithContext(ctx)
|
||||||
|
ok, err := businessTableAvailable(db, "tab_promote_business_event")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
begin := *row.BindAt
|
||||||
|
if row.ReachAt != nil && row.ReachAt.After(begin) {
|
||||||
|
begin = *row.ReachAt
|
||||||
|
}
|
||||||
|
q := db.Table("tab_promote_business_event").Where("kind='transfer' AND promote_id=? AND occurred_at>=?", *row.PromoteID, begin)
|
||||||
|
if row.BusierID > 0 {
|
||||||
|
q = q.Where("old_busier_id=?", row.BusierID)
|
||||||
|
} else {
|
||||||
|
q = q.Where("LOWER(old_busier_account)=LOWER(?)", row.BusierAccount)
|
||||||
|
}
|
||||||
|
var rows []struct{ OccurredAt time.Time }
|
||||||
|
if err := q.Select("occurred_at").Order("occurred_at,id").Limit(1).Find(&rows).Error; err != nil {
|
||||||
|
return business.ErrActivationSource
|
||||||
|
}
|
||||||
|
if len(rows) > 0 {
|
||||||
|
row.EndOnTransfer(rows[0].OccurredAt)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func activationMetricQuery(row *business.Activation) business.ActivationSpendQuery {
|
||||||
|
q := business.ActivationSpendQuery{PlatformName: row.PlatformName, Channels: row.Channels, Start: row.BindAt.Unix()}
|
||||||
|
if row.PromoteID != nil {
|
||||||
|
q.PromoteID = *row.PromoteID
|
||||||
|
}
|
||||||
|
if end := row.EndAt(); end != nil {
|
||||||
|
n := end.Unix()
|
||||||
|
q.End = &n
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
func sumActivationMoney(values []string) (string, error) {
|
||||||
|
total := new(big.Rat)
|
||||||
|
for _, v := range values {
|
||||||
|
n, ok := new(big.Rat).SetString(v)
|
||||||
|
if !ok {
|
||||||
|
return "", business.ErrStorage
|
||||||
|
}
|
||||||
|
total.Add(total, n)
|
||||||
|
}
|
||||||
|
return total.FloatString(2), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活数据与 OA 使用同一 SDK 数据源,避免商务报表副本延迟/测试副本不同导致金额不一致。
|
||||||
|
func activationRoots(q business.ActivationSpendQuery) map[string][]uint64 {
|
||||||
|
roots := map[string][]uint64{}
|
||||||
|
keys := q.Channels
|
||||||
|
if len(keys) == 0 {
|
||||||
|
keys = []business.ChannelKey{{SourceDB: q.PlatformName, ID: q.PromoteID}}
|
||||||
|
}
|
||||||
|
for _, key := range keys {
|
||||||
|
if key.ID > 0 {
|
||||||
|
roots[key.SourceDB] = append(roots[key.SourceDB], key.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
func (r *activationRepo) activationMetricsAvailable(ctx context.Context, q business.ActivationSpendQuery) (bool, error) {
|
||||||
|
for source := range activationRoots(q) {
|
||||||
|
db, err := r.databases.Database(source)
|
||||||
|
if err != nil || db == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
ok, err := businessTablesAvailable(db.WithContext(ctx), "tab_promote", "tab_spend")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
func nativeActivationTree(db *gorm.DB, roots []uint64) *gorm.DB {
|
||||||
|
return db.Table("tab_promote p").Where("p.id IN ? OR p.top_promote_id IN ? OR p.parent_id IN ?", roots, roots, roots)
|
||||||
|
}
|
||||||
|
func (r *activationRepo) Spend(ctx context.Context, q business.ActivationSpendQuery) ([]*business.ActivationSpendItem, error) {
|
||||||
|
out := []*business.ActivationSpendItem{}
|
||||||
|
for source, roots := range activationRoots(q) {
|
||||||
|
db, err := r.databases.Database(source)
|
||||||
|
if err != nil || db == nil {
|
||||||
|
return nil, business.ErrActivationMetrics
|
||||||
|
}
|
||||||
|
db = db.WithContext(ctx)
|
||||||
|
tree := nativeActivationTree(db, roots)
|
||||||
|
var n int64
|
||||||
|
if err := tree.Count(&n).Error; err != nil {
|
||||||
|
return nil, business.ErrActivationMetrics
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query := db.Table("tab_spend s").Where("s.pay_status=1 AND s.pay_time>=?", q.Start).Where("EXISTS (?)", tree.Select("1").Where("p.id=s.promote_id"))
|
||||||
|
if q.End != nil {
|
||||||
|
query = query.Where("s.pay_time<=?", *q.End)
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
GameID uint64
|
||||||
|
GameName, Amount string
|
||||||
|
}
|
||||||
|
if err := query.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("s.game_id").Scan(&rows).Error; err != nil {
|
||||||
|
return nil, business.ErrActivationMetrics
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, &business.ActivationSpendItem{PlatformName: source, GameName: row.GameName, Amount: row.Amount})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
a, okA := new(big.Rat).SetString(out[i].Amount)
|
||||||
|
b, okB := new(big.Rat).SetString(out[j].Amount)
|
||||||
|
if okA && okB && a.Cmp(b) != 0 {
|
||||||
|
return a.Cmp(b) > 0
|
||||||
|
}
|
||||||
|
if out[i].PlatformName != out[j].PlatformName {
|
||||||
|
return out[i].PlatformName < out[j].PlatformName
|
||||||
|
}
|
||||||
|
return out[i].GameName < out[j].GameName
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (r *activationRepo) registered(ctx context.Context, q business.ActivationSpendQuery, dedup bool) (*int64, error) {
|
||||||
|
total := int64(0)
|
||||||
|
identities := map[string]bool{}
|
||||||
|
for source, roots := range activationRoots(q) {
|
||||||
|
db, err := r.databases.Database(source)
|
||||||
|
if err != nil || db == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
db = db.WithContext(ctx)
|
||||||
|
ok, err := businessTablesAvailable(db, "tab_user", "tab_promote")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cast := "CAST(r.register_time AS INTEGER)"
|
||||||
|
if db.Dialector.Name() == "mysql" {
|
||||||
|
cast = "CAST(r.register_time AS UNSIGNED)"
|
||||||
|
}
|
||||||
|
query := db.Table("tab_user r").Where("r.puid=0 AND "+cast+">=?", q.Start).Where("EXISTS (?)", nativeActivationTree(db, roots).Select("1").Where("p.id=r.promote_id"))
|
||||||
|
if q.End != nil {
|
||||||
|
query = query.Where(cast+"<=?", *q.End)
|
||||||
|
}
|
||||||
|
if dedup {
|
||||||
|
var cards []string
|
||||||
|
if err := query.Where("TRIM(COALESCE(r.idcard,''))<>''").Distinct("TRIM(r.idcard)").Pluck("TRIM(r.idcard)", &cards).Error; err != nil {
|
||||||
|
return nil, business.ErrActivationMetrics
|
||||||
|
}
|
||||||
|
for _, card := range cards {
|
||||||
|
identities[card] = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var count int64
|
||||||
|
if err := query.Distinct("r.id").Count(&count).Error; err != nil {
|
||||||
|
return nil, business.ErrActivationMetrics
|
||||||
|
}
|
||||||
|
total += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dedup {
|
||||||
|
total = int64(len(identities))
|
||||||
|
}
|
||||||
|
return &total, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
)
|
||||||
|
|
||||||
|
type activationPoolPO struct {
|
||||||
|
ID, PromoteID uint64
|
||||||
|
PlatformName, PromoteAccount string
|
||||||
|
RegisterType int
|
||||||
|
ClaimFlag int
|
||||||
|
CreatedAt time.Time
|
||||||
|
DeletedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (activationPoolPO) TableName() string { return "sys_platform_promote_active_pool" }
|
||||||
|
|
||||||
|
type activationPoolRepo struct {
|
||||||
|
data Provider
|
||||||
|
databases PlatformProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewActivationPoolRepo(data Provider, databases PlatformProvider) business.ActivationPoolRepo {
|
||||||
|
return &activationPoolRepo{data: data, databases: databases}
|
||||||
|
}
|
||||||
|
func (r *activationPoolRepo) 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)
|
||||||
|
ok, err := businessTableAvailable(db, "sys_platform_promote_active_pool")
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil, business.ErrActivationSource
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
func toBizPool(p activationPoolPO) *business.ActivationPoolChannel {
|
||||||
|
kind := "个人"
|
||||||
|
if p.RegisterType == 1 {
|
||||||
|
kind = "公会"
|
||||||
|
}
|
||||||
|
return &business.ActivationPoolChannel{ID: p.ID, PromoteID: p.PromoteID, SourceDB: p.PlatformName, Name: p.PromoteAccount, ChannelType: kind, EnteredAt: p.CreatedAt}
|
||||||
|
}
|
||||||
|
func (r *activationPoolRepo) PoolChannel(ctx context.Context, id uint64) (*business.ActivationPoolChannel, error) {
|
||||||
|
db, err := r.oa(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var p activationPoolPO
|
||||||
|
if err := db.Where("id=? AND claim_flag=0 AND deleted_at IS NULL", id).Take(&p).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, business.ErrActivationNotFound
|
||||||
|
}
|
||||||
|
return nil, business.ErrActivationSource
|
||||||
|
}
|
||||||
|
return toBizPool(p), nil
|
||||||
|
}
|
||||||
|
func (r *activationPoolRepo) BindingOptions(ctx context.Context, id uint) ([]*business.ActivationBindingOption, error) {
|
||||||
|
var rows []struct {
|
||||||
|
SourceDB, Account string
|
||||||
|
ID uint64
|
||||||
|
Level int
|
||||||
|
}
|
||||||
|
err := r.data.DB().WithContext(ctx).Table("sys_user_bind_platform b").Joins("JOIN sys_users u ON u.id=b.user_id AND u.deleted_at IS NULL").Where("b.user_id=? AND b.status='0' AND u.business_status=0 AND u.enable=1", id).Select("b.source_db,b.platform_busier_id AS id,b.platform_busier_account AS account,u.level").Order("b.source_db,b.platform_busier_id").Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, business.ErrStorage
|
||||||
|
}
|
||||||
|
out := make([]*business.ActivationBindingOption, 0, len(rows))
|
||||||
|
for _, p := range rows {
|
||||||
|
db, err := r.databases.Database(p.SourceDB)
|
||||||
|
if err != nil || db == nil {
|
||||||
|
return nil, business.ErrPlatformUnavailable
|
||||||
|
}
|
||||||
|
var sdk struct{ Level int }
|
||||||
|
if err := db.WithContext(ctx).Table("tab_promote_business").Where("id=?", p.ID).Select("level").Take(&sdk).Error; err != nil {
|
||||||
|
return nil, business.ErrPlatformUnavailable
|
||||||
|
}
|
||||||
|
p.Level = sdk.Level
|
||||||
|
out = append(out, &business.ActivationBindingOption{SourceDB: p.SourceDB, Account: p.Account, ID: p.ID, Level: p.Level})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (r *activationPoolRepo) Pool(ctx context.Context, f business.ActivationPoolFilter, opts ...business.ListOption) (*business.ActivationPoolPage, error) {
|
||||||
|
out := &business.ActivationPoolPage{Rows: []*business.ActivationPoolChannel{}, Available: true}
|
||||||
|
db, err := r.oa(ctx)
|
||||||
|
if err != nil {
|
||||||
|
out.Available = false
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
q := db.Model(&activationPoolPO{}).Where("deleted_at IS NULL AND claim_flag=0")
|
||||||
|
if len(f.Sources) > 0 {
|
||||||
|
q = q.Where("platform_name IN ?", f.Sources)
|
||||||
|
}
|
||||||
|
if f.Name != "" {
|
||||||
|
q = q.Where("promote_account LIKE ?", "%"+f.Name+"%")
|
||||||
|
}
|
||||||
|
if f.ChannelType == "公会" {
|
||||||
|
q = q.Where("register_type=1")
|
||||||
|
} else if f.ChannelType == "个人" {
|
||||||
|
q = q.Where("register_type<>1")
|
||||||
|
}
|
||||||
|
var pools []activationPoolPO
|
||||||
|
if err := q.Order("created_at DESC,id DESC").Find(&pools).Error; err != nil {
|
||||||
|
return nil, business.ErrActivationSource
|
||||||
|
}
|
||||||
|
if len(pools) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
bySource := map[string][]uint64{}
|
||||||
|
ids := make([]uint64, 0, len(pools))
|
||||||
|
for _, p := range pools {
|
||||||
|
bySource[p.PlatformName] = append(bySource[p.PlatformName], p.PromoteID)
|
||||||
|
ids = append(ids, p.ID)
|
||||||
|
}
|
||||||
|
byKey := map[business.ChannelKey]channelPO{}
|
||||||
|
for source, channelIDs := range bySource {
|
||||||
|
sdk, err := r.databases.Database(source)
|
||||||
|
if err != nil || sdk == nil {
|
||||||
|
out.Available = false
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
sdk = sdk.WithContext(ctx)
|
||||||
|
for offset := 0; offset < len(channelIDs); offset += 500 {
|
||||||
|
var channels []channelPO
|
||||||
|
if err := sdk.Table("tab_promote").Select("id,status,lock_status,last_login_time,create_time").Where("id IN ?", channelIDs[offset:min(offset+500, len(channelIDs))]).Scan(&channels).Error; err != nil {
|
||||||
|
return nil, business.ErrActivationSource
|
||||||
|
}
|
||||||
|
for _, ch := range channels {
|
||||||
|
ch.SourceDB = source
|
||||||
|
byKey[business.ChannelKey{SourceDB: source, ID: ch.ID}] = ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var rejected []struct {
|
||||||
|
PoolRecordID uint64
|
||||||
|
RejectReason string
|
||||||
|
ReviewAt *time.Time
|
||||||
|
}
|
||||||
|
if err := db.Table("sys_promote_active_pool_bind b").Select("b.pool_record_id,b.reject_reason,b.review_at").Where("b.pool_record_id IN ? AND b.deleted_at IS NULL AND b.status='rejected'", ids).Where("NOT EXISTS (?)", db.Table("sys_promote_active_pool_bind newer").Select("1").Where("newer.pool_record_id=b.pool_record_id AND newer.deleted_at IS NULL AND newer.id>b.id")).Scan(&rejected).Error; err != nil {
|
||||||
|
return nil, business.ErrActivationSource
|
||||||
|
}
|
||||||
|
rejections := map[uint64]int{}
|
||||||
|
for i, p := range rejected {
|
||||||
|
rejections[p.PoolRecordID] = i
|
||||||
|
}
|
||||||
|
rows := []*business.ActivationPoolChannel{}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for _, p := range pools {
|
||||||
|
row := toBizPool(p)
|
||||||
|
ch, found := byKey[business.ChannelKey{SourceDB: p.PlatformName, ID: p.PromoteID}]
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row.Status = "正常"
|
||||||
|
if (ch.Status != nil && *ch.Status == -2) || (ch.LockStatus != nil && *ch.LockStatus == -2) {
|
||||||
|
row.Status = "解除合作"
|
||||||
|
} else if (ch.Status != nil && *ch.Status == -1) || (ch.LockStatus != nil && *ch.LockStatus < 0) {
|
||||||
|
row.Status = "锁定"
|
||||||
|
}
|
||||||
|
if f.Status != "" && row.Status != f.Status {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
login := ch.LastLoginTime
|
||||||
|
if login == nil || *login == 0 {
|
||||||
|
login = ch.CreateTime
|
||||||
|
}
|
||||||
|
if login != nil {
|
||||||
|
days := (now - *login) / 86400
|
||||||
|
if days < 0 {
|
||||||
|
days = 0
|
||||||
|
}
|
||||||
|
row.NoLoginDays = &days
|
||||||
|
}
|
||||||
|
if i, ok := rejections[p.ID]; ok {
|
||||||
|
row.Rejected = true
|
||||||
|
row.RejectReason = rejected[i].RejectReason
|
||||||
|
row.RejectedAt = rejected[i].ReviewAt
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
out.Total = int64(len(rows))
|
||||||
|
o := business.NewListOptions(opts...)
|
||||||
|
start := min(o.Offset, len(rows))
|
||||||
|
end := len(rows)
|
||||||
|
if o.Limit > 0 {
|
||||||
|
end = min(end, start+o.Limit)
|
||||||
|
}
|
||||||
|
out.Rows = rows[start:end]
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event tables are written transactionally by OA in each platform database.
|
||||||
|
type channelEventPO struct {
|
||||||
|
ID, PromoteID uint64
|
||||||
|
PromoteAccount, Kind string
|
||||||
|
PromoteLevel int
|
||||||
|
OccurredAt time.Time
|
||||||
|
OldBusierID, NewBusierID uint64
|
||||||
|
OldBusierAccount, NewBusierAccount, Operator, Reason string
|
||||||
|
FromPool, ToPool bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (channelEventPO) TableName() string { return "tab_promote_business_event" }
|
||||||
|
func eventSources(sources []string) []string {
|
||||||
|
if len(sources) == 0 {
|
||||||
|
return []string{"lx", "hw", "yq"}
|
||||||
|
}
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
func (r *activationPoolRepo) Churn(ctx context.Context, f business.ActivationPoolFilter, opts ...business.ListOption) (*business.ActivationChurnPage, error) {
|
||||||
|
out := &business.ActivationChurnPage{Rows: []*business.ActivationChurn{}, Available: true}
|
||||||
|
o := business.NewListOptions(opts...)
|
||||||
|
for _, source := range eventSources(f.Sources) {
|
||||||
|
db, err := r.databases.Database(source)
|
||||||
|
if err != nil || db == nil {
|
||||||
|
out.Available = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
db = db.WithContext(ctx)
|
||||||
|
ok, err := businessTableAvailable(db, "tab_promote_business_event")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
out.Available = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q := db.Model(&channelEventPO{}).Where("kind='transfer' AND from_pool=?", false)
|
||||||
|
if f.Name != "" {
|
||||||
|
q = q.Where("promote_account LIKE ?", "%"+f.Name+"%")
|
||||||
|
}
|
||||||
|
if f.Target != "" {
|
||||||
|
if strings.Contains("渠道激活池", f.Target) {
|
||||||
|
q = q.Where("(to_pool=? OR new_busier_account LIKE ?)", true, "%"+f.Target+"%")
|
||||||
|
} else {
|
||||||
|
q = q.Where("to_pool=? AND new_busier_account LIKE ?", false, "%"+f.Target+"%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.Begin != nil {
|
||||||
|
q = q.Where("occurred_at>=?", *f.Begin)
|
||||||
|
}
|
||||||
|
if f.End != nil {
|
||||||
|
q = q.Where("occurred_at<?", f.End.AddDate(0, 0, 1))
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
if err := q.Count(&n).Error; err != nil {
|
||||||
|
return nil, business.ErrActivationSource
|
||||||
|
}
|
||||||
|
out.Total += n
|
||||||
|
var events []channelEventPO
|
||||||
|
if err := q.Order("occurred_at DESC,id DESC").Limit(o.Offset + o.Limit).Find(&events).Error; err != nil {
|
||||||
|
return nil, business.ErrActivationSource
|
||||||
|
}
|
||||||
|
for _, e := range events {
|
||||||
|
to := e.NewBusierAccount
|
||||||
|
if e.ToPool {
|
||||||
|
to = "渠道激活池"
|
||||||
|
}
|
||||||
|
out.Rows = append(out.Rows, &business.ActivationChurn{ID: e.ID, PromoteID: e.PromoteID, SourceDB: source, Name: e.PromoteAccount, From: e.OldBusierAccount, To: to, Reason: e.Reason, Operator: e.Operator, At: e.OccurredAt, ToPool: e.ToPool})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !out.Available {
|
||||||
|
out.Rows = []*business.ActivationChurn{}
|
||||||
|
out.Total = 0
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
sort.Slice(out.Rows, func(i, j int) bool {
|
||||||
|
a, b := out.Rows[i], out.Rows[j]
|
||||||
|
if !a.At.Equal(b.At) {
|
||||||
|
return a.At.After(b.At)
|
||||||
|
}
|
||||||
|
if a.SourceDB != b.SourceDB {
|
||||||
|
return a.SourceDB < b.SourceDB
|
||||||
|
}
|
||||||
|
return a.ID > b.ID
|
||||||
|
})
|
||||||
|
start := min(o.Offset, len(out.Rows))
|
||||||
|
end := len(out.Rows)
|
||||||
|
if o.Limit > 0 {
|
||||||
|
end = min(end, start+o.Limit)
|
||||||
|
}
|
||||||
|
out.Rows = out.Rows[start:end]
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ func prepareActivations(t *testing.T) (*gorm.DB, *gorm.DB, business.ActivationRe
|
|||||||
end := time.Unix(2000, 0).UTC()
|
end := time.Unix(2000, 0).UTC()
|
||||||
channel := uint64(100)
|
channel := uint64(100)
|
||||||
amount := "30.30"
|
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}}
|
pos := []activationPO{{ID: 1, PromoteID: &channel, PlatformName: "lx", PromoteAccount: "渠道一", BusierAccount: "Own", BindAt: &start, ReachAt: &end, UnbindAt: &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 {
|
if err := oa.Create(&pos).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func prepareActivations(t *testing.T) (*gorm.DB, *gorm.DB, business.ActivationRe
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return db, oa, NewActivationRepo(testData{db}, activationDatabases{oa})
|
return db, oa, NewActivationRepo(testData{db}, activationSDKFixtures(t, db, oa))
|
||||||
}
|
}
|
||||||
func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) {
|
func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) {
|
||||||
db, _, repo := prepareActivations(t)
|
db, _, repo := prepareActivations(t)
|
||||||
@@ -67,7 +67,7 @@ func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) {
|
|||||||
t.Fatalf("OA 列表或排序错误: %+v %v", page, err)
|
t.Fatalf("OA 列表或排序错误: %+v %v", page, err)
|
||||||
}
|
}
|
||||||
page, err = repo.List(ctx, business.ActivationScope{All: true}, business.ActivationFilter{PlatformNames: []string{"lx"}, Statuses: []string{"reached"}})
|
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" {
|
if err != nil || page.Total != 1 || page.Rows[0].AccumAmount != "33.60" {
|
||||||
t.Fatalf("平台或状态筛选错误: %+v %v", page, err)
|
t.Fatalf("平台或状态筛选错误: %+v %v", page, err)
|
||||||
}
|
}
|
||||||
if _, err := repo.Find(ctx, 4); err != business.ErrActivationNotFound {
|
if _, err := repo.Find(ctx, 4); err != business.ErrActivationNotFound {
|
||||||
@@ -87,7 +87,11 @@ func TestActivationListsReadOAAndRestrictAccounts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
func TestActivationSpendTreePlatformAndInclusiveBounds(t *testing.T) {
|
func TestActivationSpendTreePlatformAndInclusiveBounds(t *testing.T) {
|
||||||
db, _, repo := prepareActivations(t)
|
_, _, repo := prepareActivations(t)
|
||||||
|
db, err := repo.(*activationRepo).databases.Database("lx")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
end := int64(2000)
|
end := int64(2000)
|
||||||
query := business.ActivationSpendQuery{PlatformName: "lx", PromoteID: 100, Start: 1000, End: &end}
|
query := business.ActivationSpendQuery{PlatformName: "lx", PromoteID: 100, Start: 1000, End: &end}
|
||||||
items, err := repo.Spend(context.Background(), query)
|
items, err := repo.Spend(context.Background(), query)
|
||||||
@@ -113,3 +117,38 @@ func TestActivationSpendTreePlatformAndInclusiveBounds(t *testing.T) {
|
|||||||
t.Fatal("确定没有渠道时应返回真实空流水")
|
t.Fatal("确定没有渠道时应返回真实空流水")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func activationSDKFixtures(t *testing.T, db, oa *gorm.DB) activationTestSources {
|
||||||
|
t.Helper()
|
||||||
|
sources := activationTestSources{"oa": oa}
|
||||||
|
var files []struct{ Name, File string }
|
||||||
|
if err := db.Raw("PRAGMA database_list").Scan(&files).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var file string
|
||||||
|
for _, f := range files {
|
||||||
|
if f.Name == "main" {
|
||||||
|
file = f.File
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, source := range []string{"lx", "hw", "yq"} {
|
||||||
|
sdk := openTestDB(t)
|
||||||
|
pool, _ := sdk.DB()
|
||||||
|
pool.SetMaxOpenConns(1)
|
||||||
|
if err := sdk.Exec("ATTACH DATABASE ? AS sample", file).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, table := range []string{"tab_promote", "tab_spend", "tab_user"} {
|
||||||
|
if db.Migrator().HasTable(table) {
|
||||||
|
if err := sdk.Exec("CREATE TABLE "+table+" AS SELECT * FROM sample."+table+" WHERE source_db=?", source).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sdk.Exec("DETACH DATABASE sample").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources[source] = sdk
|
||||||
|
}
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,10 +55,13 @@ func toBizAlertExemption(p alertExemptionPO) *business.AlertExemption {
|
|||||||
return &business.AlertExemption{ID: p.ID, ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}, ChannelName: p.ChannelName, OwnerID: p.OwnerID, AddedBy: p.AddedBy, AddedName: p.AddedName, Reason: p.Reason, AddedAt: p.AddedAt, RemovedBy: p.RemovedBy, RemovedName: p.RemovedName, RemoveReason: p.RemoveReason, RemovedAt: p.RemovedAt}
|
return &business.AlertExemption{ID: p.ID, ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}, ChannelName: p.ChannelName, OwnerID: p.OwnerID, AddedBy: p.AddedBy, AddedName: p.AddedName, Reason: p.Reason, AddedAt: p.AddedAt, RemovedBy: p.RemovedBy, RemovedName: p.RemovedName, RemoveReason: p.RemoveReason, RemovedAt: p.RemovedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
type alertGovernanceRepo struct{ data Provider }
|
type alertGovernanceRepo struct {
|
||||||
|
data Provider
|
||||||
|
databases PlatformProvider
|
||||||
|
}
|
||||||
|
|
||||||
func NewAlertGovernanceRepo(data Provider) business.AlertGovernanceRepo {
|
func NewAlertGovernanceRepo(data Provider, databases PlatformProvider) business.AlertGovernanceRepo {
|
||||||
return &alertGovernanceRepo{data}
|
return &alertGovernanceRepo{data: data, databases: databases}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *alertGovernanceRepo) Candidates(ctx context.Context, scope business.UserScope, keyword string, limit int) ([]*business.AlertChannelOption, error) {
|
func (r *alertGovernanceRepo) Candidates(ctx context.Context, scope business.UserScope, keyword string, limit int) ([]*business.AlertChannelOption, error) {
|
||||||
@@ -196,47 +199,46 @@ func alertLostQuery(db *gorm.DB, sourceColumn, idColumn string, window business.
|
|||||||
return db.Table("tab_promote lp").Select("1").Where("lp.source_db="+sourceColumn+" AND lp.id="+idColumn).
|
return db.Table("tab_promote lp").Select("1").Where("lp.source_db="+sourceColumn+" AND lp.id="+idColumn).
|
||||||
Where("(COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)>0 AND COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)<?) OR (?) < CAST(? AS DECIMAL(20,2))", window.LoginBefore, pay, window.MinPay)
|
Where("(COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)>0 AND COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)<?) OR (?) < CAST(? AS DECIMAL(20,2))", window.LoginBefore, pay, window.MinPay)
|
||||||
}
|
}
|
||||||
func (r *alertGovernanceRepo) LostExemptions(ctx context.Context, window business.AlertLossWindow) ([]*business.AlertExemption, error) {
|
|
||||||
|
// 清理依据 OA 实际入池记录;不能仅因本地流水低而绕过 OA 的豁免和跨平台判定。
|
||||||
|
func (r *alertGovernanceRepo) LostExemptions(ctx context.Context, _ business.AlertLossWindow) ([]*business.AlertExemption, error) {
|
||||||
db := r.data.DB().WithContext(ctx)
|
db := r.data.DB().WithContext(ctx)
|
||||||
var pos []alertExemptionPO
|
var entries []alertExemptionPO
|
||||||
// 没有在名单渠道时无需读取平台明细。
|
if err := db.Where("removed_at IS NULL").Find(&entries).Error; err != nil {
|
||||||
if err := db.Select("id").Where("removed_at IS NULL").Limit(1).Find(&pos).Error; err != nil {
|
|
||||||
return nil, business.ErrStorage
|
return nil, business.ErrStorage
|
||||||
}
|
}
|
||||||
if len(pos) == 0 {
|
if len(entries) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
available, err := businessTablesAvailable(db, "tab_promote", "tab_spend")
|
oa, err := r.databases.Database("oa")
|
||||||
if err != nil {
|
if err != nil || oa == nil {
|
||||||
return nil, err
|
return nil, business.ErrActivationSource
|
||||||
}
|
}
|
||||||
if !available {
|
oa = oa.WithContext(ctx)
|
||||||
return nil, business.ErrAlertChannelsUnavailable
|
ok, err := businessTableAvailable(oa, "sys_platform_promote_active_pool")
|
||||||
|
if err != nil || !ok {
|
||||||
|
return nil, business.ErrActivationSource
|
||||||
}
|
}
|
||||||
result := []*business.AlertExemption{}
|
result := []*business.AlertExemption{}
|
||||||
var cursor uint64
|
for start := 0; start < len(entries); start += 500 {
|
||||||
for {
|
batch := entries[start:min(start+500, len(entries))]
|
||||||
var batch []alertExemptionPO
|
pairs := [][]any{}
|
||||||
if err := db.Where("removed_at IS NULL AND id>?", cursor).Order("id").Limit(500).Find(&batch).Error; err != nil {
|
for _, e := range batch {
|
||||||
return nil, business.ErrStorage
|
pairs = append(pairs, []any{e.SourceDB, e.PromoteID})
|
||||||
}
|
}
|
||||||
if len(batch) == 0 {
|
var pools []activationPoolPO
|
||||||
break
|
if err := oa.Where("(platform_name,promote_id) IN ?", pairs).Find(&pools).Error; err != nil {
|
||||||
|
return nil, business.ErrActivationSource
|
||||||
}
|
}
|
||||||
keys := make([]business.ChannelKey, 0, len(batch))
|
byKey := map[business.ChannelKey]activationPoolPO{}
|
||||||
for _, p := range batch {
|
for _, p := range pools {
|
||||||
keys = append(keys, business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID})
|
byKey[business.ChannelKey{SourceDB: p.PlatformName, ID: p.PromoteID}] = p
|
||||||
}
|
}
|
||||||
states, err := loadAlertLoss(db, keys, window)
|
for _, e := range batch {
|
||||||
if err != nil {
|
if p, ok := byKey[business.ChannelKey{SourceDB: e.SourceDB, ID: e.PromoteID}]; ok && (p.DeletedAt == nil || !p.CreatedAt.Before(e.AddedAt)) {
|
||||||
return nil, err
|
result = append(result, toBizAlertExemption(e))
|
||||||
}
|
|
||||||
for _, p := range batch {
|
|
||||||
if lost := states[business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}]; lost != nil && *lost {
|
|
||||||
result = append(result, toBizAlertExemption(p))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cursor = batch[len(batch)-1].ID
|
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func exerciseAlertOwnerRecheck(t *testing.T, db *gorm.DB) {
|
|||||||
}
|
}
|
||||||
write(true)
|
write(true)
|
||||||
oldExemption := &business.AlertExemption{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 1}, OwnerID: 7, AddedBy: 7, AddedName: "原商务", Reason: "原商务的豁免", AddedAt: now}
|
oldExemption := &business.AlertExemption{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 1}, OwnerID: 7, AddedBy: 7, AddedName: "原商务", Reason: "原商务的豁免", AddedAt: now}
|
||||||
if err := NewAlertGovernanceRepo(testData{db}).AddExemption(ctx, scope, oldExemption.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return oldExemption, nil }); err != nil {
|
if err := NewAlertGovernanceRepo(testData{db}, activationDatabases{db}).AddExemption(ctx, scope, oldExemption.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return oldExemption, nil }); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ func exerciseAlertOwnerRecheck(t *testing.T, db *gorm.DB) {
|
|||||||
if len(histories) != 2 || histories[0].BusinessID != 7 || histories[0].AlertContent != "换绑前" || histories[1].BusinessID != 8 {
|
if len(histories) != 2 || histories[0].BusinessID != 7 || histories[0].AlertContent != "换绑前" || histories[1].BusinessID != 8 {
|
||||||
t.Fatalf("旧商务预警不应被转移给新商务: %+v", histories)
|
t.Fatalf("旧商务预警不应被转移给新商务: %+v", histories)
|
||||||
}
|
}
|
||||||
governance := NewAlertGovernanceRepo(testData{db})
|
governance := NewAlertGovernanceRepo(testData{db}, activationDatabases{db})
|
||||||
called := false
|
called := false
|
||||||
build := func(*business.AlertExemptionTarget) (*business.AlertExemption, error) {
|
build := func(*business.AlertExemptionTarget) (*business.AlertExemption, error) {
|
||||||
called = true
|
called = true
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func TestAlertV2ExemptionHistoryAndAutomaticRemoval(t *testing.T) {
|
|||||||
if err := db.Exec("INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(7,'lx',7,'0')").Error; err != nil {
|
if err := db.Exec("INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(7,'lx',7,'0')").Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r := NewAlertGovernanceRepo(testData{db})
|
r := NewAlertGovernanceRepo(testData{db}, activationDatabases{db})
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
entry := &business.AlertExemption{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 1}, ChannelName: "父渠道", OwnerID: 7, AddedBy: 7, AddedName: "一级商务", AddedAt: now, Reason: "长期合作"}
|
entry := &business.AlertExemption{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 1}, ChannelName: "父渠道", OwnerID: 7, AddedBy: 7, AddedName: "一级商务", AddedAt: now, Reason: "长期合作"}
|
||||||
if err := r.AddExemption(ctx, business.UserScope{All: true}, entry.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return entry, nil }); err != nil {
|
if err := r.AddExemption(ctx, business.UserScope{All: true}, entry.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return entry, nil }); err != nil {
|
||||||
@@ -118,7 +118,16 @@ func TestAlertV2ExemptionHistoryAndAutomaticRemoval(t *testing.T) {
|
|||||||
if err != nil || written {
|
if err != nil || written {
|
||||||
t.Fatalf("免预警仍生成: %v %v", written, err)
|
t.Fatalf("免预警仍生成: %v %v", written, err)
|
||||||
}
|
}
|
||||||
// 父渠道自身近 30 天流水仅 100 元,满足激活池条件;子渠道流水不冒充它自身的流水。
|
// 本地达到流失阈值不等于 OA 已入池;只有收到 OA 入池记录才移出豁免。
|
||||||
|
if err := db.AutoMigrate(&activationPoolPO{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rows, err := r.LostExemptions(ctx, business.AlertLossAt(now)); err != nil || len(rows) != 0 {
|
||||||
|
t.Fatalf("未入池误清理: %v %v", rows, err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&activationPoolPO{ID: 1, PromoteID: 1, PlatformName: "lx", CreatedAt: now}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
uc := business.NewAlertGovernanceUsecase(r, nil)
|
uc := business.NewAlertGovernanceUsecase(r, nil)
|
||||||
if err := uc.Reconcile(ctx, now); err != nil {
|
if err := uc.Reconcile(ctx, now); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ import (
|
|||||||
"kra/internal/biz/business"
|
"kra/internal/biz/business"
|
||||||
)
|
)
|
||||||
|
|
||||||
type dashboardRepo struct{ data Provider }
|
type dashboardRepo struct {
|
||||||
|
data Provider
|
||||||
|
databases PlatformProvider
|
||||||
|
}
|
||||||
|
|
||||||
func NewDashboardRepo(data Provider) business.DashboardRepo { return &dashboardRepo{data: data} }
|
func NewDashboardRepo(data Provider, databases PlatformProvider) business.DashboardRepo {
|
||||||
|
return &dashboardRepo{data: data, databases: databases}
|
||||||
|
}
|
||||||
func (r *dashboardRepo) Overview(ctx context.Context, scope business.AnalyticsScope, sources []string, w business.DashboardWindow) (*business.DashboardData, error) {
|
func (r *dashboardRepo) Overview(ctx context.Context, scope business.AnalyticsScope, sources []string, w business.DashboardWindow) (*business.DashboardData, error) {
|
||||||
db := r.data.DB().WithContext(ctx)
|
db := r.data.DB().WithContext(ctx)
|
||||||
out := &business.DashboardData{Current: business.DashboardMetrics{Pay: "0.00"}, Previous: business.DashboardMetrics{Pay: "0.00"}, Available: true}
|
out := &business.DashboardData{Current: business.DashboardMetrics{Pay: "0.00"}, Previous: business.DashboardMetrics{Pay: "0.00"}, Available: true}
|
||||||
@@ -90,5 +95,8 @@ func (r *dashboardRepo) Overview(ctx context.Context, scope business.AnalyticsSc
|
|||||||
out.Current.NewChannels, out.Previous.NewChannels = channels.CurrentNew, channels.PreviousNew
|
out.Current.NewChannels, out.Previous.NewChannels = channels.CurrentNew, channels.PreviousNew
|
||||||
out.Current.Effective, out.Previous.Effective = channels.CurrentEffective, channels.PreviousEffective
|
out.Current.Effective, out.Previous.Effective = channels.CurrentEffective, channels.PreviousEffective
|
||||||
out.Current.Pay, out.Previous.Pay = amounts.CurrentPay, amounts.PreviousPay
|
out.Current.Pay, out.Previous.Pay = amounts.CurrentPay, amounts.PreviousPay
|
||||||
|
if err := r.events(ctx, scope, sources, w, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *dashboardRepo) events(ctx context.Context, scope business.AnalyticsScope, sources []string, w business.DashboardWindow, out *business.DashboardData) error {
|
||||||
|
out.EventsAvailable = true
|
||||||
|
for _, source := range eventSources(sources) {
|
||||||
|
db, err := r.databases.Database(source)
|
||||||
|
if err != nil || db == nil {
|
||||||
|
out.EventsAvailable = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
db = db.WithContext(ctx)
|
||||||
|
ok, err := businessTableAvailable(db, "tab_promote_business_event")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
out.EventsAvailable = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var coverage []channelEventPO
|
||||||
|
if err := db.Where("kind='coverage'").Order("occurred_at,id").Limit(1).Find(&coverage).Error; err != nil {
|
||||||
|
return business.ErrActivationSource
|
||||||
|
}
|
||||||
|
if len(coverage) > 0 && w.PreviousBegin.Before(coverage[0].OccurredAt) {
|
||||||
|
out.EventsAvailable = false
|
||||||
|
out.EventsIncomplete = true
|
||||||
|
}
|
||||||
|
q := db.Model(&channelEventPO{}).Where("kind IN ? AND occurred_at>=? AND occurred_at<?", []string{"locked", "terminated"}, w.PreviousBegin, w.End.AddDate(0, 0, 1))
|
||||||
|
if !scope.Viewer.All {
|
||||||
|
var ids []uint64
|
||||||
|
if err := channelBindingQuery(r.data.DB().WithContext(ctx), scope.Viewer, business.ChannelFilter{Sources: []string{source}}).Distinct("b.platform_busier_id").Pluck("b.platform_busier_id", &ids).Error; err != nil {
|
||||||
|
return business.ErrStorage
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q = q.Where("old_busier_id IN ?", ids)
|
||||||
|
}
|
||||||
|
if !scope.IncludeSub {
|
||||||
|
q = q.Where("promote_level=1")
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
Kind string
|
||||||
|
Current bool
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
if err := q.Select("kind,(occurred_at>=?) AS current,COUNT(DISTINCT promote_id) AS count", w.Begin).Group("kind,current").Scan(&rows).Error; err != nil {
|
||||||
|
return business.ErrActivationSource
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
target := &out.Previous
|
||||||
|
if row.Current {
|
||||||
|
target = &out.Current
|
||||||
|
}
|
||||||
|
if row.Kind == "locked" {
|
||||||
|
target.LockedChannels += row.Count
|
||||||
|
} else {
|
||||||
|
target.TerminatedChannels += row.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -167,7 +167,7 @@ func TestPerformanceOwnerAggregationAndDrilldownUseSameScope(t *testing.T) {
|
|||||||
if err != nil || count != 2 || len(rows) != 1 || rows[0].ContributorID != 9 {
|
if err != nil || count != 2 || len(rows) != 1 || rows[0].ContributorID != 9 {
|
||||||
t.Fatal("contribution pagination must preserve total and stable row identity")
|
t.Fatal("contribution pagination must preserve total and stable row identity")
|
||||||
}
|
}
|
||||||
data, err := NewDashboardRepo(testData{db}).Overview(context.Background(), scope, []string{"lx"}, business.DashboardWindow{Begin: begin, End: end, PreviousBegin: begin.AddDate(0, 0, -3), PreviousEnd: begin.AddDate(0, 0, -1)})
|
data, err := NewDashboardRepo(testData{db}, activationDatabases{}).Overview(context.Background(), scope, []string{"lx"}, business.DashboardWindow{Begin: begin, End: end, PreviousBegin: begin.AddDate(0, 0, -3), PreviousEnd: begin.AddDate(0, 0, -1)})
|
||||||
if err != nil || data.Current.Effective != 1 || data.Current.ActiveChannels != 4 || portraitTestMoney(data.Current.Pay) != "1059.60" {
|
if err != nil || data.Current.Effective != 1 || data.Current.ActiveChannels != 4 || portraitTestMoney(data.Current.Pay) != "1059.60" {
|
||||||
t.Fatalf("首页按区间统计与绩效口径不一致: %+v %v", data, err)
|
t.Fatalf("首页按区间统计与绩效口径不一致: %+v %v", data, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Migrations() []migration.Step {
|
func Migrations() []migration.Step {
|
||||||
return []migration.Step{swmigration.StateMigration(), {ID: "202611060023_business_binding_status_index", Migrate: migrateBindingStatusIndex}, {ID: "202611060022_business_promote_id_capacity", Migrate: migratePromoteIDCapacity}, {ID: "202611050020_business_alert_v2", Migrate: migrateAlertV2}, {ID: "202611040017_business_groups_goals_effectiveness", Migrate: migrateBusinessManagement}, {ID: "202611040018_business_effectiveness_query_index", Migrate: migrateEffectivenessQueryIndex}, {ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
|
return []migration.Step{swmigration.StateMigration(), {ID: "202611060024_business_oa_remark", Migrate: func(db *gorm.DB) error {
|
||||||
|
if db.Migrator().HasTable(&accountPO{}) && !db.Migrator().HasColumn(&accountPO{}, "remark") {
|
||||||
|
return db.Migrator().AddColumn(&accountPO{}, "Remark")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}, {ID: "202611060023_business_binding_status_index", Migrate: migrateBindingStatusIndex}, {ID: "202611060022_business_promote_id_capacity", Migrate: migratePromoteIDCapacity}, {ID: "202611050020_business_alert_v2", Migrate: migrateAlertV2}, {ID: "202611040017_business_groups_goals_effectiveness", Migrate: migrateBusinessManagement}, {ID: "202611040018_business_effectiveness_query_index", Migrate: migrateEffectivenessQueryIndex}, {ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
|
||||||
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
|
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
|
||||||
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
|
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
|
||||||
return migration.CreateMissingTables(db, &bindingLockPO{})
|
return migration.CreateMissingTables(db, &bindingLockPO{})
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
databusiness.NewAlertRuleRepo,
|
databusiness.NewAlertRuleRepo,
|
||||||
databusiness.NewAlertRecordRepo,
|
databusiness.NewAlertRecordRepo,
|
||||||
databusiness.NewActivationRepo,
|
databusiness.NewActivationRepo,
|
||||||
|
databusiness.NewActivationPoolRepo,
|
||||||
databusiness.NewChannelRepo,
|
databusiness.NewChannelRepo,
|
||||||
databusiness.NewChannelWriteRepo,
|
databusiness.NewChannelWriteRepo,
|
||||||
databusiness.NewBusinessPortraitRepo,
|
databusiness.NewBusinessPortraitRepo,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package oa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-kratos/kratos/v3/errors"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
"kra/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type activationGateway struct {
|
||||||
|
config *config.Store
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewActivationGateway(store *config.Store) business.ActivationGateway {
|
||||||
|
return &activationGateway{config: store, client: &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||||
|
}
|
||||||
|
func (g *activationGateway) Apply(ctx context.Context, a business.ActivationApplication) error {
|
||||||
|
cfg := g.config.Snapshot()
|
||||||
|
if cfg == nil || cfg.BusinessOA == nil || cfg.BusinessOA.URL == "" || cfg.BusinessOA.Key == "" {
|
||||||
|
return business.ErrActivationSource
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
PoolID string `json:"poolId"`
|
||||||
|
BusierID string `json:"busierId"`
|
||||||
|
SourceDB string `json:"sourceDb"`
|
||||||
|
}{strconv.FormatUint(uint64(a.UserID), 10), strconv.FormatUint(a.PoolID, 10), strconv.FormatUint(a.BusierID, 10), a.SourceDB})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(cfg.BusinessOA.URL, "/")+"/internal/business/activation/apply", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return business.ErrActivationSource
|
||||||
|
}
|
||||||
|
stamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
mac := hmac.New(sha256.New, []byte(cfg.BusinessOA.Key))
|
||||||
|
mac.Write([]byte(req.Method + "\n" + req.URL.EscapedPath() + "\n" + stamp + "\n"))
|
||||||
|
mac.Write(body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Business-Timestamp", stamp)
|
||||||
|
req.Header.Set("X-Business-Signature", hex.EncodeToString(mac.Sum(nil)))
|
||||||
|
res, err := g.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return business.ErrActivationSource
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return business.ErrActivationSource
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Code *int `json:"code"`
|
||||||
|
Message string `json:"msg"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(res.Body, 65536)).Decode(&result); err != nil || result.Code == nil {
|
||||||
|
return business.ErrActivationSource
|
||||||
|
}
|
||||||
|
if *result.Code != 0 {
|
||||||
|
return errors.BadRequest("SW_ACTIVATION_APPLY", result.Message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package oa
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
"kra/internal/config"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestActivationGatewayPreservesIDsAndSignsOAPath(t *testing.T) {
|
||||||
|
key := "test-key"
|
||||||
|
calls := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
mac := hmac.New(sha256.New, []byte(key))
|
||||||
|
mac.Write([]byte(r.Method + "\n" + r.URL.EscapedPath() + "\n" + r.Header.Get("X-Business-Timestamp") + "\n"))
|
||||||
|
mac.Write(body)
|
||||||
|
if hex.EncodeToString(mac.Sum(nil)) != r.Header.Get("X-Business-Signature") || r.URL.Path != "/oa/internal/business/activation/apply" {
|
||||||
|
t.Error("invalid signature/path")
|
||||||
|
}
|
||||||
|
var values map[string]string
|
||||||
|
if err := json.Unmarshal(body, &values); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if values["poolId"] != "9007199254740993" || values["busierId"] != "42" || values["userId"] != "7" {
|
||||||
|
t.Errorf("identity precision: %v", values)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
io.WriteString(w, `{"code":0,"msg":"申请成功"}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
store := config.NewStore(&config.Config{BusinessOA: &config.BusinessOA{URL: server.URL + "/oa/", Key: key}})
|
||||||
|
if err := NewActivationGateway(store).Apply(context.Background(), business.ActivationApplication{UserID: 7, PoolID: 9007199254740993, BusierID: 42, SourceDB: "lx"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store.Replace(&config.Config{})
|
||||||
|
if err := NewActivationGateway(store).Apply(context.Background(), business.ActivationApplication{}); err != business.ErrActivationSource {
|
||||||
|
t.Fatal("missing configuration must fail closed")
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatal("unexpected request")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"kra/internal/integration/email"
|
"kra/internal/integration/email"
|
||||||
"kra/internal/integration/gameplatform"
|
"kra/internal/integration/gameplatform"
|
||||||
mqintegration "kra/internal/integration/mq"
|
mqintegration "kra/internal/integration/mq"
|
||||||
|
"kra/internal/integration/oa"
|
||||||
paymentintegration "kra/internal/integration/payment"
|
paymentintegration "kra/internal/integration/payment"
|
||||||
"kra/internal/integration/sms"
|
"kra/internal/integration/sms"
|
||||||
"kra/internal/integration/storage"
|
"kra/internal/integration/storage"
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var ProviderSet = wire.NewSet(
|
var ProviderSet = wire.NewSet(
|
||||||
|
oa.NewActivationGateway,
|
||||||
gameplatform.NewPasswords,
|
gameplatform.NewPasswords,
|
||||||
gameplatform.NewPasswordEncoder,
|
gameplatform.NewPasswordEncoder,
|
||||||
gameplatform.NewPasswordSMS,
|
gameplatform.NewPasswordSMS,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
datasystem "kra/internal/data/system"
|
||||||
|
"kra/internal/modules/surface"
|
||||||
|
"kra/pkg/module"
|
||||||
|
)
|
||||||
|
|
||||||
|
func migrateActivationSurface(db *gorm.DB) error {
|
||||||
|
if !db.Migrator().HasTable("sys_authorities") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var roots int64
|
||||||
|
if err := db.Table("sys_authorities").Where("authority_id=888 AND deleted_at IS NULL").Count(&roots).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if roots == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if db.Migrator().HasTable("sys_base_menus") {
|
||||||
|
if err := db.Table("sys_base_menus").Where("name=?", "sw_ChannelActivationList").Update("title", "激活列表").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apis := surface.APIsForPrefix("/sw/promoteActivePool/", 0)
|
||||||
|
if err := datasystem.EnsureAdminSurface(db, module.Surface{APIs: apis}, 888); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 申请绑定与流失记录对各级商务开放,升级旧的仅三级菜单授权。
|
||||||
|
if db.Migrator().HasTable("sys_authority_menus") && db.Migrator().HasTable("sys_base_menus") {
|
||||||
|
if err := db.Exec(`INSERT INTO sys_authority_menus(sys_authority_authority_id,sys_base_menu_id)
|
||||||
|
SELECT a.authority_id,m.id FROM sys_authorities a CROSS JOIN sys_base_menus m
|
||||||
|
WHERE a.business_level IN (1,2,3) AND a.deleted_at IS NULL AND m.deleted_at IS NULL
|
||||||
|
AND m.name IN ('sw_ChannelActivationList','sw_BusMgmt')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM sys_authority_menus x WHERE x.sys_authority_authority_id=a.authority_id AND x.sys_base_menu_id=m.id)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return datasystem.GrantMenuAPIs(db, "sw_ChannelActivationList", apis)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
datasystem "kra/internal/data/system"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestActivationUpgradeGivesAllBusinessLevelsApplicationAccess(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "surface.db")), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool, _ := db.DB()
|
||||||
|
defer pool.Close()
|
||||||
|
for _, step := range datasystem.Migrations() {
|
||||||
|
if err := step.Migrate(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, sql := range []string{"INSERT INTO sys_authorities(authority_id,business_level) VALUES(888,NULL),(41,1),(57,2),(83,3),(100,NULL)", "INSERT INTO sys_base_menus(id,name,title) VALUES(1,'sw_BusMgmt','商务管理'),(2,'sw_ChannelActivationList','渠道激活')"} {
|
||||||
|
if err := db.Exec(sql).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := migrateActivationSurface(db); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := db.Table("sys_authority_menus").Where("sys_base_menu_id=2 AND sys_authority_authority_id IN (41,57,83)").Count(&count).Error; err != nil || count != 3 {
|
||||||
|
t.Fatalf("business menu access %d: %v", count, err)
|
||||||
|
}
|
||||||
|
if err := db.Table("casbin_rule").Where("v0 IN ('41','57','83') AND v1='/sw/promoteActivePool/apply' AND v2='POST'").Count(&count).Error; err != nil || count != 3 {
|
||||||
|
t.Fatalf("application grants %d: %v", count, err)
|
||||||
|
}
|
||||||
|
if err := db.Table("casbin_rule").Where("v0='100' AND v1='/sw/promoteActivePool/apply'").Count(&count).Error; err != nil || count != 0 {
|
||||||
|
t.Fatal("unrelated role received grant")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,7 @@ func Definition() module.Definition {
|
|||||||
})
|
})
|
||||||
}})
|
}})
|
||||||
migrations = append(migrations, migration.Step{ID: "202611050021_business_alert_surface", Migrate: migrateAlertSurface})
|
migrations = append(migrations, migration.Step{ID: "202611050021_business_alert_surface", Migrate: migrateAlertSurface})
|
||||||
|
migrations = append(migrations, migration.Step{ID: "202611060025_business_activation_surface", Migrate: migrateActivationSurface})
|
||||||
definition := module.Definition{Name: "business", Migrations: migrations, Prerequisites: databusiness.CheckPrerequisites, ValidateSchema: databusiness.CheckSchema, TimedTasks: append([]module.TimedTask{alertDetectionTask}, tasks...), Surface: module.Surface{
|
definition := module.Definition{Name: "business", Migrations: migrations, Prerequisites: databusiness.CheckPrerequisites, ValidateSchema: databusiness.CheckSchema, TimedTasks: append([]module.TimedTask{alertDetectionTask}, tasks...), Surface: module.Surface{
|
||||||
Menus: []module.Menu{
|
Menus: []module.Menu{
|
||||||
alertExemptionMenu,
|
alertExemptionMenu,
|
||||||
@@ -119,7 +120,7 @@ func Definition() module.Definition {
|
|||||||
{Name: "sw_RegistrationDetails", Path: "RegistrationDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RegistrationDetails.vue", Title: "注册明细", Icon: databusiness.MenuIcon("sw_RegistrationDetails"), Sort: 3},
|
{Name: "sw_RegistrationDetails", Path: "RegistrationDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RegistrationDetails.vue", Title: "注册明细", Icon: databusiness.MenuIcon("sw_RegistrationDetails"), Sort: 3},
|
||||||
{Name: "sw_RechargeDetails", Path: "RechargeDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RechargeDetails.vue", Title: "充值明细", Icon: databusiness.MenuIcon("sw_RechargeDetails"), Sort: 2},
|
{Name: "sw_RechargeDetails", Path: "RechargeDetails", ParentName: "sw_DataAnalysis", Component: "view/sw/RechargeDetails.vue", Title: "充值明细", Icon: databusiness.MenuIcon("sw_RechargeDetails"), Sort: 2},
|
||||||
{Name: "sw_ChannelList", Path: "ChannelList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelList.vue", Title: "渠道列表", Icon: databusiness.MenuIcon("sw_ChannelList"), Sort: 2},
|
{Name: "sw_ChannelList", Path: "ChannelList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelList.vue", Title: "渠道列表", Icon: databusiness.MenuIcon("sw_ChannelList"), Sort: 2},
|
||||||
{Name: "sw_ChannelActivationList", Path: "ChannelActivationList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelActivationList.vue", Title: "渠道激活", Icon: databusiness.MenuIcon("sw_ChannelActivationList"), Sort: 3},
|
{Name: "sw_ChannelActivationList", Path: "ChannelActivationList", ParentName: "sw_BusMgmt", Component: "view/sw/ChannelActivationList.vue", Title: "激活列表", Icon: databusiness.MenuIcon("sw_ChannelActivationList"), Sort: 3},
|
||||||
{Name: "sw_AlertList", Path: "AlertList", ParentName: "sw_AlertCenter", Component: "view/sw/AlertList.vue", Title: "预警列表", Icon: databusiness.MenuIcon("sw_AlertList"), Sort: 1},
|
{Name: "sw_AlertList", Path: "AlertList", ParentName: "sw_AlertCenter", Component: "view/sw/AlertList.vue", Title: "预警列表", Icon: databusiness.MenuIcon("sw_AlertList"), Sort: 1},
|
||||||
{Name: "sw_AlertCenter", Path: "sw-AlertCenter", Component: "view/routerHolder.vue", Title: "预警中心", Icon: databusiness.MenuIcon("sw_AlertCenter"), Sort: 4},
|
{Name: "sw_AlertCenter", Path: "sw-AlertCenter", Component: "view/routerHolder.vue", Title: "预警中心", Icon: databusiness.MenuIcon("sw_AlertCenter"), Sort: 4},
|
||||||
{Name: "sw_AlertRules", Path: "AlertRules", ParentName: "sw_AlertCenter", Component: "view/sw/AlertRules.vue", Title: "预警规则", Icon: databusiness.MenuIcon("sw_AlertRules"), Sort: 2},
|
{Name: "sw_AlertRules", Path: "AlertRules", ParentName: "sw_AlertCenter", Component: "view/sw/AlertRules.vue", Title: "预警规则", Icon: databusiness.MenuIcon("sw_AlertRules"), Sort: 2},
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
|||||||
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := catalog.MigrationSteps(); len(got) != 39 {
|
if got := catalog.MigrationSteps(); len(got) != 41 {
|
||||||
t.Fatalf("module migrations = %d, want 39", len(got))
|
t.Fatalf("module migrations = %d, want 41", len(got))
|
||||||
}
|
}
|
||||||
var menus, apis int
|
var menus, apis int
|
||||||
for _, definition := range catalog.Definitions {
|
for _, definition := range catalog.Definitions {
|
||||||
menus += len(definition.Surface.Menus)
|
menus += len(definition.Surface.Menus)
|
||||||
apis += len(definition.Surface.APIs)
|
apis += len(definition.Surface.APIs)
|
||||||
}
|
}
|
||||||
if menus != 21 || apis != 90 {
|
if menus != 21 || apis != 94 {
|
||||||
t.Fatalf("admin surface = %d menus/%d APIs, want 21/90", menus, apis)
|
t.Fatalf("admin surface = %d menus/%d APIs, want 21/94", menus, apis)
|
||||||
}
|
}
|
||||||
if got := catalog.DefaultTimedTasks(); len(got) != 5 {
|
if got := catalog.DefaultTimedTasks(); len(got) != 5 {
|
||||||
t.Fatalf("default timed tasks = %d, want 5", len(got))
|
t.Fatalf("default timed tasks = %d, want 5", len(got))
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ var routes = map[string]routeValue{
|
|||||||
"GET /sw/promote/bindOptions": {group: "渠道管理", description: "查询可用游戏商务绑定"},
|
"GET /sw/promote/bindOptions": {group: "渠道管理", description: "查询可用游戏商务绑定"},
|
||||||
"GET /sw/promote/game-distribution": {group: "渠道管理", description: "查询推广游戏累计分布"},
|
"GET /sw/promote/game-distribution": {group: "渠道管理", description: "查询推广游戏累计分布"},
|
||||||
"POST /sw/promote": {group: "渠道管理", description: "创建源平台渠道", audit: true},
|
"POST /sw/promote": {group: "渠道管理", description: "创建源平台渠道", audit: true},
|
||||||
|
"GET /sw/promoteActivePool/pool": {group: "渠道激活", description: "查询可申请渠道"},
|
||||||
|
"GET /sw/promoteActivePool/options": {group: "渠道激活", description: "查询本人已审核游戏商务"},
|
||||||
|
"POST /sw/promoteActivePool/apply": {group: "渠道激活", description: "申请激活池渠道"},
|
||||||
|
"GET /sw/promoteActivePool/churn": {group: "渠道激活", description: "查询渠道流失记录"},
|
||||||
"GET /sw/promoteActivePool/bindList": {group: "渠道激活", description: "查询 OA 渠道激活绑定"},
|
"GET /sw/promoteActivePool/bindList": {group: "渠道激活", description: "查询 OA 渠道激活绑定"},
|
||||||
"GET /sw/promoteActivePool/bindSpendDetail": {group: "渠道激活", description: "查询绑定期间游戏流水"},
|
"GET /sw/promoteActivePool/bindSpendDetail": {group: "渠道激活", description: "查询绑定期间游戏流水"},
|
||||||
"GET /sw/alertRecord/list": {group: "预警记录", description: "查询预警列表及状态统计"},
|
"GET /sw/alertRecord/list": {group: "预警记录", description: "查询预警列表及状态统计"},
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-kratos/kratos/v3/errors"
|
||||||
|
"kra/internal/service/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Activation) Pool(c *gin.Context) {
|
||||||
|
var req dto.ActivationPoolQuery
|
||||||
|
if c.ShouldBindQuery(&req) != nil {
|
||||||
|
Fail(c, "申请绑定查询参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.Pool(c.Request.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
Fail(c, errors.FromError(err).Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OKWithData(c, result)
|
||||||
|
}
|
||||||
|
func (h *Activation) Options(c *gin.Context) {
|
||||||
|
result, err := h.service.Options(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
Fail(c, errors.FromError(err).Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OKWithData(c, result)
|
||||||
|
}
|
||||||
|
func (h *Activation) Apply(c *gin.Context) {
|
||||||
|
var req dto.ActivationApplyRequest
|
||||||
|
if c.ShouldBindJSON(&req) != nil {
|
||||||
|
Fail(c, "激活申请参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.Apply(c.Request.Context(), &req); err != nil {
|
||||||
|
Fail(c, errors.FromError(err).Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OKWithData(c, nil)
|
||||||
|
}
|
||||||
|
func (h *Activation) Churn(c *gin.Context) {
|
||||||
|
var req dto.ActivationPoolQuery
|
||||||
|
if c.ShouldBindQuery(&req) != nil {
|
||||||
|
Fail(c, "流失记录查询参数不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := h.service.Churn(c.Request.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
Fail(c, errors.FromError(err).Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OKWithData(c, result)
|
||||||
|
}
|
||||||
@@ -49,6 +49,10 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
|||||||
},
|
},
|
||||||
func() { RegisterChannel(private, r.handlers.Channel) },
|
func() { RegisterChannel(private, r.handlers.Channel) },
|
||||||
func() {
|
func() {
|
||||||
|
private.GET("/sw/promoteActivePool/pool", r.handlers.Activation.Pool)
|
||||||
|
private.GET("/sw/promoteActivePool/options", r.handlers.Activation.Options)
|
||||||
|
private.POST("/sw/promoteActivePool/apply", r.handlers.Activation.Apply)
|
||||||
|
private.GET("/sw/promoteActivePool/churn", r.handlers.Activation.Churn)
|
||||||
private.GET("/sw/promoteActivePool/bindList", r.handlers.Activation.List)
|
private.GET("/sw/promoteActivePool/bindList", r.handlers.Activation.List)
|
||||||
private.GET("/sw/promoteActivePool/bindSpendDetail", r.handlers.Activation.SpendDetail)
|
private.GET("/sw/promoteActivePool/bindSpendDetail", r.handlers.Activation.SpendDetail)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ type ActivationUsecase interface {
|
|||||||
List(context.Context, business.ActivationFilter, ...business.ListOption) (*business.ActivationPage, error)
|
List(context.Context, business.ActivationFilter, ...business.ListOption) (*business.ActivationPage, error)
|
||||||
SpendDetail(context.Context, uint64) (*business.ActivationSpendDetail, error)
|
SpendDetail(context.Context, uint64) (*business.ActivationSpendDetail, error)
|
||||||
}
|
}
|
||||||
type ActivationService struct{ uc ActivationUsecase }
|
type ActivationService struct {
|
||||||
|
uc ActivationUsecase
|
||||||
|
pool *business.ActivationPoolUsecase
|
||||||
|
}
|
||||||
|
|
||||||
func NewActivationService(uc *business.ActivationUsecase) *ActivationService {
|
func NewActivationService(uc *business.ActivationUsecase, pool *business.ActivationPoolUsecase) *ActivationService {
|
||||||
return &ActivationService{uc: uc}
|
return &ActivationService{uc: uc, pool: pool}
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitQueryValues(values []string) []string {
|
func splitQueryValues(values []string) []string {
|
||||||
@@ -57,7 +60,7 @@ func (s *ActivationService) List(ctx context.Context, req *dto.ActivationQuery)
|
|||||||
}
|
}
|
||||||
out := &dto.ActivationPage{Rows: []*dto.ActivationResponse{}, Total: result.Total, Available: result.Available}
|
out := &dto.ActivationPage{Rows: []*dto.ActivationResponse{}, Total: result.Total, Available: result.Available}
|
||||||
for _, r := range result.Rows {
|
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)})
|
out.Rows = append(out.Rows, &dto.ActivationResponse{CreatedAt: localTime(r.CreatedAt), RegisteredPlayers: r.RegisteredPlayers, MetricsAvailable: r.MetricsAvailable, 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
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -72,7 +75,12 @@ func (s *ActivationService) SpendDetail(ctx context.Context, id uint64) (*dto.Ac
|
|||||||
a := result.Activation
|
a := result.Activation
|
||||||
out := &dto.ActivationSpendDetail{PromoteAccount: a.PromoteAccount, BindAt: localTime(a.BindAt), EndAt: localTime(a.EndAt()), List: []*dto.ActivationSpendItem{}, TotalAmount: result.TotalAmount}
|
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 {
|
for _, item := range result.Items {
|
||||||
out.List = append(out.List, &dto.ActivationSpendItem{PlatformTitle: business.PlatformName(a.PlatformName), GameName: item.GameName, TotalAmount: item.Amount})
|
out.List = append(out.List, &dto.ActivationSpendItem{PlatformTitle: business.PlatformName(func() string {
|
||||||
|
if item.PlatformName != "" {
|
||||||
|
return item.PlatformName
|
||||||
|
}
|
||||||
|
return a.PlatformName
|
||||||
|
}()), GameName: item.GameName, TotalAmount: item.Amount})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package business
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"kra/internal/biz/business"
|
||||||
|
"kra/internal/service/dto"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func activationPoolFilter(req *dto.ActivationPoolQuery) (business.ActivationPoolFilter, []business.ListOption, error) {
|
||||||
|
f := business.ActivationPoolFilter{}
|
||||||
|
if req == nil {
|
||||||
|
return f, nil, business.ErrActivationInput
|
||||||
|
}
|
||||||
|
for _, source := range splitQueryValues(req.Sources) {
|
||||||
|
value, err := business.NormalizePlatform(source)
|
||||||
|
if err != nil {
|
||||||
|
return f, nil, err
|
||||||
|
}
|
||||||
|
f.Sources = append(f.Sources, value)
|
||||||
|
}
|
||||||
|
f.Name = strings.TrimSpace(req.Name)
|
||||||
|
f.Target = strings.TrimSpace(req.Target)
|
||||||
|
f.Status = req.Status
|
||||||
|
f.ChannelType = req.ChannelType
|
||||||
|
if f.ChannelType != "" && f.ChannelType != "公会" && f.ChannelType != "个人" {
|
||||||
|
return f, nil, business.ErrActivationInput
|
||||||
|
}
|
||||||
|
if f.Status != "" && f.Status != "正常" && f.Status != "锁定" && f.Status != "解除合作" {
|
||||||
|
return f, nil, business.ErrActivationInput
|
||||||
|
}
|
||||||
|
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||||
|
if req.Begin != "" {
|
||||||
|
v, err := time.ParseInLocation("2006-01-02", req.Begin, loc)
|
||||||
|
if err != nil {
|
||||||
|
return f, nil, business.ErrActivationInput
|
||||||
|
}
|
||||||
|
f.Begin = &v
|
||||||
|
}
|
||||||
|
if req.End != "" {
|
||||||
|
v, err := time.ParseInLocation("2006-01-02", req.End, loc)
|
||||||
|
if err != nil {
|
||||||
|
return f, nil, business.ErrActivationInput
|
||||||
|
}
|
||||||
|
f.End = &v
|
||||||
|
}
|
||||||
|
if f.Begin != nil && f.End != nil && f.Begin.After(*f.End) {
|
||||||
|
return f, nil, business.ErrActivationInput
|
||||||
|
}
|
||||||
|
page, size := req.Page, req.PageSize
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size < 1 {
|
||||||
|
size = 10
|
||||||
|
}
|
||||||
|
if size > 100 {
|
||||||
|
size = 100
|
||||||
|
}
|
||||||
|
if page > int(^uint(0)>>1)/size {
|
||||||
|
return f, nil, business.ErrActivationInput
|
||||||
|
}
|
||||||
|
return f, []business.ListOption{business.ListOffset((page - 1) * size), business.ListLimit(size)}, nil
|
||||||
|
}
|
||||||
|
func (s *ActivationService) Pool(ctx context.Context, req *dto.ActivationPoolQuery) (*dto.ActivationPoolPage, error) {
|
||||||
|
f, opts, err := activationPoolFilter(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
page, err := s.pool.Pool(ctx, f, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := &dto.ActivationPoolPage{Rows: []*dto.ActivationPoolRow{}, Total: page.Total, Available: page.Available}
|
||||||
|
for _, r := range page.Rows {
|
||||||
|
out.Rows = append(out.Rows, &dto.ActivationPoolRow{ID: r.ID, SourceDB: r.SourceDB, PlatformTitle: business.PlatformName(r.SourceDB), Name: r.Name, ChannelType: r.ChannelType, Status: r.Status, NoLoginDays: r.NoLoginDays, Rejected: r.Rejected, RejectReason: r.RejectReason, RejectedAt: localTime(r.RejectedAt)})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (s *ActivationService) Options(ctx context.Context) ([]*dto.ActivationBindingOption, error) {
|
||||||
|
rows, err := s.pool.Options(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []*dto.ActivationBindingOption{}
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, &dto.ActivationBindingOption{ID: r.ID, SourceDB: r.SourceDB, Account: r.Account, Level: r.Level})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (s *ActivationService) Apply(ctx context.Context, req *dto.ActivationApplyRequest) error {
|
||||||
|
if req == nil || req.PoolID == 0 || req.BusierID == 0 {
|
||||||
|
return business.ErrActivationInput
|
||||||
|
}
|
||||||
|
return s.pool.Apply(ctx, req.PoolID, req.BusierID, req.SourceDB)
|
||||||
|
}
|
||||||
|
func (s *ActivationService) Churn(ctx context.Context, req *dto.ActivationPoolQuery) (*dto.ActivationChurnPage, error) {
|
||||||
|
f, opts, err := activationPoolFilter(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
page, err := s.pool.Churn(ctx, f, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := &dto.ActivationChurnPage{Rows: []*dto.ActivationChurnRow{}, Total: page.Total, Available: page.Available}
|
||||||
|
for _, r := range page.Rows {
|
||||||
|
out.Rows = append(out.Rows, &dto.ActivationChurnRow{ID: r.ID, SourceDB: r.SourceDB, PlatformTitle: business.PlatformName(r.SourceDB), Name: r.Name, From: r.From, To: r.To, Reason: r.Reason, Operator: r.Operator, At: *localTime(&r.At), ToPool: r.ToPool})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -9,19 +9,22 @@ type ActivationQuery struct {
|
|||||||
BusierAccount string `form:"busierAccount"`
|
BusierAccount string `form:"busierAccount"`
|
||||||
}
|
}
|
||||||
type ActivationResponse struct {
|
type ActivationResponse struct {
|
||||||
ID uint64 `json:"id,string"`
|
CreatedAt *string `json:"createdAt"`
|
||||||
PromoteAccount string `json:"promoteAccount"`
|
RegisteredPlayers *int64 `json:"registeredPlayers"`
|
||||||
PlatformName string `json:"platformName"`
|
MetricsAvailable bool `json:"metricsAvailable"`
|
||||||
PlatformTitle string `json:"platformTitle"`
|
ID uint64 `json:"id,string"`
|
||||||
BusierAccount string `json:"busierAccount"`
|
PromoteAccount string `json:"promoteAccount"`
|
||||||
ApplicantOA string `json:"applicantOa"`
|
PlatformName string `json:"platformName"`
|
||||||
BindAt *string `json:"bindAt"`
|
PlatformTitle string `json:"platformTitle"`
|
||||||
UnbindAt *string `json:"unbindAt"`
|
BusierAccount string `json:"busierAccount"`
|
||||||
ReachAt *string `json:"reachAt"`
|
ApplicantOA string `json:"applicantOa"`
|
||||||
EndAt *string `json:"endAt"`
|
BindAt *string `json:"bindAt"`
|
||||||
AccumAmount string `json:"accumAmount"`
|
UnbindAt *string `json:"unbindAt"`
|
||||||
Status string `json:"status"`
|
ReachAt *string `json:"reachAt"`
|
||||||
StatusText string `json:"statusText"`
|
EndAt *string `json:"endAt"`
|
||||||
|
AccumAmount string `json:"accumAmount"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusText string `json:"statusText"`
|
||||||
}
|
}
|
||||||
type ActivationPage struct {
|
type ActivationPage struct {
|
||||||
Rows []*ActivationResponse `json:"rows"`
|
Rows []*ActivationResponse `json:"rows"`
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
type ActivationPoolQuery struct {
|
||||||
|
Page int `form:"pageNum"`
|
||||||
|
PageSize int `form:"pageSize"`
|
||||||
|
Sources []string `form:"platformNames"`
|
||||||
|
Name string `form:"promoteAccount"`
|
||||||
|
ChannelType string `form:"channelType"`
|
||||||
|
Status string `form:"status"`
|
||||||
|
Target string `form:"target"`
|
||||||
|
Begin string `form:"begin"`
|
||||||
|
End string `form:"end"`
|
||||||
|
}
|
||||||
|
type ActivationPoolRow struct {
|
||||||
|
ID uint64 `json:"id,string"`
|
||||||
|
SourceDB string `json:"sourceDb"`
|
||||||
|
PlatformTitle string `json:"platformTitle"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ChannelType string `json:"channelType"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
NoLoginDays *int64 `json:"noLoginDays"`
|
||||||
|
Rejected bool `json:"rejected"`
|
||||||
|
RejectReason string `json:"rejectReason"`
|
||||||
|
RejectedAt *string `json:"rejectedAt"`
|
||||||
|
}
|
||||||
|
type ActivationPoolPage struct {
|
||||||
|
Rows []*ActivationPoolRow `json:"rows"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
}
|
||||||
|
type ActivationBindingOption struct {
|
||||||
|
ID uint64 `json:"id,string"`
|
||||||
|
SourceDB string `json:"sourceDb"`
|
||||||
|
Account string `json:"account"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
}
|
||||||
|
type ActivationApplyRequest struct {
|
||||||
|
PoolID uint64 `json:"poolId,string"`
|
||||||
|
BusierID uint64 `json:"busierId,string"`
|
||||||
|
SourceDB string `json:"sourceDb"`
|
||||||
|
}
|
||||||
|
type ActivationChurnRow struct {
|
||||||
|
ID uint64 `json:"id,string"`
|
||||||
|
SourceDB string `json:"sourceDb"`
|
||||||
|
PlatformTitle string `json:"platformTitle"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Operator string `json:"operator"`
|
||||||
|
At string `json:"at"`
|
||||||
|
ToPool bool `json:"toPool"`
|
||||||
|
}
|
||||||
|
type ActivationChurnPage struct {
|
||||||
|
Rows []*ActivationChurnRow `json:"rows"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
}
|
||||||
@@ -2,3 +2,8 @@ import service from '@/utils/businessRequest'
|
|||||||
|
|
||||||
export const listChannelActivations = (params) => service({ url: '/sw/promoteActivePool/bindList', method: 'get', params })
|
export const listChannelActivations = (params) => service({ url: '/sw/promoteActivePool/bindList', method: 'get', params })
|
||||||
export const getActivationSpend = (id, options = {}) => service({ ...options, url: '/sw/promoteActivePool/bindSpendDetail', method: 'get', params: { id } })
|
export const getActivationSpend = (id, options = {}) => service({ ...options, url: '/sw/promoteActivePool/bindSpendDetail', method: 'get', params: { id } })
|
||||||
|
|
||||||
|
export const listActivationPool = (params, options = {}) => service({ ...options, url: '/sw/promoteActivePool/pool', method: 'get', params })
|
||||||
|
export const listActivationOptions = () => service({ url: '/sw/promoteActivePool/options', method: 'get' })
|
||||||
|
export const applyActivation = (data) => service({ url: '/sw/promoteActivePool/apply', method: 'post', data })
|
||||||
|
export const listActivationChurn = (params, options = {}) => service({ ...options, url: '/sw/promoteActivePool/churn', method: 'get', params })
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="sw-page">
|
<div class="sw-page">
|
||||||
|
<el-tabs v-model="tab" @tab-change="changeTab"><el-tab-pane label="激活列表" name="activate" /><el-tab-pane label="申请绑定" name="apply" /><el-tab-pane label="流失记录" name="churn" /></el-tabs>
|
||||||
|
<activation-resources v-if="tab !== 'activate'" :key="tab" :mode="tab" @applied="showActivations" />
|
||||||
|
<template v-else>
|
||||||
<div class="kra-search-box">
|
<div class="kra-search-box">
|
||||||
<el-form :model="query" :inline="true" @submit.prevent="search">
|
<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-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.promoteAccount" clearable placeholder="渠道名称 / 账号" /></el-form-item>
|
||||||
<el-form-item label="绑定商务"><el-input v-model="query.busierAccount" clearable placeholder="绑定商务" /></el-form-item>
|
<el-form-item label="绑定商务"><el-input v-model="query.busierAccount" clearable placeholder="绑定商务" /></el-form-item>
|
||||||
|
<el-form-item label="状态"><el-select v-model="query.statuses" clearable placeholder="全部状态" style="width:140px"><el-option v-for="(label,value) in statuses" :key="value" :label="label" :value="value" /></el-select></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-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>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
@@ -15,14 +19,16 @@
|
|||||||
<el-table-column prop="platformTitle" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb || row.platformCode" :label="row.platformTitle" /></template></el-table-column>
|
<el-table-column prop="platformTitle" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb || row.platformCode" :label="row.platformTitle" /></template></el-table-column>
|
||||||
<el-table-column prop="promoteAccount" label="渠道名称" min-width="140" show-overflow-tooltip />
|
<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 prop="busierAccount" label="绑定商务" min-width="130" show-overflow-tooltip />
|
||||||
<el-table-column label="绑定日期" min-width="120" class-name="sw-col-meta"><template #default="{ row }">{{ date(row.bindAt) }}</template></el-table-column>
|
<el-table-column prop="bindAt" sortable :sort-method="(a,b) => (a.bindAt || a.createdAt || '').localeCompare(b.bindAt || b.createdAt || '')" label="绑定日期" min-width="120" class-name="sw-col-meta"><template #default="{ row }">{{ date(row.bindAt || row.createdAt) }}</template></el-table-column>
|
||||||
<el-table-column label="解绑时间" min-width="120" class-name="sw-col-meta"><template #default="{ row }">{{ date(row.unbindAt) }}</template></el-table-column>
|
<el-table-column label="解绑时间" min-width="120" class-name="sw-col-meta"><template #default="{ row }">{{ date(row.unbindAt) }}</template></el-table-column>
|
||||||
<el-table-column label="累计流水金额" min-width="160" align="right"><template #default="{ row }"><metric-value kind="money" :value="row.accumAmount" /></template></el-table-column>
|
<el-table-column prop="accumAmount" sortable :sort-method="compareAmount" label="累计流水金额" min-width="160" align="right"><template #default="{ row }"><metric-value kind="money" :value="row.bindAt && row.metricsAvailable ? row.accumAmount : null" /></template></el-table-column>
|
||||||
|
<el-table-column label="累计注册玩家" min-width="145" align="right"><template #default="{ row }"><metric-value kind="count" :value="row.registeredPlayers" /></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="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-column label="操作" width="120" fixed="right"><template #default="{ row }"><el-button v-if="row.bindAt && !['pending','rejected'].includes(row.status)" link type="primary" @click="openFlow(row)">流水明细</el-button><span v-else>—</span></template></el-table-column>
|
||||||
</el-table>
|
</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 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>
|
</div>
|
||||||
|
</template>
|
||||||
<el-dialog class="sw-dialog" v-model="dialog" title="流水明细" width="min(640px, 95vw)" append-to-body destroy-on-close>
|
<el-dialog class="sw-dialog" v-model="dialog" title="流水明细" width="min(640px, 95vw)" append-to-body destroy-on-close>
|
||||||
<div v-loading="flowLoading">
|
<div v-loading="flowLoading">
|
||||||
<el-descriptions :column="1">
|
<el-descriptions :column="1">
|
||||||
@@ -47,6 +53,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import ActivationResources from './components/ActivationResources.vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import PlatformTag from './components/PlatformTag.vue'
|
import PlatformTag from './components/PlatformTag.vue'
|
||||||
import MetricValue from './components/MetricValue.vue'
|
import MetricValue from './components/MetricValue.vue'
|
||||||
import { computed, onBeforeUnmount, onDeactivated, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onDeactivated, onMounted, reactive, ref, watch } from 'vue'
|
||||||
@@ -54,8 +62,19 @@ import { createLatestRequest } from '@/utils/latestRequest'
|
|||||||
import { listChannelActivations, getActivationSpend } from '@/api/activation'
|
import { listChannelActivations, getActivationSpend } from '@/api/activation'
|
||||||
|
|
||||||
defineOptions({ name: 'SwChannelActivationList' })
|
defineOptions({ name: 'SwChannelActivationList' })
|
||||||
|
const route = useRoute(), router = useRouter()
|
||||||
|
const tabFromHash = () => ['apply','churn'].includes(route.hash.slice(1)) ? route.hash.slice(1) : 'activate'
|
||||||
|
const tab = ref(tabFromHash())
|
||||||
|
const statuses = { pending: '审核中', in_progress: '进行中', reached: '已达标', unbound: '已解绑' }
|
||||||
|
const changeTab = value => { router.replace({ hash: `#${value}` }); if (value === 'activate') load() }
|
||||||
|
const showActivations = () => { tab.value = 'activate'; changeTab('activate') }
|
||||||
|
watch(() => route.hash, () => { const next = tabFromHash(); if (next !== tab.value) { tab.value = next; if (next === 'activate') load() } })
|
||||||
|
const compareAmount = (a,b) => {
|
||||||
|
const cents = value => { const [whole, fraction = ''] = String(value || '0').split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2,'0').slice(0,2)) }
|
||||||
|
const x = cents(a.accumAmount), y = cents(b.accumAmount); return x === y ? 0 : x > y ? 1 : -1
|
||||||
|
}
|
||||||
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||||
const query = reactive({ pageNum: 1, pageSize: 10, platformNames: Object.keys(platforms), promoteAccount: '', busierAccount: '' })
|
const query = reactive({ pageNum: 1, pageSize: 10, platformNames: Object.keys(platforms), promoteAccount: '', busierAccount: '', statuses: '' })
|
||||||
const rows = ref([])
|
const rows = ref([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const available = ref(true)
|
const available = ref(true)
|
||||||
@@ -98,7 +117,7 @@ const load = async () => {
|
|||||||
} catch { if (version === listVersion) error.value = '渠道激活查询失败,请重试' } finally { if (version === listVersion) loading.value = false }
|
} catch { if (version === listVersion) error.value = '渠道激活查询失败,请重试' } finally { if (version === listVersion) loading.value = false }
|
||||||
}
|
}
|
||||||
const search = () => { query.pageNum = 1; return load() }
|
const search = () => { query.pageNum = 1; return load() }
|
||||||
const reset = () => { Object.assign(query, { platformNames: Object.keys(platforms), promoteAccount: '', busierAccount: '' }); return search() }
|
const reset = () => { Object.assign(query, { platformNames: Object.keys(platforms), promoteAccount: '', busierAccount: '', statuses: '' }); return search() }
|
||||||
const clearDetail = () => { detailRequests.cancel(); detail.value = null; selected.value = null; flowError.value = ''; flowLoading.value = false }
|
const clearDetail = () => { detailRequests.cancel(); detail.value = null; selected.value = null; flowError.value = ''; flowLoading.value = false }
|
||||||
const openFlow = async row => {
|
const openFlow = async row => {
|
||||||
clearDetail()
|
clearDetail()
|
||||||
@@ -116,5 +135,5 @@ const openFlow = async row => {
|
|||||||
watch(dialog, open => { if (!open) clearDetail() }, { flush: 'sync' })
|
watch(dialog, open => { if (!open) clearDetail() }, { flush: 'sync' })
|
||||||
onBeforeUnmount(clearDetail)
|
onBeforeUnmount(clearDetail)
|
||||||
onDeactivated(() => { dialog.value = false; clearDetail() })
|
onDeactivated(() => { dialog.value = false; clearDetail() })
|
||||||
onMounted(load)
|
onMounted(() => { if (tab.value === 'activate') load() })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="kra-search-box">
|
||||||
|
<el-form :inline="true" :model="query" @submit.prevent="search">
|
||||||
|
<el-form-item label="所属平台"><el-select v-model="query.platformNames" multiple collapse-tags clearable placeholder="全部平台" style="width:190px"><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 /></el-form-item>
|
||||||
|
<template v-if="isPool">
|
||||||
|
<el-form-item label="渠道类型"><el-select v-model="query.channelType" clearable placeholder="全部类型" style="width:130px"><el-option v-for="value in ['公会','个人']" :key="value" :value="value" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="锁定状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:140px"><el-option v-for="value in ['正常','锁定','解除合作']" :key="value" :value="value" /></el-select></el-form-item>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-form-item label="换绑到的商务"><el-input v-model="query.target" clearable /></el-form-item>
|
||||||
|
<el-form-item label="流失时间"><el-date-picker v-model="dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
|
||||||
|
</template>
|
||||||
|
<el-form-item><el-button @click="reset">重置</el-button><el-button type="primary" native-type="submit">搜索</el-button></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
<div class="kra-table-box">
|
||||||
|
<el-alert v-if="error" :title="error" type="error" :closable="false" class="mb-4" />
|
||||||
|
<el-alert v-else-if="!available" title="数据暂不可用,请稍后重试或联系管理员。" type="warning" :closable="false" class="mb-4" />
|
||||||
|
<el-table v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${row.id}`" @sort-change="sortChanged">
|
||||||
|
<el-table-column label="所属平台" width="110"><template #default="{row}"><platform-tag :code="row.sourceDb" :label="row.platformTitle" /></template></el-table-column>
|
||||||
|
<el-table-column label="渠道名称" min-width="180"><template #default="{row}">{{ row.name }} <el-tag v-if="row.rejected" type="danger" size="small">已驳回</el-tag></template></el-table-column>
|
||||||
|
<template v-if="isPool">
|
||||||
|
<el-table-column prop="channelType" label="渠道类型" width="120" />
|
||||||
|
<el-table-column label="锁定状态" width="120"><template #default="{row}"><el-tag :type="row.status === '正常' ? 'success' : row.status === '锁定' ? 'warning' : 'danger'">{{ row.status }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column prop="noLoginDays" label="未登录天数" sortable width="150"><template #default="{row}">{{ row.noLoginDays == null ? '—' : `${row.noLoginDays} 天` }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="120"><template #default="{row}"><el-button link type="primary" @click="openApplication(row)">申请绑定</el-button></template></el-table-column>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-table-column prop="at" label="流失时间" width="180" />
|
||||||
|
<el-table-column prop="from" label="原归属商务" min-width="150" />
|
||||||
|
<el-table-column label="换绑到的商务" min-width="150"><template #default="{row}"><el-tag v-if="row.toPool" type="warning">渠道激活池</el-tag><span v-else>{{ row.to || '—' }}</span></template></el-table-column>
|
||||||
|
<el-table-column prop="reason" label="流失原因" min-width="140" />
|
||||||
|
<el-table-column prop="operator" label="操作人" min-width="130" />
|
||||||
|
</template>
|
||||||
|
</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" @current-change="load" @size-change="search" /></div>
|
||||||
|
</div>
|
||||||
|
<el-dialog v-model="dialog" title="申请绑定" width="min(520px,95vw)" append-to-body :close-on-click-modal="!submitting" :show-close="!submitting">
|
||||||
|
<el-descriptions :column="1"><el-descriptions-item label="渠道名称">{{ selected?.name }}</el-descriptions-item><el-descriptions-item label="所属平台">{{ selected?.platformTitle }}</el-descriptions-item><el-descriptions-item label="锁定状态">{{ selected?.status }}</el-descriptions-item></el-descriptions>
|
||||||
|
<el-alert v-if="selected?.rejected" :title="`上次申请已驳回 ${selected.rejectedAt || ''}`" :description="selected.rejectReason || '请联系审核人了解原因'" type="warning" :closable="false" class="mb-4" />
|
||||||
|
<el-form label-width="85px"><el-form-item label="绑定商务" required><el-select v-model="busierId" placeholder="选择本人已绑定的游戏商务" style="width:100%" :loading="optionsLoading" :disabled="submitting"><el-option v-for="option in applicableOptions" :key="option.id" :value="option.id" :label="`${option.account}(ID ${option.id} · ${option.level}级)`" /></el-select></el-form-item></el-form>
|
||||||
|
<el-alert v-if="applyError" :title="applyError" type="error" :closable="false" />
|
||||||
|
<el-alert v-else-if="!optionsLoading && !applicableOptions.length" title="当前平台暂无已审核通过的游戏商务,请先完成账号绑定。" type="info" :closable="false" />
|
||||||
|
<template #footer><el-button :disabled="submitting" @click="dialog=false">取消</el-button><el-button type="primary" :disabled="!busierId || optionsLoading" :loading="submitting" @click="submit">提交申请</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { listActivationPool, listActivationChurn, listActivationOptions, applyActivation } from '@/api/activation'
|
||||||
|
import { createLatestRequest } from '@/utils/latestRequest'
|
||||||
|
import PlatformTag from './PlatformTag.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ mode: { type: String, required: true } }), emit = defineEmits(['applied'])
|
||||||
|
const isPool = computed(() => props.mode === 'apply'), platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||||
|
const defaults = () => ({ pageNum: 1, pageSize: 10, platformNames: [], promoteAccount: '', channelType: '', status: '', target: '' })
|
||||||
|
const query = reactive(defaults()), dates = ref([]), rows = ref([]), total = ref(0), loading = ref(false), error = ref(''), available = ref(true)
|
||||||
|
const dialog = ref(false), selected = ref(null), busierId = ref(''), options = ref([]), optionsLoading = ref(false), submitting = ref(false), applyError = ref('')
|
||||||
|
const applicableOptions = computed(() => options.value.filter(option => option.sourceDb === selected.value?.sourceDb))
|
||||||
|
const requests = createLatestRequest(); let optionVersion = 0
|
||||||
|
const load = async () => {
|
||||||
|
const request = requests.start(); loading.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await (isPool.value ? listActivationPool : listActivationChurn)({ ...query, platformNames: query.platformNames.join(','), begin: dates.value?.[0] || '', end: dates.value?.[1] || '' }, { signal: request.signal })
|
||||||
|
if (!request.isCurrent()) return
|
||||||
|
if (res.code !== 0) throw new Error(res.msg || '查询失败')
|
||||||
|
rows.value = res.data.rows; total.value = res.data.total; available.value = res.data.available
|
||||||
|
} catch (e) { if (request.isCurrent()) { rows.value = []; total.value = 0; error.value = e.message || '查询失败,请重试' } }
|
||||||
|
finally { if (request.isCurrent()) loading.value = false }
|
||||||
|
}
|
||||||
|
const search = () => { query.pageNum = 1; return load() }
|
||||||
|
const reset = () => { Object.assign(query, defaults()); dates.value = []; return load() }
|
||||||
|
const sortChanged = () => { if (query.pageNum !== 1) { query.pageNum = 1; load() } }
|
||||||
|
const openApplication = async row => {
|
||||||
|
selected.value = row; busierId.value = ''; applyError.value = ''; options.value = []; dialog.value = true; optionsLoading.value = true
|
||||||
|
const version = ++optionVersion
|
||||||
|
try { const res = await listActivationOptions(); if (version !== optionVersion) return; if (res.code !== 0) throw new Error(res.msg); options.value = res.data }
|
||||||
|
catch (e) { if (version === optionVersion) applyError.value = e.message || '商务选项读取失败' }
|
||||||
|
finally { if (version === optionVersion) optionsLoading.value = false }
|
||||||
|
}
|
||||||
|
const submit = async () => {
|
||||||
|
if (submitting.value || !busierId.value || !selected.value) return
|
||||||
|
const channel = selected.value, option = applicableOptions.value.find(item => item.id === busierId.value)
|
||||||
|
if (!option) return
|
||||||
|
try { await ElMessageBox.confirm(`确认申请绑定「${channel.name}」到「${option.account}」?提交后等待 OA 审核。`, '提交绑定申请', { type: 'warning' }) } catch { return }
|
||||||
|
submitting.value = true; applyError.value = ''
|
||||||
|
try { const res = await applyActivation({ poolId: channel.id, busierId: option.id, sourceDb: channel.sourceDb }); if (res.code !== 0) throw new Error(res.msg); dialog.value = false; ElMessage.success('申请已提交,请在激活列表查看进度'); emit('applied') }
|
||||||
|
catch (e) { applyError.value = e.message || '提交失败,请重试' }
|
||||||
|
finally { submitting.value = false }
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
onBeforeUnmount(() => { requests.cancel(); ++optionVersion })
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user