新功能(#4): 抽奖 Stage 2 人工奖领奖工单(crypto / physical / manual_other)

Closes HIF-4

Stage 2 交付:人工奖领奖工单完整闭环。crypto / physical / manual_other 三类奖品从抽中到 mark-paid 的全流程可用。

- 迁移 02159_lottery_claim:UNIQUE(draw_id) + 3 支持索引,状态机 pending_claim→reviewing→paying→paid,rejected 可复活,超时 expired
- 3 个 PrizeHandler:Dispatch→ErrDispatchNotSupported 兜底、ClaimSchema 各自形态、ValidateClaim 表驱动
- BuildCryptoClaimSchema:抽中时按奖品 config.networks 注入 enum,前端下拉直接可用
- Draw service dispatchOrEnqueueClaim:人工奖同 tx 插 pending_claim(回滚双清),nonce 重放回读 ExpiresAt + ClaimFormSchema
- POST /claim 实装:ownership 校验 → prize 类型校验 → handler.ValidateClaim → crypto network 白名单二次校验 → tx CAS status IN (pending_claim, rejected) AND expires_at > now
- Admin CRUD 5 接口:list(IN 批拉 snap + user,无 N+1)、summary(GROUP BY 一次拿计数 + overdue 单查)、approve/reject/mark-paid 全走 CAS + audit
- Scheduler @every 1h 扫过期,级联 lottery_draw.dispatch_state → expired
- 新增错误码 100005-100011(already_submitted / invalid_claim_data / draw_not_found / not_your_draw / claim_expired / claim_state_invalid)
- Rebase 后 Stage 2 测试主动 reuse PR E 的 unmetReasonsNotEmpty + evaluatedAtNotZero matcher,人工奖分支若绕过守卫会立即挂
- Stage 1 全部 4 处 guardrail 后端 rebase 时自检过:UnmetReasons、EvaluatedAt、GrantLedger.Payload、AdminMetaMiddleware 全保留

CI 全绿;28 files, +2442/-126;覆盖率 handler 78.9% / model.lottery 74.3% / draw 68.7% / queue/lottery 76.9%
This commit is contained in:
2026-07-10 04:01:34 -07:00
committed by GitHub
parent 117dc0d6a7
commit 07409eb602
28 changed files with 2471 additions and 133 deletions
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# HIF-4 Stage 2 lottery — end-to-end curl for manual-claim workflow (crypto / physical / manual_other).
#
# 环境准备:
# 1. Lottery: { Enable: true } in config.yaml
# 2. 已有可用的 admin token 和 user token
# 3. 已跑过 Stage 1 且现有活动状态是 running(或者用本脚本重新建一个)
#
# 使用:
# BASE_URL=http://127.0.0.1:8080 ADMIN_TOKEN=<jwt> USER_TOKEN=<jwt> USER_ID=<int> \
# bash qa/lottery/stage2_curl.sh
#
# 覆盖:
# ✓ 三种人工奖类型都能配置、抽中、领取
# ✓ 状态机:pending_claim → reviewing → paying → paid
# ✓ 状态机:reviewing → rejected → reviewing(用户重新提交)
# ✓ 过期窗口读取(claim_ttl_hours 覆盖默认 7d
# ✓ 后台工单列表 + summary
# ✓ mark-paid 前置校验(crypto 必填 tx_hash / physical 必填 delivery_ref
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
ADMIN_TOKEN="${ADMIN_TOKEN:-CHANGE_ME}"
USER_TOKEN="${USER_TOKEN:-CHANGE_ME}"
USER_ID="${USER_ID:-1}"
hdr_admin=(-H "Authorization: Bearer ${ADMIN_TOKEN}" -H "Content-Type: application/json")
hdr_user=(-H "Authorization: Bearer ${USER_TOKEN}" -H "Content-Type: application/json")
log() { printf '\n\033[1;34m▶ %s\033[0m\n' "$*"; }
expect_ok() {
local body="$1" step="$2" code
code=$(printf '%s' "$body" | jq -r '.code // 0')
if [[ "$code" != "0" && "$code" != "200" ]]; then
printf '\033[1;31m✗ %s failed: %s\033[0m\n' "$step" "$body" >&2
exit 1
fi
printf '\033[1;32m✓ %s\033[0m\n' "$step"
}
expect_code() {
local body="$1" step="$2" expected="$3" code
code=$(printf '%s' "$body" | jq -r '.code // 0')
if [[ "$code" != "$expected" ]]; then
printf '\033[1;31m✗ %s: expected code=%s, got %s (body=%s)\033[0m\n' "$step" "$expected" "$code" "$body" >&2
exit 1
fi
printf '\033[1;32m✓ %s (code=%s)\033[0m\n' "$step" "$expected"
}
# ---- Admin: build activity + three manual prizes -------------------------
log "Create Stage 2 activity"
start_at=$(date +%s)
end_at=$((start_at + 86400 * 30))
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/activities" "${hdr_admin[@]}" -d "$(cat <<JSON
{"title":"QA Stage 2 抽奖","description":"人工奖 e2e","start_at":${start_at},"end_at":${end_at},"grid_size":9,
"eligibility":{},"chance_sources":[{"source":"manual_grant","amount":10}],"unmet_action":"block"}
JSON
)")
expect_ok "$resp" "activity create"
ACTIVITY_ID=$(printf '%s' "$resp" | jq -r '.data.id')
log "Add crypto prize (BTC, unlimited, 48h TTL)"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "$(cat <<JSON
{"activity_id":${ACTIVITY_ID},"slot":0,"type":"crypto","name":"1 BTC","icon_url":"",
"config":{"amount":"1","currency":"BTC","networks":["BTC","TRX"],"claim_ttl_hours":48},
"weight":34,"is_fallback":false}
JSON
)")
expect_ok "$resp" "prize crypto"
CRYPTO_PRIZE_ID=$(printf '%s' "$resp" | jq -r '.data.id')
log "Add physical prize (T-shirt)"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "$(cat <<JSON
{"activity_id":${ACTIVITY_ID},"slot":1,"type":"physical","name":"限量 T 恤","icon_url":"",
"config":{"sku_id":"tee-01","sku_name":"限量 T 恤"},
"weight":33,"is_fallback":false}
JSON
)")
expect_ok "$resp" "prize physical"
log "Add manual_other prize"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/prizes" "${hdr_admin[@]}" -d "$(cat <<JSON
{"activity_id":${ACTIVITY_ID},"slot":2,"type":"manual_other","name":"运营手工奖","icon_url":"",
"config":{"desc":"运营线下联系发放"},
"weight":33,"is_fallback":false}
JSON
)")
expect_ok "$resp" "prize manual_other"
log "Publish activity"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/activities/publish" "${hdr_admin[@]}" -d "{\"id\":${ACTIVITY_ID}}")
expect_ok "$resp" "publish"
log "Grant 10 chances to user ${USER_ID}"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/chances/grant" "${hdr_admin[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"user_id\":${USER_ID},\"amount\":10,\"source_ref\":\"qa-stage2\"}")
expect_ok "$resp" "chances grant"
# ---- User: draw until we land on a crypto prize -------------------------
CRYPTO_DRAW_ID=""
attempts=0
while [[ -z "${CRYPTO_DRAW_ID}" && "$attempts" -lt 20 ]]; do
attempts=$((attempts + 1))
nonce=$(uuidgen 2>/dev/null || python3 -c "import uuid;print(uuid.uuid4())")
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/draw" "${hdr_user[@]}" -d "{\"activity_id\":${ACTIVITY_ID},\"client_nonce\":\"${nonce}\"}")
expect_ok "$resp" "draw #${attempts}"
ptype=$(printf '%s' "$resp" | jq -r '.data.prize.type // ""')
if [[ "$ptype" == "crypto" ]]; then
CRYPTO_DRAW_ID=$(printf '%s' "$resp" | jq -r '.data.draw_id')
printf 'crypto draw id: %s\n' "$CRYPTO_DRAW_ID"
printf 'claim response fields:\n%s\n' "$(printf '%s' "$resp" | jq '.data.claim')"
# 验证 schema 包含 enum
if ! printf '%s' "$resp" | jq -e '.data.claim.claim_form_schema.properties.network.enum | length > 0' >/dev/null; then
echo "✗ crypto schema missing network enum" >&2
exit 1
fi
fi
done
if [[ -z "$CRYPTO_DRAW_ID" ]]; then
echo "✗ 20 draws still no crypto — weight/rand seed check" >&2
exit 1
fi
# ---- User: submit claim (crypto) ----------------------------------------
log "POST /claim with valid BTC address"
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/claim" "${hdr_user[@]}" -d "$(cat <<JSON
{"draw_id":${CRYPTO_DRAW_ID},"claim_data":{"network":"BTC","address":"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"}}
JSON
)")
expect_ok "$resp" "claim submit (crypto)"
log "POST /claim second time — expect 100005 already_submitted"
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/claim" "${hdr_user[@]}" -d "$(cat <<JSON
{"draw_id":${CRYPTO_DRAW_ID},"claim_data":{"network":"BTC","address":"bc1qanotheraddresslongenoughxxxx"}}
JSON
)")
expect_code "$resp" "claim duplicate reject" "100005"
# ---- Admin: find claim id in list ----------------------------------------
log "GET /admin/lottery/claims?status=reviewing"
resp=$(curl -sS "${BASE_URL}/v1/admin/lottery/claims?status=reviewing&activity_id=${ACTIVITY_ID}" "${hdr_admin[@]}")
expect_ok "$resp" "list claims"
CLAIM_ID=$(printf '%s' "$resp" | jq -r ".data.claims[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .id" | head -1)
if [[ -z "$CLAIM_ID" ]]; then
echo "✗ could not locate claim for crypto draw ${CRYPTO_DRAW_ID}" >&2
exit 1
fi
printf 'claim id: %s\n' "$CLAIM_ID"
# ---- Admin: reject then user resubmits ----------------------------------
log "POST /claims/reject"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/reject" "${hdr_admin[@]}" -d "{\"id\":${CLAIM_ID},\"reason\":\"地址格式待核对\"}")
expect_ok "$resp" "reject"
log "GET /records — should show rejected + claim_form_schema"
resp=$(curl -sS "${BASE_URL}/v1/lottery/records?activity_id=${ACTIVITY_ID}" "${hdr_user[@]}")
expect_ok "$resp" "records after reject"
status=$(printf '%s' "$resp" | jq -r ".data.list[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .claim.status")
if [[ "$status" != "rejected" ]]; then
echo "✗ expected status=rejected, got '$status'" >&2
exit 1
fi
if ! printf '%s' "$resp" | jq -e ".data.list[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .claim.claim_form_schema.properties.network.enum" >/dev/null; then
echo "✗ rejected state must still expose claim_form_schema for resubmit" >&2
exit 1
fi
log "User resubmits after reject"
resp=$(curl -sS -X POST "${BASE_URL}/v1/lottery/claim" "${hdr_user[@]}" -d "$(cat <<JSON
{"draw_id":${CRYPTO_DRAW_ID},"claim_data":{"network":"BTC","address":"bc1qgoodaddresslongenoughforthevalidator1"}}
JSON
)")
expect_ok "$resp" "resubmit after reject"
# ---- Admin: approve then mark-paid --------------------------------------
log "POST /claims/approve (reviewing → paying)"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/approve" "${hdr_admin[@]}" -d "{\"id\":${CLAIM_ID}}")
expect_ok "$resp" "approve"
log "POST /claims/mark-paid (missing tx_hash → 400)"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/mark-paid" "${hdr_admin[@]}" -d "{\"id\":${CLAIM_ID}}")
expect_code "$resp" "mark-paid rejects empty tx_hash for crypto" "400"
log "POST /claims/mark-paid (with tx_hash)"
resp=$(curl -sS -X POST "${BASE_URL}/v1/admin/lottery/claims/mark-paid" "${hdr_admin[@]}" -d "$(cat <<JSON
{"id":${CLAIM_ID},"tx_hash":"0xdeadbeefcafebabe12345678"}
JSON
)")
expect_ok "$resp" "mark paid"
log "GET /records — final should show paid + tx_hash"
resp=$(curl -sS "${BASE_URL}/v1/lottery/records?activity_id=${ACTIVITY_ID}" "${hdr_user[@]}")
expect_ok "$resp" "records final"
tx=$(printf '%s' "$resp" | jq -r ".data.list[] | select(.draw_id == ${CRYPTO_DRAW_ID}) | .claim.tx_hash")
if [[ "$tx" != "0xdeadbeefcafebabe12345678" ]]; then
echo "✗ expected tx_hash=0xdead..., got '$tx'" >&2
exit 1
fi
# ---- Summary endpoint ---------------------------------------------------
log "GET /admin/lottery/claims/summary"
resp=$(curl -sS "${BASE_URL}/v1/admin/lottery/claims/summary" "${hdr_admin[@]}")
expect_ok "$resp" "summary"
printf 'summary:\n%s\n' "$(printf '%s' "$resp" | jq '.data')"
printf '\n\033[1;32m✅ Stage 2 end-to-end curl passed.\033[0m\n'