feat: add invite code validity controls

- Add invite code starts/expires fields across contracts, API, Spacetime bindings, and admin UI
- Enforce pending/expired invite code redemption behavior and expose admin status
- Add admin write-operation confirmation guard and documentation
- Add invite code contract/runtime tests
This commit is contained in:
2026-05-04 12:29:33 +08:00
parent 1142e90a35
commit 9f3e34e81a
27 changed files with 1465 additions and 97 deletions

View File

@@ -44,6 +44,7 @@ use shared_contracts::runtime::{
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, TRACKING_SCOPE_KIND_MODULE,
TRACKING_SCOPE_KIND_SITE, TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
@@ -369,6 +370,14 @@ pub async fn admin_upsert_profile_task_config(
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
// 中文注释:个人任务配置首版只开放 User scopeHTTP 层先返回清晰错误,领域层再兜底。
if scope_kind != RuntimeTrackingScopeKind::User {
return Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("个人任务 scopeKind 首版仅支持 user"),
));
}
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
@@ -558,6 +567,10 @@ pub async fn admin_upsert_profile_invite_code(
) -> Result<Json<Value>, Response> {
let metadata_json = normalize_admin_invite_code_metadata(payload.metadata)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let starts_at_micros = parse_admin_invite_code_time_field("startsAt", payload.starts_at)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let expires_at_micros = parse_admin_invite_code_time_field("expiresAt", payload.expires_at)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
@@ -565,6 +578,8 @@ pub async fn admin_upsert_profile_invite_code(
admin.session().username.clone(),
payload.invite_code,
metadata_json,
starts_at_micros,
expires_at_micros,
updated_at_micros as i64,
)
.await
@@ -873,6 +888,27 @@ fn normalize_admin_invite_code_metadata(metadata: Option<Value>) -> Result<Strin
Ok(metadata_json)
}
fn parse_admin_invite_code_time_field(
field: &'static str,
value: Option<String>,
) -> Result<Option<i64>, AppError> {
let Some(value) = value else {
return Ok(None);
};
let value = value.trim();
if value.is_empty() {
return Ok(None);
}
let parsed = parse_rfc3339(value).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(format!("邀请码 {field} 必须是 RFC3339 时间字符串"))
.with_details(json!({ "field": field, "message": error }))
})?;
Ok(Some(offset_datetime_to_unix_micros(parsed)))
}
fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"public" => Ok(RuntimeProfileRedeemCodeMode::Public),
@@ -932,6 +968,9 @@ fn build_profile_invite_code_admin_response(
user_id: record.user_id,
invite_code: record.invite_code,
metadata,
starts_at: record.starts_at,
expires_at: record.expires_at,
status: record.status.as_str().to_string(),
created_at: record.created_at,
updated_at: record.updated_at,
}
@@ -1256,9 +1295,8 @@ mod tests {
#[tokio::test]
async fn admin_profile_task_routes_require_admin_authentication() {
let app = build_router(
AppState::new(admin_enabled_test_config()).expect("state should build"),
);
let app =
build_router(AppState::new(admin_enabled_test_config()).expect("state should build"));
let list_response = app
.clone()
@@ -1302,9 +1340,8 @@ mod tests {
#[tokio::test]
async fn admin_profile_code_list_routes_require_admin_authentication() {
let app = build_router(
AppState::new(admin_enabled_test_config()).expect("state should build"),
);
let app =
build_router(AppState::new(admin_enabled_test_config()).expect("state should build"));
for uri in [
"/admin/api/profile/redeem-codes",