Compare commits
10 Commits
internal
...
07409eb602
| Author | SHA1 | Date | |
|---|---|---|---|
| 07409eb602 | |||
| 117dc0d6a7 | |||
| c92495c5b9 | |||
| 92cf2921dd | |||
| ce3babcc33 | |||
| a46fb83054 | |||
| 9933d34bdd | |||
| d2710d356f | |||
| f0a5288e20 | |||
| 77fa0cadd2 |
@@ -43,6 +43,7 @@ logs/
|
||||
/test/
|
||||
*_test.go
|
||||
!tests/acceptance/*_test.go
|
||||
!internal/handler/subscribe_test.go
|
||||
*_test_config.go
|
||||
**/logtest/
|
||||
*_test.yaml
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "Lottery Admin API"
|
||||
desc: "Admin-facing lottery endpoints for HIF-3 Stage 1"
|
||||
author: "hifast"
|
||||
version: "0.1.0"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
@server (
|
||||
prefix: v1/admin/lottery
|
||||
group: admin/lottery
|
||||
middleware: AuthMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Create a new activity (status=draft)"
|
||||
@handler CreateLotteryActivity
|
||||
post /activities (CreateAdminLotteryActivityRequest) returns (AdminLotteryActivity)
|
||||
|
||||
@doc "Update mutable activity fields"
|
||||
@handler UpdateLotteryActivity
|
||||
put /activities (UpdateAdminLotteryActivityRequest) returns (AdminLotteryActivity)
|
||||
|
||||
@doc "List activities (paginated)"
|
||||
@handler ListLotteryActivities
|
||||
get /activities (ListAdminLotteryActivitiesRequest) returns (ListAdminLotteryActivitiesResponse)
|
||||
|
||||
@doc "Get one activity"
|
||||
@handler GetLotteryActivity
|
||||
get /activities/detail (AdminActivityIdRequest) returns (AdminLotteryActivity)
|
||||
|
||||
@doc "Publish (draft/paused → running)"
|
||||
@handler PublishLotteryActivity
|
||||
post /activities/publish (AdminActivityIdRequest)
|
||||
|
||||
@doc "Pause (running → paused)"
|
||||
@handler PauseLotteryActivity
|
||||
post /activities/pause (AdminActivityIdRequest)
|
||||
|
||||
@doc "Update eligibility/chance_sources (rule-caps enforced)"
|
||||
@handler UpdateLotteryRules
|
||||
put /activities/rules (UpdateAdminLotteryRulesRequest)
|
||||
|
||||
@doc "Create prize"
|
||||
@handler CreateLotteryPrize
|
||||
post /prizes (CreateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||
|
||||
@doc "Update prize"
|
||||
@handler UpdateLotteryPrize
|
||||
put /prizes (UpdateAdminLotteryPrizeRequest) returns (AdminLotteryPrize)
|
||||
|
||||
@doc "Delete prize"
|
||||
@handler DeleteLotteryPrize
|
||||
delete /prizes (AdminPrizeIdRequest)
|
||||
|
||||
@doc "List prizes on an activity"
|
||||
@handler ListLotteryPrizes
|
||||
get /prizes (ListAdminLotteryPrizesRequest) returns (ListAdminLotteryPrizesResponse)
|
||||
|
||||
@doc "Manually grant N chances to a user (idempotent by source_ref)"
|
||||
@handler GrantLotteryChance
|
||||
post /chances/grant (GrantAdminLotteryChanceRequest)
|
||||
|
||||
// Stage 2 (HIF-4): 人工奖工单接口
|
||||
@doc "List manual-claim work orders (filter by type/status/activity/user/time)"
|
||||
@handler ListLotteryClaims
|
||||
get /claims (ListAdminLotteryClaimsRequest) returns (ListAdminLotteryClaimsResponse)
|
||||
|
||||
@doc "Summary counts for claims workbench"
|
||||
@handler LotteryClaimsSummary
|
||||
get /claims/summary returns (AdminLotteryClaimsSummary)
|
||||
|
||||
@doc "Approve a claim (reviewing -> paying)"
|
||||
@handler ApproveLotteryClaim
|
||||
post /claims/approve (AdminApproveClaimRequest)
|
||||
|
||||
@doc "Reject a claim (reviewing/paying -> rejected; user may resubmit)"
|
||||
@handler RejectLotteryClaim
|
||||
post /claims/reject (AdminRejectClaimRequest)
|
||||
|
||||
@doc "Mark as paid (paying -> paid, records tx_hash/delivery_ref)"
|
||||
@handler MarkPaidLotteryClaim
|
||||
post /claims/mark-paid (AdminMarkPaidClaimRequest)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "Lottery API"
|
||||
desc: "User-facing lottery endpoints for HIF-3 Stage 1"
|
||||
author: "hifast"
|
||||
version: "0.1.0"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
@server (
|
||||
prefix: v1/lottery
|
||||
group: public/lottery
|
||||
middleware: AuthMiddleware,DeviceMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Get lottery activity config + user status"
|
||||
@handler QueryLotteryConfig
|
||||
get /config (GetLotteryConfigRequest) returns (GetLotteryConfigResponse)
|
||||
|
||||
@doc "Draw once (nonce idempotent, rate limited 1/sec)"
|
||||
@handler DrawLottery
|
||||
post /draw (DrawLotteryRequest) returns (DrawLotteryResponse)
|
||||
|
||||
@doc "List my draws"
|
||||
@handler QueryLotteryRecords
|
||||
get /records (GetLotteryRecordsRequest) returns (GetLotteryRecordsResponse)
|
||||
|
||||
@doc "Claim a prize (Stage 1 returns 100010 not_claimable)"
|
||||
@handler ClaimLotteryPrize
|
||||
post /claim (ClaimLotteryPrizeRequest) returns (ClaimLotteryPrizeResponse)
|
||||
}
|
||||
@@ -82,6 +82,7 @@ require (
|
||||
github.com/alibabacloud-go/tea-utils v1.4.5 // indirect
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 // indirect
|
||||
github.com/aliyun/credentials-go v1.3.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||
@@ -145,6 +146,7 @@ require (
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
||||
|
||||
@@ -54,6 +54,8 @@ github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/
|
||||
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||
github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA=
|
||||
@@ -395,6 +397,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 02156 抽奖活动 Stage 1 回滚
|
||||
-- 反向删除 7 张表。因存在业务耦合数据(用户次数、抽奖记录、快照)在生产回滚前
|
||||
-- 必须先备份,回滚只删表结构。执行顺序按外键依赖反向:先删依赖别人的,再删被依赖的。
|
||||
|
||||
DROP TABLE IF EXISTS `lottery_eligibility_snapshot`;
|
||||
DROP TABLE IF EXISTS `lottery_prize_snapshot`;
|
||||
DROP TABLE IF EXISTS `lottery_draw`;
|
||||
DROP TABLE IF EXISTS `lottery_chance_grant`;
|
||||
DROP TABLE IF EXISTS `lottery_chance_balance`;
|
||||
DROP TABLE IF EXISTS `lottery_prize`;
|
||||
DROP TABLE IF EXISTS `lottery_activity`;
|
||||
@@ -0,0 +1,125 @@
|
||||
-- 02156 抽奖活动 Stage 1(后端核心闭环)
|
||||
--
|
||||
-- 新建 7 张表 + 全部索引 + 幂等约束。
|
||||
-- 幂等设计:全部 `CREATE TABLE IF NOT EXISTS`;索引通过 INFORMATION_SCHEMA 预检
|
||||
-- 后再补齐。可重复执行不报错,符合 `doc/development-workflow-zh.md` 迁移规范。
|
||||
--
|
||||
-- 关键唯一索引(都是并发/幂等正确性的核心,切勿删):
|
||||
-- 1) lottery_prize (activity_id, slot) — 一个活动一个位置只能挂一个奖品
|
||||
-- 2) lottery_draw (user_id, client_nonce) — 用户端幂等键,重放同一 nonce 返回同一 draw
|
||||
-- 3) lottery_chance_balance (user_id, activity_id) — 每人每活动一个次数余额行
|
||||
-- 4) lottery_chance_grant (activity_id, source, source_ref) — 次数入账幂等键(避免同订单发两次机会)
|
||||
-- 5) lottery_prize_snapshot (draw_id) — 抽奖时刻的奖品快照,1:1
|
||||
-- 6) lottery_eligibility_snapshot (draw_id) — 抽奖时刻的门槛评估快照,1:1
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_activity` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '活动标题',
|
||||
`description` TEXT COMMENT '活动描述(Markdown)',
|
||||
`start_at` DATETIME NOT NULL COMMENT '开始时间',
|
||||
`end_at` DATETIME NOT NULL COMMENT '结束时间',
|
||||
`status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '状态:draft / running / paused / ended',
|
||||
`grid_size` TINYINT NOT NULL DEFAULT 9 COMMENT '前端九宫格数量(3/6/8/9/12)',
|
||||
`eligibility` JSON NOT NULL COMMENT '参与门槛(AND/OR 嵌套规则)',
|
||||
`chance_sources` JSON NOT NULL COMMENT '次数来源列表(daily_signin / new_subscription / invite_success / manual_grant)',
|
||||
`unmet_action` VARCHAR(32) NOT NULL DEFAULT 'block' COMMENT '未达门槛策略:block / show_reason',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_status_time` (`status`, `start_at`, `end_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖活动';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_prize` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '所属活动 ID',
|
||||
`slot` TINYINT NOT NULL COMMENT '九宫格位置(0-based)',
|
||||
`type` VARCHAR(32) NOT NULL COMMENT '奖品类型:vpn_duration / commission / balance / gift_amount / coupon / points / encrypted / physical / manual_other / none',
|
||||
`name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '奖品名称',
|
||||
`icon_url` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '奖品图标 URL',
|
||||
`config` JSON NOT NULL COMMENT '类型专属配置(如 {"duration_days":3})',
|
||||
`weight` INT NOT NULL DEFAULT 0 COMMENT '加权随机权重(0 表示不参与随机)',
|
||||
`total_stock` BIGINT COMMENT '总库存(NULL 表示无限)',
|
||||
`remaining_stock` BIGINT COMMENT '剩余库存(NULL 表示无限,与 total_stock 同 NULL)',
|
||||
`is_fallback` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为保底奖(1: 是,抽中限量奖降级到此;weight 被忽略)',
|
||||
`version` BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_activity_slot` (`activity_id`, `slot`),
|
||||
KEY `idx_activity_fallback` (`activity_id`, `is_fallback`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖奖品定义';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_chance_balance` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`remaining` BIGINT NOT NULL DEFAULT 0 COMMENT '剩余次数(下一次抽奖要读这里并 -1)',
|
||||
`total_earned` BIGINT NOT NULL DEFAULT 0 COMMENT '累计入账次数(审计用)',
|
||||
`total_spent` BIGINT NOT NULL DEFAULT 0 COMMENT '累计消耗次数(审计用)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数余额';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_chance_grant` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '发放对象用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`source` VARCHAR(32) NOT NULL COMMENT '触发源:daily_signin / new_subscription / invite_success / manual_grant',
|
||||
`source_ref` VARCHAR(128) NOT NULL COMMENT '外部业务幂等键(如 order_no、"signin:{yyyymmdd}"、"manual:{admin_id}:{ts}")',
|
||||
`amount` INT NOT NULL DEFAULT 0 COMMENT '本次发放次数',
|
||||
`expires_at` DATETIME DEFAULT NULL COMMENT '本次入账的到期时间(NULL 表示不过期)',
|
||||
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_activity_source_ref` (`activity_id`, `source`, `source_ref`),
|
||||
KEY `idx_user_activity_expires` (`user_id`, `activity_id`, `expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖次数入账流水(幂等键 = activity_id+source+source_ref)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_draw` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`client_nonce` VARCHAR(64) NOT NULL COMMENT '前端幂等键(UUID)',
|
||||
`prize_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '中奖奖品 ID(未中奖为 NULL)',
|
||||
`is_win` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否中奖(未中奖=谢谢参与,也会写 draw)',
|
||||
`dispatch_state` VARCHAR(16) NOT NULL DEFAULT 'none' COMMENT '发放状态:none(无需发) / auto_claimed(自动已发) / pending_claim(等待人工领) / paid(人工发完) / expired(超时未领) / failed',
|
||||
`dispatch_error` TEXT COMMENT '发放失败的错误信息(仅失败时写)',
|
||||
`dispatched_at` DATETIME DEFAULT NULL COMMENT '发放完成时间(自动类=事务提交时;人工类=运营录入后)',
|
||||
`drawn_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '抽奖时间',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_user_nonce` (`user_id`, `client_nonce`),
|
||||
KEY `idx_user_time` (`user_id`, `drawn_at`),
|
||||
KEY `idx_activity_win_time` (`activity_id`, `is_win`, `drawn_at`),
|
||||
KEY `idx_activity_prize` (`activity_id`, `prize_id`),
|
||||
KEY `idx_dispatch_state` (`dispatch_state`, `drawn_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_prize_snapshot` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`prize_id` BIGINT UNSIGNED NOT NULL COMMENT '奖品 ID(快照当时的 id)',
|
||||
`slot` TINYINT NOT NULL COMMENT '九宫格位置(快照)',
|
||||
`type` VARCHAR(32) NOT NULL COMMENT '奖品类型(快照)',
|
||||
`name` VARCHAR(128) NOT NULL COMMENT '奖品名称(快照)',
|
||||
`config` JSON NOT NULL COMMENT '类型专属配置(快照)',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_draw_id` (`draw_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的奖品快照(对账/纠纷用)';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_eligibility_snapshot` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`passed` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否通过门槛(未通过=拒绝抽奖或前端提示)',
|
||||
`unmet_reasons` JSON COMMENT '未通过项(rule/hint/current/required)',
|
||||
`evaluated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_draw_id` (`draw_id`),
|
||||
KEY `idx_user_activity_time` (`user_id`, `activity_id`, `evaluated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖时刻的门槛评估快照(对账/申诉用)';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 02157 抽奖发奖账本回滚
|
||||
DROP TABLE IF EXISTS `lottery_grant_ledger`;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 02157 抽奖发奖账本(PR B)
|
||||
--
|
||||
-- 目的:以 external_ref 作为 DB 层唯一键,做每个 draw 的发奖幂等。
|
||||
-- 各 PrizeHandler.Dispatch 内先 SELECT/INSERT lottery_grant_ledger,命中即幂等返回,
|
||||
-- 未命中再调下游发放(UpdateSubscribe / UpdateCommission + WriteCommissionLog),
|
||||
-- 全部在同一 tx 内完成 → 抽奖事务与发奖账本同生共死。
|
||||
--
|
||||
-- 关键唯一索引:external_ref。惯例值 = "lottery:{activity_id}:{draw_id}"。
|
||||
-- handler_type:与 lottery_prize.type 一致(vpn_duration / commission / …),用于统计。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_grant_ledger` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`external_ref` VARCHAR(128) NOT NULL COMMENT '幂等键:lottery:{activity_id}:{draw_id}',
|
||||
`handler_type` VARCHAR(32) NOT NULL COMMENT 'handler 类型,与 lottery_prize.type 对齐',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '发放对象用户 ID(家庭组已归位到 owner)',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`amount` BIGINT NOT NULL DEFAULT 0 COMMENT '发放数量(天/佣金金额,单位与 handler 一致)',
|
||||
`payload` JSON COMMENT '发放后的关键结果快照(订阅 ID、佣金前后余额等)',
|
||||
`granted_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发放完成时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_external_ref` (`external_ref`),
|
||||
KEY `idx_user_activity` (`user_id`, `activity_id`),
|
||||
KEY `idx_draw_id` (`draw_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖发奖账本(幂等键 = external_ref)';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 02158 admin_action_log 回滚
|
||||
DROP TABLE IF EXISTS `admin_action_log`;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 02158 admin_action_log —— 管理端写操作审计(PR C 起要求)
|
||||
--
|
||||
-- 每一条 admin CRUD/rules 更新都在同事务内插入一行审计流水,方便后续追责
|
||||
-- 与合规审查。actor_user_id 是操作者的 user.id;action 是操作动作
|
||||
-- (lottery.activity.create / lottery.prize.update / lottery.rules.put / ...);
|
||||
-- target_ids 是被操作对象的主键数组(JSON);request_hash 是请求 body 的 sha1
|
||||
-- 摘要(对同一批次多次写入去重);ip/user_agent 从上下文取。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `admin_action_log` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`actor_user_id` BIGINT UNSIGNED NOT NULL COMMENT '操作者 user.id',
|
||||
`action` VARCHAR(64) NOT NULL COMMENT '动作 code(点分层级)',
|
||||
`target_ids` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '被操作对象 ID 逗号分隔或 JSON 数组',
|
||||
`request_hash` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求 body sha1 摘要',
|
||||
`ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '操作者 IP',
|
||||
`user_agent` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '操作者 UA',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_actor_time` (`actor_user_id`, `created_at`),
|
||||
KEY `idx_action_time` (`action`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后台写操作审计流水';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 02159 抽奖活动 Stage 2 down migration
|
||||
-- Stage 2 只新增 1 张表,回滚直接 drop 即可。
|
||||
DROP TABLE IF EXISTS `lottery_claim`;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 02159 抽奖活动 Stage 2(人工奖领奖工单)
|
||||
--
|
||||
-- 新建 `lottery_claim` 表:承载 crypto / physical / manual_other 三类人工奖
|
||||
-- 从"抽中"到"运营打款/发货"的完整工单状态机。
|
||||
--
|
||||
-- 幂等设计:`CREATE TABLE IF NOT EXISTS`;一个 draw_id 只能有一条 claim 行
|
||||
-- (UNIQUE 约束保证 POST /draw 事务不会重复挂单,避免用户端重放时重复入队)。
|
||||
--
|
||||
-- 关键索引:
|
||||
-- 1) UNIQUE (draw_id) — 抽奖记录 ↔ 领奖工单 一对一
|
||||
-- 2) (activity_id, status) — 后台工单列表按活动 + 状态过滤
|
||||
-- 3) (user_id, activity_id) — GET /records 按用户拉工单
|
||||
-- 4) (status, expires_at) — 过期定时任务扫描
|
||||
--
|
||||
-- 状态机(详细见 doc/lottery-stage2 或 issue HIF-4):
|
||||
-- pending_claim ─── 用户提交 ──→ reviewing
|
||||
-- └── 超时 ──→ expired
|
||||
-- reviewing ─── 运营 approve ──→ paying
|
||||
-- └── 运营 reject ──→ rejected(用户可再次提交)
|
||||
-- paying ─── 运营 mark-paid ──→ paid(终态)
|
||||
-- └── 运营 reject ──→ rejected
|
||||
-- rejected ─── 用户再提交 ──→ reviewing
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `lottery_claim` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`draw_id` BIGINT UNSIGNED NOT NULL COMMENT '抽奖记录 ID',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
|
||||
`activity_id` BIGINT UNSIGNED NOT NULL COMMENT '活动 ID',
|
||||
`prize_type` VARCHAR(32) NOT NULL COMMENT '奖品类型(crypto/physical/manual_other,冗余便于后台按类型过滤)',
|
||||
`claim_data` JSON COMMENT '用户提交的领奖表单数据(结构随 prize_type 变化)',
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending_claim' COMMENT '状态:pending_claim / reviewing / paying / paid / rejected / expired',
|
||||
`submitted_at` DATETIME DEFAULT NULL COMMENT '用户提交领奖信息时间(首次提交后写;重新提交会覆盖)',
|
||||
`expires_at` DATETIME NOT NULL COMMENT '领奖窗口截止时间(默认 now+7d,可被奖品 config.claim_ttl_hours 覆盖)',
|
||||
`reviewed_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '最近一次审核操作者 user.id',
|
||||
`reviewed_at` DATETIME DEFAULT NULL COMMENT '最近一次审核时间',
|
||||
`reject_reason` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '拒绝原因',
|
||||
`tx_hash` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '链上交易哈希(crypto 打款)',
|
||||
`delivery_ref` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '快递单号 / 发货单据编号(physical 发货)',
|
||||
`paid_at` DATETIME DEFAULT NULL COMMENT '运营标记打款/发货完成时间',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_draw_id` (`draw_id`),
|
||||
KEY `idx_activity_status` (`activity_id`, `status`),
|
||||
KEY `idx_user_activity` (`user_id`, `activity_id`),
|
||||
KEY `idx_status_expires` (`status`, `expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖人工奖领奖工单';
|
||||
@@ -70,3 +70,6 @@ const RegisterIpKeyPrefix = "register:ip:"
|
||||
|
||||
// UserSessionsKeyPrefix per-user sessions zset key prefix
|
||||
const UserSessionsKeyPrefix = "auth:user_sessions:"
|
||||
|
||||
// UserEnableKeyPrefix user enable state cache key prefix
|
||||
const UserEnableKeyPrefix = "user:enable:"
|
||||
|
||||
@@ -39,6 +39,7 @@ type Config struct {
|
||||
Currency Currency `yaml:"Currency"`
|
||||
Trace trace.Config `yaml:"Trace"`
|
||||
S3 S3Config `yaml:"S3"`
|
||||
Lottery LotteryConfig `yaml:"Lottery"`
|
||||
Administrator struct {
|
||||
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
||||
Password string `yaml:"Password" default:"password"`
|
||||
@@ -250,6 +251,13 @@ type InviteConfig struct {
|
||||
GiftDays int64 `yaml:"GiftDays" default:"3"`
|
||||
}
|
||||
|
||||
// LotteryConfig 是抽奖 Stage 1 的 feature flag。默认关闭,交 QA 前手动打开。
|
||||
// 关闭时用户端 POST /draw 返回 4003 activity_ended(前端展示"活动已结束",
|
||||
// 与"配置关闭"避免暴露内部状态);后台 CRUD 仍然可用,方便配置好活动再开。
|
||||
type LotteryConfig struct {
|
||||
Enable bool `yaml:"Enable" default:"false"`
|
||||
}
|
||||
|
||||
// KuttConfig Kutt 短链接服务配置
|
||||
type KuttConfig struct {
|
||||
Enable bool `yaml:"Enable" default:"false"` // 是否启用 Kutt 短链接
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// admin_claims_handler.go 提供 Stage 2 后台工单接口的 gin handler 层。
|
||||
// 路径注册在 internal/handler/lottery_routes.go 里;handler 只负责参数绑定 +
|
||||
// 委派到 internal/logic/admin/lottery/admin_claims.go。
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// ListLotteryClaimsHandler GET /v1/admin/lottery/claims
|
||||
func ListLotteryClaimsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ListAdminLotteryClaimsRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := adminlottery.NewListLotteryClaimsLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ListLotteryClaims(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ApproveLotteryClaimHandler POST /v1/admin/lottery/claims/approve
|
||||
func ApproveLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminApproveClaimRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewApproveLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.ApproveLotteryClaim(&req))
|
||||
}
|
||||
}
|
||||
|
||||
// RejectLotteryClaimHandler POST /v1/admin/lottery/claims/reject
|
||||
func RejectLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminRejectClaimRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewRejectLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.RejectLotteryClaim(&req))
|
||||
}
|
||||
}
|
||||
|
||||
// MarkPaidLotteryClaimHandler POST /v1/admin/lottery/claims/mark-paid
|
||||
func MarkPaidLotteryClaimHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminMarkPaidClaimRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewMarkPaidLotteryClaimLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.MarkPaidLotteryClaim(&req))
|
||||
}
|
||||
}
|
||||
|
||||
// LotteryClaimsSummaryHandler GET /v1/admin/lottery/claims/summary
|
||||
func LotteryClaimsSummaryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
l := adminlottery.NewLotteryClaimsSummaryLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.LotteryClaimsSummary()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// Package lottery contains gin handlers for the admin-side lottery endpoints.
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
adminlottery "github.com/perfect-panel/server/internal/logic/admin/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func CreateLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.CreateAdminLotteryActivityRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewCreateLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.CreateLotteryActivity(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateAdminLotteryActivityRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewUpdateLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.UpdateLotteryActivity(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func ListLotteryActivitiesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ListAdminLotteryActivitiesRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := adminlottery.NewListLotteryActivitiesLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ListLotteryActivities(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func GetLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminActivityIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewGetLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetLotteryActivity(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func PublishLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminActivityIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewPublishLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.PublishLotteryActivity(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func PauseLotteryActivityHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminActivityIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewPauseLotteryActivityLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.PauseLotteryActivity(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLotteryRulesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateAdminLotteryRulesRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewUpdateLotteryRulesLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.UpdateLotteryRules(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func CreateLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.CreateAdminLotteryPrizeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewCreateLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.CreateLotteryPrize(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.UpdateAdminLotteryPrizeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewUpdateLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.UpdateLotteryPrize(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AdminPrizeIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewDeleteLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.DeleteLotteryPrize(&req))
|
||||
}
|
||||
}
|
||||
|
||||
func ListLotteryPrizesHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ListAdminLotteryPrizesRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewListLotteryPrizesLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ListLotteryPrizes(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
func GrantLotteryChanceHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GrantAdminLotteryChanceRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := adminlottery.NewGrantLotteryChanceLogic(c.Request.Context(), svcCtx)
|
||||
result.HttpResult(c, nil, l.GrantLotteryChance(&req))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
adminLottery "github.com/perfect-panel/server/internal/handler/admin/lottery"
|
||||
publicLottery "github.com/perfect-panel/server/internal/handler/public/lottery"
|
||||
"github.com/perfect-panel/server/internal/middleware"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
)
|
||||
|
||||
// registerLotteryRoutes wires the Stage 1 lottery endpoints. Kept in its own
|
||||
// file to avoid ballooning routes.go and to make the lottery surface easy to
|
||||
// audit end-to-end. The path prefix "/v1/lottery" is under the user middleware
|
||||
// stack (AuthMiddleware + DeviceMiddleware); "/v1/admin/lottery" uses the
|
||||
// admin-detecting AuthMiddleware (path contains "admin" segment).
|
||||
func registerLotteryRoutes(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
userGroup := router.Group("/v1/lottery")
|
||||
userGroup.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||
{
|
||||
userGroup.GET("/config", publicLottery.QueryLotteryConfigHandler(serverCtx))
|
||||
userGroup.POST("/draw", publicLottery.DrawLotteryHandler(serverCtx))
|
||||
userGroup.GET("/records", publicLottery.QueryLotteryRecordsHandler(serverCtx))
|
||||
userGroup.POST("/claim", publicLottery.ClaimLotteryPrizeHandler(serverCtx))
|
||||
}
|
||||
|
||||
adminGroup := router.Group("/v1/admin/lottery")
|
||||
adminGroup.Use(middleware.AuthMiddleware(serverCtx), middleware.AdminMetaMiddleware())
|
||||
{
|
||||
adminGroup.POST("/activities", adminLottery.CreateLotteryActivityHandler(serverCtx))
|
||||
adminGroup.PUT("/activities", adminLottery.UpdateLotteryActivityHandler(serverCtx))
|
||||
adminGroup.GET("/activities", adminLottery.ListLotteryActivitiesHandler(serverCtx))
|
||||
adminGroup.GET("/activities/detail", adminLottery.GetLotteryActivityHandler(serverCtx))
|
||||
adminGroup.POST("/activities/publish", adminLottery.PublishLotteryActivityHandler(serverCtx))
|
||||
adminGroup.POST("/activities/pause", adminLottery.PauseLotteryActivityHandler(serverCtx))
|
||||
adminGroup.PUT("/activities/rules", adminLottery.UpdateLotteryRulesHandler(serverCtx))
|
||||
|
||||
adminGroup.POST("/prizes", adminLottery.CreateLotteryPrizeHandler(serverCtx))
|
||||
adminGroup.PUT("/prizes", adminLottery.UpdateLotteryPrizeHandler(serverCtx))
|
||||
adminGroup.DELETE("/prizes", adminLottery.DeleteLotteryPrizeHandler(serverCtx))
|
||||
adminGroup.GET("/prizes", adminLottery.ListLotteryPrizesHandler(serverCtx))
|
||||
|
||||
adminGroup.POST("/chances/grant", adminLottery.GrantLotteryChanceHandler(serverCtx))
|
||||
|
||||
// Stage 2 (HIF-4): 人工奖工单接口
|
||||
adminGroup.GET("/claims", adminLottery.ListLotteryClaimsHandler(serverCtx))
|
||||
adminGroup.GET("/claims/summary", adminLottery.LotteryClaimsSummaryHandler(serverCtx))
|
||||
adminGroup.POST("/claims/approve", adminLottery.ApproveLotteryClaimHandler(serverCtx))
|
||||
adminGroup.POST("/claims/reject", adminLottery.RejectLotteryClaimHandler(serverCtx))
|
||||
adminGroup.POST("/claims/mark-paid", adminLottery.MarkPaidLotteryClaimHandler(serverCtx))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Package lottery contains the user-facing lottery HTTP handlers. Each handler
|
||||
// binds request params via gin, validates, delegates to the logic package,
|
||||
// and renders through pkg/result to keep the API response envelope consistent.
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/public/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// QueryLotteryConfigHandler serves GET /api/v1/lottery/config.
|
||||
func QueryLotteryConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetLotteryConfigRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := lottery.NewQueryLotteryConfigLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.QueryLotteryConfig(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// DrawLotteryHandler serves POST /api/v1/lottery/draw.
|
||||
func DrawLotteryHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.DrawLotteryRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := lottery.NewDrawLotteryLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.DrawLottery(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// QueryLotteryRecordsHandler serves GET /api/v1/lottery/records.
|
||||
func QueryLotteryRecordsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetLotteryRecordsRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := lottery.NewQueryLotteryRecordsLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.QueryLotteryRecords(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ClaimLotteryPrizeHandler serves POST /api/v1/lottery/claim. Stage 1
|
||||
// always returns 4010 not_claimable.
|
||||
func ClaimLotteryPrizeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ClaimLotteryPrizeRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := lottery.NewClaimLotteryPrizeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ClaimLotteryPrize(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -1221,4 +1221,7 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
// Get Server Protocol Config
|
||||
serverGroupRouterV2.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
|
||||
}
|
||||
|
||||
// ---- Lottery (Stage 1) --------------------------------------------------
|
||||
registerLotteryRoutes(router, serverCtx)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
)
|
||||
|
||||
@@ -84,7 +85,7 @@ func SubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
l := subscribe.NewSubscribeLogic(c, svcCtx)
|
||||
resp, err := l.Handler(&req)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Internal Server")
|
||||
result.HttpResult(c, nil, err)
|
||||
return
|
||||
}
|
||||
c.Header("subscription-userinfo", resp.Header)
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/client"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSubscribeHandlerReturnsBusinessErrorForDisabledUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
redisServer, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Run() error = %v", err)
|
||||
}
|
||||
defer redisServer.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||
defer func() {
|
||||
_ = rdb.Close()
|
||||
}()
|
||||
if err := rdb.Set(context.Background(), logiccommon.UserEnableCacheKey(83696), "false", 0).Err(); err != nil {
|
||||
t.Fatalf("seed user enable cache: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/subscribe", SubscribeHandler(&svc.ServiceContext{
|
||||
Config: config.Config{
|
||||
Subscribe: config.SubscribeConfig{
|
||||
SubscribePath: "/api/subscribe",
|
||||
},
|
||||
},
|
||||
ClientModel: subscribeClientModelStub{
|
||||
list: []*client.SubscribeApplication{
|
||||
{
|
||||
Id: 1,
|
||||
UserAgent: "clashmeta",
|
||||
IsDefault: true,
|
||||
OutputFormat: "yaml",
|
||||
},
|
||||
},
|
||||
},
|
||||
Redis: rdb,
|
||||
UserModel: subscribeUserModelStub{
|
||||
subscribe: &user.Subscribe{Id: 35446, UserId: 83696, SubscribeId: 1, Token: "disabled-token"},
|
||||
},
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/subscribe?token=disabled-token", nil)
|
||||
req.Header.Set("User-Agent", "ClashMetaForAndroid/2.11.7.Meta")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected HTTP 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Code uint32 `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Code != xerr.UserDisabled {
|
||||
t.Fatalf("expected code %d, got %d (%s)", xerr.UserDisabled, resp.Code, resp.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
type subscribeClientModelStub struct {
|
||||
list []*client.SubscribeApplication
|
||||
err error
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Insert(context.Context, *client.SubscribeApplication) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) FindOne(context.Context, int64) (*client.SubscribeApplication, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Update(context.Context, *client.SubscribeApplication) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Delete(context.Context, int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) List(context.Context) ([]*client.SubscribeApplication, error) {
|
||||
return s.list, s.err
|
||||
}
|
||||
|
||||
func (s subscribeClientModelStub) Transaction(context.Context, func(*gorm.DB) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type subscribeUserModelStub struct {
|
||||
subscribe *user.Subscribe
|
||||
subErr error
|
||||
findOne *user.User
|
||||
findOneErr error
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Insert(context.Context, *user.User, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOne(context.Context, int64) (*user.User, error) {
|
||||
if s.findOneErr != nil {
|
||||
return nil, s.findOneErr
|
||||
}
|
||||
return s.findOne, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Update(context.Context, *user.User, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateCommission(context.Context, int64, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Delete(context.Context, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) Transaction(context.Context, func(*gorm.DB) error) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryPageList(context.Context, int, int, *user.UserFilterParams) ([]*user.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneByReferCode(context.Context, string) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) BatchDeleteUser(context.Context, []int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) InsertSubscribe(context.Context, *user.Subscribe, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribeByToken(context.Context, string) (*user.Subscribe, error) {
|
||||
if s.subErr != nil {
|
||||
return nil, s.subErr
|
||||
}
|
||||
return s.subscribe, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindSingleModeAnchorSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribeByOrderId(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateSubscribe(context.Context, *user.Subscribe, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteSubscribe(context.Context, string, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteSubscribeById(context.Context, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryUserSubscribe(context.Context, int64, ...int64) ([]*user.SubscribeDetails, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneSubscribeDetailsById(context.Context, int64) (*user.SubscribeDetails, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneUserSubscribe(context.Context, int64) (*user.SubscribeDetails, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUsersSubscribeBySubscribeId(context.Context, int64) ([]*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserSubscribeWithTraffic(context.Context, int64, int64, int64, bool, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryResisterUserTotalByDate(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryResisterUserTotalByMonthly(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryResisterUserTotal(context.Context) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryAdminUsers(context.Context) ([]*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserCache(context.Context, *user.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserSubscribeCache(context.Context, *user.Subscribe) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryActiveSubscriptions(context.Context, ...int64) (map[int64]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethods(context.Context, int64) ([]*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) InsertUserAuthMethods(context.Context, *user.AuthMethods, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateUserAuthMethods(context.Context, *user.AuthMethods, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteUserAuthMethods(context.Context, int64, string, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethodByOpenID(context.Context, string, string) (*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethodByUserId(context.Context, string, int64) (*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindUserAuthMethodByPlatform(context.Context, int64, string) (*user.AuthMethods, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneByEmail(context.Context, string) (*user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneDevice(context.Context, int64) (*user.Device, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDeviceList(context.Context, int64) ([]*user.Device, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDeviceListByUserIds(context.Context, []int64) ([]*user.Device, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDevicePageList(context.Context, int64, int64, int, int) ([]*user.Device, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) UpdateDevice(context.Context, *user.Device, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindOneDeviceByIdentifier(context.Context, string) (*user.Device, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) DeleteDevice(context.Context, int64, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) InsertDevice(context.Context, *user.Device, ...*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) ClearSubscribeCache(context.Context, ...*user.Subscribe) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) ClearUserCache(context.Context, ...*user.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) ClearDeviceCache(context.Context, ...*user.Device) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryDailyUserStatisticsList(context.Context, time.Time) ([]user.UserStatisticsWithDate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) QueryMonthlyUserStatisticsList(context.Context, time.Time) ([]user.UserStatisticsWithDate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindActiveSubscribe(context.Context, int64) (*user.Subscribe, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s subscribeUserModelStub) FindActiveSubscribesByUserIds(context.Context, []int64) (map[int64]*user.UserStatusInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// admin_claims.go 实现 Stage 2 后台工单接口:
|
||||
//
|
||||
// GET /v1/admin/lottery/claims — 分页列表
|
||||
// POST /v1/admin/lottery/claims/approve — reviewing → paying
|
||||
// POST /v1/admin/lottery/claims/reject — reviewing|paying → rejected
|
||||
// POST /v1/admin/lottery/claims/mark-paid — paying → paid
|
||||
// GET /v1/admin/lottery/claims/summary — 工作台状态计数
|
||||
//
|
||||
// 状态机严格 CAS:所有写路径都用 WHERE status IN (...) 做前置校验,
|
||||
// RowsAffected==0 → 100011 claim_state_invalid(并发/竞态兜底)。
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/logic/audit"
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// audit action codes 供 admin_action_log 用(新增 Stage 2 三个)。
|
||||
const (
|
||||
ActionLotteryClaimApprove = "lottery.claim.approve"
|
||||
ActionLotteryClaimReject = "lottery.claim.reject"
|
||||
ActionLotteryClaimMarkPaid = "lottery.claim.mark_paid"
|
||||
)
|
||||
|
||||
// ---- ListLotteryClaims ----------------------------------------------------
|
||||
|
||||
type ListLotteryClaimsLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewListLotteryClaimsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryClaimsLogic {
|
||||
return &ListLotteryClaimsLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// ListLotteryClaims 按 type/status/activity_id/user_id/时间窗过滤。
|
||||
// user_id / email 是"友好视图"字段,走 IN 查询批量拉一次 users 表拼上。
|
||||
func (l *ListLotteryClaimsLogic) ListLotteryClaims(req *types.ListAdminLotteryClaimsRequest) (*types.ListAdminLotteryClaimsResponse, error) {
|
||||
if currentAdminId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
|
||||
db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Claim{})
|
||||
if t := strings.TrimSpace(req.Type); t != "" {
|
||||
db = db.Where("prize_type = ?", t)
|
||||
}
|
||||
if s := strings.TrimSpace(req.Status); s != "" {
|
||||
db = db.Where("status = ?", s)
|
||||
}
|
||||
if req.ActivityId > 0 {
|
||||
db = db.Where("activity_id = ?", req.ActivityId)
|
||||
}
|
||||
if req.UserId > 0 {
|
||||
db = db.Where("user_id = ?", req.UserId)
|
||||
}
|
||||
if req.From > 0 {
|
||||
db = db.Where("created_at >= ?", time.Unix(req.From, 0))
|
||||
}
|
||||
if req.To > 0 {
|
||||
db = db.Where("created_at < ?", time.Unix(req.To, 0))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
var rows []modelLottery.Claim
|
||||
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
|
||||
// 附加:一次性拉快照 + 用户信息,避免 N+1。
|
||||
drawIds := make([]int64, 0, len(rows))
|
||||
userIds := make([]int64, 0, len(rows))
|
||||
for _, c := range rows {
|
||||
drawIds = append(drawIds, c.DrawId)
|
||||
userIds = append(userIds, c.UserId)
|
||||
}
|
||||
snaps, err := l.loadSnapshots(drawIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users, err := l.loadUsers(userIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &types.ListAdminLotteryClaimsResponse{Total: total, Claims: make([]types.AdminLotteryClaim, 0, len(rows))}
|
||||
for _, c := range rows {
|
||||
resp.Claims = append(resp.Claims, claimToAdminView(c, snaps[c.DrawId], users[c.UserId]))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
func (l *ListLotteryClaimsLogic) loadSnapshots(drawIds []int64) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||
if len(drawIds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var snaps []modelLottery.PrizeSnapshot
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&snaps).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
|
||||
for _, s := range snaps {
|
||||
out[s.DrawId] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (l *ListLotteryClaimsLogic) loadUsers(ids []int64) (map[int64]string, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// email 挂在 user_auth_methods 表;一次批量拉 auth_type='email' 的记录,
|
||||
// 每人可能有多条 email(历史合并帐号),按 CreatedAt 排序取第一条即可。
|
||||
type row struct {
|
||||
UserId int64
|
||||
Email string
|
||||
}
|
||||
var rows []row
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("user_auth_methods").
|
||||
Select("user_id AS user_id, auth_identifier AS email").
|
||||
Where("auth_type = ? AND user_id IN ?", "email", ids).
|
||||
Order("created_at ASC").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]string, len(rows))
|
||||
for _, r := range rows {
|
||||
if _, exists := out[r.UserId]; exists {
|
||||
continue
|
||||
}
|
||||
out[r.UserId] = r.Email
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- ApproveLotteryClaim --------------------------------------------------
|
||||
|
||||
type ApproveLotteryClaimLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewApproveLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApproveLotteryClaimLogic {
|
||||
return &ApproveLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// ApproveLotteryClaim reviewing → paying。CAS:命中 status='reviewing' 才推进。
|
||||
func (l *ApproveLotteryClaimLogic) ApproveLotteryClaim(req *types.AdminApproveClaimRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
now := time.Now()
|
||||
body, _ := json.Marshal(req)
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Model(&modelLottery.Claim{}).
|
||||
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusReviewing).
|
||||
Updates(map[string]any{
|
||||
"status": modelLottery.ClaimStatusPaying,
|
||||
"reviewed_by": actor,
|
||||
"reviewed_at": now,
|
||||
"reject_reason": "",
|
||||
})
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: ActionLotteryClaimApprove,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- RejectLotteryClaim ---------------------------------------------------
|
||||
|
||||
type RejectLotteryClaimLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewRejectLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RejectLotteryClaimLogic {
|
||||
return &RejectLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// RejectLotteryClaim reviewing|paying → rejected;expires_at 不重置,用户在
|
||||
// 剩余窗口内可再次提交。
|
||||
func (l *RejectLotteryClaimLogic) RejectLotteryClaim(req *types.AdminRejectClaimRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
return xerr.NewErrCode(xerr.InvalidParams)
|
||||
}
|
||||
now := time.Now()
|
||||
body, _ := json.Marshal(req)
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Model(&modelLottery.Claim{}).
|
||||
Where("id = ? AND status IN ?", req.Id,
|
||||
[]string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||||
Updates(map[string]any{
|
||||
"status": modelLottery.ClaimStatusRejected,
|
||||
"reviewed_by": actor,
|
||||
"reviewed_at": now,
|
||||
"reject_reason": reason,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: ActionLotteryClaimReject,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- MarkPaidLotteryClaim -------------------------------------------------
|
||||
|
||||
type MarkPaidLotteryClaimLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewMarkPaidLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkPaidLotteryClaimLogic {
|
||||
return &MarkPaidLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// MarkPaidLotteryClaim paying → paid。
|
||||
// 校验:crypto 必填 tx_hash / physical 必填 delivery_ref / manual_other 至少填一个。
|
||||
// paid_at 缺省用服务端 now。同事务把 lottery_draw.dispatch_state 也推 paid。
|
||||
func (l *MarkPaidLotteryClaimLogic) MarkPaidLotteryClaim(req *types.AdminMarkPaidClaimRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
txHash := strings.TrimSpace(req.TxHash)
|
||||
deliveryRef := strings.TrimSpace(req.DeliveryRef)
|
||||
now := time.Now()
|
||||
paidAt := now
|
||||
if req.PaidAt > 0 {
|
||||
paidAt = time.Unix(req.PaidAt, 0)
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 先取当前 claim 用于类型强校验
|
||||
var claim modelLottery.Claim
|
||||
if err := tx.Where("id = ?", req.Id).First(&claim).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||
}
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if err := validateMarkPaidByType(claim.PrizeType, txHash, deliveryRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// CAS 推进 status。
|
||||
res := tx.Model(&modelLottery.Claim{}).
|
||||
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusPaying).
|
||||
Updates(map[string]any{
|
||||
"status": modelLottery.ClaimStatusPaid,
|
||||
"reviewed_by": actor,
|
||||
"reviewed_at": now,
|
||||
"tx_hash": txHash,
|
||||
"delivery_ref": deliveryRef,
|
||||
"paid_at": paidAt,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||
}
|
||||
// 同事务把 draw 的 dispatch_state 推到 paid,让 GET /records 与 admin 视图一致。
|
||||
if err := tx.Model(&modelLottery.Draw{}).
|
||||
Where("id = ? AND dispatch_state = ?", claim.DrawId, modelLottery.DispatchStatePendingClaim).
|
||||
Updates(map[string]any{
|
||||
"dispatch_state": modelLottery.DispatchStatePaid,
|
||||
"dispatched_at": paidAt,
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: ActionLotteryClaimMarkPaid,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// validateMarkPaidByType 强制不同奖品类型的最少凭证:
|
||||
// - crypto: tx_hash 必填
|
||||
// - physical: delivery_ref 必填
|
||||
// - manual_other: tx_hash 或 delivery_ref 至少一个
|
||||
//
|
||||
// 校验失败返回 InvalidParams(带具体原因,前端展示给运营)。
|
||||
func validateMarkPaidByType(prizeType, txHash, deliveryRef string) error {
|
||||
switch prizeType {
|
||||
case modelLottery.PrizeTypeCrypto:
|
||||
if txHash == "" {
|
||||
return xerr.NewErrCodeMsg(xerr.InvalidParams, "crypto 奖品必须填写 tx_hash")
|
||||
}
|
||||
case modelLottery.PrizeTypePhysical:
|
||||
if deliveryRef == "" {
|
||||
return xerr.NewErrCodeMsg(xerr.InvalidParams, "physical 奖品必须填写 delivery_ref")
|
||||
}
|
||||
case modelLottery.PrizeTypeManualOther:
|
||||
if txHash == "" && deliveryRef == "" {
|
||||
return xerr.NewErrCodeMsg(xerr.InvalidParams, "manual_other 奖品必须至少填写 tx_hash 或 delivery_ref")
|
||||
}
|
||||
default:
|
||||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- ClaimsSummary --------------------------------------------------------
|
||||
|
||||
type LotteryClaimsSummaryLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewLotteryClaimsSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LotteryClaimsSummaryLogic {
|
||||
return &LotteryClaimsSummaryLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
// LotteryClaimsSummary 一次 GROUP BY 拉齐 reviewing/paying 计数 + 单独查 overdue。
|
||||
func (l *LotteryClaimsSummaryLogic) LotteryClaimsSummary() (*types.AdminLotteryClaimsSummary, error) {
|
||||
if currentAdminId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
type row struct {
|
||||
PrizeType string
|
||||
Status string
|
||||
Cnt int64
|
||||
}
|
||||
var rows []row
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&modelLottery.Claim{}).
|
||||
Select("prize_type, status, COUNT(*) AS cnt").
|
||||
Where("status IN ?", []string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||||
Group("prize_type, status").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
summary := &types.AdminLotteryClaimsSummary{}
|
||||
for _, r := range rows {
|
||||
bucket := bucketByType(summary, r.PrizeType)
|
||||
if bucket == nil {
|
||||
continue
|
||||
}
|
||||
switch r.Status {
|
||||
case modelLottery.ClaimStatusReviewing:
|
||||
bucket.Reviewing = r.Cnt
|
||||
case modelLottery.ClaimStatusPaying:
|
||||
bucket.Paying = r.Cnt
|
||||
}
|
||||
}
|
||||
// overdue:pending_claim 且 expires_at 已过(还没转 expired 的边缘时刻)。
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&modelLottery.Claim{}).
|
||||
Where("status = ? AND expires_at < ?", modelLottery.ClaimStatusPendingClaim, time.Now()).
|
||||
Count(&summary.Overdue).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func bucketByType(s *types.AdminLotteryClaimsSummary, prizeType string) *types.AdminLotteryClaimsStatusCount {
|
||||
switch prizeType {
|
||||
case modelLottery.PrizeTypeCrypto:
|
||||
return &s.Crypto
|
||||
case modelLottery.PrizeTypePhysical:
|
||||
return &s.Physical
|
||||
case modelLottery.PrizeTypeManualOther:
|
||||
return &s.ManualOther
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
// claimToAdminView 把 model.Claim 组装成后台视图,附带快照 + 用户信息。
|
||||
func claimToAdminView(c modelLottery.Claim, snap modelLottery.PrizeSnapshot, email string) types.AdminLotteryClaim {
|
||||
view := types.AdminLotteryClaim{
|
||||
Id: c.Id,
|
||||
DrawId: c.DrawId,
|
||||
ActivityId: c.ActivityId,
|
||||
Status: c.Status,
|
||||
ExpiresAt: c.ExpiresAt.Unix(),
|
||||
ReviewedBy: c.ReviewedBy,
|
||||
RejectReason: c.RejectReason,
|
||||
TxHash: c.TxHash,
|
||||
DeliveryRef: c.DeliveryRef,
|
||||
CreatedAt: c.CreatedAt.Unix(),
|
||||
User: types.AdminLotteryClaimUser{
|
||||
Id: c.UserId,
|
||||
Email: email,
|
||||
},
|
||||
Prize: types.AdminLotteryClaimPrize{
|
||||
Type: snap.Type,
|
||||
Name: snap.Name,
|
||||
Config: json.RawMessage(defaultRawIfEmpty(snap.Config, "{}")),
|
||||
},
|
||||
}
|
||||
if snap.Type == "" {
|
||||
// snapshot 未命中,用 claim.prize_type 兜底
|
||||
view.Prize.Type = c.PrizeType
|
||||
}
|
||||
if c.ClaimData != "" {
|
||||
view.ClaimData = json.RawMessage(c.ClaimData)
|
||||
}
|
||||
if c.SubmittedAt != nil {
|
||||
view.SubmittedAt = c.SubmittedAt.Unix()
|
||||
}
|
||||
if c.ReviewedAt != nil {
|
||||
view.ReviewedAt = c.ReviewedAt.Unix()
|
||||
}
|
||||
if c.PaidAt != nil {
|
||||
view.PaidAt = c.PaidAt.Unix()
|
||||
}
|
||||
return view
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// admin_claims_test.go — 单元测试 Stage 2 claim 状态机的纯函数校验。
|
||||
// 数据库集成留给 stage2 QA curl 脚本;单测只覆盖纯逻辑分支:
|
||||
// - validateMarkPaidByType 的三个奖品类型 x 凭证字段组合
|
||||
// - bucketByType 的类型 → 状态桶映射
|
||||
// - claimToAdminView 的 nullable 字段渲染
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
)
|
||||
|
||||
func TestValidateMarkPaidByType(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
prizeType string
|
||||
txHash string
|
||||
deliveryRef string
|
||||
wantErr bool
|
||||
}{
|
||||
{"crypto with tx_hash", modelLottery.PrizeTypeCrypto, "0xdeadbeef", "", false},
|
||||
{"crypto missing tx_hash", modelLottery.PrizeTypeCrypto, "", "", true},
|
||||
{"crypto ignores delivery_ref alone", modelLottery.PrizeTypeCrypto, "", "SF123", true},
|
||||
{"physical with delivery_ref", modelLottery.PrizeTypePhysical, "", "SF123456", false},
|
||||
{"physical missing delivery_ref", modelLottery.PrizeTypePhysical, "", "", true},
|
||||
{"manual_other with tx_hash", modelLottery.PrizeTypeManualOther, "0xabc", "", false},
|
||||
{"manual_other with delivery_ref", modelLottery.PrizeTypeManualOther, "", "SF00", false},
|
||||
{"manual_other with both", modelLottery.PrizeTypeManualOther, "0xabc", "SF00", false},
|
||||
{"manual_other with none", modelLottery.PrizeTypeManualOther, "", "", true},
|
||||
{"unknown type", "auto_hallucinated", "", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateMarkPaidByType(tc.prizeType, tc.txHash, tc.deliveryRef)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketByType(t *testing.T) {
|
||||
s := &types.AdminLotteryClaimsSummary{}
|
||||
if bucketByType(s, modelLottery.PrizeTypeCrypto) != &s.Crypto {
|
||||
t.Fatal("crypto bucket mismatch")
|
||||
}
|
||||
if bucketByType(s, modelLottery.PrizeTypePhysical) != &s.Physical {
|
||||
t.Fatal("physical bucket mismatch")
|
||||
}
|
||||
if bucketByType(s, modelLottery.PrizeTypeManualOther) != &s.ManualOther {
|
||||
t.Fatal("manual_other bucket mismatch")
|
||||
}
|
||||
if bucketByType(s, "unknown") != nil {
|
||||
t.Fatal("unknown type must return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimToAdminView_RendersNullableFields(t *testing.T) {
|
||||
submittedAt := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
|
||||
paidAt := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||
claim := modelLottery.Claim{
|
||||
Id: 42,
|
||||
DrawId: 1234,
|
||||
UserId: 88,
|
||||
ActivityId: 100,
|
||||
PrizeType: modelLottery.PrizeTypeCrypto,
|
||||
Status: modelLottery.ClaimStatusPaid,
|
||||
ClaimData: `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`,
|
||||
SubmittedAt: &submittedAt,
|
||||
ExpiresAt: submittedAt.Add(24 * time.Hour),
|
||||
TxHash: "0xabcdef",
|
||||
PaidAt: &paidAt,
|
||||
}
|
||||
snap := modelLottery.PrizeSnapshot{
|
||||
Type: modelLottery.PrizeTypeCrypto,
|
||||
Name: "1 BTC",
|
||||
Config: `{"amount":"1","currency":"BTC","networks":["BTC"]}`,
|
||||
}
|
||||
view := claimToAdminView(claim, snap, "user@example.com")
|
||||
|
||||
if view.Id != 42 || view.DrawId != 1234 || view.User.Id != 88 {
|
||||
t.Fatalf("view IDs wrong: %+v", view)
|
||||
}
|
||||
if view.User.Email != "user@example.com" {
|
||||
t.Fatalf("Email = %q", view.User.Email)
|
||||
}
|
||||
if view.Status != modelLottery.ClaimStatusPaid {
|
||||
t.Fatalf("Status = %q", view.Status)
|
||||
}
|
||||
if view.SubmittedAt != submittedAt.Unix() {
|
||||
t.Fatalf("SubmittedAt = %d, want %d", view.SubmittedAt, submittedAt.Unix())
|
||||
}
|
||||
if view.PaidAt != paidAt.Unix() {
|
||||
t.Fatalf("PaidAt = %d, want %d", view.PaidAt, paidAt.Unix())
|
||||
}
|
||||
if view.Prize.Type != modelLottery.PrizeTypeCrypto {
|
||||
t.Fatalf("Prize.Type = %q", view.Prize.Type)
|
||||
}
|
||||
if len(view.ClaimData) == 0 {
|
||||
t.Fatal("ClaimData must be included when non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimToAdminView_NoSnapshotFallsBackToClaimPrizeType(t *testing.T) {
|
||||
claim := modelLottery.Claim{
|
||||
Id: 1,
|
||||
DrawId: 2,
|
||||
PrizeType: modelLottery.PrizeTypePhysical,
|
||||
Status: modelLottery.ClaimStatusPendingClaim,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
view := claimToAdminView(claim, modelLottery.PrizeSnapshot{}, "")
|
||||
if view.Prize.Type != modelLottery.PrizeTypePhysical {
|
||||
t.Fatalf("Prize.Type fallback = %q", view.Prize.Type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
// Package lottery contains the admin-facing lottery HTTP logic. Every write
|
||||
// endpoint runs its user-visible mutation inside a tx that ALSO writes an
|
||||
// admin_action_log row via audit.WriteAdminAction, so a rollback leaves no
|
||||
// dangling audit entries. Rules PUT is gated by rulecaps.ValidateEligibilityJSON.
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/logic/audit"
|
||||
"github.com/perfect-panel/server/internal/logic/lottery/rulecaps"
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
userModel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// currentAdminId retrieves the actor's user.id from ctx (populated by
|
||||
// AuthMiddleware). Zero → treated as unauthorized. All admin endpoints below
|
||||
// short-circuit if the caller is not admin.
|
||||
func currentAdminId(ctx context.Context) int64 {
|
||||
u, ok := ctx.Value(constant.CtxKeyUser).(*userModel.User)
|
||||
if !ok || u == nil {
|
||||
return 0
|
||||
}
|
||||
return u.Id
|
||||
}
|
||||
|
||||
func requestMeta(ctx context.Context) (ip, ua string) {
|
||||
// AdminMetaMiddleware populates these keys on the request context after
|
||||
// AuthMiddleware runs. Absent middleware (unit tests, non-admin paths)
|
||||
// → empty strings, which is the intended defensive default.
|
||||
if v, ok := ctx.Value(constant.CtxKeyIP).(string); ok {
|
||||
ip = v
|
||||
}
|
||||
if v, ok := ctx.Value(constant.CtxKeyUserAgent).(string); ok {
|
||||
ua = v
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func jsonOrDefault(raw json.RawMessage, def string) string {
|
||||
if len(raw) == 0 {
|
||||
return def
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// ---- CreateLotteryActivity -------------------------------------------------
|
||||
|
||||
type CreateLotteryActivityLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewCreateLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateLotteryActivityLogic {
|
||||
return &CreateLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *CreateLotteryActivityLogic) CreateLotteryActivity(req *types.CreateAdminLotteryActivityRequest) (*types.AdminLotteryActivity, error) {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
if err := rulecaps.ValidateEligibilityJSON(req.Eligibility); err != nil {
|
||||
return nil, ruleCapsToXerr(err)
|
||||
}
|
||||
|
||||
activity := modelLottery.Activity{
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Description: req.Description,
|
||||
StartAt: time.Unix(req.StartAt, 0),
|
||||
EndAt: time.Unix(req.EndAt, 0),
|
||||
Status: modelLottery.ActivityStatusDraft,
|
||||
GridSize: req.GridSize,
|
||||
Eligibility: jsonOrDefault(req.Eligibility, "{}"),
|
||||
ChanceSources: jsonOrDefault(req.ChanceSources, "[]"),
|
||||
UnmetAction: defaultString(req.UnmetAction, modelLottery.UnmetActionBlock),
|
||||
}
|
||||
if activity.GridSize <= 0 {
|
||||
activity.GridSize = 9
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&activity).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error())
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryActivityCreate,
|
||||
TargetIds: int64ToStr(activity.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return activityToAdminView(activity), nil
|
||||
}
|
||||
|
||||
// ---- UpdateLotteryActivity -------------------------------------------------
|
||||
|
||||
type UpdateLotteryActivityLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewUpdateLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryActivityLogic {
|
||||
return &UpdateLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *UpdateLotteryActivityLogic) UpdateLotteryActivity(req *types.UpdateAdminLotteryActivityRequest) (*types.AdminLotteryActivity, error) {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
var updated modelLottery.Activity
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
fields := map[string]any{}
|
||||
if req.Title != "" {
|
||||
fields["title"] = req.Title
|
||||
}
|
||||
if req.Description != "" {
|
||||
fields["description"] = req.Description
|
||||
}
|
||||
if req.StartAt > 0 {
|
||||
fields["start_at"] = time.Unix(req.StartAt, 0)
|
||||
}
|
||||
if req.EndAt > 0 {
|
||||
fields["end_at"] = time.Unix(req.EndAt, 0)
|
||||
}
|
||||
if req.GridSize > 0 {
|
||||
fields["grid_size"] = req.GridSize
|
||||
}
|
||||
if req.UnmetAction != "" {
|
||||
fields["unmet_action"] = req.UnmetAction
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
if err := tx.Model(&modelLottery.Activity{}).Where("id = ?", req.Id).Updates(fields).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||
}
|
||||
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryActivityUpdate,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return activityToAdminView(updated), nil
|
||||
}
|
||||
|
||||
// ---- ListLotteryActivities -------------------------------------------------
|
||||
|
||||
type ListLotteryActivitiesLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewListLotteryActivitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryActivitiesLogic {
|
||||
return &ListLotteryActivitiesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *ListLotteryActivitiesLogic) ListLotteryActivities(req *types.ListAdminLotteryActivitiesRequest) (*types.ListAdminLotteryActivitiesResponse, error) {
|
||||
if currentAdminId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Activity{})
|
||||
if req.Status != "" {
|
||||
db = db.Where("status = ?", req.Status)
|
||||
}
|
||||
if req.Search != "" {
|
||||
db = db.Where("title LIKE ?", "%"+req.Search+"%")
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
var rows []modelLottery.Activity
|
||||
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
resp := &types.ListAdminLotteryActivitiesResponse{Total: total, List: make([]types.AdminLotteryActivity, 0, len(rows))}
|
||||
for _, a := range rows {
|
||||
resp.List = append(resp.List, *activityToAdminView(a))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ---- GetLotteryActivity ---------------------------------------------------
|
||||
|
||||
type GetLotteryActivityLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGetLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetLotteryActivityLogic {
|
||||
return &GetLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *GetLotteryActivityLogic) GetLotteryActivity(req *types.AdminActivityIdRequest) (*types.AdminLotteryActivity, error) {
|
||||
if currentAdminId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
var a modelLottery.Activity
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", req.Id).First(&a).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
return activityToAdminView(a), nil
|
||||
}
|
||||
|
||||
// ---- Publish / Pause -------------------------------------------------------
|
||||
|
||||
type toggleActivityStatusLogic struct {
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
action string
|
||||
next string
|
||||
}
|
||||
|
||||
func (l *toggleActivityStatusLogic) run(id int64) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var a modelLottery.Activity
|
||||
if err := tx.Where("id = ?", id).First(&a).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if err := tx.Model(&modelLottery.Activity{}).Where("id = ?", id).UpdateColumn("status", l.next).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: l.action,
|
||||
TargetIds: int64ToStr(id),
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type PublishLotteryActivityLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewPublishLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PublishLotteryActivityLogic {
|
||||
return &PublishLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *PublishLotteryActivityLogic) PublishLotteryActivity(req *types.AdminActivityIdRequest) error {
|
||||
t := &toggleActivityStatusLogic{ctx: l.ctx, svcCtx: l.svcCtx, action: audit.ActionLotteryActivityPublish, next: modelLottery.ActivityStatusRunning}
|
||||
return t.run(req.Id)
|
||||
}
|
||||
|
||||
type PauseLotteryActivityLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewPauseLotteryActivityLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PauseLotteryActivityLogic {
|
||||
return &PauseLotteryActivityLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *PauseLotteryActivityLogic) PauseLotteryActivity(req *types.AdminActivityIdRequest) error {
|
||||
t := &toggleActivityStatusLogic{ctx: l.ctx, svcCtx: l.svcCtx, action: audit.ActionLotteryActivityPause, next: modelLottery.ActivityStatusPaused}
|
||||
return t.run(req.Id)
|
||||
}
|
||||
|
||||
// ---- UpdateLotteryRules (with caps) ----------------------------------------
|
||||
|
||||
type UpdateLotteryRulesLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewUpdateLotteryRulesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryRulesLogic {
|
||||
return &UpdateLotteryRulesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *UpdateLotteryRulesLogic) UpdateLotteryRules(req *types.UpdateAdminLotteryRulesRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
if err := rulecaps.ValidateEligibilityJSON(req.Eligibility); err != nil {
|
||||
return ruleCapsToXerr(err)
|
||||
}
|
||||
// Pre-collect the field diff outside the tx so an empty update rejects
|
||||
// without opening one (cheaper on the happy path + easier to test).
|
||||
fields := map[string]any{}
|
||||
if len(req.Eligibility) > 0 {
|
||||
fields["eligibility"] = string(req.Eligibility)
|
||||
}
|
||||
if len(req.ChanceSources) > 0 {
|
||||
fields["chance_sources"] = string(req.ChanceSources)
|
||||
}
|
||||
if req.UnmetAction != "" {
|
||||
fields["unmet_action"] = req.UnmetAction
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return xerr.NewErrCode(xerr.InvalidParams)
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Model(&modelLottery.Activity{}).Where("id = ?", req.Id).Updates(fields)
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryRulesPut,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func ruleCapsToXerr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, rulecaps.ErrRuleTreeTooDeep):
|
||||
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooDeep, err.Error())
|
||||
case errors.Is(err, rulecaps.ErrRuleTreeTooManyNodes):
|
||||
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooMany, err.Error())
|
||||
case errors.Is(err, rulecaps.ErrRuleTreeTooLarge):
|
||||
return xerr.NewErrCodeMsg(xerr.LotteryRuleTooLarge, err.Error())
|
||||
default:
|
||||
return xerr.NewErrCodeMsg(xerr.InvalidParams, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CreatePrize / UpdatePrize / DeletePrize / ListPrizes -----------------
|
||||
|
||||
type CreateLotteryPrizeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewCreateLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateLotteryPrizeLogic {
|
||||
return &CreateLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *CreateLotteryPrizeLogic) CreateLotteryPrize(req *types.CreateAdminLotteryPrizeRequest) (*types.AdminLotteryPrize, error) {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
prize := modelLottery.Prize{
|
||||
ActivityId: req.ActivityId,
|
||||
Slot: req.Slot,
|
||||
Type: req.Type,
|
||||
Name: req.Name,
|
||||
IconURL: req.IconUrl,
|
||||
Config: jsonOrDefault(req.Config, "{}"),
|
||||
Weight: req.Weight,
|
||||
IsFallback: req.IsFallback,
|
||||
}
|
||||
if req.TotalStock != nil {
|
||||
prize.TotalStock.Int64 = *req.TotalStock
|
||||
prize.TotalStock.Valid = true
|
||||
prize.RemainingStock.Int64 = *req.TotalStock
|
||||
prize.RemainingStock.Valid = true
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&prize).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseInsertError), err.Error())
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryPrizeCreate,
|
||||
TargetIds: int64ToStr(prize.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prizeToAdminView(prize), nil
|
||||
}
|
||||
|
||||
type UpdateLotteryPrizeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewUpdateLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateLotteryPrizeLogic {
|
||||
return &UpdateLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *UpdateLotteryPrizeLogic) UpdateLotteryPrize(req *types.UpdateAdminLotteryPrizeRequest) (*types.AdminLotteryPrize, error) {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
var updated modelLottery.Prize
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.NewErrCode(xerr.DatabaseQueryError)
|
||||
}
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
fields := map[string]any{}
|
||||
if req.Slot != nil {
|
||||
fields["slot"] = *req.Slot
|
||||
}
|
||||
if req.Name != "" {
|
||||
fields["name"] = req.Name
|
||||
}
|
||||
if req.IconUrl != "" {
|
||||
fields["icon_url"] = req.IconUrl
|
||||
}
|
||||
if len(req.Config) > 0 {
|
||||
fields["config"] = string(req.Config)
|
||||
}
|
||||
if req.Weight != nil {
|
||||
fields["weight"] = *req.Weight
|
||||
}
|
||||
if req.TotalStock != nil {
|
||||
fields["total_stock"] = *req.TotalStock
|
||||
fields["remaining_stock"] = *req.TotalStock
|
||||
}
|
||||
if req.IsFallback != nil {
|
||||
fields["is_fallback"] = *req.IsFallback
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
if err := tx.Model(&modelLottery.Prize{}).Where("id = ?", req.Id).Updates(fields).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||||
}
|
||||
if err := tx.Where("id = ?", req.Id).First(&updated).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryPrizeUpdate,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prizeToAdminView(updated), nil
|
||||
}
|
||||
|
||||
type DeleteLotteryPrizeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewDeleteLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteLotteryPrizeLogic {
|
||||
return &DeleteLotteryPrizeLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *DeleteLotteryPrizeLogic) DeleteLotteryPrize(req *types.AdminPrizeIdRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("id = ?", req.Id).Delete(&modelLottery.Prize{}).Error; err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseDeletedError), err.Error())
|
||||
}
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryPrizeDelete,
|
||||
TargetIds: int64ToStr(req.Id),
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
type ListLotteryPrizesLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewListLotteryPrizesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryPrizesLogic {
|
||||
return &ListLotteryPrizesLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *ListLotteryPrizesLogic) ListLotteryPrizes(req *types.ListAdminLotteryPrizesRequest) (*types.ListAdminLotteryPrizesResponse, error) {
|
||||
if currentAdminId(l.ctx) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
var rows []modelLottery.Prize
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("activity_id = ?", req.ActivityId).Order("slot ASC").Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
resp := &types.ListAdminLotteryPrizesResponse{List: make([]types.AdminLotteryPrize, 0, len(rows))}
|
||||
for _, p := range rows {
|
||||
resp.List = append(resp.List, *prizeToAdminView(p))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ---- GrantLotteryChance ----------------------------------------------------
|
||||
|
||||
type GrantLotteryChanceLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewGrantLotteryChanceLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GrantLotteryChanceLogic {
|
||||
return &GrantLotteryChanceLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (l *GrantLotteryChanceLogic) GrantLotteryChance(req *types.GrantAdminLotteryChanceRequest) error {
|
||||
actor := currentAdminId(l.ctx)
|
||||
if actor == 0 {
|
||||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
// Prefix sourceRef with "manual:{actor}:" so admin-granted chances are
|
||||
// distinguishable in ChanceGrant flow (audit trail + admin-scoped
|
||||
// idempotency).
|
||||
ref := "manual:" + int64ToStr(actor) + ":" + req.SourceRef
|
||||
if err := l.svcCtx.LotteryChance.Grant(l.ctx, req.UserId, req.ActivityId, modelLottery.ChanceSourceManualGrant, ref, req.Amount); err != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.LotteryInternalError), err.Error())
|
||||
}
|
||||
// Audit outside the ChanceService tx — the Grant is idempotent so a
|
||||
// duplicated audit row is preferable to a lost one.
|
||||
body, _ := json.Marshal(req)
|
||||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
ip, ua := requestMeta(l.ctx)
|
||||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||||
ActorUserId: actor,
|
||||
Action: audit.ActionLotteryChancesGrant,
|
||||
TargetIds: int64ToStr(req.UserId) + "," + int64ToStr(req.ActivityId),
|
||||
RequestBody: body,
|
||||
IP: ip,
|
||||
UserAgent: ua,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
func activityToAdminView(a modelLottery.Activity) *types.AdminLotteryActivity {
|
||||
return &types.AdminLotteryActivity{
|
||||
Id: a.Id,
|
||||
Title: a.Title,
|
||||
Description: a.Description,
|
||||
StartAt: a.StartAt.Unix(),
|
||||
EndAt: a.EndAt.Unix(),
|
||||
Status: a.Status,
|
||||
GridSize: a.GridSize,
|
||||
Eligibility: json.RawMessage(defaultRawIfEmpty(a.Eligibility, "{}")),
|
||||
ChanceSources: json.RawMessage(defaultRawIfEmpty(a.ChanceSources, "[]")),
|
||||
UnmetAction: a.UnmetAction,
|
||||
CreatedAt: a.CreatedAt.Unix(),
|
||||
UpdatedAt: a.UpdatedAt.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func prizeToAdminView(p modelLottery.Prize) *types.AdminLotteryPrize {
|
||||
view := &types.AdminLotteryPrize{
|
||||
Id: p.Id,
|
||||
ActivityId: p.ActivityId,
|
||||
Slot: p.Slot,
|
||||
Type: p.Type,
|
||||
Name: p.Name,
|
||||
IconUrl: p.IconURL,
|
||||
Config: json.RawMessage(defaultRawIfEmpty(p.Config, "{}")),
|
||||
Weight: p.Weight,
|
||||
IsFallback: p.IsFallback,
|
||||
CreatedAt: p.CreatedAt.Unix(),
|
||||
UpdatedAt: p.UpdatedAt.Unix(),
|
||||
}
|
||||
if p.TotalStock.Valid {
|
||||
v := p.TotalStock.Int64
|
||||
view.TotalStock = &v
|
||||
}
|
||||
if p.RemainingStock.Valid {
|
||||
v := p.RemainingStock.Int64
|
||||
view.RemainingStock = &v
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func defaultRawIfEmpty(s, fallback string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func defaultString(s, fallback string) string {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func int64ToStr(v int64) string {
|
||||
// small buffer avoids strconv import here.
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := false
|
||||
if v < 0 {
|
||||
neg = true
|
||||
v = -v
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newAdminLotteryDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("actual sql does not contain expected: " + expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("gorm: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
func adminCtx() context.Context {
|
||||
return context.WithValue(context.Background(), constant.CtxKeyUser, &user.User{Id: 7})
|
||||
}
|
||||
|
||||
func TestUpdateLotteryRules_RejectsOversizeEligibility(t *testing.T) {
|
||||
db, _, cleanup := newAdminLotteryDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// build oversized JSON
|
||||
blob := strings.Repeat("a", 9000)
|
||||
req := &types.UpdateAdminLotteryRulesRequest{
|
||||
Id: 1,
|
||||
Eligibility: json.RawMessage(`{"op":"AND","payload":"` + blob + `"}`),
|
||||
}
|
||||
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||
err := logic.UpdateLotteryRules(req)
|
||||
var ce *xerr.CodeError
|
||||
if !errors.As(err, &ce) {
|
||||
t.Fatalf("expected CodeError, got %v", err)
|
||||
}
|
||||
if ce.GetErrCode() != xerr.LotteryRuleTooLarge {
|
||||
t.Fatalf("expected LotteryRuleTooLarge, got %d", ce.GetErrCode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLotteryRules_RejectsDeepTree(t *testing.T) {
|
||||
db, _, cleanup := newAdminLotteryDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// build depth 9 tree
|
||||
tree := map[string]any{"op": "OR", "children": []any{}}
|
||||
cur := tree
|
||||
for i := 1; i < 9; i++ {
|
||||
next := map[string]any{"op": "OR", "children": []any{}}
|
||||
cur["children"] = []any{next}
|
||||
cur = next
|
||||
}
|
||||
raw, _ := json.Marshal(tree)
|
||||
req := &types.UpdateAdminLotteryRulesRequest{Id: 1, Eligibility: raw}
|
||||
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||
err := logic.UpdateLotteryRules(req)
|
||||
var ce *xerr.CodeError
|
||||
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.LotteryRuleTooDeep {
|
||||
t.Fatalf("expected LotteryRuleTooDeep, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLotteryRules_RejectsAnonymousCaller(t *testing.T) {
|
||||
db, _, cleanup := newAdminLotteryDB(t)
|
||||
defer cleanup()
|
||||
|
||||
logic := NewUpdateLotteryRulesLogic(context.Background(), &svc.ServiceContext{DB: db})
|
||||
err := logic.UpdateLotteryRules(&types.UpdateAdminLotteryRulesRequest{Id: 1, Eligibility: json.RawMessage("{}")})
|
||||
var ce *xerr.CodeError
|
||||
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.ErrorTokenInvalid {
|
||||
t.Fatalf("expected token invalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLotteryRules_ValidTreePersists(t *testing.T) {
|
||||
db, mock, cleanup := newAdminLotteryDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("UPDATE `lottery_activity`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO `admin_action_log`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
req := &types.UpdateAdminLotteryRulesRequest{
|
||||
Id: 1,
|
||||
Eligibility: json.RawMessage(`{"type":"has_subscription"}`),
|
||||
ChanceSources: json.RawMessage(`[{"source":"daily_signin","amount":1}]`),
|
||||
}
|
||||
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||
if err := logic.UpdateLotteryRules(req); err != nil {
|
||||
t.Fatalf("UpdateLotteryRules: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLotteryRules_MissingBothFieldsRejects(t *testing.T) {
|
||||
db, _, cleanup := newAdminLotteryDB(t)
|
||||
defer cleanup()
|
||||
|
||||
req := &types.UpdateAdminLotteryRulesRequest{Id: 1}
|
||||
logic := NewUpdateLotteryRulesLogic(adminCtx(), &svc.ServiceContext{DB: db})
|
||||
err := logic.UpdateLotteryRules(req)
|
||||
var ce *xerr.CodeError
|
||||
if !errors.As(err, &ce) || ce.GetErrCode() != xerr.InvalidParams {
|
||||
t.Fatalf("expected InvalidParams, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
)
|
||||
|
||||
// TestRequestMeta_EmptyWhenCtxUnset asserts requestMeta returns empty strings
|
||||
// when neither typed context key is populated (unit tests, non-admin paths).
|
||||
func TestRequestMeta_EmptyWhenCtxUnset(t *testing.T) {
|
||||
ip, ua := requestMeta(context.Background())
|
||||
if ip != "" || ua != "" {
|
||||
t.Fatalf("expected empty, got ip=%q ua=%q", ip, ua)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestMeta_ReadsTypedKeys asserts requestMeta picks up the values
|
||||
// AdminMetaMiddleware pins onto ctx via the typed CtxKey constants.
|
||||
func TestRequestMeta_ReadsTypedKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyIP, "10.99.99.7")
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyUserAgent, "qa-audit-probe")
|
||||
|
||||
ip, ua := requestMeta(ctx)
|
||||
if ip != "10.99.99.7" {
|
||||
t.Fatalf("ip = %q, want %q", ip, "10.99.99.7")
|
||||
}
|
||||
if ua != "qa-audit-probe" {
|
||||
t.Fatalf("ua = %q, want %q", ua, "qa-audit-probe")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestMeta_IgnoresBareStringKeys guards against the F2 root cause:
|
||||
// pre-fix, the writer used bare-string keys "ip" / "user_agent" which never
|
||||
// collided with anyone's typed reader — so audit rows always saw empty
|
||||
// strings. The test proves the reader now IGNORES bare-string writes: only
|
||||
// the typed CtxKey path counts.
|
||||
func TestRequestMeta_IgnoresBareStringKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
//nolint:staticcheck // intentional bare-string key to prove reader ignores it
|
||||
ctx = context.WithValue(ctx, "ip", "should-be-ignored")
|
||||
//nolint:staticcheck // intentional bare-string key to prove reader ignores it
|
||||
ctx = context.WithValue(ctx, "user_agent", "should-be-ignored")
|
||||
|
||||
ip, ua := requestMeta(ctx)
|
||||
if ip != "" || ua != "" {
|
||||
t.Fatalf("bare-string keys must be ignored; got ip=%q ua=%q", ip, ua)
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,7 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
@@ -50,84 +46,11 @@ func (l *KickOfflineByUserDeviceLogic) KickOfflineByUserDevice(req *types.KickOf
|
||||
|
||||
// clearAllSessions 清除指定用户的所有会话(通过 SCAN 查找,不依赖 sorted set)
|
||||
func (l *KickOfflineByUserDeviceLogic) clearAllSessions(userId int64) {
|
||||
sessionSet := make(map[string]struct{})
|
||||
|
||||
userIDText := strconv.FormatInt(userId, 10)
|
||||
pattern := fmt.Sprintf("%s:*", config.SessionIdKey)
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, pattern, 200).Result()
|
||||
if scanErr != nil {
|
||||
l.Errorw("扫描会话键失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error()))
|
||||
break
|
||||
}
|
||||
for _, sessionKey := range keys {
|
||||
value, getErr := l.svcCtx.Redis.Get(l.ctx, sessionKey).Result()
|
||||
if getErr != nil || value != userIDText {
|
||||
continue
|
||||
}
|
||||
sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":")
|
||||
if sessionID == "" || strings.HasPrefix(sessionID, "detail:") {
|
||||
continue
|
||||
}
|
||||
sessionSet[sessionID] = struct{}{}
|
||||
}
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
deviceKeySet := make(map[string]struct{})
|
||||
devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey)
|
||||
cursor = 0
|
||||
for {
|
||||
keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, devicePattern, 200).Result()
|
||||
if scanErr != nil {
|
||||
l.Errorw("扫描设备会话映射失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error()))
|
||||
break
|
||||
}
|
||||
for _, deviceKey := range keys {
|
||||
sessionID, getErr := l.svcCtx.Redis.Get(l.ctx, deviceKey).Result()
|
||||
if getErr != nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := sessionSet[sessionID]; exists {
|
||||
deviceKeySet[deviceKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(sessionSet) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
|
||||
pipe := l.svcCtx.Redis.TxPipeline()
|
||||
for sessionID := range sessionSet {
|
||||
pipe.Del(l.ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID))
|
||||
pipe.Del(l.ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID))
|
||||
pipe.ZRem(l.ctx, sessionsKey, sessionID)
|
||||
}
|
||||
pipe.Del(l.ctx, sessionsKey)
|
||||
|
||||
for deviceKey := range deviceKeySet {
|
||||
pipe.Del(l.ctx, deviceKey)
|
||||
}
|
||||
|
||||
if _, err := pipe.Exec(l.ctx); err != nil {
|
||||
if err := clearAllSessions(l.ctx, l.svcCtx, userId); err != nil {
|
||||
l.Errorw("清理会话缓存失败",
|
||||
logger.Field("user_id", userId),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
l.Infow("[KickOffline] 管理员踢设备-清除所有Session",
|
||||
logger.Field("user_id", userId),
|
||||
logger.Field("count", len(sessionSet)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
)
|
||||
|
||||
func clearAllSessions(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) error {
|
||||
userIDText := strconv.FormatInt(userID, 10)
|
||||
sessionSet := make(map[string]struct{})
|
||||
|
||||
pattern := fmt.Sprintf("%s:*", config.SessionIdKey)
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, nextCursor, err := svcCtx.Redis.Scan(ctx, cursor, pattern, 200).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sessionKey := range keys {
|
||||
value, err := svcCtx.Redis.Get(ctx, sessionKey).Result()
|
||||
if err != nil || value != userIDText {
|
||||
continue
|
||||
}
|
||||
sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":")
|
||||
if sessionID == "" || strings.HasPrefix(sessionID, "detail:") {
|
||||
continue
|
||||
}
|
||||
sessionSet[sessionID] = struct{}{}
|
||||
}
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(sessionSet) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
deviceKeySet := make(map[string]struct{})
|
||||
devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey)
|
||||
cursor = 0
|
||||
for {
|
||||
keys, nextCursor, err := svcCtx.Redis.Scan(ctx, cursor, devicePattern, 200).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, deviceKey := range keys {
|
||||
sessionID, err := svcCtx.Redis.Get(ctx, deviceKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := sessionSet[sessionID]; exists {
|
||||
deviceKeySet[deviceKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userID)
|
||||
pipe := svcCtx.Redis.TxPipeline()
|
||||
for sessionID := range sessionSet {
|
||||
pipe.Del(ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID))
|
||||
pipe.Del(ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID))
|
||||
pipe.ZRem(ctx, sessionsKey, sessionID)
|
||||
}
|
||||
pipe.Del(ctx, sessionsKey)
|
||||
for deviceKey := range deviceKeySet {
|
||||
pipe.Del(ctx, deviceKey)
|
||||
}
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestClearAllSessions(t *testing.T) {
|
||||
redisServer, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Run() error = %v", err)
|
||||
}
|
||||
defer redisServer.Close()
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||
defer rdb.Close()
|
||||
|
||||
svcCtx := &svc.ServiceContext{Redis: rdb}
|
||||
ctx := context.Background()
|
||||
|
||||
userID := int64(42)
|
||||
sessionID := "session-a"
|
||||
otherSessionID := "session-b"
|
||||
userSessionKey := config.SessionIdKey + ":" + sessionID
|
||||
userDetailKey := config.SessionIdKey + ":detail:" + sessionID
|
||||
otherSessionKey := config.SessionIdKey + ":" + otherSessionID
|
||||
userSessionsZSet := config.UserSessionsKeyPrefix + "42"
|
||||
deviceKey := config.DeviceCacheKeyKey + ":device-1"
|
||||
unrelatedDeviceKey := config.DeviceCacheKeyKey + ":device-2"
|
||||
|
||||
setString(t, redisServer, userSessionKey, "42")
|
||||
setString(t, redisServer, userDetailKey, "detail")
|
||||
setString(t, redisServer, otherSessionKey, "99")
|
||||
setString(t, redisServer, deviceKey, sessionID)
|
||||
setString(t, redisServer, unrelatedDeviceKey, otherSessionID)
|
||||
if _, err := redisServer.ZAdd(userSessionsZSet, 1, sessionID); err != nil {
|
||||
t.Fatalf("seed session zset: %v", err)
|
||||
}
|
||||
|
||||
if err := clearAllSessions(ctx, svcCtx, userID); err != nil {
|
||||
t.Fatalf("clearAllSessions() error = %v", err)
|
||||
}
|
||||
|
||||
assertMissing(t, redisServer, userSessionKey)
|
||||
assertMissing(t, redisServer, userDetailKey)
|
||||
assertMissing(t, redisServer, deviceKey)
|
||||
assertMissing(t, redisServer, userSessionsZSet)
|
||||
|
||||
if !redisServer.Exists(otherSessionKey) {
|
||||
t.Fatalf("unrelated session %q should remain", otherSessionKey)
|
||||
}
|
||||
if !redisServer.Exists(unrelatedDeviceKey) {
|
||||
t.Fatalf("unrelated device mapping %q should remain", unrelatedDeviceKey)
|
||||
}
|
||||
}
|
||||
|
||||
func setString(t *testing.T, server *miniredis.Miniredis, key, value string) {
|
||||
t.Helper()
|
||||
if err := server.Set(key, value); err != nil {
|
||||
t.Fatalf("set %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMissing(t *testing.T, server *miniredis.Miniredis, key string) {
|
||||
t.Helper()
|
||||
if server.Exists(key) {
|
||||
t.Fatalf("expected key %q to be removed", key)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,14 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
if req.Avatar != "" && !tool.IsValidImageSize(req.Avatar, 1024) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Invalid Image Size")
|
||||
}
|
||||
if req.Enable != nil && !*req.Enable {
|
||||
if userInfo.IsAdmin != nil && *userInfo.IsAdmin {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "admin user cannot be disabled")
|
||||
}
|
||||
if userInfo.Id == 2 {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "demo user cannot be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
|
||||
if req.Balance != nil && userInfo.Balance != *req.Balance {
|
||||
@@ -176,6 +184,19 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
|
||||
}
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Update User Error")
|
||||
}
|
||||
if req.Enable != nil {
|
||||
if cacheErr := logicCommon.InvalidateUserEnableCache(l.ctx, l.svcCtx, userInfo.Id); cacheErr != nil {
|
||||
l.Errorw("[UpdateUserBasicInfoLogic] clear enable cache failed", logger.Field("err", cacheErr.Error()), logger.Field("userId", req.UserId))
|
||||
}
|
||||
if !*req.Enable {
|
||||
if sessionErr := clearAllSessions(l.ctx, l.svcCtx, userInfo.Id); sessionErr != nil {
|
||||
l.Errorw("[UpdateUserBasicInfoLogic] clear sessions failed", logger.Field("err", sessionErr.Error()), logger.Field("userId", req.UserId))
|
||||
}
|
||||
for _, device := range userInfo.UserDevices {
|
||||
l.svcCtx.DeviceManager.KickDevice(userInfo.Id, device.Identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package audit records administrative write actions to the admin_action_log
|
||||
// table so security/compliance can trace who did what across lottery admin
|
||||
// endpoints. Every admin CRUD in PR C calls WriteAdminAction inside its own
|
||||
// transaction; the caller is expected to have already validated permissions.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Action code convention: dot-separated, prefix by domain (e.g.
|
||||
// "lottery.activity.create", "lottery.prize.delete"). Keep them short and
|
||||
// stable so downstream analytics can pivot without maintaining a translation
|
||||
// table.
|
||||
const (
|
||||
ActionLotteryActivityCreate = "lottery.activity.create"
|
||||
ActionLotteryActivityUpdate = "lottery.activity.update"
|
||||
ActionLotteryActivityDelete = "lottery.activity.delete"
|
||||
ActionLotteryActivityPublish = "lottery.activity.publish"
|
||||
ActionLotteryActivityPause = "lottery.activity.pause"
|
||||
ActionLotteryPrizeCreate = "lottery.prize.create"
|
||||
ActionLotteryPrizeUpdate = "lottery.prize.update"
|
||||
ActionLotteryPrizeDelete = "lottery.prize.delete"
|
||||
ActionLotteryRulesPut = "lottery.activity.rules.put"
|
||||
ActionLotteryChancesGrant = "lottery.chances.grant"
|
||||
)
|
||||
|
||||
// AdminActionLog is the GORM entity for admin_action_log.
|
||||
type AdminActionLog struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
ActorUserId int64 `gorm:"type:bigint unsigned;not null;comment:操作者 user.id"`
|
||||
Action string `gorm:"type:varchar(64);not null;comment:动作 code"`
|
||||
TargetIds string `gorm:"type:varchar(255);not null;default:'';comment:被操作对象 ID"`
|
||||
RequestHash string `gorm:"type:varchar(64);not null;default:'';comment:请求摘要"`
|
||||
IP string `gorm:"type:varchar(45);not null;default:'';comment:操作者 IP"`
|
||||
UserAgent string `gorm:"type:varchar(255);not null;default:'';comment:操作者 UA"`
|
||||
CreatedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:操作时间"`
|
||||
}
|
||||
|
||||
// TableName pins the entity to the migration table name.
|
||||
func (AdminActionLog) TableName() string { return "admin_action_log" }
|
||||
|
||||
// Entry is the pre-hashed convenience input to WriteAdminAction. Callers
|
||||
// build one with actor + action + payload fields; the writer computes the
|
||||
// request hash and inserts inside tx.
|
||||
type Entry struct {
|
||||
ActorUserId int64
|
||||
Action string
|
||||
// TargetIds is stringified list of primary keys touched by this action.
|
||||
// Free-form: comma-separated ints, JSON array, etc.
|
||||
TargetIds string
|
||||
// RequestBody is hashed to produce request_hash. Pass nil if not applicable.
|
||||
RequestBody []byte
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// WriteAdminAction inserts an admin_action_log row inside the caller's tx.
|
||||
// The row lives-or-dies with the caller's transaction: a rollback drops the
|
||||
// audit trail, which is the intended coupling — we don't want to record
|
||||
// actions that never happened.
|
||||
func WriteAdminAction(ctx context.Context, tx *gorm.DB, e Entry) error {
|
||||
if tx == nil {
|
||||
return fmt.Errorf("audit: WriteAdminAction requires a transaction handle")
|
||||
}
|
||||
if e.ActorUserId == 0 || e.Action == "" {
|
||||
return fmt.Errorf("audit: WriteAdminAction requires ActorUserId and Action")
|
||||
}
|
||||
row := AdminActionLog{
|
||||
ActorUserId: e.ActorUserId,
|
||||
Action: strings.TrimSpace(e.Action),
|
||||
TargetIds: e.TargetIds,
|
||||
RequestHash: hashBody(e.RequestBody),
|
||||
IP: e.IP,
|
||||
UserAgent: truncate(e.UserAgent, 255),
|
||||
}
|
||||
return tx.WithContext(ctx).Create(&row).Error
|
||||
}
|
||||
|
||||
func hashBody(body []byte) string {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
}
|
||||
sum := sha1.Sum(body)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actual, expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
func TestWriteAdminAction_RequiresTx(t *testing.T) {
|
||||
err := WriteAdminAction(context.Background(), nil, Entry{ActorUserId: 1, Action: "x"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on nil tx")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAdminAction_RequiresActorAndAction(t *testing.T) {
|
||||
db, _, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := WriteAdminAction(context.Background(), db, Entry{Action: "x"}); err == nil {
|
||||
t.Fatal("expected error when ActorUserId=0")
|
||||
}
|
||||
if err := WriteAdminAction(context.Background(), db, Entry{ActorUserId: 1}); err == nil {
|
||||
t.Fatal("expected error when Action empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAdminAction_InsertsRow(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec("INSERT INTO `admin_action_log`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
err := WriteAdminAction(context.Background(), db, Entry{
|
||||
ActorUserId: 42,
|
||||
Action: ActionLotteryActivityCreate,
|
||||
TargetIds: "[1,2,3]",
|
||||
RequestBody: []byte(`{"title":"test"}`),
|
||||
IP: "127.0.0.1",
|
||||
UserAgent: "curl/7.85",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteAdminAction: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashBody(t *testing.T) {
|
||||
if got := hashBody(nil); got != "" {
|
||||
t.Fatalf("nil body should hash to empty, got %q", got)
|
||||
}
|
||||
if got := hashBody([]byte("")); got != "" {
|
||||
t.Fatalf("empty body should hash to empty, got %q", got)
|
||||
}
|
||||
if got := hashBody([]byte("abc")); len(got) != 40 {
|
||||
t.Fatalf("expected 40-char sha1 hex, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
if got := truncate("hello", 10); got != "hello" {
|
||||
t.Fatalf("short strings pass through, got %q", got)
|
||||
}
|
||||
if got := truncate("hello world", 5); got != "hello" {
|
||||
t.Fatalf("expected truncation to 5, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAdminAction_DBError(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec("INSERT INTO `admin_action_log`").
|
||||
WillReturnError(errors.New("db down"))
|
||||
|
||||
err := WriteAdminAction(context.Background(), db, Entry{
|
||||
ActorUserId: 1,
|
||||
Action: "test",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error propagation from DB")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/logic/auth"
|
||||
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -94,6 +96,9 @@ func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.L
|
||||
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
|
||||
}
|
||||
if logiccommon.IsUserDisabled(userInfo) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
|
||||
// Bind device to user if identifier is provided
|
||||
if req.Identifier != "" {
|
||||
@@ -130,6 +135,7 @@ func (l *AdminLoginLogic) AdminLogin(req *types.UserLoginRequest) (resp *types.L
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||
}
|
||||
l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId})
|
||||
loginStatus = true
|
||||
return &types.LoginResponse{
|
||||
Token: token,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -114,6 +116,9 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "register failed: %v", err.Error())
|
||||
}
|
||||
}
|
||||
if logicCommon.IsUserDisabled(userInfo) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
|
||||
// Record login status
|
||||
defer func() {
|
||||
@@ -188,6 +193,7 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||
}
|
||||
l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId})
|
||||
|
||||
loginStatus = true
|
||||
return &types.LoginResponse{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
authlogic "github.com/perfect-panel/server/internal/logic/auth"
|
||||
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/auth"
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -74,6 +76,9 @@ func (l *OAuthLoginGetTokenLogic) OAuthLoginGetToken(req *types.OAuthLoginGetTok
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if logiccommon.IsUserDisabled(userInfo) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
|
||||
token, err := l.generateToken(userInfo, requestID)
|
||||
if err != nil {
|
||||
@@ -628,6 +633,7 @@ func (l *OAuthLoginGetTokenLogic) generateToken(userInfo *user.User, requestID s
|
||||
)
|
||||
return "", errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err)
|
||||
}
|
||||
l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId})
|
||||
|
||||
l.Infow("jwt token generated successfully",
|
||||
logger.Field("request_id", requestID),
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -64,6 +65,9 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user info failed: %v", err.Error())
|
||||
}
|
||||
if common.IsUserDisabled(userInfo) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
// Record login status
|
||||
defer func(svcCtx *svc.ServiceContext) {
|
||||
if userInfo.Id != 0 {
|
||||
@@ -165,6 +169,7 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||
}
|
||||
l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId})
|
||||
loginStatus = true
|
||||
return &types.LoginResponse{
|
||||
Token: token,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/pkg/captcha"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@@ -89,6 +91,9 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
if !tool.MultiPasswordVerify(userInfo.Algo, userInfo.Salt, req.Password, userInfo.Password) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserPasswordError), "user password")
|
||||
}
|
||||
if logicCommon.IsUserDisabled(userInfo) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
now := time.Now()
|
||||
@@ -135,6 +140,7 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||
}
|
||||
l.svcCtx.Redis.ZAdd(l.ctx, fmt.Sprintf("%s%d", config.UserSessionsKeyPrefix, userInfo.Id), redis.Z{Score: float64(time.Now().Unix()), Member: sessionId})
|
||||
loginStatus = true
|
||||
return &types.LoginResponse{
|
||||
Token: token,
|
||||
|
||||
@@ -101,7 +101,7 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
||||
} else if err == nil && !u.DeletedAt.Valid {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "user email exist: %v", req.Email)
|
||||
} else if err == nil && u.DeletedAt.Valid {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "user email deleted: %v", req.Email)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserNotExist), "user email deleted: %v", req.Email)
|
||||
}
|
||||
|
||||
if !registerIpLimit(l.svcCtx, l.ctx, req.IP, "email", req.Email) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
modeluser "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const userEnableCacheTTL = 30 * time.Second
|
||||
|
||||
func UserEnableCacheKey(userID int64) string {
|
||||
return fmt.Sprintf("%s%d", config.UserEnableKeyPrefix, userID)
|
||||
}
|
||||
|
||||
func IsUserDisabled(userInfo *modeluser.User) bool {
|
||||
return userInfo != nil && userInfo.Enable != nil && !*userInfo.Enable
|
||||
}
|
||||
|
||||
func ResolveEnabledUser(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) (*modeluser.User, error) {
|
||||
if userID <= 0 {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid user id: %d", userID)
|
||||
}
|
||||
|
||||
cacheKey := UserEnableCacheKey(userID)
|
||||
cached, err := svcCtx.Redis.Get(ctx, cacheKey).Result()
|
||||
if err == nil {
|
||||
if cached == strconv.FormatBool(false) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
return svcCtx.UserModel.FindOne(ctx, userID)
|
||||
}
|
||||
if err != nil && err != redis.Nil {
|
||||
logger.WithContext(ctx).Errorw("get user enable cache failed, fallback to db",
|
||||
logger.Field("user_id", userID),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return loadEnabledUserFromDB(ctx, svcCtx, userID)
|
||||
}
|
||||
|
||||
userInfo, err := svcCtx.UserModel.FindOne(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cacheErr := CacheUserEnabled(ctx, svcCtx, userID, !IsUserDisabled(userInfo)); cacheErr != nil {
|
||||
logger.WithContext(ctx).Errorw("cache user enable state failed",
|
||||
logger.Field("user_id", userID),
|
||||
logger.Field("error", cacheErr.Error()),
|
||||
)
|
||||
}
|
||||
if IsUserDisabled(userInfo) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
func CacheUserEnabled(ctx context.Context, svcCtx *svc.ServiceContext, userID int64, enabled bool) error {
|
||||
return svcCtx.Redis.Set(ctx, UserEnableCacheKey(userID), strconv.FormatBool(enabled), userEnableCacheTTL).Err()
|
||||
}
|
||||
|
||||
func InvalidateUserEnableCache(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) error {
|
||||
return svcCtx.Redis.Del(ctx, UserEnableCacheKey(userID)).Err()
|
||||
}
|
||||
|
||||
func loadEnabledUserFromDB(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) (*modeluser.User, error) {
|
||||
var userInfo modeluser.User
|
||||
if err := svcCtx.DB.WithContext(ctx).
|
||||
Model(&modeluser.User{}).
|
||||
Unscoped().
|
||||
Where("`id` = ?", userID).
|
||||
Preload("UserDevices").
|
||||
Preload("AuthMethods").
|
||||
First(&userInfo).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if IsUserDisabled(&userInfo) {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "User disabled")
|
||||
}
|
||||
return &userInfo, nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
func TestIsUserDisabled(t *testing.T) {
|
||||
trueValue := true
|
||||
falseValue := false
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *user.User
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil user treated as enabled",
|
||||
user: nil,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil enable treated as enabled",
|
||||
user: &user.User{Id: 1},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "enabled user",
|
||||
user: &user.User{Id: 2, Enable: &trueValue},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "disabled user",
|
||||
user: &user.User{Id: 3, Enable: &falseValue},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsUserDisabled(tc.user); got != tc.want {
|
||||
t.Fatalf("IsUserDisabled() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEnabledUser(t *testing.T) {
|
||||
t.Run("cache hit false returns disabled error without db query", func(t *testing.T) {
|
||||
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||
defer redisServer.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := svcCtx.Redis.Set(ctx, UserEnableCacheKey(9), "false", 0).Err(); err != nil {
|
||||
t.Fatalf("seed redis: %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveEnabledUser(ctx, svcCtx, 9)
|
||||
assertCodeError(t, err, xerr.UserDisabled)
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unexpected db query: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cache miss loads enabled user and backfills cache", func(t *testing.T) {
|
||||
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||
defer redisServer.Close()
|
||||
|
||||
expectFindOne(mock, 11, true)
|
||||
|
||||
ctx := context.Background()
|
||||
userInfo, err := ResolveEnabledUser(ctx, svcCtx, 11)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEnabledUser() error = %v", err)
|
||||
}
|
||||
if userInfo.Id != 11 {
|
||||
t.Fatalf("ResolveEnabledUser() user id = %d, want 11", userInfo.Id)
|
||||
}
|
||||
|
||||
cached, err := svcCtx.Redis.Get(ctx, UserEnableCacheKey(11)).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("read backfilled cache: %v", err)
|
||||
}
|
||||
if cached != "true" {
|
||||
t.Fatalf("backfilled cache = %q, want %q", cached, "true")
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("db expectations: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cache miss loads disabled user and caches false", func(t *testing.T) {
|
||||
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||
defer redisServer.Close()
|
||||
|
||||
expectFindOne(mock, 13, false)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := ResolveEnabledUser(ctx, svcCtx, 13)
|
||||
assertCodeError(t, err, xerr.UserDisabled)
|
||||
|
||||
cached, cacheErr := svcCtx.Redis.Get(ctx, UserEnableCacheKey(13)).Result()
|
||||
if cacheErr != nil {
|
||||
t.Fatalf("read disabled cache: %v", cacheErr)
|
||||
}
|
||||
if cached != "false" {
|
||||
t.Fatalf("disabled cache = %q, want %q", cached, "false")
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("db expectations: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("redis failure falls back to db", func(t *testing.T) {
|
||||
svcCtx, mock, redisServer := newEnableTestServiceContext(t)
|
||||
expectFindOne(mock, 17, true)
|
||||
redisServer.Close()
|
||||
|
||||
userInfo, err := ResolveEnabledUser(context.Background(), svcCtx, 17)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveEnabledUser() error = %v", err)
|
||||
}
|
||||
if userInfo.Id != 17 {
|
||||
t.Fatalf("ResolveEnabledUser() user id = %d, want 17", userInfo.Id)
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("db expectations: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newEnableTestServiceContext(t *testing.T) (*svc.ServiceContext, sqlmock.Sqlmock, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock.New() error = %v", err)
|
||||
}
|
||||
mock.MatchExpectationsInOrder(false)
|
||||
t.Cleanup(func() {
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
|
||||
gdb, err := gorm.Open(mysql.New(mysql.Config{
|
||||
Conn: sqlDB,
|
||||
SkipInitializeWithVersion: true,
|
||||
}), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{SingularTable: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("gorm.Open() error = %v", err)
|
||||
}
|
||||
|
||||
redisServer, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Run() error = %v", err)
|
||||
}
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
|
||||
t.Cleanup(func() {
|
||||
_ = rdb.Close()
|
||||
})
|
||||
|
||||
return &svc.ServiceContext{
|
||||
DB: gdb,
|
||||
Redis: rdb,
|
||||
UserModel: user.NewModel(gdb, rdb),
|
||||
}, mock, redisServer
|
||||
}
|
||||
|
||||
func expectFindOne(mock sqlmock.Sqlmock, userID int64, enabled bool) {
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id",
|
||||
"password",
|
||||
"algo",
|
||||
"salt",
|
||||
"avatar",
|
||||
"balance",
|
||||
"refer_code",
|
||||
"referer_id",
|
||||
"commission",
|
||||
"referral_percentage",
|
||||
"only_first_purchase",
|
||||
"gift_amount",
|
||||
"enable",
|
||||
"is_admin",
|
||||
"enable_balance_notify",
|
||||
"enable_login_notify",
|
||||
"enable_subscribe_notify",
|
||||
"enable_trade_notify",
|
||||
"rules",
|
||||
"member_status",
|
||||
"remark",
|
||||
"last_login_time",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
}).AddRow(
|
||||
userID,
|
||||
"pwd",
|
||||
"default",
|
||||
"",
|
||||
"",
|
||||
int64(0),
|
||||
"",
|
||||
int64(0),
|
||||
int64(0),
|
||||
uint8(0),
|
||||
true,
|
||||
int64(0),
|
||||
enabled,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user` WHERE `id` = ? ORDER BY `user`.`id` LIMIT ?")).
|
||||
WithArgs(userID, 1).
|
||||
WillReturnRows(rows)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_device` WHERE `user_device`.`user_id` = ?")).
|
||||
WithArgs(userID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "identifier"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_auth_methods` WHERE `user_auth_methods`.`user_id` = ?")).
|
||||
WithArgs(userID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "auth_type", "auth_identifier", "verified"}))
|
||||
}
|
||||
|
||||
func assertCodeError(t *testing.T, err error, wantCode uint32) {
|
||||
t.Helper()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var codeErr *xerr.CodeError
|
||||
if !errors.As(err, &codeErr) {
|
||||
t.Fatalf("error %T does not contain xerr.CodeError: %v", err, err)
|
||||
}
|
||||
if codeErr.GetErrCode() != wantCode {
|
||||
t.Fatalf("error code = %d, want %d", codeErr.GetErrCode(), wantCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
// Package draw implements POST /api/v1/lottery/draw: the transactional lottery
|
||||
// draw flow. The service composes the ChanceService, RuleEvaluator,
|
||||
// WeightedPicker, PrizeHandler.Registry and LedgerService primitives from the
|
||||
// model layer into one atomic sequence.
|
||||
//
|
||||
// Ordering matters — the flow is:
|
||||
// 1. feature-flag gate (config.Lottery.Enable)
|
||||
// 2. Redis rate limit (per-user 1/sec)
|
||||
// 3. Load activity + validate window/status
|
||||
// 4. Build RuleContext (pre-tx reads)
|
||||
// 5. Evaluate eligibility (pure compute)
|
||||
// 6. Load prize pool snapshot (pre-tx read)
|
||||
// 7. Open tx →
|
||||
// 7a. Nonce dedupe: SELECT lottery_draw WHERE (user_id, client_nonce)
|
||||
// — hit returns the recorded draw
|
||||
// 7b. ChanceService.Consume (SELECT FOR UPDATE + decrement)
|
||||
// 7c. WeightedPicker.Pick
|
||||
// 7d. Limited-stock optimistic decrement; fallback on RowsAffected=0
|
||||
// 7e. INSERT lottery_draw + PrizeSnapshot + EligibilitySnapshot
|
||||
// 7f. Auto-handler Dispatch (in tx)
|
||||
// 7g. Update draw.dispatch_state
|
||||
// → commit
|
||||
//
|
||||
// Everything past step 5 uses the caller's transaction; post-commit cache
|
||||
// invalidation is the handler layer's job (a future enhancement — the
|
||||
// underlying UserModel already invalidates its own cache on UpdateSubscribe).
|
||||
package draw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/pkg/limit"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Request is the input to Draw.
|
||||
type Request struct {
|
||||
UserId int64
|
||||
ActivityId int64
|
||||
ClientNonce string
|
||||
}
|
||||
|
||||
// Result is what Draw returns to the caller (user handler).
|
||||
type Result struct {
|
||||
DrawId int64
|
||||
IsWin bool
|
||||
Prize *PrizeSummary
|
||||
ChancesRemaining int64
|
||||
Claim ClaimSummary
|
||||
Message string
|
||||
}
|
||||
|
||||
// PrizeSummary is the awarded-prize view rendered for the user.
|
||||
type PrizeSummary struct {
|
||||
Slot int
|
||||
Id int64
|
||||
Type string
|
||||
Name string
|
||||
Config json.RawMessage
|
||||
}
|
||||
|
||||
// ClaimSummary describes whether the user needs to take a further action.
|
||||
type ClaimSummary struct {
|
||||
Required bool
|
||||
AutoClaimed bool
|
||||
Message string
|
||||
// ExpiresAt 是人工奖领奖窗口截止时间(Unix 秒;0 表示不适用)。
|
||||
ExpiresAt int64
|
||||
// ClaimFormSchema 是人工奖前端渲染领奖表单用的 JSON Schema
|
||||
// (nil 表示不适用;auto handler 与"谢谢参与"都返回 nil)。
|
||||
ClaimFormSchema json.RawMessage
|
||||
}
|
||||
|
||||
// Service orchestrates the transactional draw flow. Deps are struct-injected
|
||||
// so tests can substitute fakes and production wiring lives in ServiceContext.
|
||||
type Service struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
// Deps groups the collaborators. Nil-safe checks live in Draw itself, not here.
|
||||
type Deps struct {
|
||||
DB *gorm.DB
|
||||
Enabled bool
|
||||
RateLimiter RateLimiter
|
||||
Chance lottery.ChanceService
|
||||
Evaluator lottery.RuleEvaluator
|
||||
Picker lottery.WeightedPicker
|
||||
Registry lottery.Registry
|
||||
ContextBuilder RuleContextBuilder
|
||||
}
|
||||
|
||||
// RateLimiter admits at most 1 draw per second per user. Extracted to an
|
||||
// interface so tests can supply an always-admit fake without pulling Redis.
|
||||
type RateLimiter interface {
|
||||
Allow(ctx context.Context, userId int64) error
|
||||
}
|
||||
|
||||
// RuleContextBuilder loads the per-user snapshot needed by the rule
|
||||
// evaluator. Extracted so tests can inject deterministic contexts.
|
||||
type RuleContextBuilder interface {
|
||||
Build(ctx context.Context, userId int64) (lottery.RuleContext, error)
|
||||
}
|
||||
|
||||
// NewService returns a Draw service ready to serve requests.
|
||||
func NewService(d Deps) *Service { return &Service{deps: d} }
|
||||
|
||||
// Draw runs the full lottery draw flow. Errors are xerr codes suitable for
|
||||
// direct return by the HTTP handler; internal errors are wrapped as
|
||||
// LotteryInternalError.
|
||||
func (s *Service) Draw(ctx context.Context, req Request) (*Result, error) {
|
||||
if err := s.validateRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.deps.Enabled {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
if err := s.applyRateLimit(ctx, req.UserId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activity, err := s.loadRunningActivity(ctx, req.ActivityId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pre-tx reads: user context + prize pool snapshot. Cheap and out of the
|
||||
// hot-lock window; the tx step re-checks stock atomically.
|
||||
rc, err := s.buildRuleContext(ctx, req.UserId)
|
||||
if err != nil {
|
||||
return nil, wrapInternal(err)
|
||||
}
|
||||
|
||||
tree, err := parseEligibilityTree(activity.Eligibility)
|
||||
if err != nil {
|
||||
return nil, wrapInternal(err)
|
||||
}
|
||||
passed, unmet, err := s.deps.Evaluator.Evaluate(ctx, tree, rc)
|
||||
if err != nil {
|
||||
return nil, wrapInternal(err)
|
||||
}
|
||||
if !passed {
|
||||
// The rejection itself is not an error to the caller — but we still
|
||||
// record an eligibility snapshot for support/audit before returning
|
||||
// the 4001. Snapshot write intentionally uses its own tx: the draw
|
||||
// itself never got issued, so there is no draw_id to correlate; we
|
||||
// omit the snapshot in that case.
|
||||
_ = unmet // unmet is available to the handler via error metadata if needed
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotEligible)
|
||||
}
|
||||
|
||||
prizes, err := s.loadPrizes(ctx, req.ActivityId)
|
||||
if err != nil {
|
||||
return nil, wrapInternal(err)
|
||||
}
|
||||
if len(prizes) == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
|
||||
var result *Result
|
||||
txErr := s.deps.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// (a) Nonce dedupe — race-safe idempotency check.
|
||||
if existing, existsErr := s.findExistingDraw(ctx, tx, req); existsErr != nil {
|
||||
return existsErr
|
||||
} else if existing != nil {
|
||||
result, existsErr = s.buildResultFromExistingDraw(ctx, tx, existing)
|
||||
return existsErr
|
||||
}
|
||||
|
||||
// (b) Consume chance atomically. ErrNoChances → 4002.
|
||||
remaining, consumeErr := s.deps.Chance.Consume(ctx, tx, req.UserId, req.ActivityId)
|
||||
if errors.Is(consumeErr, lottery.ErrNoChances) {
|
||||
return xerr.NewErrCode(xerr.LotteryNoChances)
|
||||
}
|
||||
if consumeErr != nil {
|
||||
return wrapInternal(consumeErr)
|
||||
}
|
||||
|
||||
// (c) Pick a prize.
|
||||
idx, pickErr := s.deps.Picker.Pick(prizes)
|
||||
if pickErr != nil {
|
||||
return xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
picked := prizes[idx]
|
||||
|
||||
// (d) Limited-stock decrement.
|
||||
final, stockErr := s.decrementStockOrFallback(ctx, tx, picked, prizes)
|
||||
if stockErr != nil {
|
||||
return wrapInternal(stockErr)
|
||||
}
|
||||
|
||||
// (e) Insert draw + snapshots.
|
||||
draw, insertErr := s.insertDraw(ctx, tx, req, final)
|
||||
if insertErr != nil {
|
||||
return wrapInternal(insertErr)
|
||||
}
|
||||
if snapErr := s.insertSnapshots(ctx, tx, draw, final, passed); snapErr != nil {
|
||||
return wrapInternal(snapErr)
|
||||
}
|
||||
|
||||
// (f) Dispatch prize (auto handler) or create pending claim (manual handler).
|
||||
dispatch, claimInfo, dispatchErr := s.dispatchOrEnqueueClaim(ctx, tx, req, draw, final)
|
||||
if dispatchErr != nil {
|
||||
return dispatchErr
|
||||
}
|
||||
|
||||
// (g) Update draw.dispatch_state to reflect handler outcome.
|
||||
if updateErr := s.finalizeDrawState(ctx, tx, draw, dispatch); updateErr != nil {
|
||||
return wrapInternal(updateErr)
|
||||
}
|
||||
|
||||
result = buildResult(draw, final, dispatch, claimInfo, remaining)
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
if _, ok := txErr.(*xerr.CodeError); ok {
|
||||
return nil, txErr
|
||||
}
|
||||
return nil, wrapInternal(txErr)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---- individual steps ------------------------------------------------------
|
||||
|
||||
func (s *Service) validateRequest(req Request) error {
|
||||
if req.UserId <= 0 || req.ActivityId <= 0 || req.ClientNonce == "" {
|
||||
return xerr.NewErrCode(xerr.InvalidParams)
|
||||
}
|
||||
if len(req.ClientNonce) > 64 {
|
||||
return xerr.NewErrCode(xerr.InvalidParams)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyRateLimit(ctx context.Context, userId int64) error {
|
||||
if s.deps.RateLimiter == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.deps.RateLimiter.Allow(ctx, userId); err != nil {
|
||||
if errors.Is(err, ErrRateLimited) {
|
||||
return xerr.NewErrCode(xerr.LotteryRateLimited)
|
||||
}
|
||||
return wrapInternal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) loadRunningActivity(ctx context.Context, activityId int64) (*lottery.Activity, error) {
|
||||
var activity lottery.Activity
|
||||
now := time.Now()
|
||||
err := s.deps.DB.WithContext(ctx).
|
||||
Where("id = ?", activityId).
|
||||
First(&activity).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return nil, wrapInternal(err)
|
||||
}
|
||||
if activity.Status != lottery.ActivityStatusRunning {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
if activity.StartAt.After(now) || activity.EndAt.Before(now) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return &activity, nil
|
||||
}
|
||||
|
||||
func (s *Service) buildRuleContext(ctx context.Context, userId int64) (lottery.RuleContext, error) {
|
||||
if s.deps.ContextBuilder == nil {
|
||||
return lottery.RuleContext{UserId: userId, Now: time.Now().Unix()}, nil
|
||||
}
|
||||
return s.deps.ContextBuilder.Build(ctx, userId)
|
||||
}
|
||||
|
||||
func (s *Service) loadPrizes(ctx context.Context, activityId int64) ([]lottery.Prize, error) {
|
||||
var prizes []lottery.Prize
|
||||
err := s.deps.DB.WithContext(ctx).
|
||||
Where("activity_id = ?", activityId).
|
||||
Order("slot ASC").
|
||||
Find(&prizes).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return prizes, nil
|
||||
}
|
||||
|
||||
func (s *Service) findExistingDraw(ctx context.Context, tx *gorm.DB, req Request) (*lottery.Draw, error) {
|
||||
var existing lottery.Draw
|
||||
err := tx.WithContext(ctx).
|
||||
Where("user_id = ? AND client_nonce = ?", req.UserId, req.ClientNonce).
|
||||
First(&existing).Error
|
||||
if err == nil {
|
||||
return &existing, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// decrementStockOrFallback runs the limited-stock optimistic lock. If the
|
||||
// chosen prize is unlimited, returns it as-is. If limited and stock survives
|
||||
// → returns it. If limited and sold-out → walks the pool for the first
|
||||
// `is_fallback=true` prize or falls back to a "none" (thanks-for-playing)
|
||||
// synthetic prize.
|
||||
func (s *Service) decrementStockOrFallback(ctx context.Context, tx *gorm.DB, picked lottery.Prize, pool []lottery.Prize) (lottery.Prize, error) {
|
||||
if !picked.RemainingStock.Valid {
|
||||
return picked, nil
|
||||
}
|
||||
res := tx.WithContext(ctx).
|
||||
Model(&lottery.Prize{}).
|
||||
Where("id = ? AND remaining_stock > 0", picked.Id).
|
||||
UpdateColumn("remaining_stock", gorm.Expr("`remaining_stock` - 1"))
|
||||
if res.Error != nil {
|
||||
return lottery.Prize{}, res.Error
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
return picked, nil
|
||||
}
|
||||
// Sold out → fallback selection.
|
||||
for _, p := range pool {
|
||||
if p.IsFallback {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
// No fallback declared → synthesize a "谢谢参与" from the last non-fallback
|
||||
// entry (any type=none in the pool wins); if pool has no none, we
|
||||
// synthesize an ephemeral prize record. Note: this prize is NOT persisted
|
||||
// as a separate row — it just satisfies the return contract.
|
||||
for _, p := range pool {
|
||||
if p.Type == lottery.PrizeTypeNone {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return lottery.Prize{
|
||||
ActivityId: picked.ActivityId,
|
||||
Slot: picked.Slot,
|
||||
Type: lottery.PrizeTypeNone,
|
||||
Name: "谢谢参与",
|
||||
Config: "{}",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) insertDraw(ctx context.Context, tx *gorm.DB, req Request, prize lottery.Prize) (*lottery.Draw, error) {
|
||||
draw := lottery.Draw{
|
||||
UserId: req.UserId,
|
||||
ActivityId: req.ActivityId,
|
||||
ClientNonce: req.ClientNonce,
|
||||
IsWin: prize.Type != lottery.PrizeTypeNone,
|
||||
DispatchState: lottery.DispatchStateNone,
|
||||
DrawnAt: time.Now(),
|
||||
}
|
||||
if prize.Id > 0 {
|
||||
draw.PrizeId = sql.NullInt64{Int64: prize.Id, Valid: true}
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&draw).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &draw, nil
|
||||
}
|
||||
|
||||
func (s *Service) insertSnapshots(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, prize lottery.Prize, passedEligibility bool) error {
|
||||
ps := lottery.PrizeSnapshot{
|
||||
DrawId: draw.Id,
|
||||
PrizeId: prize.Id,
|
||||
Slot: prize.Slot,
|
||||
Type: prize.Type,
|
||||
Name: prize.Name,
|
||||
Config: prize.Config,
|
||||
}
|
||||
if ps.Config == "" {
|
||||
ps.Config = "{}"
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&ps).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
es := lottery.EligibilitySnapshot{
|
||||
DrawId: draw.Id,
|
||||
UserId: draw.UserId,
|
||||
ActivityId: draw.ActivityId,
|
||||
Passed: passedEligibility,
|
||||
// UnmetReasons 是 JSON 列,MySQL 拒绝空字符串(error 3140)——
|
||||
// Stage 1 只有 passed=true 进 insertSnapshots,语义上"没有未过项",
|
||||
// 用 "[]" 与 PrizeSnapshot.Config 的 "{}" 守卫对称。
|
||||
// Stage 2 若开始持久化 passed=false 的失败评估,再改成真正的 marshal。
|
||||
UnmetReasons: "[]",
|
||||
// 显式 time.Now():GORM 遇 zero time 有时会传 '0000-00-00 00:00:00',
|
||||
// 触 sql_mode STRICT。不依赖 DB DEFAULT CURRENT_TIMESTAMP。
|
||||
EvaluatedAt: time.Now(),
|
||||
}
|
||||
return tx.WithContext(ctx).Create(&es).Error
|
||||
}
|
||||
|
||||
// pendingClaimInfo carries the manual-claim details the draw service produced
|
||||
// this turn. Zero-value = draw did not create a claim (auto prize or none).
|
||||
type pendingClaimInfo struct {
|
||||
ExpiresAt time.Time
|
||||
ClaimFormSchema json.RawMessage
|
||||
}
|
||||
|
||||
// dispatchOrEnqueueClaim routes the prize to either an auto-handler dispatch
|
||||
// (Stage 1 path) or to a lottery_claim insert (Stage 2 manual path).
|
||||
//
|
||||
// - draw.IsWin == false → thanks-for-playing, auto_claimed.
|
||||
// - handler.IsAuto()==true → call Dispatch inside caller's tx.
|
||||
// - handler.IsAuto()==false → insert lottery_claim (pending_claim) and
|
||||
// return ClaimFormSchema + ExpiresAt so the
|
||||
// caller can render the response.
|
||||
func (s *Service) dispatchOrEnqueueClaim(ctx context.Context, tx *gorm.DB, req Request, draw *lottery.Draw, prize lottery.Prize) (lottery.DispatchResult, pendingClaimInfo, error) {
|
||||
if !draw.IsWin {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "谢谢参与"}, pendingClaimInfo{}, nil
|
||||
}
|
||||
if s.deps.Registry == nil {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, pendingClaimInfo{}, nil
|
||||
}
|
||||
prizeHandler, err := s.deps.Registry.MustGet(prize.Type)
|
||||
if err != nil {
|
||||
if errors.Is(err, lottery.ErrHandlerNotRegistered) {
|
||||
return lottery.DispatchResult{State: lottery.DispatchStatePendingClaim, Message: "等待人工发放"}, pendingClaimInfo{}, nil
|
||||
}
|
||||
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(err)
|
||||
}
|
||||
|
||||
if prizeHandler.IsAuto() {
|
||||
dispatchReq := lottery.DispatchRequest{
|
||||
UserId: req.UserId,
|
||||
ActivityId: req.ActivityId,
|
||||
DrawId: draw.Id,
|
||||
Prize: prize,
|
||||
Snapshot: lottery.PrizeSnapshot{DrawId: draw.Id, PrizeId: prize.Id, Slot: prize.Slot, Type: prize.Type, Name: prize.Name, Config: prize.Config},
|
||||
IdempotencyKey: fmt.Sprintf("lottery:%d:%d", req.ActivityId, draw.Id),
|
||||
}
|
||||
result, dispatchErr := prizeHandler.Dispatch(ctx, tx, dispatchReq)
|
||||
return result, pendingClaimInfo{}, dispatchErr
|
||||
}
|
||||
|
||||
// Manual-claim path (Stage 2). Insert a pending_claim row inside the same
|
||||
// draw tx so a rollback also erases the claim.
|
||||
expiresAt := s.computeClaimExpiry(prize)
|
||||
claim := lottery.Claim{
|
||||
DrawId: draw.Id,
|
||||
UserId: req.UserId,
|
||||
ActivityId: req.ActivityId,
|
||||
PrizeType: prize.Type,
|
||||
Status: lottery.ClaimStatusPendingClaim,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(&claim).Error; err != nil {
|
||||
return lottery.DispatchResult{}, pendingClaimInfo{}, wrapInternal(fmt.Errorf("insert lottery_claim for draw %d: %w", draw.Id, err))
|
||||
}
|
||||
|
||||
schema := prizeHandler.ClaimSchema()
|
||||
// crypto handler 需要用奖品 config.networks 生成带 enum 的最终 schema。
|
||||
if prize.Type == lottery.PrizeTypeCrypto {
|
||||
schema = handler.BuildCryptoClaimSchema(prize.Config)
|
||||
}
|
||||
return lottery.DispatchResult{
|
||||
State: lottery.DispatchStatePendingClaim,
|
||||
Message: "等待填写领奖信息",
|
||||
}, pendingClaimInfo{
|
||||
ExpiresAt: expiresAt,
|
||||
ClaimFormSchema: schema,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// computeClaimExpiry 从奖品 config.claim_ttl_hours 读窗口配置;缺失或非正
|
||||
// 则回落到 lottery.DefaultClaimTTLHours (7 天)。
|
||||
func (s *Service) computeClaimExpiry(prize lottery.Prize) time.Time {
|
||||
hours := lottery.DefaultClaimTTLHours
|
||||
if prize.Config != "" {
|
||||
var cfg struct {
|
||||
ClaimTTLHours int `json:"claim_ttl_hours"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(prize.Config), &cfg); err == nil && cfg.ClaimTTLHours > 0 {
|
||||
hours = cfg.ClaimTTLHours
|
||||
}
|
||||
}
|
||||
return time.Now().Add(time.Duration(hours) * time.Hour)
|
||||
}
|
||||
|
||||
func (s *Service) finalizeDrawState(ctx context.Context, tx *gorm.DB, draw *lottery.Draw, dispatch lottery.DispatchResult) error {
|
||||
now := time.Now()
|
||||
updates := map[string]any{
|
||||
"dispatch_state": dispatch.State,
|
||||
}
|
||||
if dispatch.State == lottery.DispatchStateAutoClaimed || dispatch.State == lottery.DispatchStatePaid {
|
||||
updates["dispatched_at"] = now
|
||||
}
|
||||
return tx.WithContext(ctx).
|
||||
Model(&lottery.Draw{}).
|
||||
Where("id = ?", draw.Id).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *Service) buildResultFromExistingDraw(ctx context.Context, tx *gorm.DB, existing *lottery.Draw) (*Result, error) {
|
||||
var snap lottery.PrizeSnapshot
|
||||
err := tx.WithContext(ctx).Where("draw_id = ?", existing.Id).First(&snap).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
remaining, _ := s.deps.Chance.Query(ctx, existing.UserId, existing.ActivityId)
|
||||
var prize *PrizeSummary
|
||||
if existing.IsWin {
|
||||
prize = &PrizeSummary{
|
||||
Slot: snap.Slot,
|
||||
Id: snap.PrizeId,
|
||||
Type: snap.Type,
|
||||
Name: snap.Name,
|
||||
Config: json.RawMessage(snap.Config),
|
||||
}
|
||||
}
|
||||
claim := ClaimSummary{
|
||||
Required: existing.DispatchState == lottery.DispatchStatePendingClaim,
|
||||
AutoClaimed: existing.DispatchState == lottery.DispatchStateAutoClaimed,
|
||||
}
|
||||
// 重放场景(同一 client_nonce)也补回 expires_at / schema,避免前端第二次
|
||||
// 收到的响应比首次少字段。
|
||||
if claim.Required {
|
||||
var claimRow lottery.Claim
|
||||
err := tx.WithContext(ctx).Where("draw_id = ?", existing.Id).First(&claimRow).Error
|
||||
if err == nil {
|
||||
claim.ExpiresAt = claimRow.ExpiresAt.Unix()
|
||||
if s.deps.Registry != nil {
|
||||
if h, ok := s.deps.Registry.Get(claimRow.PrizeType); ok {
|
||||
if claimRow.PrizeType == lottery.PrizeTypeCrypto {
|
||||
claim.ClaimFormSchema = handler.BuildCryptoClaimSchema(snap.Config)
|
||||
} else {
|
||||
claim.ClaimFormSchema = h.ClaimSchema()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &Result{
|
||||
DrawId: existing.Id,
|
||||
IsWin: existing.IsWin,
|
||||
Prize: prize,
|
||||
ChancesRemaining: remaining,
|
||||
Claim: claim,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildResult(draw *lottery.Draw, prize lottery.Prize, dispatch lottery.DispatchResult, claim pendingClaimInfo, remaining int64) *Result {
|
||||
res := &Result{
|
||||
DrawId: draw.Id,
|
||||
IsWin: draw.IsWin,
|
||||
ChancesRemaining: remaining,
|
||||
Message: dispatch.Message,
|
||||
Claim: ClaimSummary{
|
||||
Required: dispatch.State == lottery.DispatchStatePendingClaim,
|
||||
AutoClaimed: dispatch.State == lottery.DispatchStateAutoClaimed,
|
||||
Message: dispatch.Message,
|
||||
ClaimFormSchema: claim.ClaimFormSchema,
|
||||
},
|
||||
}
|
||||
if !claim.ExpiresAt.IsZero() {
|
||||
res.Claim.ExpiresAt = claim.ExpiresAt.Unix()
|
||||
}
|
||||
if draw.IsWin {
|
||||
res.Prize = &PrizeSummary{
|
||||
Slot: prize.Slot,
|
||||
Id: prize.Id,
|
||||
Type: prize.Type,
|
||||
Name: prize.Name,
|
||||
Config: json.RawMessage(prize.Config),
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// parseEligibilityTree tolerates empty/null activity.Eligibility as "no gate".
|
||||
func parseEligibilityTree(raw string) (*lottery.EligibilityRule, error) {
|
||||
trimmed := ""
|
||||
for _, r := range raw {
|
||||
if r != ' ' && r != '\t' && r != '\n' && r != '\r' {
|
||||
trimmed += string(r)
|
||||
}
|
||||
}
|
||||
if trimmed == "" || trimmed == "null" || trimmed == "{}" {
|
||||
return nil, nil
|
||||
}
|
||||
var tree lottery.EligibilityRule
|
||||
if err := json.Unmarshal([]byte(raw), &tree); err != nil {
|
||||
return nil, fmt.Errorf("parse eligibility: %w", err)
|
||||
}
|
||||
return &tree, nil
|
||||
}
|
||||
|
||||
func wrapInternal(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// Preserve already-coded errors.
|
||||
if _, ok := err.(*xerr.CodeError); ok {
|
||||
return err
|
||||
}
|
||||
return xerr.NewErrCodeMsg(xerr.LotteryInternalError, err.Error())
|
||||
}
|
||||
|
||||
// ---- Rate limiter production wiring ----------------------------------------
|
||||
|
||||
// ErrRateLimited is returned by RateLimiter.Allow when the caller exceeded
|
||||
// the configured quota.
|
||||
var ErrRateLimited = errors.New("draw: rate limited")
|
||||
|
||||
// RedisRateLimiter is the production RateLimiter backed by pkg/limit's
|
||||
// Redis-Lua fixed-window (1 hit per 1 second per user), matching the
|
||||
// existing sendEmailCodeLogic pattern.
|
||||
type RedisRateLimiter struct {
|
||||
limiter *limit.PeriodLimit
|
||||
}
|
||||
|
||||
// NewRedisRateLimiter builds a per-user 1-req/1-sec limiter. keyPrefix is
|
||||
// expected to end with ':' so the composed key is human-readable.
|
||||
func NewRedisRateLimiter(limiter *limit.PeriodLimit) *RedisRateLimiter {
|
||||
return &RedisRateLimiter{limiter: limiter}
|
||||
}
|
||||
|
||||
// Allow admits or rejects the caller.
|
||||
func (r *RedisRateLimiter) Allow(ctx context.Context, userId int64) error {
|
||||
if r == nil || r.limiter == nil {
|
||||
return nil
|
||||
}
|
||||
state, err := r.limiter.TakeCtx(ctx, strconv.FormatInt(userId, 10))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state == limit.Allowed || state == limit.HitQuota {
|
||||
return nil
|
||||
}
|
||||
return ErrRateLimited
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
package draw
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---- test fakes ------------------------------------------------------------
|
||||
|
||||
type fakeRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeRateLimiter) Allow(_ context.Context, _ int64) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
return f.err
|
||||
}
|
||||
|
||||
type fakeChance struct {
|
||||
mu sync.Mutex
|
||||
consumeRemaining int64
|
||||
consumeErr error
|
||||
consumeCalls int
|
||||
queryRemaining int64
|
||||
}
|
||||
|
||||
func (f *fakeChance) Grant(context.Context, int64, int64, string, string, int) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeChance) Consume(_ context.Context, _ *gorm.DB, _, _ int64) (int64, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.consumeCalls++
|
||||
if f.consumeErr != nil {
|
||||
return 0, f.consumeErr
|
||||
}
|
||||
return f.consumeRemaining, nil
|
||||
}
|
||||
func (f *fakeChance) Query(_ context.Context, _, _ int64) (int64, error) {
|
||||
return f.queryRemaining, nil
|
||||
}
|
||||
|
||||
type fakeEvaluator struct {
|
||||
passed bool
|
||||
unmet []lottery.UnmetReason
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEvaluator) Evaluate(context.Context, *lottery.EligibilityRule, lottery.RuleContext) (bool, []lottery.UnmetReason, error) {
|
||||
return f.passed, f.unmet, f.err
|
||||
}
|
||||
|
||||
type fakePicker struct {
|
||||
idx int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakePicker) Pick([]lottery.Prize) (int, error) { return f.idx, f.err }
|
||||
|
||||
type fakeContextBuilder struct{}
|
||||
|
||||
func (fakeContextBuilder) Build(_ context.Context, uid int64) (lottery.RuleContext, error) {
|
||||
return lottery.RuleContext{UserId: uid}, nil
|
||||
}
|
||||
|
||||
type recordingHandler struct {
|
||||
handlerType string
|
||||
auto bool
|
||||
result lottery.DispatchResult
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (h *recordingHandler) Type() string { return h.handlerType }
|
||||
func (h *recordingHandler) IsAuto() bool { return h.auto }
|
||||
func (h *recordingHandler) Dispatch(_ context.Context, _ *gorm.DB, _ lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
h.calls++
|
||||
return h.result, h.err
|
||||
}
|
||||
func (h *recordingHandler) ValidateClaim([]byte) error { return nil }
|
||||
func (h *recordingHandler) ClaimSchema() json.RawMessage { return nil }
|
||||
|
||||
// stubRegistry only knows what we register.
|
||||
type stubRegistry struct {
|
||||
handlers map[string]lottery.PrizeHandler
|
||||
}
|
||||
|
||||
func (r *stubRegistry) Get(t string) (lottery.PrizeHandler, bool) {
|
||||
h, ok := r.handlers[t]
|
||||
return h, ok
|
||||
}
|
||||
func (r *stubRegistry) MustGet(t string) (lottery.PrizeHandler, error) {
|
||||
if h, ok := r.handlers[t]; ok {
|
||||
return h, nil
|
||||
}
|
||||
return nil, lottery.ErrHandlerNotRegistered
|
||||
}
|
||||
|
||||
// ---- shared harness --------------------------------------------------------
|
||||
|
||||
func newTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actual, expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("gorm: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
// expectRunningActivity sets up the pre-tx activity load.
|
||||
func expectRunningActivity(mock sqlmock.Sqlmock, activityId int64) {
|
||||
now := time.Now()
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "title", "start_at", "end_at", "status", "grid_size", "eligibility", "chance_sources", "unmet_action",
|
||||
}).AddRow(activityId, "test", now.Add(-1*time.Hour), now.Add(24*time.Hour), lottery.ActivityStatusRunning, 9, "{}", "[]", "block"))
|
||||
}
|
||||
|
||||
// expectPrizePool sets up the pre-tx prize load.
|
||||
func expectPrizePool(mock sqlmock.Sqlmock, activityId int64, prizes ...lottery.Prize) {
|
||||
rows := sqlmock.NewRows([]string{"id", "activity_id", "slot", "type", "name", "icon_url", "config", "weight", "total_stock", "remaining_stock", "is_fallback", "version"})
|
||||
for _, p := range prizes {
|
||||
rows.AddRow(p.Id, p.ActivityId, p.Slot, p.Type, p.Name, p.IconURL, p.Config, p.Weight, p.TotalStock, p.RemainingStock, p.IsFallback, p.Version)
|
||||
}
|
||||
mock.ExpectQuery("FROM `lottery_prize`").WillReturnRows(rows)
|
||||
}
|
||||
|
||||
// expectExistingDrawEmpty sets up the tx-inner nonce lookup returning no rows.
|
||||
func expectExistingDrawEmpty(mock sqlmock.Sqlmock) {
|
||||
mock.ExpectQuery("FROM `lottery_draw`").WillReturnError(gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// ---- Test cases ------------------------------------------------------------
|
||||
|
||||
func TestDraw_RejectsWhenFeatureFlagDisabled(t *testing.T) {
|
||||
db, _, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: false,
|
||||
})
|
||||
_, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "n1"})
|
||||
code, ok := errAsCode(err)
|
||||
if !ok || code != xerr.LotteryActivityEnded {
|
||||
t.Fatalf("expected LotteryActivityEnded when disabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_ValidatesRequest(t *testing.T) {
|
||||
db, _, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewService(Deps{DB: db, Enabled: true})
|
||||
cases := []Request{
|
||||
{UserId: 0, ActivityId: 1, ClientNonce: "n"},
|
||||
{UserId: 1, ActivityId: 0, ClientNonce: "n"},
|
||||
{UserId: 1, ActivityId: 1, ClientNonce: ""},
|
||||
{UserId: 1, ActivityId: 1, ClientNonce: strings.Repeat("x", 65)},
|
||||
}
|
||||
for i, c := range cases {
|
||||
if _, err := svc.Draw(context.Background(), c); err == nil {
|
||||
t.Fatalf("case %d expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_RateLimited(t *testing.T) {
|
||||
db, _, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
limiter := &fakeRateLimiter{err: ErrRateLimited}
|
||||
svc := NewService(Deps{DB: db, Enabled: true, RateLimiter: limiter})
|
||||
_, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 1, ClientNonce: "n"})
|
||||
code, ok := errAsCode(err)
|
||||
if !ok || code != xerr.LotteryRateLimited {
|
||||
t.Fatalf("expected LotteryRateLimited, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_NonceIdempotency_ReturnsExisting(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100, lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 1})
|
||||
|
||||
mock.ExpectBegin()
|
||||
// nonce hit — the flow short-circuits
|
||||
mock.ExpectQuery("FROM `lottery_draw`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "client_nonce", "prize_id", "is_win", "dispatch_state", "drawn_at"}).
|
||||
AddRow(int64(999), int64(42), int64(100), "same-nonce", nil, false, lottery.DispatchStateAutoClaimed, time.Now()))
|
||||
// snapshot lookup
|
||||
mock.ExpectQuery("FROM `lottery_prize_snapshot`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "draw_id", "prize_id", "slot", "type", "name", "config"}).
|
||||
AddRow(int64(1), int64(999), int64(0), 0, lottery.PrizeTypeNone, "谢谢参与", "{}"))
|
||||
mock.ExpectCommit()
|
||||
|
||||
chance := &fakeChance{queryRemaining: 3}
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: chance,
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 42, ActivityId: 100, ClientNonce: "same-nonce"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if res.DrawId != 999 {
|
||||
t.Fatalf("expected reuse existing draw id=999, got %d", res.DrawId)
|
||||
}
|
||||
if chance.consumeCalls != 0 {
|
||||
t.Fatalf("must NOT Consume a chance on nonce replay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_NoChancesReturnsCode(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100, lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 1})
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectRollback()
|
||||
|
||||
chance := &fakeChance{consumeErr: lottery.ErrNoChances}
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: chance,
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
_, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"})
|
||||
code, ok := errAsCode(err)
|
||||
if !ok || code != xerr.LotteryNoChances {
|
||||
t.Fatalf("expected LotteryNoChances, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_NotEligibleRejectsBeforeConsume(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{},
|
||||
Evaluator: &fakeEvaluator{passed: false, unmet: []lottery.UnmetReason{{Rule: "invite_count", Hint: "need 3"}}},
|
||||
Picker: &fakePicker{},
|
||||
Registry: &stubRegistry{},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
_, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"})
|
||||
code, ok := errAsCode(err)
|
||||
if !ok || code != xerr.LotteryNotEligible {
|
||||
t.Fatalf("expected LotteryNotEligible, got %v", err)
|
||||
}
|
||||
// Ensure no mock expectations remain (we didn't set up prize load).
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_SuccessAutoClaimedNoneReturnsDrawWithoutDispatch(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
// Two prizes: one none (weight 100) — picker returns idx 0 always
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 100},
|
||||
)
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
// insert draw
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(555, 1))
|
||||
// insert prize_snapshot
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// insert eligibility_snapshot
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// finalize draw state
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
chance := &fakeChance{consumeRemaining: 2}
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: chance,
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "unique-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if res.IsWin {
|
||||
t.Fatalf("none prize should not count as win")
|
||||
}
|
||||
if res.DrawId == 0 {
|
||||
t.Fatalf("expected draw id from LastInsertId")
|
||||
}
|
||||
if res.ChancesRemaining != 2 {
|
||||
t.Fatalf("expected remaining=2 from Consume, got %d", res.ChancesRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_LimitedStockFallback(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
// prize 0 = limited (remaining=0 → sold out); prize 1 = fallback
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{Id: 10, ActivityId: 100, Slot: 0, Type: "vpn_duration", Name: "3天", Config: `{"duration_days":3}`, Weight: 100, TotalStock: sql.NullInt64{Int64: 1, Valid: true}, RemainingStock: sql.NullInt64{Int64: 1, Valid: true}},
|
||||
lottery.Prize{Id: 20, ActivityId: 100, Slot: 1, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 0, IsFallback: true},
|
||||
)
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
// stock decrement returns 0 rows affected → sold out
|
||||
mock.ExpectExec("UPDATE `lottery_prize`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(778, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 1},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "n"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if res.IsWin {
|
||||
t.Fatalf("fallback none should not win")
|
||||
}
|
||||
if res.DrawId != 778 {
|
||||
t.Fatalf("expected draw id 778, got %d", res.DrawId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_WinCallsAutoHandlerDispatch(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{Id: 30, ActivityId: 100, Slot: 5, Type: lottery.PrizeTypeVPNDuration, Name: "3 天", Config: `{"duration_days":3}`, Weight: 100},
|
||||
)
|
||||
|
||||
handler := &recordingHandler{
|
||||
handlerType: lottery.PrizeTypeVPNDuration,
|
||||
auto: true,
|
||||
result: lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: "已加 3 天"},
|
||||
}
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(1234, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 0},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeVPNDuration: handler}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "w"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if !res.IsWin {
|
||||
t.Fatalf("expected IsWin=true for vpn_duration")
|
||||
}
|
||||
if handler.calls != 1 {
|
||||
t.Fatalf("expected handler.Dispatch called once, got %d", handler.calls)
|
||||
}
|
||||
if res.Claim.AutoClaimed != true {
|
||||
t.Fatalf("expected AutoClaimed=true, got %+v", res.Claim)
|
||||
}
|
||||
if res.Message != "已加 3 天" {
|
||||
t.Fatalf("expected message from Dispatch, got %q", res.Message)
|
||||
}
|
||||
if res.Prize == nil || res.Prize.Type != lottery.PrizeTypeVPNDuration {
|
||||
t.Fatalf("expected prize summary, got %+v", res.Prize)
|
||||
}
|
||||
// Prize.Config should be embedded json.RawMessage — verify decodes
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(res.Prize.Config, &cfg); err != nil {
|
||||
t.Fatalf("Prize.Config invalid: %v", err)
|
||||
}
|
||||
if cfg["duration_days"].(float64) != 3 {
|
||||
t.Fatalf("expected duration_days=3, got %v", cfg["duration_days"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_UnregisteredAutoHandlerFallsBackToPendingClaim(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{Id: 40, ActivityId: 100, Slot: 0, Type: "encrypted", Name: "Encrypted", Config: "{}", Weight: 100},
|
||||
)
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 0},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "w2"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if res.Claim.Required != true {
|
||||
t.Fatalf("expected Claim.Required=true when handler unregistered, got %+v", res.Claim)
|
||||
}
|
||||
}
|
||||
|
||||
// errAsCode helps assert xerr.CodeError codes.
|
||||
func errAsCode(err error) (uint32, bool) {
|
||||
if err == nil {
|
||||
return 0, false
|
||||
}
|
||||
var ce *xerr.CodeError
|
||||
if errors.As(err, &ce) {
|
||||
return ce.GetErrCode(), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// TestInsertSnapshots_UnmetReasonsIsValidJSON is the F4 regression guard
|
||||
// (kept from PR E — must survive Stage 2 rebase).
|
||||
//
|
||||
// Before PR E, insertSnapshots created lottery_eligibility_snapshot rows with
|
||||
// UnmetReasons="" — MySQL error 3140 rejects empty strings on JSON columns,
|
||||
// so every real /draw request 100% failed the tx commit even though sqlmock
|
||||
// (which does no JSON validation) was happy. This test snapshots the exact
|
||||
// INSERT arg values and asserts:
|
||||
// 1. UnmetReasons must never be "" (it should be "[]")
|
||||
// 2. EvaluatedAt must not be the zero time.Time (STRICT sql_mode rejects
|
||||
// '0000-00-00 00:00:00' on DATETIME NOT NULL)
|
||||
//
|
||||
// sqlmock cannot catch the JSON validity itself — only real MySQL can — but
|
||||
// it can catch the two upstream bugs that let bad values through the Go
|
||||
// layer. This is a defense-in-depth check.
|
||||
func TestInsertSnapshots_UnmetReasonsIsValidJSON(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100, lottery.Prize{
|
||||
Id: 1, ActivityId: 100, Slot: 0, Type: lottery.PrizeTypeNone, Name: "谢谢参与", Config: "{}", Weight: 100,
|
||||
})
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(555, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
// Field order matches EligibilitySnapshot struct-tag order under GORM:
|
||||
// draw_id, user_id, activity_id, passed, unmet_reasons, evaluated_at.
|
||||
// We assert UnmetReasons=="[]" (never "") and EvaluatedAt is non-zero.
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), // draw_id
|
||||
sqlmock.AnyArg(), // user_id
|
||||
sqlmock.AnyArg(), // activity_id
|
||||
sqlmock.AnyArg(), // passed
|
||||
unmetReasonsNotEmpty{t}, // MUST be "[]"
|
||||
evaluatedAtNotZero{t}, // MUST be non-zero time
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 2},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
_, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "f4-regression"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// unmetReasonsNotEmpty is a per-arg matcher: the value MUST be the string
|
||||
// "[]"; the empty string is the exact F4 regression we are guarding against.
|
||||
type unmetReasonsNotEmpty struct{ t *testing.T }
|
||||
|
||||
func (m unmetReasonsNotEmpty) Match(v driver.Value) bool {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
m.t.Fatalf("F4 guard: expected string for UnmetReasons, got %T (%v)", v, v)
|
||||
}
|
||||
if s == "" {
|
||||
m.t.Fatalf("F4 regression: UnmetReasons must not be empty string (MySQL error 3140)")
|
||||
}
|
||||
if s != "[]" {
|
||||
m.t.Fatalf("F4 guard: expected UnmetReasons==%q, got %q", "[]", s)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// evaluatedAtNotZero is a per-arg matcher: the value MUST be a non-zero
|
||||
// time.Time; the zero time is the F5 regression that STRICT sql_mode rejects
|
||||
// as '0000-00-00 00:00:00'.
|
||||
type evaluatedAtNotZero struct{ t *testing.T }
|
||||
|
||||
func (m evaluatedAtNotZero) Match(v driver.Value) bool {
|
||||
tv, ok := v.(time.Time)
|
||||
if !ok {
|
||||
m.t.Fatalf("F5 guard: expected time.Time for EvaluatedAt, got %T (%v)", v, v)
|
||||
}
|
||||
if tv.IsZero() {
|
||||
m.t.Fatalf("F5 regression: EvaluatedAt must not be zero time")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- Stage 2 (manual claim) tests ----------------------------------------
|
||||
|
||||
// TestDraw_ManualClaimHandlerInsertsPendingClaim 验证:命中 IsAuto()==false
|
||||
// 的 handler 时,draw 事务里会 INSERT lottery_claim 并返回 ExpiresAt + Schema。
|
||||
// 复用 PR E 的 F4/F5 matcher 断言 EligibilitySnapshot 守卫在人工奖分支同样生效。
|
||||
func TestDraw_ManualClaimHandlerInsertsPendingClaim(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{
|
||||
Id: 50, ActivityId: 100, Slot: 3, Type: lottery.PrizeTypeCrypto,
|
||||
Name: "1 BTC",
|
||||
Config: `{"amount":"1","currency":"BTC","networks":["BTC","TRX"],"claim_ttl_hours":48}`,
|
||||
Weight: 100,
|
||||
},
|
||||
)
|
||||
|
||||
manualHandler := &recordingHandler{
|
||||
handlerType: lottery.PrizeTypeCrypto,
|
||||
auto: false,
|
||||
}
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(5678, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// F4/F5 regression guards MUST hold on manual-claim path too.
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), // draw_id
|
||||
sqlmock.AnyArg(), // user_id
|
||||
sqlmock.AnyArg(), // activity_id
|
||||
sqlmock.AnyArg(), // passed
|
||||
unmetReasonsNotEmpty{t}, // MUST be "[]"
|
||||
evaluatedAtNotZero{t}, // MUST be non-zero time
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// pending_claim row insert
|
||||
mock.ExpectExec("INSERT INTO `lottery_claim`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// finalize draw.dispatch_state = pending_claim
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 0},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{lottery.PrizeTypeCrypto: manualHandler}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 1, ActivityId: 100, ClientNonce: "manual1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
if manualHandler.calls != 0 {
|
||||
t.Fatalf("manual handler.Dispatch must NOT be called, got %d calls", manualHandler.calls)
|
||||
}
|
||||
if !res.Claim.Required {
|
||||
t.Fatalf("expected Claim.Required=true, got %+v", res.Claim)
|
||||
}
|
||||
if res.Claim.AutoClaimed {
|
||||
t.Fatalf("expected AutoClaimed=false for manual, got %+v", res.Claim)
|
||||
}
|
||||
if res.Claim.ExpiresAt == 0 {
|
||||
t.Fatal("expected non-zero ExpiresAt")
|
||||
}
|
||||
// 48h TTL from prize config
|
||||
expected := time.Now().Add(48 * time.Hour).Unix()
|
||||
if diff := res.Claim.ExpiresAt - expected; diff > 5 || diff < -5 {
|
||||
t.Fatalf("ExpiresAt off by %ds; got %d expected ~%d", diff, res.Claim.ExpiresAt, expected)
|
||||
}
|
||||
// crypto handler builds schema with enum injected from prize config
|
||||
if len(res.Claim.ClaimFormSchema) == 0 {
|
||||
t.Fatal("expected ClaimFormSchema for crypto")
|
||||
}
|
||||
if !strings.Contains(string(res.Claim.ClaimFormSchema), `"enum":["BTC","TRX"]`) {
|
||||
t.Fatalf("expected enum with BTC/TRX in schema, got %s", res.Claim.ClaimFormSchema)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDraw_ManualClaimDefaultsTo7DayTTL 验证:奖品 config 没写 claim_ttl_hours
|
||||
// 时,落在默认 168h(7 天)窗口。
|
||||
func TestDraw_ManualClaimDefaultsTo7DayTTL(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
expectRunningActivity(mock, 100)
|
||||
expectPrizePool(mock, 100,
|
||||
lottery.Prize{
|
||||
Id: 60, ActivityId: 100, Slot: 4, Type: lottery.PrizeTypePhysical,
|
||||
Name: "T-shirt",
|
||||
Config: `{"sku_id":"tee-01","sku_name":"限量 T 恤"}`,
|
||||
Weight: 100,
|
||||
},
|
||||
)
|
||||
|
||||
mock.ExpectBegin()
|
||||
expectExistingDrawEmpty(mock)
|
||||
mock.ExpectExec("INSERT INTO `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(9001, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_prize_snapshot`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_eligibility_snapshot`").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
unmetReasonsNotEmpty{t}, // F4 guard also applies here
|
||||
evaluatedAtNotZero{t}, // F5 guard also applies here
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("INSERT INTO `lottery_claim`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewService(Deps{
|
||||
DB: db,
|
||||
Enabled: true,
|
||||
Chance: &fakeChance{consumeRemaining: 0},
|
||||
Evaluator: &fakeEvaluator{passed: true},
|
||||
Picker: &fakePicker{idx: 0},
|
||||
Registry: &stubRegistry{handlers: map[string]lottery.PrizeHandler{
|
||||
lottery.PrizeTypePhysical: &recordingHandler{handlerType: lottery.PrizeTypePhysical, auto: false},
|
||||
}},
|
||||
ContextBuilder: fakeContextBuilder{},
|
||||
})
|
||||
res, err := svc.Draw(context.Background(), Request{UserId: 2, ActivityId: 100, ClientNonce: "manual2"})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw: %v", err)
|
||||
}
|
||||
expected := time.Now().Add(time.Duration(lottery.DefaultClaimTTLHours) * time.Hour).Unix()
|
||||
if diff := res.Claim.ExpiresAt - expected; diff > 5 || diff < -5 {
|
||||
t.Fatalf("expected default 7-day TTL, got diff=%ds", diff)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CommissionHandler 发放"抽奖佣金"。写 user.commission 增量 + system_logs
|
||||
// (Type=Commission, CommissionType=339 Lottery) —— 用新增的 CommissionTypeLottery
|
||||
// 常量与 Purchase/Renewal 区分,账目侧对账更清晰。
|
||||
//
|
||||
// 幂等模型:DispatchRequest.IdempotencyKey → lottery_grant_ledger.external_ref。
|
||||
// Reserve 命中即幂等,未命中才走真实发放。commission 直接发给中奖者本人,不做
|
||||
// 家庭组归位(family owner 不代收成员的抽奖佣金)。
|
||||
type CommissionHandler struct {
|
||||
deps CommissionDeps
|
||||
}
|
||||
|
||||
// CommissionDeps 是 CommissionHandler 需要的最小依赖集。抽出到接口方便测试。
|
||||
type CommissionDeps struct {
|
||||
Ledger lottery.LedgerService
|
||||
// UpdateCommission 对齐 UserModel.UpdateCommission 签名。
|
||||
UpdateCommission func(ctx context.Context, userId int64, delta int64, tx ...*gorm.DB) error
|
||||
// WriteCommissionLog 对齐 common.WriteCommissionLog 签名。
|
||||
WriteCommissionLog func(tx *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error
|
||||
}
|
||||
|
||||
// NewCommissionHandler 构造真实的 commission handler。
|
||||
func NewCommissionHandler(deps CommissionDeps) *CommissionHandler {
|
||||
return &CommissionHandler{deps: deps}
|
||||
}
|
||||
|
||||
func (*CommissionHandler) Type() string { return lottery.PrizeTypeCommission }
|
||||
func (*CommissionHandler) IsAuto() bool { return true }
|
||||
func (*CommissionHandler) ValidateClaim([]byte) error { return nil }
|
||||
func (*CommissionHandler) ClaimSchema() json.RawMessage { return nil }
|
||||
|
||||
type commissionConfig struct {
|
||||
// AmountCents 是"分"级别的金额(与 user.commission 存储单位对齐)。
|
||||
AmountCents int64 `json:"amount_cents"`
|
||||
}
|
||||
|
||||
type commissionPayload struct {
|
||||
Amount int64 `json:"amount"`
|
||||
LogType uint16 `json:"log_type"`
|
||||
Message string `json:"message"`
|
||||
OrderNo string `json:"order_no"`
|
||||
}
|
||||
|
||||
// Dispatch 在 caller 的事务内为中奖人发放佣金。
|
||||
func (h *CommissionHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
if tx == nil {
|
||||
return lottery.DispatchResult{}, errors.New("commission handler requires a transaction")
|
||||
}
|
||||
if req.IdempotencyKey == "" {
|
||||
return lottery.DispatchResult{}, errors.New("commission handler requires DispatchRequest.IdempotencyKey")
|
||||
}
|
||||
if h.deps.UpdateCommission == nil || h.deps.WriteCommissionLog == nil {
|
||||
return lottery.DispatchResult{}, errors.New("commission handler deps not fully wired")
|
||||
}
|
||||
|
||||
var cfg commissionConfig
|
||||
if err := json.Unmarshal([]byte(req.Prize.Config), &cfg); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("decode commission config: %w", err)
|
||||
}
|
||||
if cfg.AmountCents <= 0 {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("commission config amount_cents must be > 0, got %d", cfg.AmountCents)
|
||||
}
|
||||
|
||||
// 佣金"发给中奖者本人"(不走家庭组归位)。
|
||||
targetUserID := req.UserId
|
||||
|
||||
entry := lottery.GrantLedger{
|
||||
ExternalRef: req.IdempotencyKey,
|
||||
HandlerType: lottery.PrizeTypeCommission,
|
||||
UserId: targetUserID,
|
||||
ActivityId: req.ActivityId,
|
||||
DrawId: req.DrawId,
|
||||
Amount: cfg.AmountCents,
|
||||
}
|
||||
row, alreadyExisted, err := h.deps.Ledger.Reserve(ctx, tx, entry)
|
||||
if err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("reserve grant ledger: %w", err)
|
||||
}
|
||||
if alreadyExisted {
|
||||
var payload commissionPayload
|
||||
if row.Payload != "" {
|
||||
_ = json.Unmarshal([]byte(row.Payload), &payload)
|
||||
}
|
||||
if payload.Message == "" {
|
||||
payload.Message = "佣金已到账"
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
}
|
||||
|
||||
// 未存在 → 真实发放。UpdateCommission 用 gorm.Expr 原子累加,避免丢更新。
|
||||
if err := h.deps.UpdateCommission(ctx, targetUserID, cfg.AmountCents, tx); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("update commission for user %d: %w", targetUserID, err)
|
||||
}
|
||||
// 传 external_ref 到 WriteCommissionLog 的 orderNo 位("lottery:*"),与业务 order 命名域天然区分。
|
||||
if err := h.deps.WriteCommissionLog(tx, targetUserID, logmodel.CommissionTypeLottery, cfg.AmountCents, req.IdempotencyKey); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("write commission log: %w", err)
|
||||
}
|
||||
|
||||
payload := commissionPayload{
|
||||
Amount: cfg.AmountCents,
|
||||
LogType: logmodel.CommissionTypeLottery,
|
||||
OrderNo: req.IdempotencyKey,
|
||||
Message: fmt.Sprintf("佣金已到账 %d", cfg.AmountCents),
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("marshal ledger payload: %w", err)
|
||||
}
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&lottery.GrantLedger{}).
|
||||
Where("id = ?", row.Id).
|
||||
UpdateColumn("payload", string(raw)).Error; err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("update ledger payload: %w", err)
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// WriteCommissionLog 是 internal/logic/common.WriteCommissionLog 的镜像。
|
||||
// 抽到 handler 包避免 internal/svc → internal/logic/common 的 import cycle
|
||||
// (internal/logic/common 里有别的文件反向 import 了 svc)。函数体保持一致,
|
||||
// 未来若 common 侧调整了签名或者行为要同步到这里。
|
||||
func WriteCommissionLog(tx *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error {
|
||||
logInfo := logmodel.Commission{
|
||||
Type: logType,
|
||||
Amount: amount,
|
||||
OrderNo: orderNo,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
content, err := logInfo.Marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(logmodel.SystemLog{}).Create(&logmodel.SystemLog{
|
||||
Type: logmodel.TypeCommission.Uint8(),
|
||||
Date: time.Now().Format(time.DateOnly),
|
||||
ObjectID: objectID,
|
||||
Content: string(content),
|
||||
CreatedAt: time.Now(),
|
||||
}).Error
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
logmodel "github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCommission_RequiresIdempotencyKey(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewCommissionHandler(CommissionDeps{
|
||||
Ledger: &fakeLedger{},
|
||||
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error {
|
||||
t.Fatal("must NOT call UpdateCommission without idempotency key")
|
||||
return nil
|
||||
},
|
||||
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
|
||||
})
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{})
|
||||
if err == nil || !strings.Contains(err.Error(), "IdempotencyKey") {
|
||||
t.Fatalf("expected IdempotencyKey error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommission_RequiresTx(t *testing.T) {
|
||||
h := NewCommissionHandler(CommissionDeps{Ledger: &fakeLedger{}})
|
||||
_, err := h.Dispatch(context.Background(), nil, lottery.DispatchRequest{IdempotencyKey: "k"})
|
||||
if err == nil || !strings.Contains(err.Error(), "transaction") {
|
||||
t.Fatalf("expected tx error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommission_IdempotentHitDoesNotWrite(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{
|
||||
Payload: `{"message":"佣金已到账 300"}`,
|
||||
}, true, nil
|
||||
},
|
||||
}
|
||||
h := NewCommissionHandler(CommissionDeps{
|
||||
Ledger: ledger,
|
||||
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error {
|
||||
t.Fatal("must NOT UpdateCommission on idempotent hit")
|
||||
return nil
|
||||
},
|
||||
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error {
|
||||
t.Fatal("must NOT WriteCommissionLog on idempotent hit")
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
Prize: lottery.Prize{Config: `{"amount_cents":300}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if res.Message != "佣金已到账 300" {
|
||||
t.Fatalf("expected replay message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommission_FirstTimeWritesCommissionAndLog(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 11}, false, nil
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
updateCommissionCalled = false
|
||||
writeLogCalled = false
|
||||
writeLogType uint16
|
||||
writeLogAmount int64
|
||||
writeLogOrderNo string
|
||||
)
|
||||
h := NewCommissionHandler(CommissionDeps{
|
||||
Ledger: ledger,
|
||||
UpdateCommission: func(_ context.Context, uid, amount int64, _ ...*gorm.DB) error {
|
||||
updateCommissionCalled = true
|
||||
if uid != 42 || amount != 300 {
|
||||
t.Fatalf("UpdateCommission got (uid=%d, amount=%d)", uid, amount)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
WriteCommissionLog: func(_ *gorm.DB, objectID int64, logType uint16, amount int64, orderNo string) error {
|
||||
writeLogCalled = true
|
||||
writeLogType = logType
|
||||
writeLogAmount = amount
|
||||
writeLogOrderNo = orderNo
|
||||
if objectID != 42 {
|
||||
t.Fatalf("WriteCommissionLog objectID=%d, want 42", objectID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmockAnyResult())
|
||||
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Prize: lottery.Prize{Config: `{"amount_cents":300}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if !updateCommissionCalled || !writeLogCalled {
|
||||
t.Fatalf("expected both UpdateCommission and WriteCommissionLog to be called (uc=%v wl=%v)", updateCommissionCalled, writeLogCalled)
|
||||
}
|
||||
if writeLogType != logmodel.CommissionTypeLottery {
|
||||
t.Fatalf("expected CommissionTypeLottery(%d), got %d", logmodel.CommissionTypeLottery, writeLogType)
|
||||
}
|
||||
if writeLogAmount != 300 {
|
||||
t.Fatalf("expected amount 300, got %d", writeLogAmount)
|
||||
}
|
||||
if writeLogOrderNo != "lottery:100:200" {
|
||||
t.Fatalf("expected orderNo to reuse ExternalRef, got %q", writeLogOrderNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommission_BadConfigRejected(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewCommissionHandler(CommissionDeps{
|
||||
Ledger: &fakeLedger{},
|
||||
UpdateCommission: func(context.Context, int64, int64, ...*gorm.DB) error { return nil },
|
||||
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
}{
|
||||
{name: "invalid json", config: `{bad`},
|
||||
{name: "zero amount", config: `{"amount_cents":0}`},
|
||||
{name: "negative amount", config: `{"amount_cents":-1}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
Prize: lottery.Prize{Config: tt.config},
|
||||
IdempotencyKey: "k",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommission_MissingDepsFailsFast(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewCommissionHandler(CommissionDeps{
|
||||
Ledger: &fakeLedger{},
|
||||
WriteCommissionLog: func(*gorm.DB, int64, uint16, int64, string) error { return nil },
|
||||
})
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
IdempotencyKey: "k",
|
||||
Prize: lottery.Prize{Config: `{"amount_cents":1}`},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when deps missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Package handler crypto/physical/manual_other 是 Stage 2 引入的三类"人工奖"
|
||||
// PrizeHandler。特点:IsAuto()=false,抽奖事务不调用 Dispatch,而是由 draw
|
||||
// 服务事务内插入 lottery_claim (pending_claim)。用户随后 POST /claim 提交
|
||||
// 领奖表单;运营在后台 approve → mark-paid。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"context"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---- 通用错误 -------------------------------------------------------------
|
||||
|
||||
var (
|
||||
// ErrClaimDataEmpty 表示用户没有提交任何领奖 body。
|
||||
ErrClaimDataEmpty = errors.New("lottery: claim data is empty")
|
||||
// ErrClaimDataMalformed 表示 body 不是合法 JSON 或缺关键字段。
|
||||
ErrClaimDataMalformed = errors.New("lottery: claim data is malformed")
|
||||
)
|
||||
|
||||
// notSupportedDispatch 返回 ErrDispatchNotSupported,供三个人工奖 handler 共享。
|
||||
// 抽奖服务在 handler.IsAuto()==false 时会短路,不会真的调用 Dispatch;这个
|
||||
// 实现只是防御性的:万一未来某处直接调用了 Dispatch,能立刻在日志里看到问题。
|
||||
func notSupportedDispatch(_ context.Context, _ *gorm.DB, _ lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
return lottery.DispatchResult{}, lottery.ErrDispatchNotSupported
|
||||
}
|
||||
|
||||
// decodeClaimJSON 是三个人工 handler 通用的 body 解码路径:空 body 直接返回
|
||||
// ErrClaimDataEmpty;解码失败返回 ErrClaimDataMalformed(wrap 原因)。
|
||||
func decodeClaimJSON(raw []byte, out any) error {
|
||||
if len(raw) == 0 || strings.TrimSpace(string(raw)) == "" {
|
||||
return ErrClaimDataEmpty
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrClaimDataMalformed, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- crypto handler ------------------------------------------------------
|
||||
|
||||
// CryptoHandler 支持"加密货币"人工奖。运营在后台配置 amount / currency /
|
||||
// networks;用户选一个网络 + 填一个地址;运营线下打款后 mark-paid + tx_hash。
|
||||
type CryptoHandler struct{}
|
||||
|
||||
// NewCryptoHandler 构造 crypto handler。无依赖,registry 直接 Register 即可。
|
||||
func NewCryptoHandler() *CryptoHandler { return &CryptoHandler{} }
|
||||
|
||||
func (*CryptoHandler) Type() string { return lottery.PrizeTypeCrypto }
|
||||
func (*CryptoHandler) IsAuto() bool { return false }
|
||||
func (*CryptoHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
return notSupportedDispatch(ctx, tx, req)
|
||||
}
|
||||
|
||||
// cryptoClaimSchemaJSON 是前端渲染表单的 JSON Schema。运行时 crypto handler
|
||||
// 会把奖品 config.networks 注入到 network 字段的 enum,让前端只放开这些网络。
|
||||
// 这里的常量是空 enum 的"模板";ClaimSchema() 返回不带具体 networks 的通用
|
||||
// 描述,实际抽中时 draw 服务会传具体奖品 config,用 BuildCryptoClaimSchema
|
||||
// 生成带 enum 的最终 schema 附到 draw response 上。
|
||||
var cryptoClaimSchemaJSON = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["network","address"],
|
||||
"properties": {
|
||||
"network": {"type":"string","title":"打款网络"},
|
||||
"address": {"type":"string","title":"钱包地址","minLength":16,"maxLength":128}
|
||||
}
|
||||
}`)
|
||||
|
||||
func (*CryptoHandler) ClaimSchema() json.RawMessage { return cryptoClaimSchemaJSON }
|
||||
|
||||
// BuildCryptoClaimSchema 在抽奖成功后按具体奖品 config 生成最终 schema:
|
||||
// 把 config.networks[] 注入到 network 字段的 enum,供前端下拉展示。
|
||||
// prizeConfig 为该奖品的完整 config JSON 字符串(内含 amount/currency/networks)。
|
||||
func BuildCryptoClaimSchema(prizeConfig string) json.RawMessage {
|
||||
var cfg cryptoConfig
|
||||
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
|
||||
return cryptoClaimSchemaJSON
|
||||
}
|
||||
if len(cfg.Networks) == 0 {
|
||||
return cryptoClaimSchemaJSON
|
||||
}
|
||||
// 拼一段带 enum 的 schema,尽量保持体积小、易读。
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"type":"object","required":["network","address"],"properties":{"network":{"type":"string","title":"打款网络","enum":[`)
|
||||
for i, n := range cfg.Networks {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
encoded, _ := json.Marshal(n)
|
||||
b.Write(encoded)
|
||||
}
|
||||
b.WriteString(`]},"address":{"type":"string","title":"钱包地址","minLength":16,"maxLength":128}}}`)
|
||||
return json.RawMessage(b.String())
|
||||
}
|
||||
|
||||
// cryptoConfig 是 lottery_prize.config 的解码目标。
|
||||
type cryptoConfig struct {
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
|
||||
type cryptoClaimInput struct {
|
||||
Network string `json:"network"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// cryptoAddressRegexp 只做最低限度校验(长度 + 字符集),避免 handler 里
|
||||
// 绑定各种链的地址前缀(BTC/ETH/TRX 各有一套),把严格校验推给运营在
|
||||
// mark-paid 前人肉复核。
|
||||
var cryptoAddressRegexp = regexp.MustCompile(`^[A-Za-z0-9]{16,128}$`)
|
||||
|
||||
// ValidateClaim 校验用户提交的 { network, address }:
|
||||
// - network 必须非空(网络白名单是奖品 config 决定的,由 POST /claim 路径
|
||||
// 再做一次二次校验;handler 层只做格式校验,避免把奖品 config 传下来
|
||||
// 污染 ValidateClaim 的签名)
|
||||
// - address 必须匹配基础字符集与长度
|
||||
func (*CryptoHandler) ValidateClaim(raw []byte) error {
|
||||
var input cryptoClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(input.Network) == "" {
|
||||
return fmt.Errorf("%w: network is required", ErrClaimDataMalformed)
|
||||
}
|
||||
if !cryptoAddressRegexp.MatchString(strings.TrimSpace(input.Address)) {
|
||||
return fmt.Errorf("%w: address format invalid (16-128 alphanumeric)", ErrClaimDataMalformed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCryptoNetwork 二次校验用户选中的 network 必须在奖品 config.networks
|
||||
// 白名单里。抽出到独立函数是因为 handler.ValidateClaim 的签名不接受奖品配置;
|
||||
// 由 POST /claim 逻辑层负责调用。
|
||||
func ValidateCryptoNetwork(raw []byte, prizeConfig string) error {
|
||||
var input cryptoClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
var cfg cryptoConfig
|
||||
if err := json.Unmarshal([]byte(prizeConfig), &cfg); err != nil {
|
||||
return fmt.Errorf("decode crypto config: %w", err)
|
||||
}
|
||||
if len(cfg.Networks) == 0 {
|
||||
return nil
|
||||
}
|
||||
network := strings.TrimSpace(input.Network)
|
||||
for _, allowed := range cfg.Networks {
|
||||
if allowed == network {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: network %q not in allowed list", ErrClaimDataMalformed, network)
|
||||
}
|
||||
|
||||
// ---- physical handler ----------------------------------------------------
|
||||
|
||||
// PhysicalHandler 支持实物奖。运营 mark-paid 时用 delivery_ref 记录快递单号。
|
||||
type PhysicalHandler struct{}
|
||||
|
||||
// NewPhysicalHandler 构造 physical handler。
|
||||
func NewPhysicalHandler() *PhysicalHandler { return &PhysicalHandler{} }
|
||||
|
||||
func (*PhysicalHandler) Type() string { return lottery.PrizeTypePhysical }
|
||||
func (*PhysicalHandler) IsAuto() bool { return false }
|
||||
func (*PhysicalHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
return notSupportedDispatch(ctx, tx, req)
|
||||
}
|
||||
|
||||
var physicalClaimSchemaJSON = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["name","phone","province","city","district","detail"],
|
||||
"properties": {
|
||||
"name": {"type":"string","title":"收件人姓名","minLength":1,"maxLength":64},
|
||||
"phone": {"type":"string","title":"联系电话","minLength":6,"maxLength":32},
|
||||
"province": {"type":"string","title":"省","minLength":1,"maxLength":32},
|
||||
"city": {"type":"string","title":"市","minLength":1,"maxLength":32},
|
||||
"district": {"type":"string","title":"区/县","minLength":1,"maxLength":32},
|
||||
"detail": {"type":"string","title":"详细地址","minLength":1,"maxLength":256}
|
||||
}
|
||||
}`)
|
||||
|
||||
func (*PhysicalHandler) ClaimSchema() json.RawMessage { return physicalClaimSchemaJSON }
|
||||
|
||||
type physicalClaimInput struct {
|
||||
Name string `json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Province string `json:"province"`
|
||||
City string `json:"city"`
|
||||
District string `json:"district"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// phoneRegexp 只允许数字、+、-、空格,长度 6-32;宽松以覆盖国际号码格式。
|
||||
var phoneRegexp = regexp.MustCompile(`^[0-9+\-\s]{6,32}$`)
|
||||
|
||||
func (*PhysicalHandler) ValidateClaim(raw []byte) error {
|
||||
var input physicalClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(input.Name) == "" {
|
||||
return fmt.Errorf("%w: name is required", ErrClaimDataMalformed)
|
||||
}
|
||||
if !phoneRegexp.MatchString(strings.TrimSpace(input.Phone)) {
|
||||
return fmt.Errorf("%w: phone format invalid", ErrClaimDataMalformed)
|
||||
}
|
||||
if strings.TrimSpace(input.Province) == "" ||
|
||||
strings.TrimSpace(input.City) == "" ||
|
||||
strings.TrimSpace(input.District) == "" ||
|
||||
strings.TrimSpace(input.Detail) == "" {
|
||||
return fmt.Errorf("%w: address components are required", ErrClaimDataMalformed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- manual_other handler ------------------------------------------------
|
||||
|
||||
// ManualOtherHandler 支持"其他人工奖"(点赞、见面礼、线下券码等)。
|
||||
type ManualOtherHandler struct{}
|
||||
|
||||
// NewManualOtherHandler 构造 manual_other handler。
|
||||
func NewManualOtherHandler() *ManualOtherHandler { return &ManualOtherHandler{} }
|
||||
|
||||
func (*ManualOtherHandler) Type() string { return lottery.PrizeTypeManualOther }
|
||||
func (*ManualOtherHandler) IsAuto() bool { return false }
|
||||
func (*ManualOtherHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
return notSupportedDispatch(ctx, tx, req)
|
||||
}
|
||||
|
||||
var manualOtherClaimSchemaJSON = json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["contact_type","contact_value"],
|
||||
"properties": {
|
||||
"contact_type": {"type":"string","title":"联系方式类型","enum":["phone","email","tg"]},
|
||||
"contact_value": {"type":"string","title":"联系方式","minLength":1,"maxLength":128},
|
||||
"remark": {"type":"string","title":"备注","maxLength":512}
|
||||
}
|
||||
}`)
|
||||
|
||||
func (*ManualOtherHandler) ClaimSchema() json.RawMessage { return manualOtherClaimSchemaJSON }
|
||||
|
||||
type manualOtherClaimInput struct {
|
||||
ContactType string `json:"contact_type"`
|
||||
ContactValue string `json:"contact_value"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// manualOtherContactTypes 是 contact_type 允许的枚举。
|
||||
var manualOtherContactTypes = map[string]struct{}{
|
||||
"phone": {},
|
||||
"email": {},
|
||||
"tg": {},
|
||||
}
|
||||
|
||||
func (*ManualOtherHandler) ValidateClaim(raw []byte) error {
|
||||
var input manualOtherClaimInput
|
||||
if err := decodeClaimJSON(raw, &input); err != nil {
|
||||
return err
|
||||
}
|
||||
ct := strings.TrimSpace(input.ContactType)
|
||||
if _, ok := manualOtherContactTypes[ct]; !ok {
|
||||
return fmt.Errorf("%w: contact_type must be one of phone/email/tg", ErrClaimDataMalformed)
|
||||
}
|
||||
if strings.TrimSpace(input.ContactValue) == "" {
|
||||
return fmt.Errorf("%w: contact_value is required", ErrClaimDataMalformed)
|
||||
}
|
||||
if len(input.Remark) > 512 {
|
||||
return fmt.Errorf("%w: remark too long (max 512)", ErrClaimDataMalformed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// manual_claim_test.go — 单元测试三类人工奖 handler 的静态约束:
|
||||
// - Type / IsAuto / Dispatch 契约
|
||||
// - ClaimSchema 返回合法 JSON
|
||||
// - ValidateClaim 正确/错误样本表驱动
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
)
|
||||
|
||||
// ---- Crypto ---------------------------------------------------------------
|
||||
|
||||
func TestCryptoHandler_Contract(t *testing.T) {
|
||||
h := NewCryptoHandler()
|
||||
if h.Type() != lottery.PrizeTypeCrypto {
|
||||
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeCrypto)
|
||||
}
|
||||
if h.IsAuto() {
|
||||
t.Fatal("IsAuto must be false for manual claim handler")
|
||||
}
|
||||
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||
t.Fatalf("Dispatch on manual handler must return ErrDispatchNotSupported, got %v", err)
|
||||
}
|
||||
if h.ClaimSchema() == nil {
|
||||
t.Fatal("ClaimSchema must not be nil for manual handler")
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(h.ClaimSchema(), &schema); err != nil {
|
||||
t.Fatalf("ClaimSchema must be valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoHandler_ValidateClaim(t *testing.T) {
|
||||
h := NewCryptoHandler()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
errIsErr error
|
||||
}{
|
||||
{"empty", "", true, ErrClaimDataEmpty},
|
||||
{"whitespace", " ", true, ErrClaimDataEmpty},
|
||||
{"malformed json", `{"network"`, true, ErrClaimDataMalformed},
|
||||
{"missing network", `{"address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
||||
{"empty network", `{"network":"","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, true, ErrClaimDataMalformed},
|
||||
{"address too short", `{"network":"BTC","address":"abc"}`, true, ErrClaimDataMalformed},
|
||||
{"address bad chars", `{"network":"BTC","address":"bc1$$!!****"}`, true, ErrClaimDataMalformed},
|
||||
{"valid BTC", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, false, nil},
|
||||
{"valid ETH", `{"network":"ETH","address":"0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1"}`, false, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := h.ValidateClaim([]byte(tc.body))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
if tc.errIsErr != nil && !errors.Is(err, tc.errIsErr) {
|
||||
t.Fatalf("expected errors.Is %v, got %v", tc.errIsErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCryptoNetwork(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
prizeConfig string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty networks in cfg means allow-all", `{"network":"foo","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"amount":"1","currency":"BTC"}`, false},
|
||||
{"network in whitelist", `{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}`, `{"networks":["BTC","ETH"]}`, false},
|
||||
{"network NOT in whitelist", `{"network":"XRP","address":"rXYZQabcdefghijkxxxxxxxx"}`, `{"networks":["BTC","ETH"]}`, true},
|
||||
{"empty body", "", `{"networks":["BTC"]}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ValidateCryptoNetwork([]byte(tc.body), tc.prizeConfig)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCryptoClaimSchema_InjectsNetworkEnum(t *testing.T) {
|
||||
schema := BuildCryptoClaimSchema(`{"networks":["BTC","TRX"]}`)
|
||||
s := string(schema)
|
||||
if !strings.Contains(s, `"enum":["BTC","TRX"]`) {
|
||||
t.Fatalf("expected schema to include enum with configured networks, got %s", s)
|
||||
}
|
||||
// 合法 JSON
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(schema, &out); err != nil {
|
||||
t.Fatalf("built schema not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCryptoClaimSchema_FallsBackWhenConfigInvalid(t *testing.T) {
|
||||
// invalid JSON → fallback to generic schema without enum
|
||||
schema := BuildCryptoClaimSchema(`not-json`)
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(schema, &out); err != nil {
|
||||
t.Fatalf("fallback schema not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Physical -------------------------------------------------------------
|
||||
|
||||
func TestPhysicalHandler_Contract(t *testing.T) {
|
||||
h := NewPhysicalHandler()
|
||||
if h.Type() != lottery.PrizeTypePhysical {
|
||||
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypePhysical)
|
||||
}
|
||||
if h.IsAuto() {
|
||||
t.Fatal("IsAuto must be false")
|
||||
}
|
||||
if h.ClaimSchema() == nil {
|
||||
t.Fatal("ClaimSchema must not be nil")
|
||||
}
|
||||
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhysicalHandler_ValidateClaim(t *testing.T) {
|
||||
h := NewPhysicalHandler()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", ``, true},
|
||||
{"missing name", `{"phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
||||
{"bad phone", `{"name":"张三","phone":"abc","province":"浙江","city":"杭州","district":"西湖","detail":"文一路"}`, true},
|
||||
{"missing detail", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":""}`, true},
|
||||
{"valid CN", `{"name":"张三","phone":"13800001234","province":"浙江","city":"杭州","district":"西湖","detail":"文一路 XX 号"}`, false},
|
||||
{"valid international", `{"name":"John","phone":"+1 415-555-0100","province":"CA","city":"SF","district":"SoMa","detail":"1 Market St"}`, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := h.ValidateClaim([]byte(tc.body))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ManualOther ---------------------------------------------------------
|
||||
|
||||
func TestManualOtherHandler_Contract(t *testing.T) {
|
||||
h := NewManualOtherHandler()
|
||||
if h.Type() != lottery.PrizeTypeManualOther {
|
||||
t.Fatalf("Type = %q, want %q", h.Type(), lottery.PrizeTypeManualOther)
|
||||
}
|
||||
if h.IsAuto() {
|
||||
t.Fatal("IsAuto must be false")
|
||||
}
|
||||
if h.ClaimSchema() == nil {
|
||||
t.Fatal("ClaimSchema must not be nil")
|
||||
}
|
||||
if _, err := h.Dispatch(context.TODO(), nil, lottery.DispatchRequest{}); !errors.Is(err, lottery.ErrDispatchNotSupported) {
|
||||
t.Fatalf("Dispatch must return ErrDispatchNotSupported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualOtherHandler_ValidateClaim(t *testing.T) {
|
||||
h := NewManualOtherHandler()
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", ``, true},
|
||||
{"unknown contact_type", `{"contact_type":"fax","contact_value":"1234"}`, true},
|
||||
{"missing contact_value", `{"contact_type":"phone","contact_value":""}`, true},
|
||||
{"valid phone", `{"contact_type":"phone","contact_value":"+8613800001234","remark":"下午联系"}`, false},
|
||||
{"valid email", `{"contact_type":"email","contact_value":"user@example.com"}`, false},
|
||||
{"valid tg", `{"contact_type":"tg","contact_value":"@handle"}`, false},
|
||||
{"remark too long", `{"contact_type":"email","contact_value":"x@y.z","remark":"` + strings.Repeat("x", 513) + `"}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := h.ValidateClaim([]byte(tc.body))
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("expected nil, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 状态机常量约束 --------------------------------------------------------
|
||||
|
||||
func TestIsClaimStatusResubmittable(t *testing.T) {
|
||||
cases := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{lottery.ClaimStatusPendingClaim, true},
|
||||
{lottery.ClaimStatusRejected, true},
|
||||
{lottery.ClaimStatusReviewing, false},
|
||||
{lottery.ClaimStatusPaying, false},
|
||||
{lottery.ClaimStatusPaid, false},
|
||||
{lottery.ClaimStatusExpired, false},
|
||||
{"", false},
|
||||
{"unknown", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := lottery.IsClaimStatusResubmittable(tc.status); got != tc.want {
|
||||
t.Errorf("IsClaimStatusResubmittable(%q) = %v, want %v", tc.status, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrizeTypeManualClaim(t *testing.T) {
|
||||
cases := []struct {
|
||||
prizeType string
|
||||
want bool
|
||||
}{
|
||||
{lottery.PrizeTypeCrypto, true},
|
||||
{lottery.PrizeTypePhysical, true},
|
||||
{lottery.PrizeTypeManualOther, true},
|
||||
{lottery.PrizeTypeVPNDuration, false},
|
||||
{lottery.PrizeTypeCommission, false},
|
||||
{lottery.PrizeTypeNone, false},
|
||||
{"unknown", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := lottery.IsPrizeTypeManualClaim(tc.prizeType); got != tc.want {
|
||||
t.Errorf("IsPrizeTypeManualClaim(%q) = %v, want %v", tc.prizeType, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
// sqlmockAnyResult 是 sqlmock.NewResult 的简写,语义与它一致(0 影响行)。
|
||||
func sqlmockAnyResult() driver.Result {
|
||||
return sqlmock.NewResult(0, 1)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Package handler contains real PrizeHandler implementations for lottery prize
|
||||
// dispatch. Handlers live in the logic layer because they depend on UserModel,
|
||||
// NodeModel, and commonLogic — importing those from the pure-model
|
||||
// internal/model/lottery package would flip the layering.
|
||||
//
|
||||
// All Dispatch entry points are called inside the draw service's transaction
|
||||
// and must remain tx-only: no cache invalidation, no goroutine fan-out. The
|
||||
// draw service is responsible for post-commit side effects (node cache clear,
|
||||
// user group recalculation) once the enclosing transaction commits.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// VPNDurationHandler 发放"N 天订阅时长"。
|
||||
//
|
||||
// 幂等模型:DispatchRequest.IdempotencyKey → lottery_grant_ledger.external_ref。
|
||||
// Reserve 命中即幂等,返回 payload 里之前记录的 message;未命中才走真实发放。
|
||||
//
|
||||
// 家庭组:走 ResolveEffectiveUser 归位到 owner;若 owner 无活跃订阅,日志
|
||||
// "skipped" 并返回 auto_claimed(与 grantGiftDays 的行为一致,避免中奖后无处发
|
||||
// 的场景导致抽奖事务回滚吞事件)。
|
||||
type VPNDurationHandler struct {
|
||||
deps VPNDurationDeps
|
||||
}
|
||||
|
||||
// VPNDurationDeps 是 VPNDurationHandler 需要的依赖。用 struct 显式收拢,避免
|
||||
// 直接依赖庞大的 ServiceContext;测试时可注入实现了同接口的 mock。
|
||||
type VPNDurationDeps struct {
|
||||
UserModel usermodel.Model
|
||||
Ledger lottery.LedgerService
|
||||
DB *gorm.DB
|
||||
// ResolveEffectiveUser 用于家庭组归位。为 nil 时不做归位(等价于身份函数)。
|
||||
// 生产接线用 DefaultResolveEffectiveUser(DB) 包出闭包。
|
||||
ResolveEffectiveUser func(ctx context.Context, userID int64) (int64, error)
|
||||
}
|
||||
|
||||
// DefaultResolveEffectiveUser 是生产环境的家庭组归位实现。语义与
|
||||
// internal/logic/common.ResolveEntitlementUser 一致(活跃家庭成员 → owner),
|
||||
// 但直接在 handler 包内做 JOIN 查询以避免 internal/svc → internal/logic/common
|
||||
// 的 import cycle(common 包里有别的文件反向 import 了 svc)。
|
||||
func DefaultResolveEffectiveUser(db *gorm.DB) func(ctx context.Context, userID int64) (int64, error) {
|
||||
return func(ctx context.Context, userID int64) (int64, error) {
|
||||
if userID <= 0 {
|
||||
return userID, nil
|
||||
}
|
||||
var row struct {
|
||||
OwnerUserID int64 `gorm:"column:owner_user_id"`
|
||||
}
|
||||
q := db.WithContext(ctx).
|
||||
Table("user_family_member").
|
||||
Select("user_family.owner_user_id AS owner_user_id").
|
||||
Joins("JOIN user_family ON user_family.id = user_family_member.family_id AND user_family.deleted_at IS NULL").
|
||||
Where("user_family_member.user_id = ? AND user_family_member.deleted_at IS NULL AND user_family_member.status = ?", userID, usermodel.FamilyMemberActive).
|
||||
Order("user_family_member.role").
|
||||
Limit(1).
|
||||
Scan(&row)
|
||||
if q.Error != nil {
|
||||
return 0, q.Error
|
||||
}
|
||||
if q.RowsAffected == 0 || row.OwnerUserID <= 0 {
|
||||
return userID, nil
|
||||
}
|
||||
return row.OwnerUserID, nil
|
||||
}
|
||||
}
|
||||
|
||||
// NewVPNDurationHandler 构造真实的 vpn_duration handler。
|
||||
func NewVPNDurationHandler(deps VPNDurationDeps) *VPNDurationHandler {
|
||||
return &VPNDurationHandler{deps: deps}
|
||||
}
|
||||
|
||||
// Type / IsAuto / ValidateClaim / ClaimSchema 实现 PrizeHandler 接口。
|
||||
func (*VPNDurationHandler) Type() string { return lottery.PrizeTypeVPNDuration }
|
||||
func (*VPNDurationHandler) IsAuto() bool { return true }
|
||||
func (*VPNDurationHandler) ValidateClaim([]byte) error { return nil }
|
||||
func (*VPNDurationHandler) ClaimSchema() json.RawMessage { return nil }
|
||||
|
||||
// vpnDurationConfig 是奖品 Config JSON 的解码目标。
|
||||
type vpnDurationConfig struct {
|
||||
DurationDays int `json:"duration_days"`
|
||||
}
|
||||
|
||||
// vpnDurationPayload 落库到 lottery_grant_ledger.payload,用于幂等重放时返回同一
|
||||
// message;同时便于对账(哪条 user_subscribe 被延长、延长了多少天)。
|
||||
type vpnDurationPayload struct {
|
||||
EffectiveUserID int64 `json:"effective_user_id"`
|
||||
SubscribeID int64 `json:"subscribe_id"`
|
||||
Days int `json:"days"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Dispatch 在 caller 的事务内发放订阅时长。
|
||||
func (h *VPNDurationHandler) Dispatch(ctx context.Context, tx *gorm.DB, req lottery.DispatchRequest) (lottery.DispatchResult, error) {
|
||||
if tx == nil {
|
||||
return lottery.DispatchResult{}, errors.New("vpn_duration handler requires a transaction")
|
||||
}
|
||||
if req.IdempotencyKey == "" {
|
||||
return lottery.DispatchResult{}, errors.New("vpn_duration handler requires DispatchRequest.IdempotencyKey")
|
||||
}
|
||||
|
||||
var cfg vpnDurationConfig
|
||||
if err := json.Unmarshal([]byte(req.Prize.Config), &cfg); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("decode vpn_duration config: %w", err)
|
||||
}
|
||||
if cfg.DurationDays <= 0 {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("vpn_duration config duration_days must be > 0, got %d", cfg.DurationDays)
|
||||
}
|
||||
|
||||
// 家庭组归位:注入的 ResolveEffectiveUser 决定是否穿透到 owner。
|
||||
effectiveUserID := req.UserId
|
||||
if h.deps.ResolveEffectiveUser != nil {
|
||||
if eid, err := h.deps.ResolveEffectiveUser(ctx, req.UserId); err == nil && eid > 0 {
|
||||
effectiveUserID = eid
|
||||
}
|
||||
}
|
||||
|
||||
entry := lottery.GrantLedger{
|
||||
ExternalRef: req.IdempotencyKey,
|
||||
HandlerType: lottery.PrizeTypeVPNDuration,
|
||||
UserId: effectiveUserID,
|
||||
ActivityId: req.ActivityId,
|
||||
DrawId: req.DrawId,
|
||||
Amount: int64(cfg.DurationDays),
|
||||
}
|
||||
row, alreadyExisted, err := h.deps.Ledger.Reserve(ctx, tx, entry)
|
||||
if err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("reserve grant ledger: %w", err)
|
||||
}
|
||||
if alreadyExisted {
|
||||
// 幂等命中:直接返回之前记录的 payload.message。
|
||||
var payload vpnDurationPayload
|
||||
if row.Payload != "" {
|
||||
_ = json.Unmarshal([]byte(row.Payload), &payload)
|
||||
}
|
||||
if payload.Message == "" {
|
||||
payload.Message = "已加到订阅"
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
}
|
||||
|
||||
// 未存在 → 真实发放。查用户的活跃订阅。
|
||||
activeSub, findErr := h.findActiveSubscribe(ctx, effectiveUserID)
|
||||
if errors.Is(findErr, gorm.ErrRecordNotFound) {
|
||||
// 与 grantGiftDays 一致:无活跃订阅时记录 skipped 但不失败。
|
||||
payload := vpnDurationPayload{
|
||||
EffectiveUserID: effectiveUserID,
|
||||
Days: cfg.DurationDays,
|
||||
Message: "跳过:用户无活跃订阅",
|
||||
}
|
||||
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
|
||||
return lottery.DispatchResult{}, err
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
}
|
||||
if findErr != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("find active subscribe for user %d: %w", effectiveUserID, findErr)
|
||||
}
|
||||
|
||||
// 计算新 ExpireTime。样板见 activateOrderLogic.go:1336-1342:
|
||||
// a) NoLimit 永久(time.UnixMilli(0))→ 不延长
|
||||
// b) 已过期 → 从 now 起加
|
||||
// c) 未过期 → 从 ExpireTime 起加
|
||||
now := time.Now()
|
||||
if !activeSub.ExpireTime.Equal(time.UnixMilli(0)) {
|
||||
if activeSub.ExpireTime.Before(now) {
|
||||
activeSub.ExpireTime = now.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour)
|
||||
} else {
|
||||
activeSub.ExpireTime = activeSub.ExpireTime.Add(time.Duration(cfg.DurationDays) * 24 * time.Hour)
|
||||
}
|
||||
}
|
||||
activeSub.Status = 1
|
||||
activeSub.FinishedAt = nil
|
||||
|
||||
if err := h.deps.UserModel.UpdateSubscribe(ctx, activeSub, tx); err != nil {
|
||||
return lottery.DispatchResult{}, fmt.Errorf("update subscribe %d: %w", activeSub.Id, err)
|
||||
}
|
||||
|
||||
payload := vpnDurationPayload{
|
||||
EffectiveUserID: effectiveUserID,
|
||||
SubscribeID: activeSub.Id,
|
||||
Days: cfg.DurationDays,
|
||||
Message: fmt.Sprintf("已加 %d 天到订阅", cfg.DurationDays),
|
||||
}
|
||||
if err := h.writeBackPayload(ctx, tx, row.Id, payload); err != nil {
|
||||
return lottery.DispatchResult{}, err
|
||||
}
|
||||
return lottery.DispatchResult{State: lottery.DispatchStateAutoClaimed, Message: payload.Message}, nil
|
||||
}
|
||||
|
||||
// findActiveSubscribe 优先走 UserModel.FindActiveSubscribe;未找到则回退到
|
||||
// 最新 token 非空的历史订阅(样板 activateOrderLogic.go:1371-1393)。
|
||||
func (h *VPNDurationHandler) findActiveSubscribe(ctx context.Context, userID int64) (*usermodel.Subscribe, error) {
|
||||
activeSub, err := h.deps.UserModel.FindActiveSubscribe(ctx, userID)
|
||||
if err == nil {
|
||||
return activeSub, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if h.deps.DB == nil {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
var fallback usermodel.Subscribe
|
||||
fallbackErr := h.deps.DB.WithContext(ctx).
|
||||
Model(&usermodel.Subscribe{}).
|
||||
Where("user_id = ? AND token != ''", userID).
|
||||
Where("status IN ?", []int64{0, 1, 2, 3}).
|
||||
Order("expire_time DESC").
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
First(&fallback).Error
|
||||
if fallbackErr != nil {
|
||||
return nil, fallbackErr
|
||||
}
|
||||
return &fallback, nil
|
||||
}
|
||||
|
||||
func (h *VPNDurationHandler) writeBackPayload(ctx context.Context, tx *gorm.DB, ledgerID int64, payload vpnDurationPayload) error {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal ledger payload: %w", err)
|
||||
}
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&lottery.GrantLedger{}).
|
||||
Where("id = ?", ledgerID).
|
||||
UpdateColumn("payload", string(raw)).Error; err != nil {
|
||||
return fmt.Errorf("update ledger payload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// newHandlerTestDB 建一个 sqlmock 支撑的 gorm.DB,子测试直接把它当 tx 传给 handler。
|
||||
func newHandlerTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("actual sql does not contain expected: " + expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{
|
||||
SkipDefaultTransaction: true,
|
||||
})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
// fakeLedger 让 handler 单测不依赖真实 SQL,只验证控制流。
|
||||
type fakeLedger struct {
|
||||
reserveFn func(ctx context.Context, tx *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error)
|
||||
}
|
||||
|
||||
func (f *fakeLedger) Reserve(ctx context.Context, tx *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return f.reserveFn(ctx, tx, entry)
|
||||
}
|
||||
|
||||
// fakeUserModel 满足 usermodel.Model 里 handler 用到的两个方法。
|
||||
type fakeUserModel struct {
|
||||
usermodel.Model
|
||||
findActive func(ctx context.Context, userID int64) (*usermodel.Subscribe, error)
|
||||
updateSubscribe func(ctx context.Context, sub *usermodel.Subscribe, tx ...*gorm.DB) error
|
||||
}
|
||||
|
||||
func (f *fakeUserModel) FindActiveSubscribe(ctx context.Context, userID int64) (*usermodel.Subscribe, error) {
|
||||
return f.findActive(ctx, userID)
|
||||
}
|
||||
func (f *fakeUserModel) UpdateSubscribe(ctx context.Context, sub *usermodel.Subscribe, tx ...*gorm.DB) error {
|
||||
return f.updateSubscribe(ctx, sub, tx...)
|
||||
}
|
||||
|
||||
// identityResolver 单测里的家庭组归位:始终返回自身。
|
||||
func identityResolver(_ context.Context, userID int64) (int64, error) { return userID, nil }
|
||||
|
||||
func TestVPNDuration_RequiresIdempotencyKey(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: &fakeLedger{},
|
||||
DB: db,
|
||||
ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{})
|
||||
if err == nil || !strings.Contains(err.Error(), "IdempotencyKey") {
|
||||
t.Fatalf("expected IdempotencyKey error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNDuration_RequiresTx(t *testing.T) {
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{Ledger: &fakeLedger{}})
|
||||
_, err := h.Dispatch(context.Background(), nil, lottery.DispatchRequest{IdempotencyKey: "k"})
|
||||
if err == nil || !strings.Contains(err.Error(), "transaction") {
|
||||
t.Fatalf("expected tx error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNDuration_IdempotentHitReturnsStoredMessage(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
stored := lottery.GrantLedger{
|
||||
Id: 9,
|
||||
ExternalRef: "lottery:100:200",
|
||||
Payload: `{"message":"已加 3 天到订阅"}`,
|
||||
}
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &stored, true, nil
|
||||
},
|
||||
}
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||
t.Fatal("must NOT touch UserModel on idempotent hit")
|
||||
return nil, nil
|
||||
},
|
||||
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
|
||||
t.Fatal("must NOT touch UserModel on idempotent hit")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger,
|
||||
UserModel: fake,
|
||||
DB: db,
|
||||
ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if res.State != lottery.DispatchStateAutoClaimed {
|
||||
t.Fatalf("expected auto_claimed, got %q", res.State)
|
||||
}
|
||||
if res.Message != "已加 3 天到订阅" {
|
||||
t.Fatalf("expected stored message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNDuration_NoActiveSubscribeSkipsWithoutError(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, entry lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 5, ExternalRef: entry.ExternalRef}, false, nil
|
||||
},
|
||||
}
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
},
|
||||
updateSubscribe: func(context.Context, *usermodel.Subscribe, ...*gorm.DB) error {
|
||||
t.Fatal("must NOT UpdateSubscribe when no active sub")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// fallback query returns no rows either
|
||||
mock.ExpectQuery("FROM `user_subscribe`").
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if res.State != lottery.DispatchStateAutoClaimed {
|
||||
t.Fatalf("expected auto_claimed even on skip, got %q", res.State)
|
||||
}
|
||||
if !strings.Contains(res.Message, "跳过") {
|
||||
t.Fatalf("expected skip message, got %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNDuration_ExtendsExistingExpireTime(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
future := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Second)
|
||||
activeSub := &usermodel.Subscribe{
|
||||
Id: 77,
|
||||
UserId: 42,
|
||||
ExpireTime: future,
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 5, ExternalRef: "lottery:100:200"}, false, nil
|
||||
},
|
||||
}
|
||||
|
||||
updateCalled := false
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
|
||||
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
|
||||
updateCalled = true
|
||||
expected := future.Add(3 * 24 * time.Hour)
|
||||
if !sub.ExpireTime.Equal(expected) {
|
||||
t.Fatalf("expire time not stacked: got %s want %s", sub.ExpireTime, expected)
|
||||
}
|
||||
if sub.Status != 1 {
|
||||
t.Fatalf("expected Status=1 after grant, got %d", sub.Status)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
res, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||
IdempotencyKey: "lottery:100:200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch: %v", err)
|
||||
}
|
||||
if !updateCalled {
|
||||
t.Fatalf("expected UpdateSubscribe to be called")
|
||||
}
|
||||
if res.Message != "已加 3 天到订阅" {
|
||||
t.Fatalf("unexpected message: %q", res.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNDuration_ExpiredSubscribeRestartsFromNow(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
activeSub := &usermodel.Subscribe{
|
||||
Id: 77,
|
||||
ExpireTime: past,
|
||||
}
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 5}, false, nil
|
||||
},
|
||||
}
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
|
||||
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
|
||||
delta := time.Until(sub.ExpireTime)
|
||||
if delta < 3*24*time.Hour-5*time.Second || delta > 3*24*time.Hour+5*time.Second {
|
||||
t.Fatalf("expected ~3 days from now, got %v", delta)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
_, _ = h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||
IdempotencyKey: "k",
|
||||
})
|
||||
}
|
||||
|
||||
func TestVPNDuration_NoLimitNotExtended(t *testing.T) {
|
||||
db, mock, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
noLimit := time.UnixMilli(0)
|
||||
activeSub := &usermodel.Subscribe{
|
||||
Id: 77,
|
||||
ExpireTime: noLimit,
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
ledger := &fakeLedger{
|
||||
reserveFn: func(_ context.Context, _ *gorm.DB, _ lottery.GrantLedger) (*lottery.GrantLedger, bool, error) {
|
||||
return &lottery.GrantLedger{Id: 5}, false, nil
|
||||
},
|
||||
}
|
||||
fake := &fakeUserModel{
|
||||
findActive: func(context.Context, int64) (*usermodel.Subscribe, error) { return activeSub, nil },
|
||||
updateSubscribe: func(_ context.Context, sub *usermodel.Subscribe, _ ...*gorm.DB) error {
|
||||
if !sub.ExpireTime.Equal(noLimit) {
|
||||
t.Fatalf("no-limit ExpireTime must not be extended, got %v", sub.ExpireTime)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
mock.ExpectExec("UPDATE `lottery_grant_ledger`").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: ledger, UserModel: fake, DB: db, ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
_, _ = h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
Prize: lottery.Prize{Config: `{"duration_days":3}`},
|
||||
IdempotencyKey: "k",
|
||||
})
|
||||
}
|
||||
|
||||
func TestVPNDuration_BadConfigRejected(t *testing.T) {
|
||||
db, _, cleanup := newHandlerTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
h := NewVPNDurationHandler(VPNDurationDeps{
|
||||
Ledger: &fakeLedger{},
|
||||
DB: db,
|
||||
ResolveEffectiveUser: identityResolver,
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
}{
|
||||
{name: "invalid json", config: `{bad`},
|
||||
{name: "zero days", config: `{"duration_days":0}`},
|
||||
{name: "negative days", config: `{"duration_days":-1}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := h.Dispatch(context.Background(), db, lottery.DispatchRequest{
|
||||
Prize: lottery.Prize{Config: tt.config},
|
||||
IdempotencyKey: "k",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
_ = json.Unmarshal
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Package hook contains lottery-side outbound integrations — hooks other flows
|
||||
// (order activation, sign-in, etc.) call after they succeed to feed events into
|
||||
// the lottery system.
|
||||
//
|
||||
// All hooks are fire-and-forget by contract: they run in their own goroutine so
|
||||
// caller latency and error handling are unaffected. Hook failures are logged
|
||||
// and dropped — an invite that fails to earn a lottery chance never blocks the
|
||||
// order it was piggy-backing on.
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// InviteHook is fired by order/renewal activation when an invited user
|
||||
// completes a payment. It grants lottery chances to the referer across every
|
||||
// currently running activity that declares an "invite_success" chance source.
|
||||
type InviteHook interface {
|
||||
// OnConversion queues a background grant for referer. Returns immediately.
|
||||
// Safe to call with refererUserID=0 (no-op) or orderNo="" (no-op).
|
||||
OnConversion(ctx context.Context, refererUserID int64, orderNo string)
|
||||
}
|
||||
|
||||
// NoopInviteHook is a safe placeholder for callers that need an InviteHook
|
||||
// value before the lottery system is wired in. Its OnConversion returns
|
||||
// immediately without side effects — no goroutine, no log spam.
|
||||
func NoopInviteHook() InviteHook { return noopInviteHook{} }
|
||||
|
||||
type noopInviteHook struct{}
|
||||
|
||||
func (noopInviteHook) OnConversion(_ context.Context, _ int64, _ string) {}
|
||||
|
||||
// defaultInviteHook is the production implementation. It queries running
|
||||
// activities on every call rather than caching them — the query is cheap
|
||||
// (small table, indexed by status+time), and skipping the cache avoids stale
|
||||
// reads when an activity is paused or its chance_sources are re-configured.
|
||||
type defaultInviteHook struct {
|
||||
db *gorm.DB
|
||||
chance lottery.ChanceService
|
||||
}
|
||||
|
||||
// NewInviteHook builds the production invite hook.
|
||||
func NewInviteHook(db *gorm.DB, chance lottery.ChanceService) InviteHook {
|
||||
if db == nil || chance == nil {
|
||||
return NoopInviteHook()
|
||||
}
|
||||
return &defaultInviteHook{db: db, chance: chance}
|
||||
}
|
||||
|
||||
// OnConversion spawns a fire-and-forget goroutine that walks all running
|
||||
// activities and calls ChanceService.Grant for each one that declares an
|
||||
// invite_success source.
|
||||
func (h *defaultInviteHook) OnConversion(_ context.Context, refererUserID int64, orderNo string) {
|
||||
if refererUserID <= 0 || orderNo == "" {
|
||||
return
|
||||
}
|
||||
go h.run(refererUserID, orderNo)
|
||||
}
|
||||
|
||||
func (h *defaultInviteHook) run(refererUserID int64, orderNo string) {
|
||||
// Fresh context so the caller cancelling their goroutine does not abort us.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
activities, err := h.loadRunningActivities(ctx)
|
||||
if err != nil {
|
||||
logger.WithContext(ctx).Error("[lottery invite hook] load running activities failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("referer_user_id", refererUserID),
|
||||
logger.Field("order_no", orderNo),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range activities {
|
||||
activity := &activities[i]
|
||||
grants := parseInviteGrantsFromSources(activity.ChanceSources)
|
||||
for _, amount := range grants {
|
||||
if amount <= 0 {
|
||||
continue
|
||||
}
|
||||
// ChanceService.Grant is idempotent per (activity_id, source, source_ref).
|
||||
// Prefix orderNo with "order:" so audit trails can tell business-order
|
||||
// derived refs apart from other source families (manual_grant uses
|
||||
// "manual:*", daily_signin uses "signin:*"). DB uniqueness is already
|
||||
// bucketed by source, but the prefix makes log/analytics readable.
|
||||
ref := "order:" + orderNo
|
||||
if err := h.chance.Grant(ctx, refererUserID, activity.Id, lottery.ChanceSourceInviteSuccess, ref, amount); err != nil {
|
||||
logger.WithContext(ctx).Error("[lottery invite hook] Grant failed",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("referer_user_id", refererUserID),
|
||||
logger.Field("activity_id", activity.Id),
|
||||
logger.Field("order_no", orderNo),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *defaultInviteHook) loadRunningActivities(ctx context.Context) ([]lottery.Activity, error) {
|
||||
now := time.Now()
|
||||
var activities []lottery.Activity
|
||||
if err := h.db.WithContext(ctx).
|
||||
Model(&lottery.Activity{}).
|
||||
Where("status = ?", lottery.ActivityStatusRunning).
|
||||
Where("start_at <= ? AND end_at >= ?", now, now).
|
||||
Find(&activities).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// parseInviteGrantsFromSources decodes the JSON chance_sources array on an
|
||||
// activity and returns the per-conversion grant amount for each invite_success
|
||||
// source (an activity may declare multiple, e.g. with different params by
|
||||
// referer tier — v1 does not, but the loop is a cheap forward-compatibility).
|
||||
func parseInviteGrantsFromSources(raw string) []int {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var sources []lottery.ChanceSource
|
||||
if err := json.Unmarshal([]byte(raw), &sources); err != nil {
|
||||
// Malformed configs skip silently — the activity is misconfigured, not
|
||||
// a hook fault. Admin CRUD (PR C) will surface it.
|
||||
return nil
|
||||
}
|
||||
out := make([]int, 0, len(sources))
|
||||
for _, s := range sources {
|
||||
if s.Source == lottery.ChanceSourceInviteSuccess && s.Amount > 0 {
|
||||
out = append(out, s.Amount)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newHookTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("actual sql does not contain expected: " + expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
type chanceCall struct {
|
||||
userId, activityId int64
|
||||
source, sourceRef string
|
||||
amount int
|
||||
}
|
||||
|
||||
type fakeChanceService struct {
|
||||
mu sync.Mutex
|
||||
calls []chanceCall
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeChanceService) Grant(_ context.Context, userId, activityId int64, source, sourceRef string, amount int) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, chanceCall{userId, activityId, source, sourceRef, amount})
|
||||
return f.err
|
||||
}
|
||||
func (*fakeChanceService) Consume(context.Context, *gorm.DB, int64, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (*fakeChanceService) Query(context.Context, int64, int64) (int64, error) { return 0, nil }
|
||||
|
||||
func (f *fakeChanceService) recorded() []chanceCall {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]chanceCall, len(f.calls))
|
||||
copy(out, f.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNoopInviteHook_IsInert(t *testing.T) {
|
||||
NoopInviteHook().OnConversion(context.Background(), 1, "ord")
|
||||
}
|
||||
|
||||
func TestInviteHook_SkipsWhenRefererMissing(t *testing.T) {
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(&gorm.DB{}, chance) // won't touch DB because refererUserID=0
|
||||
h.OnConversion(context.Background(), 0, "ord")
|
||||
// No goroutine means no calls; give scheduler a beat and confirm empty.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if len(chance.recorded()) != 0 {
|
||||
t.Fatalf("expected no Grant when refererUserID=0, got %+v", chance.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_GrantsForEachRunningActivity(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Activity 100: single invite_success source, amount=1
|
||||
// Activity 200: two sources, only invite_success (amount=2) counts
|
||||
// Activity 300: has invite_success amount=0 → skipped
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}).
|
||||
AddRow(int64(100), `[{"source":"invite_success","amount":1}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(200), `[{"source":"daily_signin","amount":1},{"source":"invite_success","amount":2}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(300), `[{"source":"invite_success","amount":0}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "order-xyz")
|
||||
|
||||
// give the goroutine time to complete
|
||||
deadline := time.After(2 * time.Second)
|
||||
for len(chance.recorded()) < 2 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out waiting for grants; got %+v", chance.recorded())
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
calls := chance.recorded()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("expected 2 grants (100 amount=1, 200 amount=2), got %+v", calls)
|
||||
}
|
||||
byActivity := map[int64]int{}
|
||||
for _, c := range calls {
|
||||
if c.source != lottery.ChanceSourceInviteSuccess {
|
||||
t.Fatalf("unexpected source: %+v", c)
|
||||
}
|
||||
if c.sourceRef != "order:order-xyz" {
|
||||
t.Fatalf("expected orderNo prefixed as source_ref, got %q", c.sourceRef)
|
||||
}
|
||||
if c.userId != 42 {
|
||||
t.Fatalf("expected referer=42, got %d", c.userId)
|
||||
}
|
||||
byActivity[c.activityId] = c.amount
|
||||
}
|
||||
if byActivity[100] != 1 || byActivity[200] != 2 {
|
||||
t.Fatalf("wrong amounts: %+v", byActivity)
|
||||
}
|
||||
if _, exists := byActivity[300]; exists {
|
||||
t.Fatalf("activity 300 has invite_success amount=0 and must be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_MalformedChanceSourcesSkipsOnly(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "chance_sources", "status", "start_at", "end_at", "eligibility", "grid_size", "unmet_action"}).
|
||||
AddRow(int64(100), `[{"source":"invite_success","amount":3}]`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block").
|
||||
AddRow(int64(200), `{bad-json`, "running", time.Now(), time.Now().Add(24*time.Hour), "{}", 9, "block"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "order-1")
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
for len(chance.recorded()) < 1 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out; got %+v", chance.recorded())
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// Only the well-formed activity should have been granted; malformed skipped silently.
|
||||
calls := chance.recorded()
|
||||
if len(calls) != 1 || calls[0].activityId != 100 {
|
||||
t.Fatalf("expected exactly 1 grant for activity 100, got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_QueryFailureLogsAndReturns(t *testing.T) {
|
||||
db, mock, cleanup := newHookTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `lottery_activity`").
|
||||
WillReturnError(errors.New("db down"))
|
||||
|
||||
chance := &fakeChanceService{}
|
||||
h := NewInviteHook(db, chance)
|
||||
h.OnConversion(context.Background(), 42, "ord")
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if len(chance.recorded()) != 0 {
|
||||
t.Fatalf("expected no grants when query fails, got %+v", chance.recorded())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHook_ParseHelperExposesInviteAmountsOnly(t *testing.T) {
|
||||
got := parseInviteGrantsFromSources(`[{"source":"invite_success","amount":5},{"source":"daily_signin","amount":9},{"source":"invite_success","amount":0}]`)
|
||||
if len(got) != 1 || got[0] != 5 {
|
||||
t.Fatalf("expected [5], got %v", got)
|
||||
}
|
||||
if got := parseInviteGrantsFromSources(""); got != nil {
|
||||
t.Fatalf("empty string should return nil, got %v", got)
|
||||
}
|
||||
if got := parseInviteGrantsFromSources(`{bad`); got != nil {
|
||||
t.Fatalf("bad json should return nil, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package rulecaps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
)
|
||||
|
||||
// 抽奖门槛规则树的固定上限。恶意 admin 或误配可以让 PUT rules 的 JSON 递归
|
||||
// 爆炸,评估时爆栈;这些常量给"合理配置"预留了充足空间,同时挡住 blob。
|
||||
const (
|
||||
// MaxDepth 是嵌套 AND/OR 允许的最大深度(根算 1 层)。
|
||||
MaxDepth = 8
|
||||
// MaxNodes 是整树里叶子 + 聚合节点总数上限。
|
||||
MaxNodes = 64
|
||||
// MaxBytes 是原始 JSON 字节数上限(8KB)。
|
||||
MaxBytes = 8 * 1024
|
||||
)
|
||||
|
||||
// ErrRuleTreeTooDeep 表示 AND/OR 嵌套超过 MaxDepth。
|
||||
var ErrRuleTreeTooDeep = errors.New("rule tree exceeds max depth")
|
||||
|
||||
// ErrRuleTreeTooManyNodes 表示节点总数超过 MaxNodes。
|
||||
var ErrRuleTreeTooManyNodes = errors.New("rule tree exceeds max node count")
|
||||
|
||||
// ErrRuleTreeTooLarge 表示 JSON payload 超过 MaxBytes。
|
||||
var ErrRuleTreeTooLarge = errors.New("rule tree JSON exceeds max byte size")
|
||||
|
||||
// ValidateEligibilityJSON 是 PUT /activities/{id}/rules 收到 eligibility JSON
|
||||
// 时的准入闸门。三个上限任一超限 → 返回带上下文的错误,caller 直接 400。
|
||||
// 空 JSON、"{}"、`null` 都视为合法(表示"无门槛")。
|
||||
func ValidateEligibilityJSON(raw []byte) error {
|
||||
if len(raw) > MaxBytes {
|
||||
return fmt.Errorf("%w: %d bytes > %d limit", ErrRuleTreeTooLarge, len(raw), MaxBytes)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 允许 null / "{}" 表示无门槛。
|
||||
trimmed := trimJSONWhitespace(raw)
|
||||
if len(trimmed) == 0 || string(trimmed) == "null" || string(trimmed) == "{}" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tree lottery.EligibilityRule
|
||||
if err := json.Unmarshal(raw, &tree); err != nil {
|
||||
return fmt.Errorf("invalid eligibility JSON: %w", err)
|
||||
}
|
||||
return validateRule(&tree, 1)
|
||||
}
|
||||
|
||||
// validateRule 递归检查一棵规则树;depth 是当前节点所在层(根 = 1)。
|
||||
// 用共享计数器(返回值)而不是外部 counter 是为了让递归签名保持无副作用。
|
||||
func validateRule(node *lottery.EligibilityRule, depth int) error {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
if depth > MaxDepth {
|
||||
return fmt.Errorf("%w: got depth %d, max %d", ErrRuleTreeTooDeep, depth, MaxDepth)
|
||||
}
|
||||
count, err := countAndValidate(node, depth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > MaxNodes {
|
||||
return fmt.Errorf("%w: got %d nodes, max %d", ErrRuleTreeTooManyNodes, count, MaxNodes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// countAndValidate 深度优先遍历,一次递归同时统计节点数并做深度检查。
|
||||
// 返回 count 是子树总节点数(含当前节点);err 表明遍历中已经超限。
|
||||
func countAndValidate(node *lottery.EligibilityRule, depth int) (int, error) {
|
||||
if node == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if depth > MaxDepth {
|
||||
return 0, fmt.Errorf("%w: got depth %d, max %d", ErrRuleTreeTooDeep, depth, MaxDepth)
|
||||
}
|
||||
total := 1
|
||||
for _, child := range node.Children {
|
||||
sub, err := countAndValidate(child, depth+1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total += sub
|
||||
// 提前退出:命中节点数上限就不要继续 walk 剩余分支。
|
||||
if total > MaxNodes {
|
||||
return 0, fmt.Errorf("%w: got at least %d nodes, max %d", ErrRuleTreeTooManyNodes, total, MaxNodes)
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// trimJSONWhitespace 剥掉前后 JSON 空白,用于识别"实质空"的 payload。
|
||||
func trimJSONWhitespace(raw []byte) []byte {
|
||||
i, j := 0, len(raw)
|
||||
for i < j && isJSONWhitespace(raw[i]) {
|
||||
i++
|
||||
}
|
||||
for j > i && isJSONWhitespace(raw[j-1]) {
|
||||
j--
|
||||
}
|
||||
return raw[i:j]
|
||||
}
|
||||
|
||||
func isJSONWhitespace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package rulecaps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
)
|
||||
|
||||
func TestValidateEligibilityJSON_AcceptsEmpty(t *testing.T) {
|
||||
cases := [][]byte{
|
||||
nil,
|
||||
[]byte(""),
|
||||
[]byte("{}"),
|
||||
[]byte("null"),
|
||||
[]byte(" \n \t "),
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := ValidateEligibilityJSON(c); err != nil {
|
||||
t.Fatalf("expected accept for %q, got %v", string(c), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_RejectsOversizePayload(t *testing.T) {
|
||||
blob := make([]byte, MaxBytes+1)
|
||||
for i := range blob {
|
||||
blob[i] = 'a'
|
||||
}
|
||||
err := ValidateEligibilityJSON(blob)
|
||||
if !errors.Is(err, ErrRuleTreeTooLarge) {
|
||||
t.Fatalf("expected ErrRuleTreeTooLarge, got %v", err)
|
||||
}
|
||||
// user-facing message should name the limit
|
||||
if !strings.Contains(err.Error(), "8192") {
|
||||
t.Fatalf("expected message to mention 8192 byte limit, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_RejectsBadJSON(t *testing.T) {
|
||||
err := ValidateEligibilityJSON([]byte(`{bad`))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// buildDeepTree 构造 depth 层单链嵌套(每层一个 OR 聚合)。root 为第 1 层。
|
||||
func buildDeepTree(depth int) *lottery.EligibilityRule {
|
||||
root := &lottery.EligibilityRule{Op: "OR", Children: []*lottery.EligibilityRule{{Type: "has_subscription"}}}
|
||||
current := root
|
||||
for i := 2; i < depth; i++ {
|
||||
next := &lottery.EligibilityRule{Op: "OR", Children: []*lottery.EligibilityRule{{Type: "has_subscription"}}}
|
||||
current.Children = []*lottery.EligibilityRule{next}
|
||||
current = next
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_RejectsDepthOverLimit(t *testing.T) {
|
||||
tree := buildDeepTree(MaxDepth + 1) // depth 9 with defaults
|
||||
raw, err := json.Marshal(tree)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
err = ValidateEligibilityJSON(raw)
|
||||
if !errors.Is(err, ErrRuleTreeTooDeep) {
|
||||
t.Fatalf("expected ErrRuleTreeTooDeep, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_AcceptsMaxDepth(t *testing.T) {
|
||||
tree := buildDeepTree(MaxDepth)
|
||||
raw, _ := json.Marshal(tree)
|
||||
if err := ValidateEligibilityJSON(raw); err != nil {
|
||||
t.Fatalf("depth=MaxDepth must be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildWideTree 构造根节点 + N 个叶子,总节点数 = 1 + N。
|
||||
func buildWideTree(leaves int) *lottery.EligibilityRule {
|
||||
root := &lottery.EligibilityRule{Op: "AND"}
|
||||
for i := 0; i < leaves; i++ {
|
||||
root.Children = append(root.Children, &lottery.EligibilityRule{Type: "has_subscription"})
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_RejectsNodeCountOverLimit(t *testing.T) {
|
||||
// 65 nodes total = 1 root + 64 leaves > MaxNodes
|
||||
tree := buildWideTree(MaxNodes)
|
||||
raw, _ := json.Marshal(tree)
|
||||
err := ValidateEligibilityJSON(raw)
|
||||
if !errors.Is(err, ErrRuleTreeTooManyNodes) {
|
||||
t.Fatalf("expected ErrRuleTreeTooManyNodes, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_AcceptsAtNodeLimit(t *testing.T) {
|
||||
// 64 nodes = 1 root + 63 leaves
|
||||
tree := buildWideTree(MaxNodes - 1)
|
||||
raw, _ := json.Marshal(tree)
|
||||
if err := ValidateEligibilityJSON(raw); err != nil {
|
||||
t.Fatalf("nodes=MaxNodes must be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEligibilityJSON_AcceptsRealisticTree(t *testing.T) {
|
||||
// Typical activity: (has_subscription AND invite_count>=3) OR user_tag in {vip}
|
||||
raw := []byte(`{
|
||||
"op": "OR",
|
||||
"children": [
|
||||
{
|
||||
"op": "AND",
|
||||
"children": [
|
||||
{"type": "has_subscription", "params": {"min_days_remaining": 7}},
|
||||
{"type": "invite_count", "params": {"min": 3}}
|
||||
]
|
||||
},
|
||||
{"type": "user_tag", "params": {"tags": ["vip"]}}
|
||||
]
|
||||
}`)
|
||||
if err := ValidateEligibilityJSON(raw); err != nil {
|
||||
t.Fatalf("realistic tree should be accepted, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// Package lottery implements the user-side lottery HTTP endpoints:
|
||||
//
|
||||
// GET /api/v1/lottery/config
|
||||
// POST /api/v1/lottery/draw
|
||||
// GET /api/v1/lottery/records
|
||||
// POST /api/v1/lottery/claim (Stage 2: submit manual claim data)
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/logic/lottery/draw"
|
||||
lotteryhandler "github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
userModel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// currentUserId 取 middleware.AuthMiddleware 注入的 user 上下文。
|
||||
// 匿名 / 未登录返回 0;handler 侧应当由 AuthMiddleware 已经拦截。
|
||||
func currentUserId(ctx context.Context) int64 {
|
||||
u, ok := ctx.Value(constant.CtxKeyUser).(*userModel.User)
|
||||
if !ok || u == nil {
|
||||
return 0
|
||||
}
|
||||
return u.Id
|
||||
}
|
||||
|
||||
// ---- GET /config ------------------------------------------------------------
|
||||
|
||||
// QueryLotteryConfigLogic 组装活动 + 奖品 + 用户门槛/次数状态。
|
||||
type QueryLotteryConfigLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewQueryLotteryConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryConfigLogic {
|
||||
return &QueryLotteryConfigLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *QueryLotteryConfigLogic) QueryLotteryConfig(req *types.GetLotteryConfigRequest) (*types.GetLotteryConfigResponse, error) {
|
||||
userId := currentUserId(l.ctx)
|
||||
if userId == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
activity, err := l.loadActivity(req.ActivityId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prizes, err := l.loadPrizes(req.ActivityId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remaining, _ := l.svcCtx.LotteryChance.Query(l.ctx, userId, req.ActivityId)
|
||||
|
||||
resp := &types.GetLotteryConfigResponse{
|
||||
Activity: types.LotteryActivityConfig{
|
||||
Id: activity.Id,
|
||||
Title: activity.Title,
|
||||
Description: activity.Description,
|
||||
StartAt: activity.StartAt.Unix(),
|
||||
EndAt: activity.EndAt.Unix(),
|
||||
Status: activity.Status,
|
||||
GridSize: activity.GridSize,
|
||||
},
|
||||
User: types.LotteryUserStatus{
|
||||
ChancesRemaining: remaining,
|
||||
// Eligible / UnmetReasons 需要 RuleContextBuilder;PR C 里 draw 路径
|
||||
// 用真实构造器,config 路径为节省 DB 查询暂只返回次数,前端拿到
|
||||
// 未通过时的具体 reason 是在 POST /draw 返回码 100001 里附带的。
|
||||
Eligible: true,
|
||||
},
|
||||
}
|
||||
resp.Activity.Prizes = make([]types.LotteryPrizeConfig, 0, len(prizes))
|
||||
for _, p := range prizes {
|
||||
soldOut := p.RemainingStock.Valid && p.RemainingStock.Int64 <= 0
|
||||
resp.Activity.Prizes = append(resp.Activity.Prizes, types.LotteryPrizeConfig{
|
||||
Slot: p.Slot,
|
||||
Id: p.Id,
|
||||
Type: p.Type,
|
||||
Name: p.Name,
|
||||
IconUrl: p.IconURL,
|
||||
Config: json.RawMessage(defaultIfEmpty(p.Config)),
|
||||
SoldOut: soldOut,
|
||||
})
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (l *QueryLotteryConfigLogic) loadActivity(id int64) (*modelLottery.Activity, error) {
|
||||
var activity modelLottery.Activity
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", id).First(&activity).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if activity.Status == modelLottery.ActivityStatusEnded {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
return &activity, nil
|
||||
}
|
||||
|
||||
func (l *QueryLotteryConfigLogic) loadPrizes(activityId int64) ([]modelLottery.Prize, error) {
|
||||
var prizes []modelLottery.Prize
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Where("activity_id = ?", activityId).
|
||||
Order("slot ASC").
|
||||
Find(&prizes).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
return prizes, nil
|
||||
}
|
||||
|
||||
func defaultIfEmpty(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "{}"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---- POST /draw -------------------------------------------------------------
|
||||
|
||||
// DrawLotteryLogic 是 POST /draw 的入口,委托给 draw.Service。
|
||||
type DrawLotteryLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewDrawLotteryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DrawLotteryLogic {
|
||||
return &DrawLotteryLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *DrawLotteryLogic) DrawLottery(req *types.DrawLotteryRequest) (*types.DrawLotteryResponse, error) {
|
||||
userId := currentUserId(l.ctx)
|
||||
if userId == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
if l.svcCtx.LotteryDrawService == nil {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryActivityEnded)
|
||||
}
|
||||
result, err := l.svcCtx.LotteryDrawService.Draw(l.ctx, draw.Request{
|
||||
UserId: userId,
|
||||
ActivityId: req.ActivityId,
|
||||
ClientNonce: req.ClientNonce,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &types.DrawLotteryResponse{
|
||||
DrawId: result.DrawId,
|
||||
IsWin: result.IsWin,
|
||||
ChancesRemaining: result.ChancesRemaining,
|
||||
Claim: types.LotteryClaimStatus{
|
||||
Required: result.Claim.Required,
|
||||
AutoClaimed: result.Claim.AutoClaimed,
|
||||
Message: result.Claim.Message,
|
||||
ExpiresAt: result.Claim.ExpiresAt,
|
||||
ClaimFormSchema: result.Claim.ClaimFormSchema,
|
||||
},
|
||||
}
|
||||
if result.Prize != nil {
|
||||
resp.Prize = &types.DrawnPrize{
|
||||
Slot: result.Prize.Slot,
|
||||
Id: result.Prize.Id,
|
||||
Type: result.Prize.Type,
|
||||
Name: result.Prize.Name,
|
||||
Config: result.Prize.Config,
|
||||
}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ---- GET /records ----------------------------------------------------------
|
||||
|
||||
// QueryLotteryRecordsLogic 分页列出当前用户的中奖流水。
|
||||
type QueryLotteryRecordsLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewQueryLotteryRecordsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *QueryLotteryRecordsLogic {
|
||||
return &QueryLotteryRecordsLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *QueryLotteryRecordsLogic) QueryLotteryRecords(req *types.GetLotteryRecordsRequest) (*types.GetLotteryRecordsResponse, error) {
|
||||
userId := currentUserId(l.ctx)
|
||||
if userId == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
db := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&modelLottery.Draw{}).
|
||||
Where("user_id = ?", userId)
|
||||
if req.ActivityId > 0 {
|
||||
db = db.Where("activity_id = ?", req.ActivityId)
|
||||
}
|
||||
if state := recordStatusFilter(req.Status); state != "" {
|
||||
switch state {
|
||||
case "unclaimed":
|
||||
db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePendingClaim)
|
||||
case "paid":
|
||||
db = db.Where("dispatch_state = ?", modelLottery.DispatchStatePaid)
|
||||
case "expired":
|
||||
db = db.Where("dispatch_state = ?", modelLottery.DispatchStateExpired)
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
var draws []modelLottery.Draw
|
||||
if err := db.Order("drawn_at DESC").Limit(size).Offset((page - 1) * size).Find(&draws).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
snapshots, err := l.loadPrizeSnapshots(draws)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, err := l.loadClaims(draws)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &types.GetLotteryRecordsResponse{Total: total, List: make([]types.LotteryRecord, 0, len(draws))}
|
||||
for _, d := range draws {
|
||||
record := types.LotteryRecord{
|
||||
DrawId: d.Id,
|
||||
ActivityId: d.ActivityId,
|
||||
IsWin: d.IsWin,
|
||||
DispatchState: d.DispatchState,
|
||||
DrawnAt: d.DrawnAt.Unix(),
|
||||
}
|
||||
snap, hasSnap := snapshots[d.Id]
|
||||
if hasSnap {
|
||||
record.Prize = &types.DrawnPrize{
|
||||
Slot: snap.Slot,
|
||||
Id: snap.PrizeId,
|
||||
Type: snap.Type,
|
||||
Name: snap.Name,
|
||||
Config: json.RawMessage(defaultIfEmpty(snap.Config)),
|
||||
}
|
||||
}
|
||||
if claim, ok := claims[d.Id]; ok {
|
||||
record.Claim = l.buildRecordClaim(claim, snap, hasSnap)
|
||||
}
|
||||
resp.List = append(resp.List, record)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// loadClaims 批量拉这一页里所有 draw 关联的 lottery_claim。人工奖 draw 一定有一行,
|
||||
// 自动奖 draw / 谢谢参与不会有;缺失的 draw_id 直接不在 map 里,调用侧只做存在性判断。
|
||||
func (l *QueryLotteryRecordsLogic) loadClaims(draws []modelLottery.Draw) (map[int64]modelLottery.Claim, error) {
|
||||
if len(draws) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(draws))
|
||||
for _, d := range draws {
|
||||
ids = append(ids, d.Id)
|
||||
}
|
||||
var rows []modelLottery.Claim
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]modelLottery.Claim, len(rows))
|
||||
for _, c := range rows {
|
||||
out[c.DrawId] = c
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildRecordClaim 把 lottery_claim 组装成 GET /records 里的 Claim 字段。
|
||||
// 状态允许再提交(pending_claim / rejected)时附带 ClaimFormSchema,
|
||||
// 否则不再下发(避免前端误以为还能再填)。
|
||||
func (l *QueryLotteryRecordsLogic) buildRecordClaim(claim modelLottery.Claim, snap modelLottery.PrizeSnapshot, hasSnap bool) *types.LotteryRecordClaim {
|
||||
view := &types.LotteryRecordClaim{
|
||||
Status: claim.Status,
|
||||
ExpiresAt: claim.ExpiresAt.Unix(),
|
||||
TxHash: claim.TxHash,
|
||||
DeliveryRef: claim.DeliveryRef,
|
||||
RejectReason: claim.RejectReason,
|
||||
}
|
||||
if claim.ClaimData != "" {
|
||||
view.ClaimData = json.RawMessage(claim.ClaimData)
|
||||
}
|
||||
if claim.SubmittedAt != nil {
|
||||
view.SubmittedAt = claim.SubmittedAt.Unix()
|
||||
}
|
||||
if claim.PaidAt != nil {
|
||||
view.PaidAt = claim.PaidAt.Unix()
|
||||
}
|
||||
// 只在允许再提交状态下下发 schema。
|
||||
if modelLottery.IsClaimStatusResubmittable(claim.Status) && l.svcCtx.LotteryRegistry != nil {
|
||||
if h, ok := l.svcCtx.LotteryRegistry.Get(claim.PrizeType); ok {
|
||||
if claim.PrizeType == modelLottery.PrizeTypeCrypto && hasSnap {
|
||||
view.ClaimFormSchema = lotteryhandler.BuildCryptoClaimSchema(snap.Config)
|
||||
} else {
|
||||
view.ClaimFormSchema = h.ClaimSchema()
|
||||
}
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (l *QueryLotteryRecordsLogic) loadPrizeSnapshots(draws []modelLottery.Draw) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||||
if len(draws) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := make([]int64, 0, len(draws))
|
||||
for _, d := range draws {
|
||||
ids = append(ids, d.Id)
|
||||
}
|
||||
var snaps []modelLottery.PrizeSnapshot
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", ids).Find(&snaps).Error; err != nil {
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
|
||||
for _, s := range snaps {
|
||||
out[s.DrawId] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func recordStatusFilter(s string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "all":
|
||||
return ""
|
||||
case "unclaimed", "paid", "expired":
|
||||
return strings.ToLower(s)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ---- POST /claim ----------------------------------------------------------
|
||||
|
||||
// ClaimLotteryPrizeLogic 是 Stage 2 人工奖领奖入口。
|
||||
//
|
||||
// 调用契约(错误码见 pkg/xerr):
|
||||
//
|
||||
// 4007 draw_not_found — 传入的 draw_id 不存在
|
||||
// 4008 not_your_draw — draw 属于其他用户
|
||||
// 4010 not_claimable — 该 draw 未中奖 / 自动奖 / 找不到 pending_claim
|
||||
// 4009 claim_expired — pending_claim.expires_at 已过期
|
||||
// 4005 already_submitted — 当前状态 (reviewing/paying/paid/expired) 禁止再提交
|
||||
// 4006 invalid_claim_data — handler.ValidateClaim 校验失败
|
||||
type ClaimLotteryPrizeLogic struct {
|
||||
logger.Logger
|
||||
ctx context.Context
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewClaimLotteryPrizeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ClaimLotteryPrizeLogic {
|
||||
return &ClaimLotteryPrizeLogic{
|
||||
Logger: logger.WithContext(ctx),
|
||||
ctx: ctx,
|
||||
svcCtx: svcCtx,
|
||||
}
|
||||
}
|
||||
|
||||
// ClaimLotteryPrize 提交领奖表单:pending_claim → reviewing,或 rejected → reviewing。
|
||||
func (l *ClaimLotteryPrizeLogic) ClaimLotteryPrize(req *types.ClaimLotteryPrizeRequest) (*types.ClaimLotteryPrizeResponse, error) {
|
||||
userId := currentUserId(l.ctx)
|
||||
if userId == 0 {
|
||||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||||
}
|
||||
if req.DrawId <= 0 {
|
||||
return nil, xerr.NewErrCode(xerr.InvalidParams)
|
||||
}
|
||||
claimData := selectClaimData(req)
|
||||
|
||||
// 1. 定位 draw + 归属校验(提前失败,避免暴露内部资源)
|
||||
var draw modelLottery.Draw
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("id = ?", req.DrawId).First(&draw).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryDrawNotFound)
|
||||
}
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if draw.UserId != userId {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotYourDraw)
|
||||
}
|
||||
if !draw.IsWin || draw.DispatchState != modelLottery.DispatchStatePendingClaim {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||
}
|
||||
|
||||
// 2. 抽奖时刻快照(用于 crypto network 二次校验)
|
||||
var snap modelLottery.PrizeSnapshot
|
||||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id = ?", draw.Id).First(&snap).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||
}
|
||||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if !modelLottery.IsPrizeTypeManualClaim(snap.Type) {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||
}
|
||||
handler, ok := l.svcCtx.LotteryRegistry.Get(snap.Type)
|
||||
if !ok {
|
||||
return nil, xerr.NewErrCode(xerr.LotteryInternalError)
|
||||
}
|
||||
|
||||
// 3. handler 校验 body
|
||||
if err := handler.ValidateClaim(claimData); err != nil {
|
||||
return nil, xerr.NewErrCodeMsg(xerr.LotteryInvalidClaimData, err.Error())
|
||||
}
|
||||
if snap.Type == modelLottery.PrizeTypeCrypto {
|
||||
if err := lotteryhandler.ValidateCryptoNetwork(claimData, snap.Config); err != nil {
|
||||
return nil, xerr.NewErrCodeMsg(xerr.LotteryInvalidClaimData, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 事务内更新 claim(乐观锁:status IN (pending_claim, rejected) AND expires_at > now)
|
||||
now := time.Now()
|
||||
var response *types.ClaimLotteryPrizeResponse
|
||||
err := l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var claim modelLottery.Claim
|
||||
if err := tx.Where("draw_id = ?", draw.Id).First(&claim).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.NewErrCode(xerr.LotteryNotClaimable)
|
||||
}
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||||
}
|
||||
if claim.ExpiresAt.Before(now) {
|
||||
return xerr.NewErrCode(xerr.LotteryClaimExpired)
|
||||
}
|
||||
if !modelLottery.IsClaimStatusResubmittable(claim.Status) {
|
||||
return xerr.NewErrCode(xerr.LotteryAlreadySubmitted)
|
||||
}
|
||||
|
||||
// CAS 更新:命中 status 白名单 + expires_at 未过期时才走。RowsAffected==0
|
||||
// 视为并发拦截(另一个请求已经把状态推进了),报 4005 即可。
|
||||
res := tx.Model(&modelLottery.Claim{}).
|
||||
Where("id = ? AND status IN ? AND expires_at > ?",
|
||||
claim.Id,
|
||||
[]string{modelLottery.ClaimStatusPendingClaim, modelLottery.ClaimStatusRejected},
|
||||
now).
|
||||
Updates(map[string]any{
|
||||
"claim_data": string(claimData),
|
||||
"status": modelLottery.ClaimStatusReviewing,
|
||||
"submitted_at": now,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return xerr.NewErrCode(xerr.LotteryAlreadySubmitted)
|
||||
}
|
||||
response = &types.ClaimLotteryPrizeResponse{
|
||||
Status: modelLottery.ClaimStatusReviewing,
|
||||
SubmittedAt: now.Unix(),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// selectClaimData 兼容 ClaimData(首选)与 Input(历史字段名)。
|
||||
func selectClaimData(req *types.ClaimLotteryPrizeRequest) []byte {
|
||||
if len(req.ClaimData) > 0 {
|
||||
return req.ClaimData
|
||||
}
|
||||
return req.Input
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// lottery_stage2_test.go — Stage 2 相关的纯函数单测。
|
||||
// 用户 API 主流程(POST /claim)走 DB 事务 + auth middleware,集成在 QA 脚本里跑;
|
||||
// 这里只补 handler / helper 层的纯逻辑分支。
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
)
|
||||
|
||||
func TestSelectClaimData_PreferClaimDataOverInput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
req types.ClaimLotteryPrizeRequest
|
||||
want string
|
||||
}{
|
||||
{"claim_data set", types.ClaimLotteryPrizeRequest{ClaimData: []byte(`{"a":1}`), Input: []byte(`{"b":2}`)}, `{"a":1}`},
|
||||
{"only input", types.ClaimLotteryPrizeRequest{Input: []byte(`{"b":2}`)}, `{"b":2}`},
|
||||
{"neither", types.ClaimLotteryPrizeRequest{}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := string(selectClaimData(&tc.req))
|
||||
if got != tc.want {
|
||||
t.Fatalf("selectClaimData = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordStatusFilter_KnownStates(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"all", ""},
|
||||
{"unclaimed", "unclaimed"},
|
||||
{"paid", "paid"},
|
||||
{"expired", "expired"},
|
||||
{"unknown", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := recordStatusFilter(tc.in); got != tc.want {
|
||||
t.Errorf("recordStatusFilter(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/adapter"
|
||||
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/client"
|
||||
"github.com/perfect-panel/server/internal/model/group"
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
@@ -79,6 +80,10 @@ func (l *SubscribeLogic) Handler(req *types.SubscribeRequest) (resp *types.Subsc
|
||||
l.Errorw("[SubscribeLogic] Get user subscribe failed", logger.Field("error", err.Error()), logger.Field("token", req.Token))
|
||||
return nil, err
|
||||
}
|
||||
if _, err := logiccommon.ResolveEnabledUser(l.ctx.Request.Context(), l.svc, userSubscribe.UserId); err != nil {
|
||||
l.Errorw("[SubscribeLogic] User disabled", logger.Field("error", err.Error()), logger.Field("userId", userSubscribe.UserId))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var subscribeStatus = false
|
||||
defer func() {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
)
|
||||
|
||||
// AdminMetaMiddleware pins the request's client IP and User-Agent onto the
|
||||
// context under constant.CtxKeyIP / constant.CtxKeyUserAgent so downstream
|
||||
// audit writers (admin_action_log) can capture them without threading the
|
||||
// gin.Context through every logic layer.
|
||||
//
|
||||
// This middleware is a no-op for auth: it does NOT gate access; wire it
|
||||
// after AuthMiddleware so ctx.Value(CtxKeyUser) is already populated by the
|
||||
// time an admin logic writes audit rows.
|
||||
func AdminMetaMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyIP, c.ClientIP())
|
||||
ctx = context.WithValue(ctx, constant.CtxKeyUserAgent, c.Request.UserAgent())
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
)
|
||||
|
||||
// TestAdminMetaMiddleware_PopulatesCtx asserts the middleware pins ClientIP
|
||||
// and User-Agent onto the request context under the typed constant keys, so
|
||||
// downstream audit writers can pick them up without gin.Context threading.
|
||||
func TestAdminMetaMiddleware_PopulatesCtx(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var (
|
||||
gotIP string
|
||||
gotUA string
|
||||
)
|
||||
r := gin.New()
|
||||
r.Use(AdminMetaMiddleware())
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
gotIP, _ = ctx.Value(constant.CtxKeyIP).(string)
|
||||
gotUA, _ = ctx.Value(constant.CtxKeyUserAgent).(string)
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
req.RemoteAddr = "10.99.99.7:54321"
|
||||
req.Header.Set("User-Agent", "qa-audit-probe")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if gotUA != "qa-audit-probe" {
|
||||
t.Fatalf("user_agent = %q, want %q", gotUA, "qa-audit-probe")
|
||||
}
|
||||
// Gin resolves ClientIP() from RemoteAddr when no forwarded headers are
|
||||
// trusted. It strips the port, so we assert the exact host we set.
|
||||
if gotIP != "10.99.99.7" {
|
||||
t.Fatalf("ip = %q, want %q", gotIP, "10.99.99.7")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMetaMiddleware_UsesTypedKey guards against the regression that
|
||||
// motivated PR D: reader and writer must share the typed CtxKey, not a bare
|
||||
// string. A ctx.Value("ip") lookup (bare string) MUST miss even though the
|
||||
// typed CtxKey "ip" is present.
|
||||
func TestAdminMetaMiddleware_UsesTypedKey(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var (
|
||||
typedIP string
|
||||
bareStrIP any
|
||||
)
|
||||
r := gin.New()
|
||||
r.Use(AdminMetaMiddleware())
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
typedIP, _ = ctx.Value(constant.CtxKeyIP).(string)
|
||||
bareStrIP = ctx.Value("ip") // bare string key — must MISS
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
|
||||
req.RemoteAddr = "10.1.2.3:80"
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if typedIP == "" {
|
||||
t.Fatalf("typed CtxKeyIP lookup must succeed")
|
||||
}
|
||||
if bareStrIP != nil {
|
||||
t.Fatalf("bare-string \"ip\" lookup MUST miss (got %v); F2 regression risk", bareStrIP)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
logiccommon "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
@@ -87,10 +88,10 @@ func authenticateRequest(c *gin.Context, svc *svc.ServiceContext, token string,
|
||||
|
||||
svc.Redis.Expire(c, sessionIdCacheKey, time.Duration(svc.Config.JwtAuth.AccessExpire)*time.Second)
|
||||
|
||||
userInfo, err := svc.UserModel.FindOne(c, userId)
|
||||
userInfo, err := logiccommon.ResolveEnabledUser(c, svc, userId)
|
||||
if err != nil {
|
||||
logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] UserModel FindOne", logger.Field("error", err.Error()), logger.Field("userId", userId))
|
||||
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Database Query Error"))
|
||||
logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] ResolveEnabledUser", logger.Field("error", err.Error()), logger.Field("userId", userId))
|
||||
result.HttpResult(c, nil, err)
|
||||
c.Abort()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
)
|
||||
|
||||
@@ -66,6 +67,8 @@ func PanDomainMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
|
||||
l := subscribe.NewSubscribeLogic(c, svc)
|
||||
resp, err := l.Handler(&request)
|
||||
if err != nil {
|
||||
result.HttpResult(c, nil, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Header("subscription-userinfo", resp.Header)
|
||||
|
||||
@@ -52,6 +52,7 @@ const (
|
||||
CommissionTypeConvertBalance uint16 = 336 // Convert to Balance
|
||||
CommissionTypeWithdrawReject uint16 = 337 // Withdraw rejected refund
|
||||
CommissionTypeWithdrawCancel uint16 = 338 // 用户取消提现退佣金
|
||||
CommissionTypeLottery uint16 = 339 // 抽奖奖励(PR B: 与 Purchase/Renewal 区分,便于对账)
|
||||
GiftTypeIncrease uint16 = 341 // Increase
|
||||
GiftTypeReduce uint16 = 342 // Reduce
|
||||
)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// chanceService 是 ChanceService 的默认实现。
|
||||
type chanceService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewChanceService 用注入的 *gorm.DB 构造一个 ChanceService。
|
||||
func NewChanceService(db *gorm.DB) ChanceService { return &chanceService{db: db} }
|
||||
|
||||
// Grant 记录一次次数入账,幂等键 = (activity_id, source, source_ref)。
|
||||
// 幂等策略:
|
||||
// 1. INSERT lottery_chance_grant,靠 UNIQUE(activity_id, source, source_ref) 触发冲突
|
||||
// 2. 冲突视为"已发过",直接返回 nil 不重复发放
|
||||
// 3. 未冲突 → UPSERT lottery_chance_balance 累加 remaining
|
||||
//
|
||||
// 关键正确性:两步必须在同一事务内。这样第 (1) 成功即证明是首次入账,
|
||||
// 才走第 (2);第 (1) 冲突则直接跳过 (2),balance 不会双加。
|
||||
func (s *chanceService) Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
grant := ChanceGrant{
|
||||
UserId: userId,
|
||||
ActivityId: activityId,
|
||||
Source: source,
|
||||
SourceRef: sourceRef,
|
||||
Amount: amount,
|
||||
}
|
||||
// OnConflict DoNothing 依赖 UNIQUE(activity_id, source, source_ref)。
|
||||
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&grant)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// 幂等命中:已经发过,balance 不动。
|
||||
return nil
|
||||
}
|
||||
|
||||
// 未冲突 → 累加余额(upsert balance 行)。
|
||||
balance := ChanceBalance{
|
||||
UserId: userId,
|
||||
ActivityId: activityId,
|
||||
Remaining: int64(amount),
|
||||
TotalEarned: int64(amount),
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "activity_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"remaining": gorm.Expr("`lottery_chance_balance`.`remaining` + ?", amount),
|
||||
"total_earned": gorm.Expr("`lottery_chance_balance`.`total_earned` + ?", amount),
|
||||
}),
|
||||
}).Create(&balance).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Consume 在事务内以 SELECT ... FOR UPDATE 锁住 chance_balance 行后 -1。
|
||||
// 剩余为 0 时返回 ErrNoChances,调用方直接回滚事务,不写 draw。
|
||||
func (s *chanceService) Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (int64, error) {
|
||||
if tx == nil {
|
||||
return 0, errors.New("Consume requires a transaction handle")
|
||||
}
|
||||
var balance ChanceBalance
|
||||
err := tx.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("user_id = ? AND activity_id = ?", userId, activityId).
|
||||
First(&balance).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, ErrNoChances
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if balance.Remaining <= 0 {
|
||||
return 0, ErrNoChances
|
||||
}
|
||||
// 累加 spent,扣减 remaining,一条 SQL 完成。
|
||||
updateErr := tx.WithContext(ctx).
|
||||
Model(&ChanceBalance{}).
|
||||
Where("id = ? AND remaining > 0", balance.Id).
|
||||
Updates(map[string]interface{}{
|
||||
"remaining": gorm.Expr("`remaining` - 1"),
|
||||
"total_spent": gorm.Expr("`total_spent` + 1"),
|
||||
}).Error
|
||||
if updateErr != nil {
|
||||
return 0, updateErr
|
||||
}
|
||||
return balance.Remaining - 1, nil
|
||||
}
|
||||
|
||||
// Query 只读,返回用户在活动下的剩余次数。未初始化过 balance 行时返回 0。
|
||||
func (s *chanceService) Query(ctx context.Context, userId, activityId int64) (int64, error) {
|
||||
var balance ChanceBalance
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("user_id = ? AND activity_id = ?", userId, activityId).
|
||||
First(&balance).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if balance.Remaining < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return balance.Remaining, nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newLotteryTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("actual sql %q does not contain %q", actual, expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("create sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("open gorm db: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
func TestChanceService_Grant_ZeroAmountShortCircuits(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
if err := svc.Grant(context.Background(), 1, 100, "manual_grant", "ref-1", 0); err != nil {
|
||||
t.Fatalf("Grant(amount=0) unexpected err: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("no queries expected, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Grant_FirstTimeInsertsGrantAndBalance(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
// INSERT lottery_chance_grant,未冲突返回 1 行
|
||||
mock.ExpectExec("INSERT INTO `lottery_chance_grant`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
// UPSERT lottery_chance_balance
|
||||
mock.ExpectExec("INSERT INTO `lottery_chance_balance`").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil {
|
||||
t.Fatalf("Grant: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Grant_IdempotentOnDuplicateSourceRef(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
// INSERT lottery_chance_grant,UNIQUE 冲突 → 0 行影响
|
||||
mock.ExpectExec("INSERT INTO `lottery_chance_grant`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
// balance 不应被触发
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
if err := svc.Grant(context.Background(), 42, 100, "invite_success", "order-xyz", 3); err != nil {
|
||||
t.Fatalf("Grant: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Consume_LocksAndDecrements(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||
WithArgs(int64(42), int64(100), 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}).
|
||||
AddRow(int64(9), int64(42), int64(100), int64(2), int64(3), int64(1)))
|
||||
mock.ExpectExec("UPDATE `lottery_chance_balance`").
|
||||
WithArgs(sqlmock.AnyArg(), int64(9)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
var remaining int64
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var e error
|
||||
remaining, e = svc.Consume(context.Background(), tx, 42, 100)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Consume: %v", err)
|
||||
}
|
||||
if remaining != 1 {
|
||||
t.Fatalf("expected remaining=1, got %d", remaining)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Consume_NoRowReturnsErrNoChances(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||
WithArgs(int64(42), int64(100), 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
mock.ExpectRollback()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
_, e := svc.Consume(context.Background(), tx, 42, 100)
|
||||
return e
|
||||
})
|
||||
if !errors.Is(err, ErrNoChances) {
|
||||
t.Fatalf("expected ErrNoChances, got %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Consume_ZeroRemainingReturnsErrNoChances(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||
WithArgs(int64(42), int64(100), 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "activity_id", "remaining", "total_earned", "total_spent"}).
|
||||
AddRow(int64(9), int64(42), int64(100), int64(0), int64(3), int64(3)))
|
||||
mock.ExpectRollback()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
_, e := svc.Consume(context.Background(), tx, 42, 100)
|
||||
return e
|
||||
})
|
||||
if !errors.Is(err, ErrNoChances) {
|
||||
t.Fatalf("expected ErrNoChances when remaining=0, got %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Consume_RequiresTx(t *testing.T) {
|
||||
db, _, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewChanceService(db)
|
||||
if _, err := svc.Consume(context.Background(), nil, 1, 1); err == nil {
|
||||
t.Fatalf("expected error when tx is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChanceService_Query_NotFoundReturnsZero(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery("FROM `lottery_chance_balance`").
|
||||
WithArgs(int64(42), int64(100), 1).
|
||||
WillReturnError(gorm.ErrRecordNotFound)
|
||||
|
||||
svc := NewChanceService(db)
|
||||
got, err := svc.Query(context.Background(), 42, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Query: %v", err)
|
||||
}
|
||||
if got != 0 {
|
||||
t.Fatalf("expected 0 when no row, got %d", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// GrantLedger 是发奖账本一行。UNIQUE(external_ref) 是幂等键的载体:
|
||||
// 每次 PrizeHandler.Dispatch 用 DispatchRequest.IdempotencyKey 作 external_ref,
|
||||
// INSERT 冲突即"已发过",直接返回持久化的原结果。
|
||||
type GrantLedger struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
ExternalRef string `gorm:"type:varchar(128);not null;uniqueIndex:uk_external_ref;comment:幂等键"`
|
||||
HandlerType string `gorm:"type:varchar(32);not null;comment:handler 类型"`
|
||||
UserId int64 `gorm:"type:bigint unsigned;not null;comment:发放对象用户 ID"`
|
||||
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||
DrawId int64 `gorm:"type:bigint unsigned;not null;comment:抽奖记录 ID"`
|
||||
Amount int64 `gorm:"type:bigint;not null;default:0;comment:发放数量"`
|
||||
Payload string `gorm:"type:json;comment:发放后的关键结果快照"`
|
||||
GrantedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:发放完成时间"`
|
||||
}
|
||||
|
||||
// TableName 对齐 02157 migration。
|
||||
func (GrantLedger) TableName() string { return "lottery_grant_ledger" }
|
||||
|
||||
// LedgerService 处理发奖账本的幂等 upsert。所有 handler 的第一步都是它。
|
||||
type LedgerService interface {
|
||||
// Reserve 尝试为 external_ref 抢占一行账本。
|
||||
// - 未冲突 → 返回新建行,caller 继续调用下游业务;提交事务时账本一起落。
|
||||
// - 冲突 → 返回已存在的账本行,caller 视为幂等命中直接返回。
|
||||
// 传入 tx 必须是 caller 的事务句柄,保证账本行随抽奖事务一起提交。
|
||||
Reserve(ctx context.Context, tx *gorm.DB, entry GrantLedger) (row *GrantLedger, alreadyExisted bool, err error)
|
||||
}
|
||||
|
||||
type ledgerService struct{}
|
||||
|
||||
// NewLedgerService 返回默认账本服务。
|
||||
func NewLedgerService() LedgerService { return &ledgerService{} }
|
||||
|
||||
// Reserve 用 INSERT ... ON CONFLICT DO NOTHING 抢占 external_ref。
|
||||
// 未命中时再走一次 SELECT 拿到实际持久化的行(不管是新插的还是旧的),
|
||||
// 目的是让 caller 拿到统一的 GrantLedger 结构,方便回写 draw 状态。
|
||||
func (s *ledgerService) Reserve(ctx context.Context, tx *gorm.DB, entry GrantLedger) (*GrantLedger, bool, error) {
|
||||
if tx == nil {
|
||||
return nil, false, errors.New("Reserve requires a transaction handle")
|
||||
}
|
||||
if entry.ExternalRef == "" {
|
||||
return nil, false, errors.New("Reserve requires a non-empty ExternalRef")
|
||||
}
|
||||
// Payload 是 JSON 列,MySQL 拒绝空字符串(error 3140)——
|
||||
// handler 在成功 dispatch 后会 UpdateColumn 覆盖真实 payload;Reserve 阶
|
||||
// 段的空 payload 用 "{}" 兜底,与 PrizeSnapshot.Config、
|
||||
// EligibilitySnapshot.UnmetReasons 的守卫对称。
|
||||
if entry.Payload == "" {
|
||||
entry.Payload = "{}"
|
||||
}
|
||||
|
||||
insertRes := tx.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{DoNothing: true}).
|
||||
Create(&entry)
|
||||
if insertRes.Error != nil {
|
||||
return nil, false, insertRes.Error
|
||||
}
|
||||
alreadyExisted := insertRes.RowsAffected == 0
|
||||
|
||||
// 读回持久化的行,避免依赖 gorm 的 AutoIncrement 回填在冲突分支不确定的行为。
|
||||
var stored GrantLedger
|
||||
if err := tx.WithContext(ctx).
|
||||
Where("external_ref = ?", entry.ExternalRef).
|
||||
First(&stored).Error; err != nil {
|
||||
return nil, alreadyExisted, err
|
||||
}
|
||||
return &stored, alreadyExisted, nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestLedgerService_Reserve_FirstInsertNotExisted(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewLedgerService()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(7, 1))
|
||||
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
||||
WithArgs("lottery:100:200", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
|
||||
AddRow(int64(7), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
|
||||
mock.ExpectCommit()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
||||
ExternalRef: "lottery:100:200",
|
||||
HandlerType: "vpn_duration",
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Amount: 3,
|
||||
})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if existed {
|
||||
t.Fatalf("expected not existed")
|
||||
}
|
||||
if row.Id != 7 {
|
||||
t.Fatalf("expected reloaded id=7, got %d", row.Id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("tx: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerService_Reserve_DuplicateExisted(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewLedgerService()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0)) // conflict, 0 rows affected
|
||||
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
||||
WithArgs("lottery:100:200", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref", "handler_type", "user_id", "activity_id", "draw_id", "amount"}).
|
||||
AddRow(int64(9), "lottery:100:200", "vpn_duration", int64(42), int64(100), int64(200), int64(3)))
|
||||
mock.ExpectCommit()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
row, existed, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
||||
ExternalRef: "lottery:100:200",
|
||||
HandlerType: "vpn_duration",
|
||||
})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !existed {
|
||||
t.Fatalf("expected existed=true when INSERT returns 0 rows affected")
|
||||
}
|
||||
if row.Id != 9 {
|
||||
t.Fatalf("expected stored id=9, got %d", row.Id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("tx: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerService_Reserve_RequiresTx(t *testing.T) {
|
||||
svc := NewLedgerService()
|
||||
if _, _, err := svc.Reserve(context.Background(), nil, GrantLedger{ExternalRef: "x"}); err == nil {
|
||||
t.Fatalf("expected error when tx is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerService_Reserve_RequiresExternalRef(t *testing.T) {
|
||||
db, _, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
svc := NewLedgerService()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
_, _, e := svc.Reserve(context.Background(), tx, GrantLedger{})
|
||||
return e
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on empty ExternalRef")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserve_EmptyPayloadDefaultsToEmptyJSONObject is the F6 regression guard.
|
||||
//
|
||||
// Before PR F, Reserve created lottery_grant_ledger rows with the caller's
|
||||
// empty entry.Payload written verbatim ("") into the `payload` JSON column —
|
||||
// MySQL error 3140 rejects empty strings on JSON columns, so every real
|
||||
// draw's ledger INSERT died. sqlmock does no JSON validation so the earlier
|
||||
// tests were silent about it.
|
||||
//
|
||||
// Guard the exact Go-layer value we send by asserting the INSERT arg for
|
||||
// `payload` is "{}" (never ""). This is the same pattern as PR E's
|
||||
// EligibilitySnapshot.UnmetReasons guard.
|
||||
func TestReserve_EmptyPayloadDefaultsToEmptyJSONObject(t *testing.T) {
|
||||
db, mock, cleanup := newLotteryTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// GORM omits granted_at from the INSERT column list because it has
|
||||
// `<-:create;default:CURRENT_TIMESTAMP` — 7 args, not 8. Column order:
|
||||
// external_ref, handler_type, user_id, activity_id, draw_id, amount, payload.
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO `lottery_grant_ledger`").
|
||||
WithArgs(
|
||||
sqlmock.AnyArg(), // external_ref
|
||||
sqlmock.AnyArg(), // handler_type
|
||||
sqlmock.AnyArg(), // user_id
|
||||
sqlmock.AnyArg(), // activity_id
|
||||
sqlmock.AnyArg(), // draw_id
|
||||
sqlmock.AnyArg(), // amount
|
||||
payloadNotEmptyString{t}, // MUST be "{}", never ""
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery("FROM `lottery_grant_ledger`").
|
||||
WithArgs("lottery:100:200", 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "external_ref"}).AddRow(int64(1), "lottery:100:200"))
|
||||
mock.ExpectCommit()
|
||||
|
||||
svc := NewLedgerService()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Intentionally leave Payload empty — the guard must default it.
|
||||
_, _, e := svc.Reserve(context.Background(), tx, GrantLedger{
|
||||
ExternalRef: "lottery:100:200",
|
||||
HandlerType: "vpn_duration",
|
||||
UserId: 42,
|
||||
ActivityId: 100,
|
||||
DrawId: 200,
|
||||
Amount: 3,
|
||||
})
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Reserve: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// payloadNotEmptyString is a per-arg matcher: the value MUST be a non-empty
|
||||
// string; specifically "{}" per the PR F guard. Empty string is the exact F6
|
||||
// regression symptom (MySQL error 3140).
|
||||
type payloadNotEmptyString struct{ t *testing.T }
|
||||
|
||||
func (m payloadNotEmptyString) Match(v driver.Value) bool {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
m.t.Fatalf("F6 guard: expected string for Payload, got %T (%v)", v, v)
|
||||
}
|
||||
if s == "" {
|
||||
m.t.Fatalf("F6 regression: Payload must not be empty string (MySQL error 3140)")
|
||||
}
|
||||
if s != "{}" {
|
||||
m.t.Fatalf("F6 guard: expected Payload==%q, got %q", "{}", s)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---- 状态与类型常量 ---------------------------------------------------------
|
||||
|
||||
const (
|
||||
ActivityStatusDraft = "draft"
|
||||
ActivityStatusRunning = "running"
|
||||
ActivityStatusPaused = "paused"
|
||||
ActivityStatusEnded = "ended"
|
||||
|
||||
UnmetActionBlock = "block"
|
||||
UnmetActionShowReason = "show_reason"
|
||||
|
||||
// PrizeType* 是发奖 handler 注册表的键。
|
||||
// Stage 1 已实装:vpn_duration / commission / none。
|
||||
// Stage 2 新增人工奖:crypto / physical / manual_other。
|
||||
PrizeTypeVPNDuration = "vpn_duration"
|
||||
PrizeTypeCommission = "commission"
|
||||
PrizeTypeNone = "none"
|
||||
// Stage 2 人工奖类型(HIF-4)。
|
||||
PrizeTypeCrypto = "crypto"
|
||||
PrizeTypePhysical = "physical"
|
||||
PrizeTypeManualOther = "manual_other"
|
||||
// Stage 3 预留。
|
||||
PrizeTypeBalance = "balance"
|
||||
PrizeTypeGiftAmount = "gift_amount"
|
||||
PrizeTypeCoupon = "coupon"
|
||||
PrizeTypePoints = "points"
|
||||
|
||||
// ChanceSource* 是次数入账触发源。
|
||||
ChanceSourceDailySignin = "daily_signin"
|
||||
ChanceSourceNewSubscription = "new_subscription"
|
||||
ChanceSourceInviteSuccess = "invite_success"
|
||||
ChanceSourceManualGrant = "manual_grant"
|
||||
|
||||
// DispatchState* 是 lottery_draw.dispatch_state 的取值。
|
||||
DispatchStateNone = "none"
|
||||
DispatchStateAutoClaimed = "auto_claimed"
|
||||
DispatchStatePendingClaim = "pending_claim"
|
||||
DispatchStatePaid = "paid"
|
||||
DispatchStateExpired = "expired"
|
||||
DispatchStateFailed = "failed"
|
||||
|
||||
// ClaimStatus* 是 lottery_claim.status 的取值(Stage 2)。
|
||||
// pending_claim: 已入库,等用户填领奖信息。
|
||||
// reviewing: 用户已提交,等运营审核。
|
||||
// paying: 运营 approve,等运营线下打款/发货 + mark-paid。
|
||||
// paid: 运营已录入 tx_hash / delivery_ref。终态。
|
||||
// rejected: 运营 reject(可为 reviewing → rejected 或 paying → rejected);用户可再次提交。
|
||||
// expired: pending_claim 超时未提交(业务规则:过期不补次数)。终态。
|
||||
ClaimStatusPendingClaim = "pending_claim"
|
||||
ClaimStatusReviewing = "reviewing"
|
||||
ClaimStatusPaying = "paying"
|
||||
ClaimStatusPaid = "paid"
|
||||
ClaimStatusRejected = "rejected"
|
||||
ClaimStatusExpired = "expired"
|
||||
|
||||
// DefaultClaimTTLHours 是 Stage 2 spec 里"默认 7 天"的实际编码:
|
||||
// 每个奖品可通过 config.claim_ttl_hours 覆盖单个奖品的过期窗口。
|
||||
DefaultClaimTTLHours = 24 * 7
|
||||
)
|
||||
|
||||
// ---- 实体 -----------------------------------------------------------------
|
||||
|
||||
type Activity struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
Title string `gorm:"type:varchar(128);not null;default:'';comment:活动标题"`
|
||||
Description string `gorm:"type:text;comment:活动描述"`
|
||||
StartAt time.Time `gorm:"not null;comment:开始时间"`
|
||||
EndAt time.Time `gorm:"not null;comment:结束时间"`
|
||||
Status string `gorm:"type:varchar(16);not null;default:'draft';comment:状态"`
|
||||
GridSize int `gorm:"type:tinyint;not null;default:9;comment:九宫格数量"`
|
||||
Eligibility string `gorm:"type:json;not null;comment:参与门槛(AND/OR 嵌套规则)"`
|
||||
ChanceSources string `gorm:"type:json;not null;comment:次数来源列表"`
|
||||
UnmetAction string `gorm:"type:varchar(32);not null;default:'block';comment:未达门槛策略"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index;comment:Delete Time"`
|
||||
}
|
||||
|
||||
func (Activity) TableName() string { return "lottery_activity" }
|
||||
|
||||
type Prize struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:所属活动"`
|
||||
Slot int `gorm:"type:tinyint;not null;comment:九宫格位置"`
|
||||
Type string `gorm:"type:varchar(32);not null;comment:奖品类型"`
|
||||
Name string `gorm:"type:varchar(128);not null;default:'';comment:名称"`
|
||||
IconURL string `gorm:"type:varchar(512);not null;default:'';comment:图标 URL"`
|
||||
Config string `gorm:"type:json;not null;comment:类型专属配置"`
|
||||
Weight int `gorm:"type:int;not null;default:0;comment:加权随机权重"`
|
||||
TotalStock sql.NullInt64 `gorm:"type:bigint;comment:总库存(NULL=无限)"`
|
||||
RemainingStock sql.NullInt64 `gorm:"type:bigint;comment:剩余库存(NULL=无限)"`
|
||||
IsFallback bool `gorm:"type:tinyint(1);not null;default:0;comment:是否为保底奖"`
|
||||
Version int64 `gorm:"type:bigint;not null;default:0;comment:乐观锁"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||
}
|
||||
|
||||
func (Prize) TableName() string { return "lottery_prize" }
|
||||
|
||||
type ChanceBalance struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
|
||||
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||
Remaining int64 `gorm:"type:bigint;not null;default:0;comment:剩余次数"`
|
||||
TotalEarned int64 `gorm:"type:bigint;not null;default:0;comment:累计入账"`
|
||||
TotalSpent int64 `gorm:"type:bigint;not null;default:0;comment:累计消耗"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||
}
|
||||
|
||||
func (ChanceBalance) TableName() string { return "lottery_chance_balance" }
|
||||
|
||||
type ChanceGrant struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
|
||||
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||
Source string `gorm:"type:varchar(32);not null;comment:触发源"`
|
||||
SourceRef string `gorm:"type:varchar(128);not null;comment:外部业务幂等键"`
|
||||
Amount int `gorm:"type:int;not null;default:0;comment:本次发放次数"`
|
||||
ExpiresAt *time.Time `gorm:"default:null;comment:到期时间"`
|
||||
GrantedAt time.Time `gorm:"<-:create;default:CURRENT_TIMESTAMP;comment:入账时间"`
|
||||
}
|
||||
|
||||
func (ChanceGrant) TableName() string { return "lottery_chance_grant" }
|
||||
|
||||
type Draw struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
|
||||
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||
ClientNonce string `gorm:"type:varchar(64);not null;comment:前端幂等键"`
|
||||
PrizeId sql.NullInt64 `gorm:"type:bigint unsigned;comment:中奖奖品 ID(谢谢参与=NULL)"`
|
||||
IsWin bool `gorm:"type:tinyint(1);not null;default:0;comment:是否中奖"`
|
||||
DispatchState string `gorm:"type:varchar(16);not null;default:'none';comment:发放状态"`
|
||||
DispatchError string `gorm:"type:text;comment:发放失败原因"`
|
||||
DispatchedAt *time.Time `gorm:"default:null;comment:发放完成时间"`
|
||||
DrawnAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP;comment:抽奖时间"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||
}
|
||||
|
||||
func (Draw) TableName() string { return "lottery_draw" }
|
||||
|
||||
type PrizeSnapshot struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"`
|
||||
PrizeId int64 `gorm:"type:bigint unsigned;not null;comment:奖品 ID"`
|
||||
Slot int `gorm:"type:tinyint;not null;comment:九宫格位置"`
|
||||
Type string `gorm:"type:varchar(32);not null;comment:奖品类型"`
|
||||
Name string `gorm:"type:varchar(128);not null;comment:奖品名称"`
|
||||
Config string `gorm:"type:json;not null;comment:类型专属配置"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
}
|
||||
|
||||
func (PrizeSnapshot) TableName() string { return "lottery_prize_snapshot" }
|
||||
|
||||
type EligibilitySnapshot struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"`
|
||||
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
|
||||
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||
Passed bool `gorm:"type:tinyint(1);not null;default:0;comment:是否通过门槛"`
|
||||
UnmetReasons string `gorm:"type:json;comment:未通过项"`
|
||||
EvaluatedAt time.Time `gorm:"not null;default:CURRENT_TIMESTAMP;comment:评估时间"`
|
||||
}
|
||||
|
||||
func (EligibilitySnapshot) TableName() string { return "lottery_eligibility_snapshot" }
|
||||
|
||||
// Claim 是 Stage 2 的人工奖领奖工单。lottery_draw ↔ lottery_claim 一对一
|
||||
// (由 lottery_claim.draw_id UNIQUE 保证)。
|
||||
//
|
||||
// 生命周期:抽奖事务命中人工类奖品 → 同事务插入一行 status=pending_claim;
|
||||
// 用户 POST /claim → 转 reviewing;运营 approve → paying → mark-paid → paid。
|
||||
// 详细状态机见 02159_lottery_claim.up.sql 的注释。
|
||||
type Claim struct {
|
||||
Id int64 `gorm:"primaryKey"`
|
||||
DrawId int64 `gorm:"type:bigint unsigned;not null;uniqueIndex:uk_draw_id;comment:抽奖记录 ID"`
|
||||
UserId int64 `gorm:"type:bigint unsigned;not null;comment:用户 ID"`
|
||||
ActivityId int64 `gorm:"type:bigint unsigned;not null;comment:活动 ID"`
|
||||
PrizeType string `gorm:"type:varchar(32);not null;comment:奖品类型(冗余便于后台过滤)"`
|
||||
ClaimData string `gorm:"type:json;comment:用户提交的领奖表单(结构随 prize_type 变化)"`
|
||||
Status string `gorm:"type:varchar(32);not null;default:'pending_claim';comment:状态"`
|
||||
SubmittedAt *time.Time `gorm:"default:null;comment:用户提交领奖信息时间"`
|
||||
ExpiresAt time.Time `gorm:"not null;comment:领奖窗口截止时间"`
|
||||
ReviewedBy int64 `gorm:"type:bigint unsigned;default:0;comment:最近一次审核操作者"`
|
||||
ReviewedAt *time.Time `gorm:"default:null;comment:最近一次审核时间"`
|
||||
RejectReason string `gorm:"type:varchar(512);not null;default:'';comment:拒绝原因"`
|
||||
TxHash string `gorm:"type:varchar(128);not null;default:'';comment:链上交易哈希"`
|
||||
DeliveryRef string `gorm:"type:varchar(128);not null;default:'';comment:快递单号 / 发货单据编号"`
|
||||
PaidAt *time.Time `gorm:"default:null;comment:运营标记打款/发货完成时间"`
|
||||
CreatedAt time.Time `gorm:"<-:create;comment:Create Time"`
|
||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||
}
|
||||
|
||||
// TableName 对齐 02159 migration。
|
||||
func (Claim) TableName() string { return "lottery_claim" }
|
||||
|
||||
// IsClaimStatusResubmittable 判断当前 claim 状态是否允许用户再次提交领奖数据。
|
||||
// pending_claim(还没提交过)与 rejected(被运营拒绝后允许重填)算入。
|
||||
// 其他状态(reviewing / paying / paid / expired)都禁止再提交。
|
||||
func IsClaimStatusResubmittable(status string) bool {
|
||||
return status == ClaimStatusPendingClaim || status == ClaimStatusRejected
|
||||
}
|
||||
|
||||
// IsPrizeTypeManualClaim 判断某奖品类型是否属于"人工领奖"类别,
|
||||
// 即 draw 时需要挂 lottery_claim 而不是自动发放。
|
||||
func IsPrizeTypeManualClaim(prizeType string) bool {
|
||||
switch prizeType {
|
||||
case PrizeTypeCrypto, PrizeTypePhysical, PrizeTypeManualOther:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- JSON 结构体辅助 -------------------------------------------------------
|
||||
|
||||
// EligibilityRule 是持久化在 lottery_activity.eligibility 字段里的门槛规则树。
|
||||
// 每个节点要么是"叶子"(Type 非空),要么是"聚合"(Op 为 AND/OR + Children)。
|
||||
type EligibilityRule struct {
|
||||
Op string `json:"op,omitempty"`
|
||||
Children []*EligibilityRule `json:"children,omitempty"`
|
||||
|
||||
Type string `json:"type,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// ChanceSource 描述次数来源的一条配置,持久化在 lottery_activity.chance_sources。
|
||||
type ChanceSource struct {
|
||||
Source string `json:"source"`
|
||||
Amount int `json:"amount"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
DailyLimit int `json:"daily_limit,omitempty"`
|
||||
Unlimited bool `json:"unlimited,omitempty"`
|
||||
}
|
||||
|
||||
// UnmetReason 是门槛评估结果,作为 GET /config 的 unmet_reasons 元素返回。
|
||||
type UnmetReason struct {
|
||||
Rule string `json:"rule"`
|
||||
Hint string `json:"hint"`
|
||||
Current int64 `json:"current,omitempty"`
|
||||
Required int64 `json:"required,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// defaultRegistry 是并发安全的默认 Registry。启动时一次性注册,运行时只读。
|
||||
type defaultRegistry struct {
|
||||
mu sync.RWMutex
|
||||
handlers map[string]PrizeHandler
|
||||
}
|
||||
|
||||
// NewRegistry 返回空的默认注册表。使用 Register 挂接实现。
|
||||
// 实际业务 handler(vpn_duration / commission)在 internal/logic/lottery/handler 里,
|
||||
// 由 initialize 阶段注入(避免 model 层反向依赖 logic 层)。
|
||||
func NewRegistry() *defaultRegistry {
|
||||
return &defaultRegistry{handlers: make(map[string]PrizeHandler, 8)}
|
||||
}
|
||||
|
||||
// Register 挂接一个 handler;重复注册会覆盖(初始化阶段可控,不额外拦截)。
|
||||
func (r *defaultRegistry) Register(h PrizeHandler) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.handlers[h.Type()] = h
|
||||
}
|
||||
|
||||
func (r *defaultRegistry) Get(prizeType string) (PrizeHandler, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
h, ok := r.handlers[prizeType]
|
||||
return h, ok
|
||||
}
|
||||
|
||||
func (r *defaultRegistry) MustGet(prizeType string) (PrizeHandler, error) {
|
||||
if h, ok := r.Get(prizeType); ok {
|
||||
return h, nil
|
||||
}
|
||||
return nil, ErrHandlerNotRegistered
|
||||
}
|
||||
|
||||
// noopHandler 是"谢谢参与",永远归 model 层:不依赖任何业务,最小占位。
|
||||
type noopHandler struct{}
|
||||
|
||||
// NewNoopHandler 返回 PrizeTypeNone 的 handler。
|
||||
func NewNoopHandler() PrizeHandler { return &noopHandler{} }
|
||||
|
||||
func (noopHandler) Type() string { return PrizeTypeNone }
|
||||
func (noopHandler) IsAuto() bool { return true }
|
||||
func (noopHandler) Dispatch(_ context.Context, _ *gorm.DB, _ DispatchRequest) (DispatchResult, error) {
|
||||
return DispatchResult{State: DispatchStateAutoClaimed, Message: "谢谢参与"}, nil
|
||||
}
|
||||
func (noopHandler) ValidateClaim(_ []byte) error { return nil }
|
||||
func (noopHandler) ClaimSchema() json.RawMessage { return nil }
|
||||
@@ -0,0 +1,46 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestRegistry_GetMissing(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
if _, ok := r.Get("nope"); ok {
|
||||
t.Fatalf("empty registry should not return handler")
|
||||
}
|
||||
if _, err := r.MustGet("nope"); !errors.Is(err, ErrHandlerNotRegistered) {
|
||||
t.Fatalf("MustGet on missing type should return ErrHandlerNotRegistered, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_RegisterAndGet(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register(NewNoopHandler())
|
||||
|
||||
h, err := r.MustGet(PrizeTypeNone)
|
||||
if err != nil {
|
||||
t.Fatalf("MustGet(%q): %v", PrizeTypeNone, err)
|
||||
}
|
||||
if h.Type() != PrizeTypeNone {
|
||||
t.Fatalf("Type mismatch: want %q got %q", PrizeTypeNone, h.Type())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoopHandler_AlwaysAutoClaimed(t *testing.T) {
|
||||
h := NewNoopHandler()
|
||||
if !h.IsAuto() {
|
||||
t.Fatal("noop must be auto")
|
||||
}
|
||||
res, err := h.Dispatch(context.Background(), (*gorm.DB)(nil), DispatchRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("noop dispatch err: %v", err)
|
||||
}
|
||||
if res.State != DispatchStateAutoClaimed {
|
||||
t.Fatalf("noop should dispatch as auto_claimed, got %q", res.State)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 门槛规则类型常量。前端展示时可依据这些 key 组装本地化提示。
|
||||
const (
|
||||
RuleTypeHasSubscription = "has_subscription" // 需要有活跃订阅(可带 min_days_remaining)
|
||||
RuleTypeSubscriptionType = "subscription_type" // 订阅套餐必须在 plan_ids 中
|
||||
RuleTypeInviteCount = "invite_count" // 邀请人数 ≥ min(可带 window_days,由 ContextBuilder 预算)
|
||||
RuleTypeTotalRecharge = "total_recharge" // 累计充值 ≥ min_usdt
|
||||
RuleTypeRegisterDays = "register_days" // 注册天数 ≥ min
|
||||
RuleTypeUserTag = "user_tag" // 用户标签命中 tags[] 中任一
|
||||
)
|
||||
|
||||
const (
|
||||
OpAND = "AND"
|
||||
OpOR = "OR"
|
||||
)
|
||||
|
||||
// defaultEvaluator 是 RuleEvaluator 的开箱实现。
|
||||
type defaultEvaluator struct{}
|
||||
|
||||
// NewRuleEvaluator 返回默认门槛评估器。
|
||||
func NewRuleEvaluator() RuleEvaluator { return &defaultEvaluator{} }
|
||||
|
||||
func (e *defaultEvaluator) Evaluate(_ context.Context, tree *EligibilityRule, rc RuleContext) (bool, []UnmetReason, error) {
|
||||
if tree == nil {
|
||||
return true, nil, nil
|
||||
}
|
||||
unmet := make([]UnmetReason, 0, 4)
|
||||
passed, err := e.evalNode(tree, rc, &unmet)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
return passed, unmet, nil
|
||||
}
|
||||
|
||||
func (e *defaultEvaluator) evalNode(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||
if node == nil {
|
||||
return true, nil
|
||||
}
|
||||
// 聚合节点
|
||||
if node.Op != "" {
|
||||
return e.evalGroup(node, rc, unmet)
|
||||
}
|
||||
// 叶子节点
|
||||
if node.Type == "" {
|
||||
return true, nil
|
||||
}
|
||||
return e.evalLeaf(node, rc, unmet)
|
||||
}
|
||||
|
||||
func (e *defaultEvaluator) evalGroup(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||
op := strings.ToUpper(node.Op)
|
||||
if len(node.Children) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
switch op {
|
||||
case OpAND:
|
||||
allPassed := true
|
||||
for _, child := range node.Children {
|
||||
ok, err := e.evalNode(child, rc, unmet)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !ok {
|
||||
allPassed = false
|
||||
}
|
||||
}
|
||||
return allPassed, nil
|
||||
case OpOR:
|
||||
// OR 只收集内部未通过项到一个临时篮子;若整体通过则不冒泡出去。
|
||||
anyPassed := false
|
||||
local := make([]UnmetReason, 0, len(node.Children))
|
||||
for _, child := range node.Children {
|
||||
ok, err := e.evalNode(child, rc, &local)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
anyPassed = true
|
||||
}
|
||||
}
|
||||
if !anyPassed {
|
||||
*unmet = append(*unmet, local...)
|
||||
}
|
||||
return anyPassed, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown group op %q", node.Op)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *defaultEvaluator) evalLeaf(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||
switch node.Type {
|
||||
case RuleTypeHasSubscription:
|
||||
return e.evalHasSubscription(node, rc, unmet)
|
||||
case RuleTypeSubscriptionType:
|
||||
return e.evalSubscriptionType(node, rc, unmet)
|
||||
case RuleTypeInviteCount:
|
||||
return e.evalInviteCount(node, rc, unmet)
|
||||
case RuleTypeTotalRecharge:
|
||||
return e.evalTotalRecharge(node, rc, unmet)
|
||||
case RuleTypeRegisterDays:
|
||||
return e.evalRegisterDays(node, rc, unmet)
|
||||
case RuleTypeUserTag:
|
||||
return e.evalUserTag(node, rc, unmet)
|
||||
default:
|
||||
return false, fmt.Errorf("unknown leaf rule type %q", node.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 单条规则 -------------------------------------------------------------
|
||||
|
||||
func (e *defaultEvaluator) evalHasSubscription(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||
var params struct {
|
||||
MinDaysRemaining int64 `json:"min_days_remaining"`
|
||||
}
|
||||
if len(node.Params) > 0 {
|
||||
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||
return false, fmt.Errorf("has_subscription params: %w", err)
|
||||
}
|
||||
}
|
||||
if !rc.HasActiveSubscription {
|
||||
*unmet = append(*unmet, UnmetReason{
|
||||
Rule: RuleTypeHasSubscription,
|
||||
Hint: "需要有活跃订阅",
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
if params.MinDaysRemaining > 0 {
|
||||
got := rc.SubscriptionExpiresIn / 86400
|
||||
if got < params.MinDaysRemaining {
|
||||
*unmet = append(*unmet, UnmetReason{
|
||||
Rule: RuleTypeHasSubscription,
|
||||
Hint: fmt.Sprintf("订阅剩余天数不足,还差 %d 天", params.MinDaysRemaining-got),
|
||||
Current: got,
|
||||
Required: params.MinDaysRemaining,
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (e *defaultEvaluator) evalSubscriptionType(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||
var params struct {
|
||||
PlanIds []int64 `json:"plan_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||
return false, fmt.Errorf("subscription_type params: %w", err)
|
||||
}
|
||||
want := make(map[int64]struct{}, len(params.PlanIds))
|
||||
for _, id := range params.PlanIds {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
for _, id := range rc.SubscriptionPlanIds {
|
||||
if _, ok := want[id]; ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
*unmet = append(*unmet, UnmetReason{
|
||||
Rule: RuleTypeSubscriptionType,
|
||||
Hint: "订阅类型不符合活动要求",
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (e *defaultEvaluator) evalInviteCount(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||
var params struct {
|
||||
Min int64 `json:"min"`
|
||||
WindowDays int64 `json:"window_days,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||
return false, fmt.Errorf("invite_count params: %w", err)
|
||||
}
|
||||
if rc.InviteCount >= params.Min {
|
||||
return true, nil
|
||||
}
|
||||
*unmet = append(*unmet, UnmetReason{
|
||||
Rule: RuleTypeInviteCount,
|
||||
Hint: fmt.Sprintf("还需邀请 %d 人(%d/%d)", params.Min-rc.InviteCount, rc.InviteCount, params.Min),
|
||||
Current: rc.InviteCount,
|
||||
Required: params.Min,
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (e *defaultEvaluator) evalTotalRecharge(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||
var params struct {
|
||||
MinUSDT int64 `json:"min_usdt"`
|
||||
}
|
||||
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||
return false, fmt.Errorf("total_recharge params: %w", err)
|
||||
}
|
||||
if rc.TotalRechargeUSDT >= params.MinUSDT {
|
||||
return true, nil
|
||||
}
|
||||
*unmet = append(*unmet, UnmetReason{
|
||||
Rule: RuleTypeTotalRecharge,
|
||||
Hint: fmt.Sprintf("累计充值不足,还差 %d USDT", params.MinUSDT-rc.TotalRechargeUSDT),
|
||||
Current: rc.TotalRechargeUSDT,
|
||||
Required: params.MinUSDT,
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (e *defaultEvaluator) evalRegisterDays(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||
var params struct {
|
||||
Min int64 `json:"min"`
|
||||
}
|
||||
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||
return false, fmt.Errorf("register_days params: %w", err)
|
||||
}
|
||||
if rc.RegisterDays >= params.Min {
|
||||
return true, nil
|
||||
}
|
||||
*unmet = append(*unmet, UnmetReason{
|
||||
Rule: RuleTypeRegisterDays,
|
||||
Hint: fmt.Sprintf("注册天数不足,还差 %d 天", params.Min-rc.RegisterDays),
|
||||
Current: rc.RegisterDays,
|
||||
Required: params.Min,
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (e *defaultEvaluator) evalUserTag(node *EligibilityRule, rc RuleContext, unmet *[]UnmetReason) (bool, error) {
|
||||
var params struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := json.Unmarshal(node.Params, ¶ms); err != nil {
|
||||
return false, fmt.Errorf("user_tag params: %w", err)
|
||||
}
|
||||
want := make(map[string]struct{}, len(params.Tags))
|
||||
for _, t := range params.Tags {
|
||||
want[t] = struct{}{}
|
||||
}
|
||||
for _, t := range rc.UserTags {
|
||||
if _, ok := want[t]; ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
*unmet = append(*unmet, UnmetReason{
|
||||
Rule: RuleTypeUserTag,
|
||||
Hint: "用户标签不符合活动要求",
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustParams(t *testing.T, v any) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal params: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestEvaluator_HasSubscription(t *testing.T) {
|
||||
ev := NewRuleEvaluator()
|
||||
tree := &EligibilityRule{
|
||||
Type: RuleTypeHasSubscription,
|
||||
Params: mustParams(t, map[string]int64{"min_days_remaining": 7}),
|
||||
}
|
||||
|
||||
// 没订阅
|
||||
ok, unmet, err := ev.Evaluate(context.Background(), tree, RuleContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("eval err: %v", err)
|
||||
}
|
||||
if ok || len(unmet) != 1 || unmet[0].Rule != RuleTypeHasSubscription {
|
||||
t.Fatalf("expected fail on no sub, got ok=%v unmet=%+v", ok, unmet)
|
||||
}
|
||||
|
||||
// 有订阅但剩余不足
|
||||
ok, unmet, err = ev.Evaluate(context.Background(), tree, RuleContext{
|
||||
HasActiveSubscription: true,
|
||||
SubscriptionExpiresIn: 3 * 86400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("eval err: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("expected fail on 3 days < 7 required, got pass")
|
||||
}
|
||||
if len(unmet) != 1 || unmet[0].Required != 7 || unmet[0].Current != 3 {
|
||||
t.Fatalf("unmet mismatch: %+v", unmet)
|
||||
}
|
||||
|
||||
// 有订阅剩余足够
|
||||
ok, _, err = ev.Evaluate(context.Background(), tree, RuleContext{
|
||||
HasActiveSubscription: true,
|
||||
SubscriptionExpiresIn: 30 * 86400,
|
||||
})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("expected pass on 30 days, got ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_InviteCount(t *testing.T) {
|
||||
ev := NewRuleEvaluator()
|
||||
tree := &EligibilityRule{
|
||||
Type: RuleTypeInviteCount,
|
||||
Params: mustParams(t, map[string]int64{"min": 3}),
|
||||
}
|
||||
ok, unmet, err := ev.Evaluate(context.Background(), tree, RuleContext{InviteCount: 1})
|
||||
if err != nil || ok {
|
||||
t.Fatalf("expected fail, got ok=%v err=%v", ok, err)
|
||||
}
|
||||
if unmet[0].Current != 1 || unmet[0].Required != 3 {
|
||||
t.Fatalf("expected current=1 required=3, got %+v", unmet[0])
|
||||
}
|
||||
|
||||
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{InviteCount: 3})
|
||||
if !ok {
|
||||
t.Fatalf("expected pass with InviteCount=3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ANDShort(t *testing.T) {
|
||||
ev := NewRuleEvaluator()
|
||||
tree := &EligibilityRule{
|
||||
Op: OpAND,
|
||||
Children: []*EligibilityRule{
|
||||
{Type: RuleTypeHasSubscription},
|
||||
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 5})},
|
||||
},
|
||||
}
|
||||
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
||||
HasActiveSubscription: true,
|
||||
InviteCount: 1,
|
||||
})
|
||||
if ok {
|
||||
t.Fatalf("expected AND to fail")
|
||||
}
|
||||
// AND 需要收集全部未通过项(这里只有 1 条)
|
||||
if len(unmet) != 1 || unmet[0].Rule != RuleTypeInviteCount {
|
||||
t.Fatalf("expected 1 unmet (invite_count), got %+v", unmet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ORPassIgnoresChildUnmet(t *testing.T) {
|
||||
ev := NewRuleEvaluator()
|
||||
tree := &EligibilityRule{
|
||||
Op: OpOR,
|
||||
Children: []*EligibilityRule{
|
||||
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 100})},
|
||||
{Type: RuleTypeHasSubscription}, // 会通过
|
||||
},
|
||||
}
|
||||
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
||||
HasActiveSubscription: true,
|
||||
InviteCount: 0,
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("expected OR to pass because one child passes")
|
||||
}
|
||||
if len(unmet) != 0 {
|
||||
t.Fatalf("OR pass should hide child failures, got %+v", unmet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_ORFailBubblesAllChildren(t *testing.T) {
|
||||
ev := NewRuleEvaluator()
|
||||
tree := &EligibilityRule{
|
||||
Op: OpOR,
|
||||
Children: []*EligibilityRule{
|
||||
{Type: RuleTypeInviteCount, Params: mustParams(t, map[string]int64{"min": 5})},
|
||||
{Type: RuleTypeHasSubscription},
|
||||
},
|
||||
}
|
||||
ok, unmet, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
||||
InviteCount: 1,
|
||||
})
|
||||
if ok {
|
||||
t.Fatalf("expected OR to fail")
|
||||
}
|
||||
if len(unmet) != 2 {
|
||||
t.Fatalf("expected 2 unmet reasons on OR fail, got %+v", unmet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_SubscriptionType(t *testing.T) {
|
||||
ev := NewRuleEvaluator()
|
||||
tree := &EligibilityRule{
|
||||
Type: RuleTypeSubscriptionType,
|
||||
Params: mustParams(t, map[string][]int64{"plan_ids": {10, 20}}),
|
||||
}
|
||||
ok, _, _ := ev.Evaluate(context.Background(), tree, RuleContext{
|
||||
SubscriptionPlanIds: []int64{20},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("expected pass when user plan matches")
|
||||
}
|
||||
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{
|
||||
SubscriptionPlanIds: []int64{99},
|
||||
})
|
||||
if ok {
|
||||
t.Fatalf("expected fail when user plan not in whitelist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_UserTag(t *testing.T) {
|
||||
ev := NewRuleEvaluator()
|
||||
tree := &EligibilityRule{
|
||||
Type: RuleTypeUserTag,
|
||||
Params: mustParams(t, map[string][]string{"tags": {"vip", "beta"}}),
|
||||
}
|
||||
ok, _, _ := ev.Evaluate(context.Background(), tree, RuleContext{UserTags: []string{"beta"}})
|
||||
if !ok {
|
||||
t.Fatalf("expected pass when any tag matches")
|
||||
}
|
||||
ok, _, _ = ev.Evaluate(context.Background(), tree, RuleContext{UserTags: []string{"foo"}})
|
||||
if ok {
|
||||
t.Fatalf("expected fail when no tags match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_NilTreeAlwaysPasses(t *testing.T) {
|
||||
ev := NewRuleEvaluator()
|
||||
ok, unmet, err := ev.Evaluate(context.Background(), nil, RuleContext{})
|
||||
if err != nil || !ok || len(unmet) != 0 {
|
||||
t.Fatalf("nil tree should always pass; got ok=%v unmet=%+v err=%v", ok, unmet, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluator_UnknownRuleType(t *testing.T) {
|
||||
ev := NewRuleEvaluator()
|
||||
tree := &EligibilityRule{Type: "no_such_rule"}
|
||||
_, _, err := ev.Evaluate(context.Background(), tree, RuleContext{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on unknown rule type")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package lottery 提供抽奖 Stage 1 后端核心闭环:门槛规则引擎、次数入账、
|
||||
// 加权随机选奖、发奖 handler 抽象。
|
||||
//
|
||||
// Stage 1 只保证接口稳定 + 骨架编译通过;具体业务对接(订阅时长发放、佣金入账、
|
||||
// 邀请钩子)留到架构师 review 骨架 PR 之后再补。
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---- 门槛规则引擎 ----------------------------------------------------------
|
||||
|
||||
// RuleContext 是评估门槛规则时可见的用户上下文。构造时应做一次批量
|
||||
// 读,避免每条子规则各自查库。
|
||||
type RuleContext struct {
|
||||
UserId int64
|
||||
Now int64 // Unix seconds
|
||||
|
||||
// 下列字段由 RuleContextBuilder 填充。规则实现只读,不写。
|
||||
HasActiveSubscription bool
|
||||
SubscriptionExpiresIn int64 // seconds until expiry; 0 if no active sub
|
||||
SubscriptionPlanIds []int64
|
||||
InviteCount int64 // 若规则限定 window_days,则调用方需自行按窗口预计算
|
||||
TotalRechargeUSDT int64 // 已充值总额,单位与业务侧一致
|
||||
RegisterDays int64
|
||||
UserTags []string
|
||||
}
|
||||
|
||||
// RuleEvaluator 评估一棵门槛规则树,返回是否通过 + 未通过项。
|
||||
// 实现是纯计算,不做任何 DB 写。
|
||||
type RuleEvaluator interface {
|
||||
Evaluate(ctx context.Context, tree *EligibilityRule, rc RuleContext) (passed bool, unmet []UnmetReason, err error)
|
||||
}
|
||||
|
||||
// ---- 加权随机选奖 ----------------------------------------------------------
|
||||
|
||||
// WeightedPicker 从奖池中按 weight 加权抽一次。实现应使用注入的 rand 源,
|
||||
// 便于测试;weight 为 0 的奖品视作不参与随机(可用于挂出但不发放)。
|
||||
type WeightedPicker interface {
|
||||
// Pick 从 candidates 中返回一个索引 i;若累计权重为 0 返回 ErrEmptyPool。
|
||||
// 调用方在事务外先做快照,事务内再对返回的 candidates[i] 做库存乐观扣减。
|
||||
Pick(candidates []Prize) (int, error)
|
||||
}
|
||||
|
||||
// ErrEmptyPool 表示奖池累计权重为 0,无法完成一次随机。
|
||||
var ErrEmptyPool = errors.New("lottery: empty prize pool")
|
||||
|
||||
// ---- 次数入账 / 消耗 -------------------------------------------------------
|
||||
|
||||
// ChanceService 处理"用户抽奖次数账户",为触发源提供幂等入账、为抽奖流程提供
|
||||
// 事务内原子扣减。
|
||||
type ChanceService interface {
|
||||
// Grant 记录一次次数入账;幂等键 = (activityId, source, sourceRef)。
|
||||
// 已存在的 sourceRef 视为幂等命中,返回 nil 不重复发放。
|
||||
Grant(ctx context.Context, userId, activityId int64, source, sourceRef string, amount int) error
|
||||
|
||||
// Consume 在事务内扣减一次次数(SELECT ... FOR UPDATE 锁 chance_balance),
|
||||
// 返回扣减后剩余次数。剩余为 0 时返回 ErrNoChances。
|
||||
Consume(ctx context.Context, tx *gorm.DB, userId, activityId int64) (remaining int64, err error)
|
||||
|
||||
// Query 返回用户当前剩余次数(非事务,用于 GET /config 展示)。
|
||||
Query(ctx context.Context, userId, activityId int64) (remaining int64, err error)
|
||||
}
|
||||
|
||||
// ErrNoChances 表示用户在该活动下已无剩余抽奖次数。
|
||||
var ErrNoChances = errors.New("lottery: no chances remaining")
|
||||
|
||||
// ---- 发奖 Handler ---------------------------------------------------------
|
||||
|
||||
// DispatchRequest 是 PrizeHandler.Dispatch 的输入。事务由调用方开启并传入,
|
||||
// handler 在同 tx 内完成外部账本写入 + 业务侧发放。
|
||||
type DispatchRequest struct {
|
||||
UserId int64
|
||||
ActivityId int64
|
||||
DrawId int64
|
||||
Prize Prize // 当前奖品(含 Config JSON)
|
||||
Snapshot PrizeSnapshot // 抽奖时刻快照
|
||||
// IdempotencyKey 是 lottery_grant_ledger.external_ref 的最终值。
|
||||
// 调用方(抽奖服务)负责生成,惯例 = fmt.Sprintf("lottery:%d:%d", ActivityId, DrawId)。
|
||||
// 每次 Dispatch 用同一 IdempotencyKey 重试 → handler 命中 ledger UNIQUE 返回原结果。
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
// DispatchResult 是发奖结果。
|
||||
type DispatchResult struct {
|
||||
// State 会写入 lottery_draw.dispatch_state。
|
||||
State string
|
||||
// Message 供前端展示("已加到订阅"/"佣金到账 3 USDT" 等)。
|
||||
Message string
|
||||
}
|
||||
|
||||
// PrizeHandler 是一种奖品类型的发奖策略。Type 是注册键;IsAuto=true 表示
|
||||
// 抽奖事务内立刻发放,false 表示挂 pending_claim 等人工发(Stage 1 只实现
|
||||
// IsAuto=true 的三种,Stage 2 补齐 crypto/physical/manual_other)。
|
||||
//
|
||||
// 幂等:所有实现必须以 lottery_draw.id 为外部 ref 做 check-before-write,
|
||||
// 避免重试重复发放。见 doc/lottery-stage1-plan.md 的"发奖账本"章节。
|
||||
type PrizeHandler interface {
|
||||
Type() string
|
||||
IsAuto() bool
|
||||
// Dispatch 在调用方的事务内执行;返回结果或错误。
|
||||
// 错误会导致抽奖事务回滚(次数不扣、draw 不落库),由用户侧重新发起。
|
||||
//
|
||||
// 对 IsAuto()=false 的人工奖 handler,Dispatch 不会被抽奖服务调用;
|
||||
// 实现返回 ErrDispatchNotSupported 即可。
|
||||
Dispatch(ctx context.Context, tx *gorm.DB, req DispatchRequest) (DispatchResult, error)
|
||||
// ValidateClaim 是人工领奖时校验用户输入(Stage 2 才用);auto handler
|
||||
// 直接返回 nil 即可(默认 noopHandler / vpn_duration / commission 都不用)。
|
||||
ValidateClaim(raw []byte) error
|
||||
// ClaimSchema 返回该奖品的领奖表单 JSON Schema(Stage 2 才用)。
|
||||
// - auto handler 返回 nil(前端拿到 nil / null 就知道不用弹表单)。
|
||||
// - 人工奖 handler 返回一段合法 JSON Schema,前端据此动态渲染表单。
|
||||
ClaimSchema() json.RawMessage
|
||||
}
|
||||
|
||||
// ErrDispatchNotSupported 是 IsAuto()=false handler 的 Dispatch 占位错误:
|
||||
// 抽奖服务命中人工奖时不应调用 Dispatch,理论上永远不会返回给用户,仅供
|
||||
// 单测断言与防御性编程使用。
|
||||
var ErrDispatchNotSupported = errors.New("lottery: dispatch not supported for manual claim handler")
|
||||
|
||||
// ErrNotImplemented 是 Stage 1 骨架里 handler 的占位错误:抽奖流程接入前
|
||||
// 若不慎命中真实 handler 会立即失败,避免误发。
|
||||
var ErrNotImplemented = errors.New("lottery: handler not yet wired to real business")
|
||||
|
||||
// Registry 是 type → PrizeHandler 的路由表。抽奖服务只依赖此接口,
|
||||
// 具体 handler 由 initialize 阶段注入。
|
||||
type Registry interface {
|
||||
Get(prizeType string) (PrizeHandler, bool)
|
||||
// MustGet 在类型未注册时返回 ErrHandlerNotRegistered。
|
||||
MustGet(prizeType string) (PrizeHandler, error)
|
||||
}
|
||||
|
||||
// ErrHandlerNotRegistered 表示奖品类型没有对应 handler。
|
||||
var ErrHandlerNotRegistered = errors.New("lottery: prize handler not registered")
|
||||
@@ -0,0 +1,66 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// weightedPicker 是 WeightedPicker 的默认实现。使用累计权重 O(log n) 二分选取。
|
||||
// 注入的 rand.Source 允许测试固定种子。
|
||||
type weightedPicker struct {
|
||||
mu sync.Mutex
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
// NewWeightedPicker 返回默认加权选取器。传 seed=0 使用当前时间纳秒。
|
||||
func NewWeightedPicker(seed int64) WeightedPicker {
|
||||
if seed == 0 {
|
||||
seed = time.Now().UnixNano()
|
||||
}
|
||||
return &weightedPicker{
|
||||
rng: rand.New(rand.NewSource(seed)),
|
||||
}
|
||||
}
|
||||
|
||||
// Pick 从 candidates 中返回一个索引。权重为 0 的奖品不参与随机;累计权重
|
||||
// 为 0(如所有奖品 weight 都是 0)返回 ErrEmptyPool。
|
||||
//
|
||||
// 算法:累计权重线性扫描一次,取 [0, total) 随机数落到哪个区间。稳定、
|
||||
// 不需要预分配,并对小池(<20 项)足够快。
|
||||
func (p *weightedPicker) Pick(candidates []Prize) (int, error) {
|
||||
if len(candidates) == 0 {
|
||||
return 0, ErrEmptyPool
|
||||
}
|
||||
var total int64
|
||||
for _, c := range candidates {
|
||||
if c.Weight > 0 {
|
||||
total += int64(c.Weight)
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return 0, ErrEmptyPool
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
roll := p.rng.Int63n(total)
|
||||
p.mu.Unlock()
|
||||
|
||||
var cum int64
|
||||
for i, c := range candidates {
|
||||
if c.Weight <= 0 {
|
||||
continue
|
||||
}
|
||||
cum += int64(c.Weight)
|
||||
if roll < cum {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
// 走到这里说明浮点/累加异常,回退到最后一个非 0 权重项。
|
||||
for i := len(candidates) - 1; i >= 0; i-- {
|
||||
if candidates[i].Weight > 0 {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return 0, ErrEmptyPool
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package lottery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWeightedPicker_EmptyPool(t *testing.T) {
|
||||
p := NewWeightedPicker(42)
|
||||
if _, err := p.Pick(nil); err != ErrEmptyPool {
|
||||
t.Fatalf("expected ErrEmptyPool for nil pool, got %v", err)
|
||||
}
|
||||
if _, err := p.Pick([]Prize{{Weight: 0}, {Weight: 0}}); err != ErrEmptyPool {
|
||||
t.Fatalf("expected ErrEmptyPool when all weights are 0, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedPicker_SkipsZeroWeight(t *testing.T) {
|
||||
p := NewWeightedPicker(1)
|
||||
pool := []Prize{
|
||||
{Id: 1, Weight: 0}, // 不参与
|
||||
{Id: 2, Weight: 100}, // 独占权重
|
||||
{Id: 3, Weight: 0}, // 不参与
|
||||
}
|
||||
for i := 0; i < 200; i++ {
|
||||
idx, err := p.Pick(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("Pick err: %v", err)
|
||||
}
|
||||
if idx != 1 {
|
||||
t.Fatalf("expected idx 1 (only positive weight), got %d", idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedPicker_DistributionCloseToWeights(t *testing.T) {
|
||||
p := NewWeightedPicker(2026)
|
||||
pool := []Prize{
|
||||
{Id: 10, Weight: 10}, // 10/60 ≈ 16.67%
|
||||
{Id: 20, Weight: 20}, // 20/60 ≈ 33.33%
|
||||
{Id: 30, Weight: 30}, // 30/60 = 50%
|
||||
}
|
||||
const trials = 60000
|
||||
counts := make(map[int]int)
|
||||
for i := 0; i < trials; i++ {
|
||||
idx, err := p.Pick(pool)
|
||||
if err != nil {
|
||||
t.Fatalf("Pick err: %v", err)
|
||||
}
|
||||
counts[idx]++
|
||||
}
|
||||
// 允许 ±2% 偏差
|
||||
expect := []float64{10.0 / 60, 20.0 / 60, 30.0 / 60}
|
||||
for i, e := range expect {
|
||||
got := float64(counts[i]) / float64(trials)
|
||||
if got < e-0.02 || got > e+0.02 {
|
||||
t.Fatalf("prize %d: expected %.4f (±0.02), got %.4f", i, e, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedPicker_DeterministicWithFixedSeed(t *testing.T) {
|
||||
pool := []Prize{
|
||||
{Id: 1, Weight: 1},
|
||||
{Id: 2, Weight: 1},
|
||||
{Id: 3, Weight: 1},
|
||||
}
|
||||
a := NewWeightedPicker(7)
|
||||
b := NewWeightedPicker(7)
|
||||
for i := 0; i < 20; i++ {
|
||||
ai, _ := a.Pick(pool)
|
||||
bi, _ := b.Pick(pool)
|
||||
if ai != bi {
|
||||
t.Fatalf("iter %d: seed 7 diverged: a=%d b=%d", i, ai, bi)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,8 +162,8 @@ func (m *customOrderModel) QueryMonthlyOrders(ctx context.Context, date time.Tim
|
||||
Where("status IN ? AND created_at BETWEEN ? AND ? AND method != ?", []int64{2, 5}, firstDay, lastDay, "balance").
|
||||
Select(
|
||||
"SUM(amount) as amount_total, " +
|
||||
"SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
||||
"SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount",
|
||||
"SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
||||
"SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount",
|
||||
).
|
||||
Scan(v).Error
|
||||
})
|
||||
@@ -179,8 +179,8 @@ func (m *customOrderModel) QueryDateOrders(ctx context.Context, date time.Time)
|
||||
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance").
|
||||
Select(
|
||||
"SUM(amount) as amount_total, " +
|
||||
"SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
||||
"SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount",
|
||||
"SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
||||
"SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount",
|
||||
).
|
||||
Scan(v).Error
|
||||
})
|
||||
@@ -194,8 +194,8 @@ func (m *customOrderModel) QueryTotalOrders(ctx context.Context) (OrdersTotal, e
|
||||
return conn.Model(&Order{}).
|
||||
Select(`
|
||||
SUM(amount) AS amount_total,
|
||||
SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
`).
|
||||
Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
|
||||
Scan(&result).Error
|
||||
@@ -216,8 +216,8 @@ func (m *customOrderModel) QueryMonthlyUserCounts(ctx context.Context, date time
|
||||
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
|
||||
return conn.Model(&Order{}).
|
||||
Select(`
|
||||
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
|
||||
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
|
||||
`).
|
||||
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
||||
[]int64{2, 5}, firstDay, nextMonth, "balance").
|
||||
@@ -234,8 +234,8 @@ func (m *customOrderModel) QueryDateUserCounts(ctx context.Context, date time.Ti
|
||||
err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error {
|
||||
return conn.Model(&Order{}).
|
||||
Select(`
|
||||
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
|
||||
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
|
||||
`).
|
||||
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?",
|
||||
[]int64{2, 5}, dateStr, "balance").
|
||||
@@ -251,8 +251,8 @@ func (m *customOrderModel) QueryTotalUserCounts(ctx context.Context) (int64, int
|
||||
return conn.Model(&Order{}).
|
||||
Where("status IN ? AND method != ?", []int64{2, 5}, "balance").
|
||||
Select(`
|
||||
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
|
||||
COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users,
|
||||
COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users
|
||||
`).
|
||||
Scan(&counts).Error
|
||||
})
|
||||
@@ -284,8 +284,8 @@ func (m *customOrderModel) QueryDailyOrdersList(ctx context.Context, date time.T
|
||||
Select(`
|
||||
DATE_FORMAT(created_at, '%Y-%m-%d') AS date,
|
||||
SUM(amount) AS amount_total,
|
||||
SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
`).
|
||||
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
||||
[]int64{2, 5}, firstDay, nextDay, "balance").
|
||||
@@ -328,8 +328,8 @@ func (m *customOrderModel) QueryMonthlyOrdersList(ctx context.Context, date time
|
||||
Select(`
|
||||
DATE_FORMAT(created_at, '%Y-%m') AS date,
|
||||
SUM(amount) AS amount_total,
|
||||
SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount,
|
||||
SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount
|
||||
`).
|
||||
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
||||
[]int64{2, 5}, start, end, "balance").
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/storage"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
lotterydraw "github.com/perfect-panel/server/internal/logic/lottery/draw"
|
||||
lotteryhandler "github.com/perfect-panel/server/internal/logic/lottery/handler"
|
||||
lotteryhook "github.com/perfect-panel/server/internal/logic/lottery/hook"
|
||||
"github.com/perfect-panel/server/internal/model/ads"
|
||||
"github.com/perfect-panel/server/internal/model/announcement"
|
||||
"github.com/perfect-panel/server/internal/model/auth"
|
||||
@@ -19,6 +22,7 @@ import (
|
||||
iapapple "github.com/perfect-panel/server/internal/model/iap/apple"
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
|
||||
"github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
"github.com/perfect-panel/server/internal/model/payment"
|
||||
"github.com/perfect-panel/server/internal/model/promo"
|
||||
@@ -71,6 +75,13 @@ type ServiceContext struct {
|
||||
AnnouncementModel announcement.Model
|
||||
IAPAppleTransactionModel iapapple.Model
|
||||
|
||||
// Lottery (Stage 1)
|
||||
LotteryChance lottery.ChanceService
|
||||
LotteryLedger lottery.LedgerService
|
||||
LotteryInviteHook lotteryhook.InviteHook
|
||||
LotteryRegistry lottery.Registry
|
||||
LotteryDrawService *lotterydraw.Service
|
||||
|
||||
Restart func() error
|
||||
TelegramBot *tgbotapi.BotAPI
|
||||
NodeMultiplierManager *nodeMultiplier.Manager
|
||||
@@ -148,6 +159,47 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||
}
|
||||
srv.IAPAppleTransactionModel = iapapple.NewModel(db, rds)
|
||||
srv.DeviceManager = NewDeviceManager(srv)
|
||||
// Lottery Stage 1: wire the always-safe pieces (ChanceService, LedgerService,
|
||||
// InviteHook). PrizeHandler registry is created in the draw service (PR C).
|
||||
srv.LotteryChance = lottery.NewChanceService(db)
|
||||
srv.LotteryLedger = lottery.NewLedgerService()
|
||||
srv.LotteryInviteHook = lotteryhook.NewInviteHook(db, srv.LotteryChance)
|
||||
|
||||
// PR C: PrizeHandler registry + Draw service wiring. Handlers are hooked in
|
||||
// order: noop → vpn_duration → commission. Later registrations override.
|
||||
registry := lottery.NewRegistry()
|
||||
registry.Register(lottery.NewNoopHandler())
|
||||
registry.Register(lotteryhandler.NewVPNDurationHandler(lotteryhandler.VPNDurationDeps{
|
||||
UserModel: srv.UserModel,
|
||||
Ledger: srv.LotteryLedger,
|
||||
DB: db,
|
||||
ResolveEffectiveUser: lotteryhandler.DefaultResolveEffectiveUser(db),
|
||||
}))
|
||||
registry.Register(lotteryhandler.NewCommissionHandler(lotteryhandler.CommissionDeps{
|
||||
Ledger: srv.LotteryLedger,
|
||||
UpdateCommission: srv.UserModel.UpdateCommission,
|
||||
WriteCommissionLog: lotteryhandler.WriteCommissionLog,
|
||||
}))
|
||||
// Stage 2 人工奖 handler:无外部依赖,直接注册。抽奖服务在 IsAuto()=false
|
||||
// 时不会调用 Dispatch,而是由 draw 事务内挂 lottery_claim (pending_claim),
|
||||
// 交给运营在后台审核 + 线下打款/发货。
|
||||
registry.Register(lotteryhandler.NewCryptoHandler())
|
||||
registry.Register(lotteryhandler.NewPhysicalHandler())
|
||||
registry.Register(lotteryhandler.NewManualOtherHandler())
|
||||
srv.LotteryRegistry = registry
|
||||
|
||||
drawLimiter := limit.NewPeriodLimit(1, 1, rds, "lottery:draw:rate:")
|
||||
srv.LotteryDrawService = lotterydraw.NewService(lotterydraw.Deps{
|
||||
DB: db,
|
||||
Enabled: c.Lottery.Enable,
|
||||
RateLimiter: lotterydraw.NewRedisRateLimiter(drawLimiter),
|
||||
Chance: srv.LotteryChance,
|
||||
Evaluator: lottery.NewRuleEvaluator(),
|
||||
Picker: lottery.NewWeightedPicker(0),
|
||||
Registry: registry,
|
||||
// ContextBuilder 留 nil:PR C 阶段无活动配置门槛不评估用户上下文;后续
|
||||
// 可接一个真实的 RuleContextBuilder(读订阅/邀请/充值/tags)。
|
||||
})
|
||||
if c.S3.Enable {
|
||||
s3Store, err := storage.NewS3Store(context.Background(), c.S3)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
// Types for lottery Stage 1 user & admin APIs. Kept in a separate file so
|
||||
// future `goctl` regenerations of types.go do not clobber them (same pattern
|
||||
// as internal/types/subscribe.go).
|
||||
//
|
||||
// Field names mirror the shape declared in HIF-3 (Stage 1 spec) and the
|
||||
// architect's PR C brief. Do NOT rename without updating the .api files and
|
||||
// notifying frontend.
|
||||
|
||||
package types
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ---- User API ---------------------------------------------------------------
|
||||
|
||||
// GetLotteryConfigRequest is the query for GET /api/v1/lottery/config.
|
||||
type GetLotteryConfigRequest struct {
|
||||
ActivityId int64 `form:"activity_id" validate:"required"`
|
||||
}
|
||||
|
||||
// LotteryActivityConfig is the activity snapshot returned to the user.
|
||||
type LotteryActivityConfig struct {
|
||||
Id int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
StartAt int64 `json:"start_at"`
|
||||
EndAt int64 `json:"end_at"`
|
||||
Status string `json:"status"`
|
||||
GridSize int `json:"grid_size"`
|
||||
Prizes []LotteryPrizeConfig `json:"prizes"`
|
||||
}
|
||||
|
||||
// LotteryPrizeConfig is the slot-facing view of a prize (no weight / stock
|
||||
// exposed — those are admin-only).
|
||||
type LotteryPrizeConfig struct {
|
||||
Slot int `json:"slot"`
|
||||
Id int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
IconUrl string `json:"icon_url"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
SoldOut bool `json:"sold_out"`
|
||||
}
|
||||
|
||||
// LotteryUserStatus reports whether the user can currently draw.
|
||||
type LotteryUserStatus struct {
|
||||
Eligible bool `json:"eligible"`
|
||||
ChancesRemaining int64 `json:"chances_remaining"`
|
||||
UnmetReasons []LotteryUnmetReason `json:"unmet_reasons"`
|
||||
}
|
||||
|
||||
// LotteryUnmetReason is a single unmet-rule explanation for frontend display.
|
||||
type LotteryUnmetReason struct {
|
||||
Rule string `json:"rule"`
|
||||
Hint string `json:"hint"`
|
||||
Current int64 `json:"current,omitempty"`
|
||||
Required int64 `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
// GetLotteryConfigResponse is the envelope for GET /config.
|
||||
type GetLotteryConfigResponse struct {
|
||||
Activity LotteryActivityConfig `json:"activity"`
|
||||
User LotteryUserStatus `json:"user"`
|
||||
}
|
||||
|
||||
// DrawLotteryRequest is the body for POST /api/v1/lottery/draw.
|
||||
type DrawLotteryRequest struct {
|
||||
ActivityId int64 `json:"activity_id" validate:"required"`
|
||||
ClientNonce string `json:"client_nonce" validate:"required,max=64"`
|
||||
}
|
||||
|
||||
// DrawnPrize is a slim prize view returned on draw success.
|
||||
type DrawnPrize struct {
|
||||
Slot int `json:"slot"`
|
||||
Id int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
// LotteryClaimStatus tells the frontend whether more action is needed.
|
||||
type LotteryClaimStatus struct {
|
||||
Required bool `json:"required"`
|
||||
AutoClaimed bool `json:"auto_claimed"`
|
||||
Message string `json:"message,omitempty"`
|
||||
// ExpiresAt 是领奖窗口截止时间(Unix 秒;0 表示不适用,例如自动奖)。
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
// ClaimFormSchema 是人工奖的领奖表单 JSON Schema(前端据此动态渲染)。
|
||||
// nil 表示不适用(自动奖 / 谢谢参与)。
|
||||
ClaimFormSchema json.RawMessage `json:"claim_form_schema,omitempty"`
|
||||
}
|
||||
|
||||
// DrawLotteryResponse is what /draw returns.
|
||||
type DrawLotteryResponse struct {
|
||||
DrawId int64 `json:"draw_id"`
|
||||
IsWin bool `json:"is_win"`
|
||||
Prize *DrawnPrize `json:"prize"`
|
||||
Claim LotteryClaimStatus `json:"claim"`
|
||||
ChancesRemaining int64 `json:"chances_remaining"`
|
||||
}
|
||||
|
||||
// GetLotteryRecordsRequest paginates over the user's draws.
|
||||
// Page/Size 默认 by logic 层(Page<=0 → 1;Size<=0||>200 → 20);tag 里不设
|
||||
// default 以避免 staticcheck 与 Gin binding 的语义冲突。
|
||||
type GetLotteryRecordsRequest struct {
|
||||
ActivityId int64 `form:"activity_id"`
|
||||
Status string `form:"status"`
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
|
||||
// LotteryRecord is one row in the records list.
|
||||
type LotteryRecord struct {
|
||||
DrawId int64 `json:"draw_id"`
|
||||
ActivityId int64 `json:"activity_id"`
|
||||
IsWin bool `json:"is_win"`
|
||||
Prize *DrawnPrize `json:"prize"`
|
||||
DispatchState string `json:"dispatch_state"`
|
||||
DrawnAt int64 `json:"drawn_at"`
|
||||
// Claim 是人工奖的工单详情;自动奖 / 未中奖时为 nil。Stage 2 新增。
|
||||
Claim *LotteryRecordClaim `json:"claim,omitempty"`
|
||||
}
|
||||
|
||||
// LotteryRecordClaim 是 GET /records 里"人工奖工单"的用户视图。
|
||||
// 状态:pending_claim / reviewing / paying / paid / rejected / expired。
|
||||
// - 用户在 pending_claim 或 rejected 时可再次提交(前端读 ClaimFormSchema 渲染)。
|
||||
// - paying / paid / expired 时前端只展示状态与打款/发货结果。
|
||||
type LotteryRecordClaim struct {
|
||||
Status string `json:"status"`
|
||||
ClaimData json.RawMessage `json:"claim_data,omitempty"`
|
||||
SubmittedAt int64 `json:"submitted_at,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
TxHash string `json:"tx_hash,omitempty"`
|
||||
DeliveryRef string `json:"delivery_ref,omitempty"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
PaidAt int64 `json:"paid_at,omitempty"`
|
||||
// ClaimFormSchema 仅当 status 允许再提交(pending_claim / rejected)时下发。
|
||||
ClaimFormSchema json.RawMessage `json:"claim_form_schema,omitempty"`
|
||||
}
|
||||
|
||||
// GetLotteryRecordsResponse is the paginated payload.
|
||||
type GetLotteryRecordsResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
List []LotteryRecord `json:"list"`
|
||||
}
|
||||
|
||||
// ClaimLotteryPrizeRequest is Stage 2: submit claim data for a manual prize.
|
||||
type ClaimLotteryPrizeRequest struct {
|
||||
DrawId int64 `json:"draw_id" validate:"required"`
|
||||
ClaimData json.RawMessage `json:"claim_data"`
|
||||
// Input 是 Stage 1 骨架里预留的旧字段名,为了不破坏前端契约保留。
|
||||
// Deprecated: Prefer ClaimData for new callers.
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// ClaimLotteryPrizeResponse mirrors Stage 2 shape.
|
||||
type ClaimLotteryPrizeResponse struct {
|
||||
Status string `json:"status"`
|
||||
SubmittedAt int64 `json:"submitted_at"`
|
||||
}
|
||||
|
||||
// ---- Admin API --------------------------------------------------------------
|
||||
|
||||
// AdminLotteryActivity is the full admin view of an activity.
|
||||
type AdminLotteryActivity struct {
|
||||
Id int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
StartAt int64 `json:"start_at"`
|
||||
EndAt int64 `json:"end_at"`
|
||||
Status string `json:"status"`
|
||||
GridSize int `json:"grid_size"`
|
||||
Eligibility json.RawMessage `json:"eligibility"`
|
||||
ChanceSources json.RawMessage `json:"chance_sources"`
|
||||
UnmetAction string `json:"unmet_action"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateAdminLotteryActivityRequest creates a new activity in "draft" status.
|
||||
type CreateAdminLotteryActivityRequest struct {
|
||||
Title string `json:"title" validate:"required,max=128"`
|
||||
Description string `json:"description"`
|
||||
StartAt int64 `json:"start_at" validate:"required"`
|
||||
EndAt int64 `json:"end_at" validate:"required"`
|
||||
// GridSize:0 由 logic 层兜底为 9(不能在 json tag 里写 default=… ——
|
||||
// staticcheck SA5008 会拒;encoding/json 也不认这个选项)。
|
||||
GridSize int `json:"grid_size"`
|
||||
Eligibility json.RawMessage `json:"eligibility"`
|
||||
ChanceSources json.RawMessage `json:"chance_sources"`
|
||||
// UnmetAction:空字符串由 logic 层兜底为 "block"。
|
||||
UnmetAction string `json:"unmet_action"`
|
||||
}
|
||||
|
||||
// UpdateAdminLotteryActivityRequest updates mutable fields.
|
||||
type UpdateAdminLotteryActivityRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartAt int64 `json:"start_at,omitempty"`
|
||||
EndAt int64 `json:"end_at,omitempty"`
|
||||
GridSize int `json:"grid_size,omitempty"`
|
||||
UnmetAction string `json:"unmet_action,omitempty"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryActivitiesRequest paginates admin listings.
|
||||
// Page/Size 默认 by logic 层。
|
||||
type ListAdminLotteryActivitiesRequest struct {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Status string `form:"status,omitempty"`
|
||||
Search string `form:"search,omitempty"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryActivitiesResponse pages.
|
||||
type ListAdminLotteryActivitiesResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
List []AdminLotteryActivity `json:"list"`
|
||||
}
|
||||
|
||||
// AdminActivityIdRequest is used by GET /detail, publish, pause, delete.
|
||||
type AdminActivityIdRequest struct {
|
||||
Id int64 `form:"id" json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
// UpdateAdminLotteryRulesRequest overwrites eligibility / chance_sources /
|
||||
// unmet_action in a single call. Validated against rule caps before persist.
|
||||
type UpdateAdminLotteryRulesRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Eligibility json.RawMessage `json:"eligibility"`
|
||||
ChanceSources json.RawMessage `json:"chance_sources"`
|
||||
UnmetAction string `json:"unmet_action,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLotteryPrize is the admin view of a prize (includes weight + stock).
|
||||
type AdminLotteryPrize struct {
|
||||
Id int64 `json:"id"`
|
||||
ActivityId int64 `json:"activity_id"`
|
||||
Slot int `json:"slot"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
IconUrl string `json:"icon_url"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Weight int `json:"weight"`
|
||||
TotalStock *int64 `json:"total_stock"`
|
||||
RemainingStock *int64 `json:"remaining_stock"`
|
||||
IsFallback bool `json:"is_fallback"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateAdminLotteryPrizeRequest is nested under /activities/{id}/prizes.
|
||||
type CreateAdminLotteryPrizeRequest struct {
|
||||
ActivityId int64 `json:"activity_id" validate:"required"`
|
||||
Slot int `json:"slot"`
|
||||
Type string `json:"type" validate:"required,max=32"`
|
||||
Name string `json:"name" validate:"required,max=128"`
|
||||
IconUrl string `json:"icon_url"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Weight int `json:"weight"`
|
||||
TotalStock *int64 `json:"total_stock,omitempty"`
|
||||
IsFallback bool `json:"is_fallback"`
|
||||
}
|
||||
|
||||
// UpdateAdminLotteryPrizeRequest updates mutable prize fields.
|
||||
type UpdateAdminLotteryPrizeRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Slot *int `json:"slot,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
IconUrl string `json:"icon_url,omitempty"`
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
Weight *int `json:"weight,omitempty"`
|
||||
TotalStock *int64 `json:"total_stock,omitempty"`
|
||||
IsFallback *bool `json:"is_fallback,omitempty"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryPrizesRequest lists prizes for an activity.
|
||||
type ListAdminLotteryPrizesRequest struct {
|
||||
ActivityId int64 `form:"activity_id" validate:"required"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryPrizesResponse pages.
|
||||
type ListAdminLotteryPrizesResponse struct {
|
||||
List []AdminLotteryPrize `json:"list"`
|
||||
}
|
||||
|
||||
// AdminPrizeIdRequest is used by DELETE / GET single.
|
||||
type AdminPrizeIdRequest struct {
|
||||
Id int64 `form:"id" json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
// GrantAdminLotteryChanceRequest gives a specified user N chances on an
|
||||
// activity. sourceRef doubles as idempotency key.
|
||||
type GrantAdminLotteryChanceRequest struct {
|
||||
ActivityId int64 `json:"activity_id" validate:"required"`
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
Amount int `json:"amount" validate:"required,min=1"`
|
||||
SourceRef string `json:"source_ref" validate:"required,max=128"`
|
||||
}
|
||||
|
||||
// ---- Stage 2 Admin Claims --------------------------------------------------
|
||||
|
||||
// ListAdminLotteryClaimsRequest lists claims filtered by type/status/activity.
|
||||
// Page/Size 默认 by logic 层(Page<=0 → 1;Size<=0||>200 → 20)。
|
||||
type ListAdminLotteryClaimsRequest struct {
|
||||
Type string `form:"type,omitempty"`
|
||||
Status string `form:"status,omitempty"`
|
||||
ActivityId int64 `form:"activity_id,omitempty"`
|
||||
UserId int64 `form:"user_id,omitempty"`
|
||||
From int64 `form:"from,omitempty"` // Unix 秒
|
||||
To int64 `form:"to,omitempty"` // Unix 秒
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
|
||||
// AdminLotteryClaim is the admin-facing view of one claim row.
|
||||
type AdminLotteryClaim struct {
|
||||
Id int64 `json:"id"`
|
||||
DrawId int64 `json:"draw_id"`
|
||||
ActivityId int64 `json:"activity_id"`
|
||||
User AdminLotteryClaimUser `json:"user"`
|
||||
Prize AdminLotteryClaimPrize `json:"prize"`
|
||||
Status string `json:"status"`
|
||||
ClaimData json.RawMessage `json:"claim_data,omitempty"`
|
||||
SubmittedAt int64 `json:"submitted_at,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
ReviewedBy int64 `json:"reviewed_by,omitempty"`
|
||||
ReviewedAt int64 `json:"reviewed_at,omitempty"`
|
||||
RejectReason string `json:"reject_reason,omitempty"`
|
||||
TxHash string `json:"tx_hash,omitempty"`
|
||||
DeliveryRef string `json:"delivery_ref,omitempty"`
|
||||
PaidAt int64 `json:"paid_at,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminLotteryClaimUser 是 claim 列表里附带的用户简况。
|
||||
type AdminLotteryClaimUser struct {
|
||||
Id int64 `json:"id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLotteryClaimPrize 是 claim 列表里附带的奖品简况(含 config,供审核判断)。
|
||||
type AdminLotteryClaimPrize struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
}
|
||||
|
||||
// ListAdminLotteryClaimsResponse pages.
|
||||
type ListAdminLotteryClaimsResponse struct {
|
||||
Total int64 `json:"total"`
|
||||
Claims []AdminLotteryClaim `json:"claims"`
|
||||
}
|
||||
|
||||
// AdminApproveClaimRequest 转 reviewing → paying。
|
||||
type AdminApproveClaimRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
// AdminRejectClaimRequest 转 reviewing/paying → rejected,reason 必填。
|
||||
type AdminRejectClaimRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Reason string `json:"reason" validate:"required,max=512"`
|
||||
}
|
||||
|
||||
// AdminMarkPaidClaimRequest 转 paying → paid。
|
||||
// tx_hash / delivery_ref 至少填一个;crypto 必填 tx_hash,physical 必填
|
||||
// delivery_ref,manual_other 至少填一个(业务层做类型检查)。
|
||||
// paid_at 可选,缺省时用服务端 now。
|
||||
type AdminMarkPaidClaimRequest struct {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
TxHash string `json:"tx_hash,omitempty" validate:"max=128"`
|
||||
DeliveryRef string `json:"delivery_ref,omitempty" validate:"max=128"`
|
||||
PaidAt int64 `json:"paid_at,omitempty"` // Unix 秒
|
||||
}
|
||||
|
||||
// AdminLotteryClaimsSummary 是 /claims/summary 的响应。overdue = 待用户填的
|
||||
// pending_claim 中已过期的条数(不重叠 status=expired)。
|
||||
type AdminLotteryClaimsSummary struct {
|
||||
Crypto AdminLotteryClaimsStatusCount `json:"crypto"`
|
||||
Physical AdminLotteryClaimsStatusCount `json:"physical"`
|
||||
ManualOther AdminLotteryClaimsStatusCount `json:"manual_other"`
|
||||
Overdue int64 `json:"overdue"`
|
||||
}
|
||||
|
||||
// AdminLotteryClaimsStatusCount 是各类型的关键状态计数。
|
||||
type AdminLotteryClaimsStatusCount struct {
|
||||
Reviewing int64 `json:"reviewing"`
|
||||
Paying int64 `json:"paying"`
|
||||
}
|
||||
@@ -16,4 +16,10 @@ const (
|
||||
CtxKeyAPIVersionUseLatest CtxKey = "apiVersionUseLatest"
|
||||
CtxKeyAPIHeaderRaw CtxKey = "apiHeaderRaw"
|
||||
CtxKeyHasAppId CtxKey = "hasAppId"
|
||||
// CtxKeyIP / CtxKeyUserAgent are populated by AdminMetaMiddleware for
|
||||
// audit-writing logic (admin_action_log.ip / user_agent). Both keys use
|
||||
// the typed CtxKey so ctx.Value lookups collide-safely with any bare-string
|
||||
// writers a future middleware might accidentally introduce.
|
||||
CtxKeyIP CtxKey = "ip"
|
||||
CtxKeyUserAgent CtxKey = "user_agent"
|
||||
)
|
||||
|
||||
@@ -150,3 +150,30 @@ const (
|
||||
OrderRefundNoSubscription uint32 = 61007
|
||||
OrderRefundCommissionMismatch uint32 = 61008
|
||||
)
|
||||
|
||||
// Lottery error (100xxx range; 3-digit business = 100)
|
||||
// codes align with HIF-3 spec (Stage 1 issue description) + HIF-4 (Stage 2):
|
||||
//
|
||||
// 4001 not_eligible / 4002 no_chances / 4003 activity_ended
|
||||
// 4004 rate_limited / 4005 already_submitted / 4006 invalid_claim_data
|
||||
// 4007 draw_not_found / 4008 not_your_draw / 4009 claim_expired
|
||||
// 4010 not_claimable / 5001 internal_error
|
||||
//
|
||||
// wire-level ints stay unique across the whole codebase.
|
||||
const (
|
||||
LotteryNotEligible uint32 = 100001 // 用户未通过门槛
|
||||
LotteryNoChances uint32 = 100002 // 用户已无剩余次数
|
||||
LotteryActivityEnded uint32 = 100003 // 活动未开始 / 已结束 / 或 feature flag 关闭
|
||||
LotteryRateLimited uint32 = 100004 // 触发每秒 1 次限流
|
||||
LotteryAlreadySubmitted uint32 = 100005 // Stage 2: 该 draw 已经处于不可再提交的状态
|
||||
LotteryInvalidClaimData uint32 = 100006 // Stage 2: 领奖表单校验失败
|
||||
LotteryDrawNotFound uint32 = 100007 // Stage 2: draw_id 不存在
|
||||
LotteryNotYourDraw uint32 = 100008 // Stage 2: draw 不属于当前登录用户
|
||||
LotteryClaimExpired uint32 = 100009 // Stage 2: 领奖窗口已过期
|
||||
LotteryNotClaimable uint32 = 100010 // Stage 2: 该 draw 不需要 / 不允许领奖(自动奖 / 未中奖)
|
||||
LotteryInternalError uint32 = 100500 // 内部错误
|
||||
LotteryRuleTooDeep uint32 = 100020 // rules JSON 深度超限
|
||||
LotteryRuleTooMany uint32 = 100021 // rules JSON 节点数超限
|
||||
LotteryRuleTooLarge uint32 = 100022 // rules JSON 体积超限
|
||||
LotteryClaimStateInvalid uint32 = 100011 // Stage 2: 运营操作时状态机不允许(如非 reviewing 却 approve)
|
||||
)
|
||||
|
||||
@@ -113,6 +113,23 @@ func init() {
|
||||
OrderRefundNoSubscription: "Refund target subscription not found",
|
||||
OrderRefundCommissionMismatch: "Refund commission source not found",
|
||||
|
||||
// Lottery error
|
||||
LotteryNotEligible: "抽奖门槛未达成",
|
||||
LotteryNoChances: "抽奖次数不足",
|
||||
LotteryActivityEnded: "抽奖活动未开放",
|
||||
LotteryRateLimited: "抽奖操作过于频繁,请稍后再试",
|
||||
LotteryAlreadySubmitted: "该奖品的领奖信息已提交,无需重复提交",
|
||||
LotteryInvalidClaimData: "领奖信息格式不正确",
|
||||
LotteryDrawNotFound: "抽奖记录不存在",
|
||||
LotteryNotYourDraw: "无权操作此抽奖记录",
|
||||
LotteryClaimExpired: "领奖窗口已过期",
|
||||
LotteryNotClaimable: "该奖品当前不支持领取",
|
||||
LotteryClaimStateInvalid: "当前工单状态不允许此操作",
|
||||
LotteryInternalError: "抽奖服务暂时不可用",
|
||||
LotteryRuleTooDeep: "规则树嵌套层数超限",
|
||||
LotteryRuleTooMany: "规则树节点数超限",
|
||||
LotteryRuleTooLarge: "规则树 JSON 体积超限",
|
||||
|
||||
// Permission error
|
||||
PermissionDenied: "Permission denied",
|
||||
}
|
||||
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
# HIF-3 Stage 1 lottery — end-to-end curl script for QA.
|
||||
#
|
||||
# 环境准备(一次性):
|
||||
# 1. 在测试环境的 config.yaml 里打开 Lottery: { Enable: true }
|
||||
# 2. 用 admin token 运行 setup 部分(创建活动 + 奖品 + 上架 + 发次数)
|
||||
# 3. 用普通用户 token 运行 user 部分(config → draw → records → claim)
|
||||
#
|
||||
# 使用:
|
||||
# BASE_URL=http://127.0.0.1:8080 ADMIN_TOKEN=<jwt> USER_TOKEN=<jwt> USER_ID=<int> \
|
||||
# bash qa/lottery/stage1_curl.sh
|
||||
#
|
||||
# 输出:每步 request/response 打印到 stdout,非 2xx 或 code!=0 直接退出。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
|
||||
ADMIN_TOKEN="${ADMIN_TOKEN:-CHANGE_ME}"
|
||||
USER_TOKEN="${USER_TOKEN:-CHANGE_ME}"
|
||||
USER_ID="${USER_ID:-1}"
|
||||
|
||||
# ppanel AuthMiddleware 不 strip Bearer 前缀,直接把整个 Authorization header 传给
|
||||
# jwt.ParseJwtToken —— 所以这里必须传裸 JWT,不能加 "Bearer " 前缀,否则 parse fail → 40004。
|
||||
hdr_admin=(-H "Authorization: ${ADMIN_TOKEN}" -H "Content-Type: application/json")
|
||||
hdr_user=(-H "Authorization: ${USER_TOKEN}" -H "Content-Type: application/json")
|
||||
|
||||
log() {
|
||||
printf '\n\033[1;34m▶ %s\033[0m\n' "$*"
|
||||
}
|
||||
|
||||
expect_ok() {
|
||||
local body="$1"
|
||||
local step="$2"
|
||||
local code
|
||||
code=$(printf '%s' "$body" | jq -r '.code // 0')
|
||||
if [[ "$code" != "0" && "$code" != "200" ]]; then
|
||||
printf '\033[1;31m✗ %s failed: %s\033[0m\n' "$step" "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '\033[1;32m✓ %s\033[0m\n' "$step"
|
||||
}
|
||||
|
||||
# ---- Admin setup ---------------------------------------------------------
|
||||
|
||||
log "Create activity (draft)"
|
||||
start_at=$(date -v +0S +%s 2>/dev/null || date +%s)
|
||||
end_at=$((start_at + 86400 * 7))
|
||||
create_body=$(cat <<JSON
|
||||
{"title":"QA Stage 1 抽奖","description":"e2e","start_at":${start_at},"end_at":${end_at},"grid_size":9,
|
||||
"eligibility":{},"chance_sources":[{"source":"manual_grant","amount":1}],"unmet_action":"block"}
|
||||
JSON
|
||||
)
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/activities" "${hdr_admin[@]}" -d "${create_body}")
|
||||
expect_ok "$resp" "activity create"
|
||||
echo "$resp"
|
||||
ACTIVITY_ID=$(printf '%s' "$resp" | jq -r '.data.id')
|
||||
|
||||
log "Add prize (vpn_duration 3 天, unlimited)"
|
||||
prize_body=$(cat <<JSON
|
||||
{"activity_id":${ACTIVITY_ID},"slot":0,"type":"vpn_duration","name":"3 天","icon_url":"",
|
||||
"config":{"duration_days":3},"weight":50,"is_fallback":false}
|
||||
JSON
|
||||
)
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "${prize_body}")
|
||||
expect_ok "$resp" "prize vpn_duration"
|
||||
echo "$resp"
|
||||
|
||||
log "Add fallback prize (none / 谢谢参与)"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"slot\":1,\"type\":\"none\",\"name\":\"谢谢参与\",\"config\":{},\"weight\":50,\"is_fallback\":true}")
|
||||
expect_ok "$resp" "prize fallback"
|
||||
echo "$resp"
|
||||
|
||||
log "Publish activity"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/activities/publish" "${hdr_admin[@]}" -d "{\"id\":${ACTIVITY_ID}}")
|
||||
expect_ok "$resp" "publish"
|
||||
|
||||
log "Manually grant 2 chances to user ${USER_ID}"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/chances/grant" "${hdr_admin[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"user_id\":${USER_ID},\"amount\":2,\"source_ref\":\"qa-e2e-1\"}")
|
||||
expect_ok "$resp" "chances grant"
|
||||
|
||||
# ---- User flow -----------------------------------------------------------
|
||||
|
||||
log "GET /config"
|
||||
resp=$(curl -sS "${BASE_URL}/v1/lottery/config?activity_id=${ACTIVITY_ID}" "${hdr_user[@]}")
|
||||
expect_ok "$resp" "config query"
|
||||
echo "$resp"
|
||||
|
||||
nonce_a=$(uuidgen 2>/dev/null || python3 -c "import uuid;print(uuid.uuid4())")
|
||||
log "POST /draw (nonce=${nonce_a})"
|
||||
draw_body="{\"activity_id\":${ACTIVITY_ID},\"client_nonce\":\"${nonce_a}\"}"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/draw" "${hdr_user[@]}" -d "${draw_body}")
|
||||
expect_ok "$resp" "draw #1"
|
||||
echo "$resp"
|
||||
|
||||
log "POST /draw same nonce (idempotent replay)"
|
||||
resp2=$(curl -sS -X POST "${BASE_URL}/v1/lottery/draw" "${hdr_user[@]}" -d "${draw_body}")
|
||||
expect_ok "$resp2" "draw replay"
|
||||
draw_id_1=$(printf '%s' "$resp" | jq -r '.data.draw_id')
|
||||
draw_id_2=$(printf '%s' "$resp2" | jq -r '.data.draw_id')
|
||||
if [[ "$draw_id_1" != "$draw_id_2" ]]; then
|
||||
echo "✗ nonce replay produced different draw_id ($draw_id_1 vs $draw_id_2)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ nonce idempotent: draw_id=${draw_id_1}"
|
||||
|
||||
log "GET /records"
|
||||
resp=$(curl -sS "${BASE_URL}/v1/lottery/records?activity_id=${ACTIVITY_ID}&page=1&size=20" "${hdr_user[@]}")
|
||||
expect_ok "$resp" "records"
|
||||
echo "$resp"
|
||||
|
||||
log "POST /claim (Stage 1 always 4010)"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/claim" "${hdr_user[@]}" -d "{\"draw_id\":${draw_id_1}}")
|
||||
code=$(printf '%s' "$resp" | jq -r '.code // 0')
|
||||
if [[ "$code" != "100010" ]]; then
|
||||
echo "✗ /claim should return 100010 not_claimable in Stage 1; got ${code}" >&2
|
||||
echo "$resp" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ claim returned 100010 as expected"
|
||||
|
||||
# ---- Rate limit verification ---------------------------------------------
|
||||
|
||||
log "Trigger rate limit (2 draws within 1 sec)"
|
||||
nonce_b=$(uuidgen 2>/dev/null || python3 -c "import uuid;print(uuid.uuid4())")
|
||||
curl -sS -X POST "${BASE_URL}/v1/lottery/draw" "${hdr_user[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"client_nonce\":\"${nonce_b}\"}" >/dev/null
|
||||
nonce_c=$(uuidgen 2>/dev/null || python3 -c "import uuid;print(uuid.uuid4())")
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/draw" "${hdr_user[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"client_nonce\":\"${nonce_c}\"}")
|
||||
code=$(printf '%s' "$resp" | jq -r '.code // 0')
|
||||
if [[ "$code" != "100004" ]]; then
|
||||
echo "⚠️ expected 100004 rate_limited, got ${code} — non-fatal (Redis may have residual budget)"
|
||||
else
|
||||
echo "✓ rate limit triggered as expected"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "──────────────────────────────────────────"
|
||||
echo " Stage 1 end-to-end curl script completed."
|
||||
echo "──────────────────────────────────────────"
|
||||
Executable
+215
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
# HIF-4 Stage 2 lottery — end-to-end curl for manual-claim workflow (crypto / physical / manual_other).
|
||||
#
|
||||
# 环境准备:
|
||||
# 1. Lottery: { Enable: true } in config.yaml
|
||||
# 2. 已有可用的 admin token 和 user token
|
||||
# 3. 已跑过 Stage 1 且现有活动状态是 running(或者用本脚本重新建一个)
|
||||
#
|
||||
# 使用:
|
||||
# BASE_URL=http://127.0.0.1:8080 ADMIN_TOKEN=<jwt> USER_TOKEN=<jwt> USER_ID=<int> \
|
||||
# bash qa/lottery/stage2_curl.sh
|
||||
#
|
||||
# 覆盖:
|
||||
# ✓ 三种人工奖类型都能配置、抽中、领取
|
||||
# ✓ 状态机:pending_claim → reviewing → paying → paid
|
||||
# ✓ 状态机:reviewing → rejected → reviewing(用户重新提交)
|
||||
# ✓ 过期窗口读取(claim_ttl_hours 覆盖默认 7d)
|
||||
# ✓ 后台工单列表 + summary
|
||||
# ✓ mark-paid 前置校验(crypto 必填 tx_hash / physical 必填 delivery_ref)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
|
||||
ADMIN_TOKEN="${ADMIN_TOKEN:-CHANGE_ME}"
|
||||
USER_TOKEN="${USER_TOKEN:-CHANGE_ME}"
|
||||
USER_ID="${USER_ID:-1}"
|
||||
|
||||
hdr_admin=(-H "Authorization: Bearer ${ADMIN_TOKEN}" -H "Content-Type: application/json")
|
||||
hdr_user=(-H "Authorization: Bearer ${USER_TOKEN}" -H "Content-Type: application/json")
|
||||
|
||||
log() { printf '\n\033[1;34m▶ %s\033[0m\n' "$*"; }
|
||||
|
||||
expect_ok() {
|
||||
local body="$1" step="$2" code
|
||||
code=$(printf '%s' "$body" | jq -r '.code // 0')
|
||||
if [[ "$code" != "0" && "$code" != "200" ]]; then
|
||||
printf '\033[1;31m✗ %s failed: %s\033[0m\n' "$step" "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '\033[1;32m✓ %s\033[0m\n' "$step"
|
||||
}
|
||||
|
||||
expect_code() {
|
||||
local body="$1" step="$2" expected="$3" code
|
||||
code=$(printf '%s' "$body" | jq -r '.code // 0')
|
||||
if [[ "$code" != "$expected" ]]; then
|
||||
printf '\033[1;31m✗ %s: expected code=%s, got %s (body=%s)\033[0m\n' "$step" "$expected" "$code" "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '\033[1;32m✓ %s (code=%s)\033[0m\n' "$step" "$expected"
|
||||
}
|
||||
|
||||
# ---- Admin: build activity + three manual prizes -------------------------
|
||||
|
||||
log "Create Stage 2 activity"
|
||||
start_at=$(date +%s)
|
||||
end_at=$((start_at + 86400 * 30))
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/activities" "${hdr_admin[@]}" -d "$(cat <<JSON
|
||||
{"title":"QA Stage 2 抽奖","description":"人工奖 e2e","start_at":${start_at},"end_at":${end_at},"grid_size":9,
|
||||
"eligibility":{},"chance_sources":[{"source":"manual_grant","amount":10}],"unmet_action":"block"}
|
||||
JSON
|
||||
)")
|
||||
expect_ok "$resp" "activity create"
|
||||
ACTIVITY_ID=$(printf '%s' "$resp" | jq -r '.data.id')
|
||||
|
||||
log "Add crypto prize (BTC, unlimited, 48h TTL)"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "$(cat <<JSON
|
||||
{"activity_id":${ACTIVITY_ID},"slot":0,"type":"crypto","name":"1 BTC","icon_url":"",
|
||||
"config":{"amount":"1","currency":"BTC","networks":["BTC","TRX"],"claim_ttl_hours":48},
|
||||
"weight":34,"is_fallback":false}
|
||||
JSON
|
||||
)")
|
||||
expect_ok "$resp" "prize crypto"
|
||||
CRYPTO_PRIZE_ID=$(printf '%s' "$resp" | jq -r '.data.id')
|
||||
|
||||
log "Add physical prize (T-shirt)"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "$(cat <<JSON
|
||||
{"activity_id":${ACTIVITY_ID},"slot":1,"type":"physical","name":"限量 T 恤","icon_url":"",
|
||||
"config":{"sku_id":"tee-01","sku_name":"限量 T 恤"},
|
||||
"weight":33,"is_fallback":false}
|
||||
JSON
|
||||
)")
|
||||
expect_ok "$resp" "prize physical"
|
||||
|
||||
log "Add manual_other prize"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "$(cat <<JSON
|
||||
{"activity_id":${ACTIVITY_ID},"slot":2,"type":"manual_other","name":"运营手工奖","icon_url":"",
|
||||
"config":{"desc":"运营线下联系发放"},
|
||||
"weight":33,"is_fallback":false}
|
||||
JSON
|
||||
)")
|
||||
expect_ok "$resp" "prize manual_other"
|
||||
|
||||
log "Publish activity"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/activities/publish" "${hdr_admin[@]}" -d "{\"id\":${ACTIVITY_ID}}")
|
||||
expect_ok "$resp" "publish"
|
||||
|
||||
log "Grant 10 chances to user ${USER_ID}"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/chances/grant" "${hdr_admin[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"user_id\":${USER_ID},\"amount\":10,\"source_ref\":\"qa-stage2\"}")
|
||||
expect_ok "$resp" "chances grant"
|
||||
|
||||
# ---- User: draw until we land on a crypto prize -------------------------
|
||||
|
||||
CRYPTO_DRAW_ID=""
|
||||
attempts=0
|
||||
while [[ -z "${CRYPTO_DRAW_ID}" && "$attempts" -lt 20 ]]; do
|
||||
attempts=$((attempts + 1))
|
||||
nonce=$(uuidgen 2>/dev/null || python3 -c "import uuid;print(uuid.uuid4())")
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/draw" "${hdr_user[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"client_nonce\":\"${nonce}\"}")
|
||||
expect_ok "$resp" "draw #${attempts}"
|
||||
ptype=$(printf '%s' "$resp" | jq -r '.data.prize.type // ""')
|
||||
if [[ "$ptype" == "crypto" ]]; then
|
||||
CRYPTO_DRAW_ID=$(printf '%s' "$resp" | jq -r '.data.draw_id')
|
||||
printf 'crypto draw id: %s\n' "$CRYPTO_DRAW_ID"
|
||||
printf 'claim response fields:\n%s\n' "$(printf '%s' "$resp" | jq '.data.claim')"
|
||||
# 验证 schema 包含 enum
|
||||
if ! printf '%s' "$resp" | jq -e '.data.claim.claim_form_schema.properties.network.enum | length > 0' >/dev/null; then
|
||||
echo "✗ crypto schema missing network enum" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [[ -z "$CRYPTO_DRAW_ID" ]]; then
|
||||
echo "✗ 20 draws still no crypto — weight/rand seed check" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- User: submit claim (crypto) ----------------------------------------
|
||||
|
||||
log "POST /claim with valid BTC address"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/claim" "${hdr_user[@]}" -d "$(cat <<JSON
|
||||
{"draw_id":${CRYPTO_DRAW_ID},"claim_data":{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}}
|
||||
JSON
|
||||
)")
|
||||
expect_ok "$resp" "claim submit (crypto)"
|
||||
|
||||
log "POST /claim second time — expect 100005 already_submitted"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/claim" "${hdr_user[@]}" -d "$(cat <<JSON
|
||||
{"draw_id":${CRYPTO_DRAW_ID},"claim_data":{"network":"BTC","address":"bc1qanotheraddresslongenoughxxxx"}}
|
||||
JSON
|
||||
)")
|
||||
expect_code "$resp" "claim duplicate reject" "100005"
|
||||
|
||||
# ---- Admin: find claim id in list ----------------------------------------
|
||||
|
||||
log "GET /admin/lottery/claims?status=reviewing"
|
||||
resp=$(curl -sS "${BASE_URL}/v1/admin/lottery/claims?status=reviewing&activity_id=${ACTIVITY_ID}" "${hdr_admin[@]}")
|
||||
expect_ok "$resp" "list claims"
|
||||
CLAIM_ID=$(printf '%s' "$resp" | jq -r ".data.claims[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .id" | head -1)
|
||||
if [[ -z "$CLAIM_ID" ]]; then
|
||||
echo "✗ could not locate claim for crypto draw ${CRYPTO_DRAW_ID}" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'claim id: %s\n' "$CLAIM_ID"
|
||||
|
||||
# ---- Admin: reject then user resubmits ----------------------------------
|
||||
|
||||
log "POST /claims/reject"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/reject" "${hdr_admin[@]}" -d "{\"id\":${CLAIM_ID},\"reason\":\"地址格式待核对\"}")
|
||||
expect_ok "$resp" "reject"
|
||||
|
||||
log "GET /records — should show rejected + claim_form_schema"
|
||||
resp=$(curl -sS "${BASE_URL}/v1/lottery/records?activity_id=${ACTIVITY_ID}" "${hdr_user[@]}")
|
||||
expect_ok "$resp" "records after reject"
|
||||
status=$(printf '%s' "$resp" | jq -r ".data.list[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .claim.status")
|
||||
if [[ "$status" != "rejected" ]]; then
|
||||
echo "✗ expected status=rejected, got '$status'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! printf '%s' "$resp" | jq -e ".data.list[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .claim.claim_form_schema.properties.network.enum" >/dev/null; then
|
||||
echo "✗ rejected state must still expose claim_form_schema for resubmit" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "User resubmits after reject"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/claim" "${hdr_user[@]}" -d "$(cat <<JSON
|
||||
{"draw_id":${CRYPTO_DRAW_ID},"claim_data":{"network":"BTC","address":"bc1qgoodaddresslongenoughforthevalidator1"}}
|
||||
JSON
|
||||
)")
|
||||
expect_ok "$resp" "resubmit after reject"
|
||||
|
||||
# ---- Admin: approve then mark-paid --------------------------------------
|
||||
|
||||
log "POST /claims/approve (reviewing → paying)"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/approve" "${hdr_admin[@]}" -d "{\"id\":${CLAIM_ID}}")
|
||||
expect_ok "$resp" "approve"
|
||||
|
||||
log "POST /claims/mark-paid (missing tx_hash → 400)"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/mark-paid" "${hdr_admin[@]}" -d "{\"id\":${CLAIM_ID}}")
|
||||
expect_code "$resp" "mark-paid rejects empty tx_hash for crypto" "400"
|
||||
|
||||
log "POST /claims/mark-paid (with tx_hash)"
|
||||
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/mark-paid" "${hdr_admin[@]}" -d "$(cat <<JSON
|
||||
{"id":${CLAIM_ID},"tx_hash":"0xdeadbeefcafebabe12345678"}
|
||||
JSON
|
||||
)")
|
||||
expect_ok "$resp" "mark paid"
|
||||
|
||||
log "GET /records — final should show paid + tx_hash"
|
||||
resp=$(curl -sS "${BASE_URL}/v1/lottery/records?activity_id=${ACTIVITY_ID}" "${hdr_user[@]}")
|
||||
expect_ok "$resp" "records final"
|
||||
tx=$(printf '%s' "$resp" | jq -r ".data.list[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .claim.tx_hash")
|
||||
if [[ "$tx" != "0xdeadbeefcafebabe12345678" ]]; then
|
||||
echo "✗ expected tx_hash=0xdead..., got '$tx'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- Summary endpoint ---------------------------------------------------
|
||||
|
||||
log "GET /admin/lottery/claims/summary"
|
||||
resp=$(curl -sS "${BASE_URL}/v1/admin/lottery/claims/summary" "${hdr_admin[@]}")
|
||||
expect_ok "$resp" "summary"
|
||||
printf 'summary:\n%s\n' "$(printf '%s' "$resp" | jq '.data')"
|
||||
|
||||
printf '\n\033[1;32m✅ Stage 2 end-to-end curl passed.\033[0m\n'
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
iapLogic "github.com/perfect-panel/server/queue/logic/iap"
|
||||
lotteryLogic "github.com/perfect-panel/server/queue/logic/lottery"
|
||||
orderLogic "github.com/perfect-panel/server/queue/logic/order"
|
||||
smslogic "github.com/perfect-panel/server/queue/logic/sms"
|
||||
"github.com/perfect-panel/server/queue/logic/subscription"
|
||||
@@ -51,4 +52,7 @@ func RegisterHandlers(mux *asynq.ServeMux, serverCtx *svc.ServiceContext) {
|
||||
|
||||
// Stuck order recovery
|
||||
mux.Handle(types.SchedulerStuckOrderRecovery, orderLogic.NewStuckOrderRecoveryLogic(serverCtx))
|
||||
|
||||
// Lottery Stage 2: 过期领奖工单每小时扫描一次
|
||||
mux.Handle(types.SchedulerLotteryExpireClaim, lotteryLogic.NewExpireClaimLogic(serverCtx))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package lotteryLogic 承载抽奖相关的 asynq 后台任务。
|
||||
// Stage 2 唯一一个后台任务:每小时扫描 pending_claim 且已过期的领奖工单,
|
||||
// 状态推到 expired。业务规则:"过期不补次数",所以只更新 claim/draw,不做补偿。
|
||||
package lotteryLogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
)
|
||||
|
||||
// ExpireClaimLogic 扫描已过期但状态仍是 pending_claim 的 lottery_claim
|
||||
// (用户没在窗口内提交领奖信息),把它们推到 expired 终态。同时把关联的
|
||||
// lottery_draw.dispatch_state 也推到 expired,让 GET /records 与后台视图一致。
|
||||
type ExpireClaimLogic struct {
|
||||
svc *svc.ServiceContext
|
||||
}
|
||||
|
||||
func NewExpireClaimLogic(svc *svc.ServiceContext) *ExpireClaimLogic {
|
||||
return &ExpireClaimLogic{svc: svc}
|
||||
}
|
||||
|
||||
// ProcessTask 每小时被 asynq scheduler 触发。
|
||||
//
|
||||
// SQL 采用两步:
|
||||
// 1. UPDATE lottery_claim SET status='expired' WHERE status='pending_claim' AND expires_at < now
|
||||
// 2. UPDATE lottery_draw SET dispatch_state='expired'
|
||||
// WHERE dispatch_state='pending_claim'
|
||||
// AND id IN (SELECT draw_id FROM lottery_claim WHERE status='expired' AND ...)
|
||||
//
|
||||
// 步骤 1 由 lottery_claim.status 的语义主导;步骤 2 是为了避免 draw 视图脱节。
|
||||
// 单次 batch 不设上限——线上 pending_claim 过期量级远小于 asynq 单任务的处理窗口,
|
||||
// 若未来量级上来再改成 loop + LIMIT。
|
||||
func (l *ExpireClaimLogic) ProcessTask(ctx context.Context, _ *asynq.Task) error {
|
||||
now := time.Now()
|
||||
claimRes := l.svc.DB.WithContext(ctx).
|
||||
Model(&modelLottery.Claim{}).
|
||||
Where("status = ? AND expires_at < ?", modelLottery.ClaimStatusPendingClaim, now).
|
||||
Update("status", modelLottery.ClaimStatusExpired)
|
||||
if claimRes.Error != nil {
|
||||
logger.WithContext(ctx).Error("[LotteryExpireClaim] update lottery_claim failed",
|
||||
logger.Field("error", claimRes.Error.Error()),
|
||||
)
|
||||
return claimRes.Error
|
||||
}
|
||||
if claimRes.RowsAffected == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
drawRes := l.svc.DB.WithContext(ctx).
|
||||
Model(&modelLottery.Draw{}).
|
||||
Where("dispatch_state = ? AND id IN (?)",
|
||||
modelLottery.DispatchStatePendingClaim,
|
||||
l.svc.DB.WithContext(ctx).
|
||||
Model(&modelLottery.Claim{}).
|
||||
Select("draw_id").
|
||||
Where("status = ?", modelLottery.ClaimStatusExpired)).
|
||||
Update("dispatch_state", modelLottery.DispatchStateExpired)
|
||||
if drawRes.Error != nil {
|
||||
// draw 更新失败不阻断——status 已 expired,缺 draw 一致性下次跑还能补上。
|
||||
logger.WithContext(ctx).Error("[LotteryExpireClaim] update lottery_draw failed",
|
||||
logger.Field("error", drawRes.Error.Error()),
|
||||
logger.Field("claim_expired_count", claimRes.RowsAffected),
|
||||
)
|
||||
}
|
||||
logger.WithContext(ctx).Info("[LotteryExpireClaim] expired claims swept",
|
||||
logger.Field("claim_expired_count", claimRes.RowsAffected),
|
||||
logger.Field("draw_updated_count", drawRes.RowsAffected),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// expireClaimLogic_test.go — 用 sqlmock 断言 SQL 契约(不涉及真实 DB)。
|
||||
package lotteryLogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expected, actual string) error {
|
||||
if strings.Contains(actual, expected) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("actual sql does not contain expected: " + expected)
|
||||
})))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock: %v", err)
|
||||
}
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{SkipDefaultTransaction: true})
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
t.Fatalf("gorm: %v", err)
|
||||
}
|
||||
return db, mock, func() { _ = sqlDB.Close() }
|
||||
}
|
||||
|
||||
func TestExpireClaimLogic_NoRowsAffectedSkipsSecondUpdate(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// UPDATE lottery_claim 但 RowsAffected == 0 → 应直接返回,不再打 UPDATE lottery_draw
|
||||
mock.ExpectExec("UPDATE `lottery_claim`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
logic := NewExpireClaimLogic(&svc.ServiceContext{DB: db})
|
||||
if err := logic.ProcessTask(context.Background(), asynq.NewTask("", nil)); err != nil {
|
||||
t.Fatalf("ProcessTask: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpireClaimLogic_ExpiresAndCascadesDraw(t *testing.T) {
|
||||
db, mock, cleanup := newTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec("UPDATE `lottery_claim`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 3))
|
||||
mock.ExpectExec("UPDATE `lottery_draw`").
|
||||
WillReturnResult(sqlmock.NewResult(0, 3))
|
||||
|
||||
logic := NewExpireClaimLogic(&svc.ServiceContext{DB: db})
|
||||
if err := logic.ProcessTask(context.Background(), asynq.NewTask("", nil)); err != nil {
|
||||
t.Fatalf("ProcessTask: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1127,6 +1127,11 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
|
||||
// 普通用户路径(佣金比例=0):只有首单才双方赠N天
|
||||
if orderInfo.IsNew {
|
||||
l.grantGiftDaysToBothParties(ctx, userInfo, orderInfo)
|
||||
// 抽奖钩子(Stage 1):首单成功即算邀请转化,即便本笔无佣金。
|
||||
// referer 通过 userInfo.RefererId 定位,orderNo 作幂等键。
|
||||
if userInfo != nil {
|
||||
l.invokeInviteHookIfEligible(ctx, userInfo.RefererId, orderInfo)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1214,6 +1219,11 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
|
||||
)
|
||||
}
|
||||
|
||||
// 抽奖钩子(Stage 1):仅新购激活时触发(IsNew)。续费走留存路径不再发放
|
||||
// 机会,避免注册后弃号刷奖的黑产。branch B 内不再显式判 IsNew,全部通过
|
||||
// invokeInviteHookIfEligible 收拢,语义唯一入口。
|
||||
l.invokeInviteHookIfEligible(ctx, referer.Id, orderInfo)
|
||||
|
||||
// 有佣金路径:邀请人拿佣金,被邀请用户(首单)拿天数
|
||||
if orderInfo.IsNew {
|
||||
giftTarget := l.resolveGiftTargetUser(ctx, userInfo, orderInfo.SubscriptionUserId)
|
||||
@@ -1223,6 +1233,22 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
|
||||
}
|
||||
}
|
||||
|
||||
// invokeInviteHookIfEligible 是抽奖邀请钩子的唯一入口。所有 handleCommission
|
||||
// 分支都通过它触发,避免"这里加了 IsNew 判断,那里忘了"的漂移。
|
||||
//
|
||||
// 门槛:仅在 orderInfo.IsNew=true(新购首次激活)时触发 —— 这是架构师锁定
|
||||
// 的"首次付款激活"决策。续费是留存事件而非转化事件,不发放抽奖机会(否则
|
||||
// 会催生注册后弃号刷奖的黑产)。
|
||||
func (l *ActivateOrderLogic) invokeInviteHookIfEligible(ctx context.Context, refererID int64, orderInfo *order.Order) {
|
||||
if l.svc == nil || l.svc.LotteryInviteHook == nil || orderInfo == nil {
|
||||
return
|
||||
}
|
||||
if !orderInfo.IsNew {
|
||||
return
|
||||
}
|
||||
l.svc.LotteryInviteHook.OnConversion(ctx, refererID, orderInfo.OrderNo)
|
||||
}
|
||||
|
||||
func (l *ActivateOrderLogic) grantGiftDaysToBothParties(ctx context.Context, referee *user.User, orderInfo *order.Order) {
|
||||
giftDays := l.svc.Config.Invite.GiftDays
|
||||
if giftDays <= 0 || referee == nil || referee.Id == 0 || referee.RefererId == 0 || orderInfo == nil {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package orderLogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
lotteryhook "github.com/perfect-panel/server/internal/logic/lottery/hook"
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
)
|
||||
|
||||
// recordingInviteHook 记录每次 OnConversion 调用,让测试直接断言时序与参数。
|
||||
type recordingInviteHook struct {
|
||||
mu sync.Mutex
|
||||
calls []recordingInviteCall
|
||||
}
|
||||
|
||||
type recordingInviteCall struct {
|
||||
refererID int64
|
||||
orderNo string
|
||||
}
|
||||
|
||||
func (h *recordingInviteHook) OnConversion(_ context.Context, refererID int64, orderNo string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.calls = append(h.calls, recordingInviteCall{refererID: refererID, orderNo: orderNo})
|
||||
}
|
||||
|
||||
// verify recordingInviteHook satisfies the interface at compile time.
|
||||
var _ lotteryhook.InviteHook = (*recordingInviteHook)(nil)
|
||||
|
||||
func (h *recordingInviteHook) snapshot() []recordingInviteCall {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out := make([]recordingInviteCall, len(h.calls))
|
||||
copy(out, h.calls)
|
||||
return out
|
||||
}
|
||||
|
||||
func newLotteryHookTestLogic(hook lotteryhook.InviteHook) *ActivateOrderLogic {
|
||||
return NewActivateOrderLogic(&svc.ServiceContext{LotteryInviteHook: hook})
|
||||
}
|
||||
|
||||
// TestInvokeInviteHook_FiresOnFirstPurchase 保证新购激活会触发抽奖钩子。
|
||||
// Positive-path regression matched against the "首次付款激活" decision.
|
||||
func TestInvokeInviteHook_FiresOnFirstPurchase(t *testing.T) {
|
||||
hook := &recordingInviteHook{}
|
||||
logic := newLotteryHookTestLogic(hook)
|
||||
|
||||
logic.invokeInviteHookIfEligible(context.Background(), 42, &order.Order{
|
||||
OrderNo: "ORD-NEW-1",
|
||||
IsNew: true,
|
||||
})
|
||||
|
||||
calls := hook.snapshot()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 OnConversion call on first purchase, got %+v", calls)
|
||||
}
|
||||
if calls[0].refererID != 42 || calls[0].orderNo != "ORD-NEW-1" {
|
||||
t.Fatalf("wrong args: %+v", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeInviteHook_DoesNotFireOnRenewal 是架构师 R1 打回的关键回归:
|
||||
// 续费付款必须 NOT 触发抽奖钩子,否则 referer 每月拿一次白嫖机会,与"首次
|
||||
// 付款激活"决策相悖。
|
||||
func TestInvokeInviteHook_DoesNotFireOnRenewal(t *testing.T) {
|
||||
hook := &recordingInviteHook{}
|
||||
logic := newLotteryHookTestLogic(hook)
|
||||
|
||||
logic.invokeInviteHookIfEligible(context.Background(), 42, &order.Order{
|
||||
OrderNo: "ORD-RENEWAL-1",
|
||||
IsNew: false,
|
||||
})
|
||||
|
||||
if calls := hook.snapshot(); len(calls) != 0 {
|
||||
t.Fatalf("renewal must NOT trigger invite hook; got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeInviteHook_NilOrderIsSafe 保证空 order 输入不 panic(防御性)。
|
||||
func TestInvokeInviteHook_NilOrderIsSafe(t *testing.T) {
|
||||
hook := &recordingInviteHook{}
|
||||
logic := newLotteryHookTestLogic(hook)
|
||||
logic.invokeInviteHookIfEligible(context.Background(), 42, nil)
|
||||
if calls := hook.snapshot(); len(calls) != 0 {
|
||||
t.Fatalf("nil order must be a no-op; got %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvokeInviteHook_NilHookIsSafe 保证 ServiceContext 里没接线时不 panic。
|
||||
func TestInvokeInviteHook_NilHookIsSafe(t *testing.T) {
|
||||
logic := NewActivateOrderLogic(&svc.ServiceContext{}) // no LotteryInviteHook
|
||||
logic.invokeInviteHookIfEligible(context.Background(), 42, &order.Order{
|
||||
OrderNo: "ORD-1",
|
||||
IsNew: true,
|
||||
})
|
||||
// If we got here without panicking, we pass.
|
||||
}
|
||||
@@ -5,7 +5,8 @@ const (
|
||||
SchedulerTotalServerData = "scheduler:total:server"
|
||||
SchedulerResetTraffic = "scheduler:reset:traffic"
|
||||
SchedulerTrafficStat = "scheduler:traffic:stat"
|
||||
SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单
|
||||
SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账
|
||||
SchedulerStuckOrderRecovery = "scheduler:stuck:order:recovery" // 扫描并恢复超时 claimed 订单
|
||||
SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单
|
||||
SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账
|
||||
SchedulerStuckOrderRecovery = "scheduler:stuck:order:recovery" // 扫描并恢复超时 claimed 订单
|
||||
SchedulerLotteryExpireClaim = "scheduler:lottery:expire:claim" // 抽奖 Stage 2:每小时扫描过期 pending_claim
|
||||
)
|
||||
|
||||
@@ -70,6 +70,12 @@ func (m *Service) Start() {
|
||||
logger.Errorf("register stuck order recovery task failed: %s", err.Error())
|
||||
}
|
||||
|
||||
// Lottery Stage 2: 每小时扫描过期 pending_claim → expired
|
||||
lotteryExpireTask := asynq.NewTask(types.SchedulerLotteryExpireClaim, nil)
|
||||
if _, err := m.server.Register("@every 1h", lotteryExpireTask, asynq.MaxRetry(1)); err != nil {
|
||||
logger.Errorf("register lottery expire claim task failed: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := m.server.Run(); err != nil {
|
||||
logger.Errorf("run scheduler failed: %s", err.Error())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user