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:
@@ -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 scope,HTTP 层先返回清晰错误,领域层再兜底。
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user