Compare commits

..

1 Commits

Author SHA1 Message Date
shanshanzhong147 a615724580 fix: align revenue statistics with order type
持续集成 / 构建/Vet/测试 (pull_request) Has been cancelled
持续集成 / golangci-lint (pull_request) Has been cancelled
测试环境部署 / 构建镜像并部署到测试环境 (push) Has been cancelled
2026-06-23 09:12:42 -07:00
89 changed files with 87 additions and 10219 deletions
-1
View File
@@ -43,7 +43,6 @@ logs/
/test/
*_test.go
!tests/acceptance/*_test.go
!internal/handler/subscribe_test.go
*_test_config.go
**/logtest/
*_test.yaml
-86
View File
@@ -1,86 +0,0 @@
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)
}
-33
View File
@@ -1,33 +0,0 @@
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)
}
-2
View File
@@ -82,7 +82,6 @@ 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
@@ -146,7 +145,6 @@ 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
-4
View File
@@ -54,8 +54,6 @@ 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=
@@ -397,8 +395,6 @@ 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=
@@ -1,11 +0,0 @@
-- 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`;
@@ -1,125 +0,0 @@
-- 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='抽奖时刻的门槛评估快照(对账/申诉用)';
@@ -1,2 +0,0 @@
-- 02157 抽奖发奖账本回滚
DROP TABLE IF EXISTS `lottery_grant_ledger`;
@@ -1,25 +0,0 @@
-- 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';
@@ -1,2 +0,0 @@
-- 02158 admin_action_log 回滚
DROP TABLE IF EXISTS `admin_action_log`;
@@ -1,21 +0,0 @@
-- 02158 admin_action_log —— 管理端写操作审计(PR C 起要求)
--
-- 每一条 admin CRUD/rules 更新都在同事务内插入一行审计流水,方便后续追责
-- 与合规审查。actor_user_id 是操作者的 user.idaction 是操作动作
-- 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='后台写操作审计流水';
@@ -1,3 +0,0 @@
-- 02159 抽奖活动 Stage 2 down migration
-- Stage 2 只新增 1 张表,回滚直接 drop 即可。
DROP TABLE IF EXISTS `lottery_claim`;
@@ -1,47 +0,0 @@
-- 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='抽奖人工奖领奖工单';
-3
View File
@@ -70,6 +70,3 @@ 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:"
-8
View File
@@ -39,7 +39,6 @@ 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"`
@@ -251,13 +250,6 @@ 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 短链接
@@ -1,74 +0,0 @@
// 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)
}
}
@@ -1,169 +0,0 @@
// 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))
}
}
-51
View File
@@ -1,51 +0,0 @@
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))
}
}
@@ -1,69 +0,0 @@
// 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)
}
}
-3
View File
@@ -1221,7 +1221,4 @@ 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)
}
+1 -2
View File
@@ -9,7 +9,6 @@ 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"
)
@@ -85,7 +84,7 @@ func SubscribeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
l := subscribe.NewSubscribeLogic(c, svcCtx)
resp, err := l.Handler(&req)
if err != nil {
result.HttpResult(c, nil, err)
c.String(http.StatusInternalServerError, "Internal Server")
return
}
c.Header("subscription-userinfo", resp.Header)
-332
View File
@@ -1,332 +0,0 @@
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
}
@@ -1,471 +0,0 @@
// 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 → rejectedexpires_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
}
}
// overduepending_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
}
@@ -1,122 +0,0 @@
// 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)
}
}
-688
View File
@@ -1,688 +0,0 @@
// 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:])
}
@@ -1,134 +0,0 @@
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)
}
}
@@ -1,51 +0,0 @@
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,7 +2,11 @@ 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"
@@ -46,11 +50,84 @@ func (l *KickOfflineByUserDeviceLogic) KickOfflineByUserDevice(req *types.KickOf
// clearAllSessions 清除指定用户的所有会话(通过 SCAN 查找,不依赖 sorted set
func (l *KickOfflineByUserDeviceLogic) clearAllSessions(userId int64) {
if err := clearAllSessions(l.ctx, l.svcCtx, userId); err != nil {
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 {
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)),
)
}
-81
View File
@@ -1,81 +0,0 @@
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
}
-74
View File
@@ -1,74 +0,0 @@
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,14 +44,6 @@ 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 {
@@ -184,19 +176,6 @@ 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
}
-100
View File
@@ -1,100 +0,0 @@
// 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]
}
@@ -1,111 +0,0 @@
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,7 +7,6 @@ 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"
@@ -20,7 +19,6 @@ 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"
)
@@ -96,9 +94,6 @@ 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 != "" {
@@ -135,7 +130,6 @@ 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,
-6
View File
@@ -7,7 +7,6 @@ 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"
@@ -19,7 +18,6 @@ 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"
)
@@ -116,9 +114,6 @@ 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() {
@@ -193,7 +188,6 @@ 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,7 +9,6 @@ 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"
@@ -24,7 +23,6 @@ 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"
)
@@ -76,9 +74,6 @@ 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 {
@@ -633,7 +628,6 @@ 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,7 +21,6 @@ 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"
)
@@ -65,9 +64,6 @@ 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 {
@@ -169,7 +165,6 @@ 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,
-6
View File
@@ -5,7 +5,6 @@ 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"
@@ -18,7 +17,6 @@ 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"
@@ -91,9 +89,6 @@ 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()
@@ -140,7 +135,6 @@ 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,
+1 -1
View File
@@ -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.UserNotExist), "user email deleted: %v", req.Email)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserDisabled), "user email deleted: %v", req.Email)
}
if !registerIpLimit(l.svcCtx, l.ctx, req.IP, "email", req.Email) {
-89
View File
@@ -1,89 +0,0 @@
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
}
-266
View File
@@ -1,266 +0,0 @@
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)
}
}
-642
View File
@@ -1,642 +0,0 @@
// 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
}
-765
View File
@@ -1,765 +0,0 @@
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)
}
}
@@ -1,128 +0,0 @@
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
}
@@ -1,32 +0,0 @@
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
}
@@ -1,185 +0,0 @@
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")
}
}
@@ -1,280 +0,0 @@
// 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;解码失败返回 ErrClaimDataMalformedwrap 原因)。
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
}
@@ -1,252 +0,0 @@
// 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)
}
}
}
@@ -1,12 +0,0 @@
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)
}
@@ -1,240 +0,0 @@
// 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 cyclecommon 包里有别的文件反向 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
}
@@ -1,345 +0,0 @@
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
}
-140
View File
@@ -1,140 +0,0 @@
// 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
}
-191
View File
@@ -1,191 +0,0 @@
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)
}
}
-112
View File
@@ -1,112 +0,0 @@
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'
}
@@ -1,127 +0,0 @@
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)
}
}
-499
View File
@@ -1,499 +0,0 @@
// 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 上下文。
// 匿名 / 未登录返回 0handler 侧应当由 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 需要 RuleContextBuilderPR 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
}
@@ -1,49 +0,0 @@
// 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,7 +7,6 @@ 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"
@@ -80,10 +79,6 @@ 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() {
@@ -1,26 +0,0 @@
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()
}
}
@@ -1,81 +0,0 @@
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)
}
}
+3 -4
View File
@@ -6,7 +6,6 @@ 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"
@@ -88,10 +87,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 := logiccommon.ResolveEnabledUser(c, svc, userId)
userInfo, err := svc.UserModel.FindOne(c, userId)
if err != nil {
logger.WithContext(c.Request.Context()).Debug("[AuthMiddleware] ResolveEnabledUser", logger.Field("error", err.Error()), logger.Field("userId", userId))
result.HttpResult(c, nil, err)
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"))
c.Abort()
return false
}
@@ -9,7 +9,6 @@ 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"
)
@@ -67,8 +66,6 @@ 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)
-1
View File
@@ -52,7 +52,6 @@ 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
)
-116
View File
@@ -1,116 +0,0 @@
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
}
@@ -1,197 +0,0 @@
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_grantUNIQUE 冲突 → 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)
}
}
-78
View File
@@ -1,78 +0,0 @@
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 覆盖真实 payloadReserve 阶
// 段的空 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
}
-188
View File
@@ -1,188 +0,0 @@
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
}
-251
View File
@@ -1,251 +0,0 @@
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"`
}
-57
View File
@@ -1,57 +0,0 @@
package lottery
import (
"context"
"encoding/json"
"sync"
"gorm.io/gorm"
)
// defaultRegistry 是并发安全的默认 Registry。启动时一次性注册,运行时只读。
type defaultRegistry struct {
mu sync.RWMutex
handlers map[string]PrizeHandler
}
// NewRegistry 返回空的默认注册表。使用 Register 挂接实现。
// 实际业务 handlervpn_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 }
-46
View File
@@ -1,46 +0,0 @@
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)
}
}
-252
View File
@@ -1,252 +0,0 @@
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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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
}
@@ -1,193 +0,0 @@
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")
}
}
-139
View File
@@ -1,139 +0,0 @@
// 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 的人工奖 handlerDispatch 不会被抽奖服务调用;
// 实现返回 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 SchemaStage 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")
-66
View File
@@ -1,66 +0,0 @@
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
}
@@ -1,76 +0,0 @@
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)
}
}
}
-52
View File
@@ -11,9 +11,6 @@ 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"
@@ -22,7 +19,6 @@ 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"
@@ -75,13 +71,6 @@ 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
@@ -159,47 +148,6 @@ 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 留 nilPR C 阶段无活动配置门槛不评估用户上下文;后续
// 可接一个真实的 RuleContextBuilder(读订阅/邀请/充值/tags)。
})
if c.S3.Enable {
s3Store, err := storage.NewS3Store(context.Background(), c.S3)
if err != nil {
-389
View File
@@ -1,389 +0,0 @@
// 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 → 1Size<=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"`
// GridSize0 由 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 → 1Size<=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 → rejectedreason 必填。
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_hashphysical 必填
// delivery_refmanual_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"`
}
-6
View File
@@ -16,10 +16,4 @@ 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"
)
-27
View File
@@ -150,30 +150,3 @@ 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
)
-17
View File
@@ -113,23 +113,6 @@ 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",
}
-138
View File
@@ -1,138 +0,0 @@
#!/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 "──────────────────────────────────────────"
-215
View File
@@ -1,215 +0,0 @@
#!/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
View File
@@ -4,7 +4,6 @@ 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"
@@ -52,7 +51,4 @@ 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))
}
-75
View File
@@ -1,75 +0,0 @@
// 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
}
@@ -1,69 +0,0 @@
// 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)
}
}
-26
View File
@@ -1127,11 +1127,6 @@ 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
}
@@ -1219,11 +1214,6 @@ 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)
@@ -1233,22 +1223,6 @@ 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 {
-100
View File
@@ -1,100 +0,0 @@
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.
}
+3 -4
View File
@@ -5,8 +5,7 @@ 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 订单
SchedulerLotteryExpireClaim = "scheduler:lottery:expire:claim" // 抽奖 Stage 2:每小时扫描过期 pending_claim
SchedulerIAPReconcile = "scheduler:iap:reconcile" // 第二层:每 5 分钟扫描待支付 IAP 订单
SchedulerIAPDailyReconcile = "scheduler:iap:daily:reconcile" // 第三层:日终全量对账
SchedulerStuckOrderRecovery = "scheduler:stuck:order:recovery" // 扫描并恢复超时 claimed 订单
)
-6
View File
@@ -70,12 +70,6 @@ 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())
}