修正兑换码按用户限兑校验

唯一码和私有码改为按用户限制重复兑换

兑换失败日志补充请求与用户上下文

补充兑换码模式校验测试

同步共享决策记录
This commit is contained in:
2026-06-30 18:22:48 +08:00
parent daffdef5d0
commit cf585b6316
4 changed files with 37 additions and 3 deletions
@@ -3758,3 +3758,10 @@
- 指标口径:生产素材数统计 `editor_project_resource.source_type = generated`;消耗泥点数统计 `profile_wallet_ledger.source_type = asset_operation_consume` 的负向流水绝对值;总注册用户和新增用户数均来自 `profile_dashboard_state`,其中新增用户数按 `created_at` 落入当前时间窗统计;访问次数只统计 `tracking_daily_stat.scope_kind = site`;访问人数和当前使用人数按登录用户去重,匿名访问人数需要未来补 visitor id 后才能统计。
- 影响范围:`/admin/api/dashboard``shared-contracts` admin DTO、`apps/admin-web` 默认路由和 Dashboard 页面、后台运营文档。
- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml admin``npm run admin-web:typecheck``npx vitest run apps/admin-web/src/pages/AdminDashboardPage.test.tsx apps/admin-web/src/app/adminRoutes.test.ts --reporter verbose``npm run check:encoding``git diff --check`
## 2026-06-30 唯一码和私有码按用户限兑一次
- 背景:运营私有码按内部 user_id 指定用户后,指定用户仍可能无法兑换;排查 release `SEEDUSERLUO0630` 时确认兑换校验把私有码当成全局次数上限,而不是每个允许用户各自限兑一次。
- 决策:兑换码使用校验中,公共码继续按 `max_uses` 控制单用户可兑次数;唯一码和私有码改为同一 `code + user_id` 只能成功兑换一次。私有码仍先校验 `allowed_user_ids`,命中允许名单后再按该用户历史使用次数拒绝重复兑换;`global_used_count` 只保留为统计字段,不再作为唯一码 / 私有码的兑换阻断条件。
- 影响范围:`module-runtime::validate_runtime_profile_redeem_code_usage``profile_redeem_code_usage` 计次语义。
- 验证方式:`cargo test -p module-runtime --manifest-path server-rs/Cargo.toml runtime_profile_redeem_code_usage_validation_matches_modes``npm run check:encoding``git diff --check`
@@ -601,9 +601,15 @@ pub async fn redeem_profile_reward_code(
let redeemed_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.redeem_profile_reward_code(user_id, payload.code, redeemed_at_micros as i64)
.redeem_profile_reward_code(user_id.clone(), payload.code, redeemed_at_micros as i64)
.await
.map_err(|error| {
tracing::warn!(
request_id = request_context.request_id(),
user_id = %user_id,
error = %error,
"个人中心兑换码兑换失败"
);
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
@@ -1837,14 +1837,14 @@ pub fn validate_runtime_profile_redeem_code_usage(
RuntimeProfileRedeemCodeMode::Public if user_used_count >= code.max_uses => {
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
}
RuntimeProfileRedeemCodeMode::Unique if code.global_used_count >= code.max_uses => {
RuntimeProfileRedeemCodeMode::Unique if user_used_count >= 1 => {
Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted)
}
RuntimeProfileRedeemCodeMode::Private => {
if !code.allowed_user_ids.iter().any(|item| item == user_id) {
return Err(RuntimeProfileFieldError::RedeemCodeNotAllowedForUser);
}
if code.global_used_count >= code.max_uses {
if user_used_count >= 1 {
return Err(RuntimeProfileFieldError::RedeemCodeUsesExhausted);
}
Ok(())
@@ -1188,9 +1188,23 @@ mod tests {
RuntimeProfileFieldError::RedeemCodeUsesExhausted
);
let unique = RuntimeProfileRedeemCodeSnapshot {
mode: RuntimeProfileRedeemCodeMode::Unique,
global_used_count: 9,
..base.clone()
};
validate_runtime_profile_redeem_code_usage(&unique, "user-1", 0)
.expect("unique code should allow a user that has not redeemed it");
assert_eq!(
validate_runtime_profile_redeem_code_usage(&unique, "user-1", 1)
.expect_err("unique code should reject the same user twice"),
RuntimeProfileFieldError::RedeemCodeUsesExhausted
);
let private = RuntimeProfileRedeemCodeSnapshot {
mode: RuntimeProfileRedeemCodeMode::Private,
allowed_user_ids: vec!["user-2".to_string()],
global_used_count: 9,
..base.clone()
};
assert_eq!(
@@ -1198,6 +1212,13 @@ mod tests {
.expect_err("private code should check allow list"),
RuntimeProfileFieldError::RedeemCodeNotAllowedForUser
);
validate_runtime_profile_redeem_code_usage(&private, "user-2", 0)
.expect("private code should allow an allowed user that has not redeemed it");
assert_eq!(
validate_runtime_profile_redeem_code_usage(&private, "user-2", 1)
.expect_err("private code should reject the same allowed user twice"),
RuntimeProfileFieldError::RedeemCodeUsesExhausted
);
let disabled = RuntimeProfileRedeemCodeSnapshot {
enabled: false,