diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 43c261ee1..a26107ca5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 067046e47..6f5ae4d9a 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -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), diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 4a92491a0..52fa45072 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -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(()) diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index bf8a5be23..d4192a1cd 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -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,