Files
Genarrative/server-rs/crates/api-server/src/runtime_profile.rs
2026-05-31 22:44:22 +08:00

2951 lines
110 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use axum::{
Json,
extract::{Extension, Path, Query, State},
http::{HeaderMap, StatusCode},
response::Response,
};
use hmac::{Hmac, Mac};
use module_runtime::{
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord,
RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord,
RuntimeProfileInviteCodeRecord, RuntimeProfileMembershipBenefitRecord,
RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRechargeProductKind,
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord,
RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus,
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord,
RuntimeTrackingScopeKind,
};
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::Sha256;
use shared_contracts::runtime::{
ANALYTICS_GRANULARITY_DAY, ANALYTICS_GRANULARITY_MONTH, ANALYTICS_GRANULARITY_QUARTER,
ANALYTICS_GRANULARITY_WEEK, ANALYTICS_GRANULARITY_YEAR, AdminDisableProfileRedeemCodeRequest,
AdminDisableProfileTaskConfigRequest, AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest, AnalyticsBucketMetricResponse,
AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderRequest,
CreateProfileRechargeOrderResponse, PROFILE_FEEDBACK_STATUS_OPEN,
PROFILE_MEMBERSHIP_TIER_MONTH, PROFILE_MEMBERSHIP_TIER_NORMAL, PROFILE_MEMBERSHIP_TIER_SEASON,
PROFILE_MEMBERSHIP_TIER_YEAR, PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP,
PROFILE_RECHARGE_PRODUCT_KIND_POINTS, PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE,
PROFILE_TASK_STATUS_CLAIMED, PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse,
ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse,
ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse,
ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse,
ProfileReferralInvitedUserResponse, ProfileTaskCenterResponse,
ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, ProfileTaskItemResponse,
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest,
SubmitProfileFeedbackResponse, TRACKING_SCOPE_KIND_MODULE, TRACKING_SCOPE_KIND_SITE,
TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK, WechatMiniProgramPaymentParamsResponse,
WechatMiniProgramVirtualPayParamsResponse,
};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
use crate::{
admin::AuthenticatedAdmin,
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
request_context::RequestContext,
state::AppState,
wechat_pay::{
WechatPayNotifyOrder, build_wechat_payment_request, build_wechat_web_payment_request,
current_unix_micros, map_wechat_pay_error,
},
};
type HmacSha256 = Hmac<Sha256>;
pub async fn get_profile_dashboard(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_dashboard(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileDashboardSummaryResponse {
wallet_balance: record.wallet_balance,
total_play_time_ms: record.total_play_time_ms,
played_world_count: record.played_world_count,
updated_at: record.updated_at,
},
))
}
pub async fn get_profile_wallet_ledger(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let entries = state
.spacetime_client()
.list_profile_wallet_ledger(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileWalletLedgerResponse {
entries: entries
.into_iter()
.map(build_profile_wallet_ledger_entry_response)
.collect(),
},
))
}
fn format_profile_wallet_ledger_source_type(
source_type: RuntimeProfileWalletLedgerSourceType,
) -> &'static str {
match source_type {
RuntimeProfileWalletLedgerSourceType::SnapshotSync => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC
}
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD
}
RuntimeProfileWalletLedgerSourceType::InviteInviterReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD
}
RuntimeProfileWalletLedgerSourceType::InviteInviteeReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD
}
RuntimeProfileWalletLedgerSourceType::PointsRecharge => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE
}
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
}
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
}
RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD
}
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
}
RuntimeProfileWalletLedgerSourceType::DailyTaskReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD
}
}
}
pub async fn get_profile_recharge_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_recharge_center(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_recharge_center_response(record),
))
}
pub async fn create_profile_recharge_order(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
Json(payload): Json<CreateProfileRechargeOrderRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let payment_channel = normalize_recharge_payment_channel(payload.payment_channel)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
validate_recharge_device_for_payment_channel(authenticated.claims(), &payment_channel)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
validate_recharge_payment_channel(&state, &payment_channel)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let created_at_micros = current_unix_micros();
let (center, order) = state
.spacetime_client()
.create_profile_recharge_order(
user_id,
payload.product_id,
payment_channel.clone(),
created_at_micros,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
let wechat_mini_program_pay_params = if payment_channel
== PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
{
let identity = resolve_wechat_identity_for_payment(&state, &order.user_id)
.await
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
Some(WechatMiniProgramPaymentParamsResponse::Ordinary(
state
.wechat_pay_client()
.create_mini_program_order(build_wechat_payment_request(
order.order_id.clone(),
order.product_title.clone(),
order.amount_cents,
identity,
))
.await
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?,
))
} else if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
let openid = resolve_wechat_identity_for_payment(&state, &order.user_id)
.await
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
Some(
build_wechat_virtual_pay_params(&state, &center, &order, &openid)
.map(WechatMiniProgramPaymentParamsResponse::Virtual)
.map_err(|error| runtime_profile_error_response(&request_context, error))?,
)
} else {
None
};
let wechat_h5_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 {
Some(
state
.wechat_pay_client()
.create_h5_order(build_wechat_web_payment_request(
order.order_id.clone(),
order.product_title.clone(),
order.amount_cents,
resolve_wechat_pay_client_ip(&headers),
))
.await
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?,
)
} else {
None
};
let wechat_native_payment = if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
{
Some(
state
.wechat_pay_client()
.create_native_order(build_wechat_web_payment_request(
order.order_id.clone(),
order.product_title.clone(),
order.amount_cents,
resolve_wechat_pay_client_ip(&headers),
))
.await
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?,
)
} else {
None
};
Ok(json_success_body(
Some(&request_context),
CreateProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
wechat_mini_program_pay_params,
wechat_h5_payment,
wechat_native_payment,
},
))
}
pub async fn confirm_wechat_profile_recharge_order(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(order_id): Path<String>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let (center, order) = state
.spacetime_client()
.get_profile_recharge_order(order_id.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
if order.user_id != user_id {
return Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"),
));
}
if !is_wechat_recharge_payment_channel(&order.payment_channel) {
return Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("该充值订单不是微信支付订单"),
));
}
if order.status == RuntimeProfileRechargeOrderStatus::Paid {
return Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
));
}
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
return Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
));
}
let wechat_order = state
.wechat_pay_client()
.query_order_by_out_trade_no(&order.order_id)
.await
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?;
if wechat_order.out_trade_no != order.order_id {
return Err(runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("微信支付查单返回的商户订单号与本地订单不一致")
.with_details(json!({ "provider": "wechat_pay" })),
));
}
if wechat_order.trade_state != "SUCCESS" {
return Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
));
}
let paid_at_micros = paid_at_micros_from_wechat_order(&wechat_order);
let (center, order) = state
.spacetime_client()
.mark_profile_recharge_order_paid(
wechat_order.out_trade_no,
paid_at_micros,
wechat_order.transaction_id,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ConfirmWechatProfileRechargeOrderResponse {
order: build_profile_recharge_order_response(order),
center: build_profile_recharge_center_response(center),
},
))
}
pub async fn submit_profile_feedback(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<SubmitProfileFeedbackRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let created_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let evidence_items = payload
.evidence_items
.into_iter()
.map(|item| RuntimeProfileFeedbackEvidenceSnapshot {
evidence_id: String::new(),
file_name: item.file_name,
content_type: item.content_type,
size_bytes: item.size_bytes,
data_url: item.data_url,
})
.collect();
let record = state
.spacetime_client()
.submit_profile_feedback(
user_id,
payload.description,
payload.contact_phone,
evidence_items,
created_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
SubmitProfileFeedbackResponse {
feedback: build_profile_feedback_submission_response(record),
},
))
}
pub async fn get_profile_referral_invite_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_referral_invite_center(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_referral_invite_center_response(record),
))
}
pub async fn redeem_profile_referral_invite_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RedeemProfileReferralInviteCodeRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_at_micros as i64)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_redeem_profile_referral_invite_code_response(record),
))
}
pub async fn redeem_profile_reward_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RedeemProfileRewardCodeRequest>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
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)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_redeem_profile_reward_code_response(record),
))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalyticsMetricQueryParams {
pub event_key: String,
pub scope_kind: String,
pub scope_id: String,
pub granularity: String,
}
pub async fn get_profile_analytics_metric(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Query(query): Query<AnalyticsMetricQueryParams>,
) -> Result<Json<Value>, Response> {
let scope_kind = parse_tracking_scope_kind(&query.scope_kind).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let granularity = parse_analytics_granularity(&query.granularity).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let record = state
.spacetime_client()
.query_analytics_metric(query.event_key, scope_kind, query.scope_id, granularity)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_analytics_metric_query_response(record),
))
}
pub async fn get_profile_task_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_task_center(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_center_response(record),
))
}
pub async fn claim_profile_task_reward(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(task_id): Path<String>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.claim_profile_task_reward(user_id, task_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_claim_profile_task_reward_response(record),
))
}
pub async fn admin_list_profile_task_configs(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_task_configs(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileTaskConfigAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_task_config_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_task_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileTaskConfigRequest>,
) -> Result<Json<Value>, Response> {
let cycle = parse_profile_task_cycle(&payload.cycle).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let scope_kind = parse_tracking_scope_kind(&payload.scope_kind).map_err(|error| {
runtime_profile_error_response(
&request_context,
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()
.admin_upsert_profile_task_config(
admin.session().subject.clone(),
payload.task_id,
payload.title,
payload.description.unwrap_or_default(),
payload.event_key,
cycle,
scope_kind,
payload.threshold,
payload.reward_points,
payload.enabled,
payload.sort_order.unwrap_or(10),
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_config_admin_response(record),
))
}
pub async fn admin_disable_profile_task_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminDisableProfileTaskConfigRequest>,
) -> Result<Json<Value>, Response> {
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_disable_profile_task_config(
admin.session().subject.clone(),
payload.task_id,
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_config_admin_response(record),
))
}
pub async fn admin_list_profile_recharge_products(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_recharge_products(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileRechargeProductConfigAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_recharge_product_config_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_recharge_product(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileRechargeProductRequest>,
) -> Result<Json<Value>, Response> {
let kind = parse_profile_recharge_product_kind(&payload.kind).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let tier = parse_profile_membership_tier(&payload.tier).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_upsert_profile_recharge_product(
admin.session().subject.clone(),
payload.product_id,
payload.title,
payload.price_cents,
kind,
payload.points_amount,
payload.bonus_points,
payload.duration_days,
payload.badge_label.unwrap_or_default(),
payload.description.unwrap_or_default(),
tier,
payload.enabled,
payload.sort_order.unwrap_or(10),
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_recharge_product_config_admin_response(record),
))
}
pub async fn admin_list_profile_redeem_codes(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_redeem_codes(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileRedeemCodeAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_redeem_code_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_redeem_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileRedeemCodeRequest>,
) -> Result<Json<Value>, Response> {
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let mode = parse_profile_redeem_code_mode(&payload.mode).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let record = state
.spacetime_client()
.admin_upsert_profile_redeem_code(
admin.session().subject.clone(),
payload.code,
mode,
payload.reward_points,
payload.max_uses,
payload.enabled,
payload.allowed_user_ids,
payload.allowed_public_user_codes,
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_redeem_code_admin_response(record),
))
}
pub async fn admin_disable_profile_redeem_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminDisableProfileRedeemCodeRequest>,
) -> Result<Json<Value>, Response> {
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_disable_profile_redeem_code(
admin.session().subject.clone(),
payload.code,
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_redeem_code_admin_response(record),
))
}
pub async fn admin_list_profile_invite_codes(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_invite_codes(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileInviteCodeAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_invite_code_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_invite_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileInviteCodeRequest>,
) -> 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()
.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
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_invite_code_admin_response(record),
))
}
pub async fn get_profile_play_stats(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_play_stats(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfilePlayStatsResponse {
total_play_time_ms: record.total_play_time_ms,
played_works: record
.played_works
.into_iter()
.map(|entry| ProfilePlayedWorkSummaryResponse {
world_key: entry.world_key,
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
world_type: entry.world_type,
world_title: entry.world_title,
world_subtitle: entry.world_subtitle,
first_played_at: entry.first_played_at,
last_played_at: entry.last_played_at,
last_observed_play_time_ms: entry.last_observed_play_time_ms,
})
.collect(),
updated_at: record.updated_at,
},
))
}
fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError {
let (status, provider) = match error {
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"),
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
};
AppError::from_status(status).with_details(json!({
"provider": provider,
"message": error.to_string(),
}))
}
fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
fn normalize_recharge_payment_channel(raw: Option<String>) -> Result<String, AppError> {
raw.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道不能为空")
})
}
fn validate_recharge_payment_channel(
state: &AppState,
payment_channel: &str,
) -> Result<(), AppError> {
if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK {
if is_recharge_mock_channel_allowed(state) {
return Ok(());
}
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("生产充值不允许使用 mock 支付渠道"));
}
if is_wechat_recharge_payment_channel(payment_channel) {
validate_real_wechat_recharge_payment_provider(state)?;
return Ok(());
}
Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("充值支付渠道无效"))
}
fn validate_recharge_device_for_payment_channel(
claims: &platform_auth::AccessTokenClaims,
payment_channel: &str,
) -> Result<(), AppError> {
if payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK {
return Ok(());
}
if !is_wechat_recharge_payment_channel(payment_channel) {
return Ok(());
}
let is_supported_device = match payment_channel {
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM => {
claims.is_wechat_mini_program_device()
}
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL => {
claims.is_wechat_mini_program_device()
}
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 => claims.is_mobile_wechat_browser_device(),
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE => claims.is_desktop_wechat_browser_device(),
_ => false,
};
if is_supported_device {
return Ok(());
}
Err(AppError::from_status(StatusCode::FORBIDDEN)
.with_message("当前登录设备不支持充值,请在微信环境内登录后重试"))
}
fn validate_real_wechat_recharge_payment_provider(state: &AppState) -> Result<(), AppError> {
if !state.config.wechat_pay_enabled {
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("微信支付真实渠道暂未启用"));
}
if state
.config
.wechat_pay_provider
.trim()
.eq_ignore_ascii_case("real")
{
return Ok(());
}
Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message("真实微信支付渠道不能使用 mock 支付配置"))
}
fn is_recharge_mock_channel_allowed(state: &AppState) -> bool {
if cfg!(test) {
return state
.config
.wechat_pay_provider
.trim()
.eq_ignore_ascii_case(PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK);
}
false
}
fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool {
matches!(
payment_channel,
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5
| PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE
)
}
fn resolve_wechat_pay_client_ip(headers: &HeaderMap) -> String {
headers
.get("x-forwarded-for")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
headers
.get("x-real-ip")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
})
.unwrap_or("127.0.0.1")
.to_string()
}
async fn resolve_wechat_identity_for_payment(
state: &AppState,
user_id: &str,
) -> Result<String, AppError> {
if let Some(identity) = state
.wechat_auth_service()
.get_identity_by_user_id(user_id)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("读取微信身份失败:{error}"))
})?
{
return Ok(identity.provider_uid);
}
Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付"))
}
fn build_wechat_virtual_pay_params(
state: &AppState,
center: &RuntimeProfileRechargeCenterRecord,
order: &RuntimeProfileRechargeOrderRecord,
openid: &str,
) -> Result<WechatMiniProgramVirtualPayParamsResponse, AppError> {
let product = match order.kind {
RuntimeProfileRechargeProductKind::Points => center
.point_products
.iter()
.find(|item| item.product_id == order.product_id)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前充值商品不存在,请刷新后再试")
})?,
RuntimeProfileRechargeProductKind::Membership => center
.membership_products
.iter()
.find(|item| item.product_id == order.product_id)
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前充值商品不存在,请刷新后再试")
})?,
};
let identity = state
.wechat_auth_service()
.get_identity_by_user_id(&order.user_id)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("读取微信身份失败:{error}"))
})?
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前账号缺少微信小程序身份,请在小程序内重新登录后再支付")
})?;
let session_key = identity.session_key.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("当前微信登录态缺少 session_key请重新登录后再试")
})?;
let offer_id = required_wechat_virtual_payment_config(
state
.config
.wechat_mini_program_virtual_payment_offer_id
.as_deref(),
"微信虚拟支付 OfferId 未配置",
)?;
let mode = match order.kind {
RuntimeProfileRechargeProductKind::Points => "short_series_coin",
RuntimeProfileRechargeProductKind::Membership => "short_series_goods",
};
let buy_quantity = match product.kind {
RuntimeProfileRechargeProductKind::Points => product.points_amount,
RuntimeProfileRechargeProductKind::Membership => 1,
};
let mut sign_data = serde_json::json!({
"offerId": offer_id,
"buyQuantity": buy_quantity,
"env": state.config.wechat_mini_program_virtual_payment_env,
"currencyType": "CNY",
"outTradeNo": order.order_id,
"attach": serde_json::json!({
"userId": order.user_id,
"productId": order.product_id,
"paymentChannel": order.payment_channel,
"openId": openid,
}).to_string(),
});
if order.kind == RuntimeProfileRechargeProductKind::Membership {
sign_data["productId"] = json!(order.product_id);
sign_data["goodsPrice"] = json!(order.amount_cents);
}
let sign_data = sign_data.to_string();
let pay_sig = calc_wechat_virtual_payment_signature(state, &sign_data, false)?;
let signature = calc_wechat_virtual_payment_user_signature_with_key(&session_key, &sign_data)?;
Ok(WechatMiniProgramVirtualPayParamsResponse {
mode: mode.to_string(),
sign_data,
pay_sig,
signature,
})
}
fn calc_wechat_virtual_payment_signature(
state: &AppState,
sign_data: &str,
use_sandbox_key: bool,
) -> Result<String, AppError> {
let env = state.config.wechat_mini_program_virtual_payment_env;
let app_key = if use_sandbox_key || env == 1 {
required_wechat_virtual_payment_config(
state
.config
.wechat_mini_program_virtual_payment_sandbox_app_key
.as_deref(),
"微信虚拟支付沙箱 AppKey 未配置",
)?
} else {
required_wechat_virtual_payment_config(
state
.config
.wechat_mini_program_virtual_payment_app_key
.as_deref(),
"微信虚拟支付 AppKey 未配置",
)?
};
calc_wechat_virtual_payment_pay_signature_with_key(app_key, sign_data)
}
fn required_wechat_virtual_payment_config<'a>(
value: Option<&'a str>,
message: &str,
) -> Result<&'a str, AppError> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message))
}
fn calc_wechat_virtual_payment_pay_signature_with_key(
key: &str,
sign_data: &str,
) -> Result<String, AppError> {
let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(|_| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message("微信虚拟支付签名密钥初始化失败")
})?;
mac.update(format!("requestVirtualPayment&{sign_data}").as_bytes());
Ok(to_lower_hex(mac.finalize().into_bytes().as_slice()))
}
fn calc_wechat_virtual_payment_user_signature_with_key(
session_key: &str,
sign_data: &str,
) -> Result<String, AppError> {
let mut mac = HmacSha256::new_from_slice(session_key.as_bytes()).map_err(|_| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message("微信虚拟支付用户态签名密钥初始化失败")
})?;
mac.update(sign_data.as_bytes());
Ok(to_lower_hex(mac.finalize().into_bytes().as_slice()))
}
fn to_lower_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
output.push(char::from(HEX[(byte >> 4) as usize]));
output.push(char::from(HEX[(byte & 0x0f) as usize]));
}
output
}
fn paid_at_micros_from_wechat_order(order: &WechatPayNotifyOrder) -> i64 {
order
.success_time
.as_deref()
.and_then(|value| parse_rfc3339(value).ok())
.map(offset_datetime_to_unix_micros)
.unwrap_or_else(current_unix_micros)
}
fn build_profile_recharge_center_response(
record: RuntimeProfileRechargeCenterRecord,
) -> ProfileRechargeCenterResponse {
ProfileRechargeCenterResponse {
wallet_balance: record.wallet_balance,
membership: ProfileMembershipResponse {
status: record.membership.status.as_str().to_string(),
tier: record.membership.tier.as_str().to_string(),
started_at: record.membership.started_at,
expires_at: record.membership.expires_at,
updated_at: record.membership.updated_at,
},
point_products: record
.point_products
.into_iter()
.map(build_profile_recharge_product_response)
.collect(),
membership_products: record
.membership_products
.into_iter()
.map(build_profile_recharge_product_response)
.collect(),
benefits: record
.benefits
.into_iter()
.map(build_profile_membership_benefit_response)
.collect(),
latest_order: record
.latest_order
.map(build_profile_recharge_order_response),
has_points_recharged: record.has_points_recharged,
}
}
fn build_profile_recharge_product_response(
record: RuntimeProfileRechargeProductRecord,
) -> ProfileRechargeProductResponse {
ProfileRechargeProductResponse {
product_id: record.product_id,
title: record.title,
price_cents: record.price_cents,
kind: record.kind.as_str().to_string(),
points_amount: record.points_amount,
bonus_points: record.bonus_points,
duration_days: record.duration_days,
badge_label: record.badge_label,
description: record.description,
tier: record.tier.as_str().to_string(),
}
}
fn build_profile_membership_benefit_response(
record: RuntimeProfileMembershipBenefitRecord,
) -> ProfileMembershipBenefitResponse {
ProfileMembershipBenefitResponse {
benefit_name: record.benefit_name,
normal_value: record.normal_value,
month_value: record.month_value,
season_value: record.season_value,
year_value: record.year_value,
}
}
fn build_profile_recharge_order_response(
record: RuntimeProfileRechargeOrderRecord,
) -> ProfileRechargeOrderResponse {
ProfileRechargeOrderResponse {
order_id: record.order_id,
product_id: record.product_id,
product_title: record.product_title,
kind: record.kind.as_str().to_string(),
amount_cents: record.amount_cents,
status: record.status.as_str().to_string(),
payment_channel: record.payment_channel,
paid_at: record.paid_at,
provider_transaction_id: record.provider_transaction_id,
created_at: record.created_at,
points_delta: record.points_delta,
membership_expires_at: record.membership_expires_at,
}
}
fn build_profile_feedback_submission_response(
record: RuntimeProfileFeedbackSubmissionRecord,
) -> ProfileFeedbackSubmissionResponse {
ProfileFeedbackSubmissionResponse {
feedback_id: record.feedback_id,
status: match record.status {
module_runtime::RuntimeProfileFeedbackStatus::Open => {
PROFILE_FEEDBACK_STATUS_OPEN.to_string()
}
},
created_at: record.created_at,
evidence_items: record
.evidence_items
.into_iter()
.map(build_profile_feedback_evidence_response)
.collect(),
}
}
fn build_profile_feedback_evidence_response(
record: RuntimeProfileFeedbackEvidenceRecord,
) -> ProfileFeedbackEvidenceItemResponse {
ProfileFeedbackEvidenceItemResponse {
evidence_id: record.evidence_id,
file_name: record.file_name,
content_type: record.content_type,
size_bytes: record.size_bytes,
}
}
fn build_profile_referral_invite_center_response(
record: RuntimeReferralInviteCenterRecord,
) -> ProfileReferralInviteCenterResponse {
ProfileReferralInviteCenterResponse {
invite_code: record.invite_code,
invite_link_path: record.invite_link_path,
invited_count: record.invited_count,
rewarded_invite_count: record.rewarded_invite_count,
today_inviter_reward_count: record.today_inviter_reward_count,
today_inviter_reward_remaining: record.today_inviter_reward_remaining,
reward_points: record.reward_points,
invited_users: record
.invited_users
.into_iter()
.map(|user| ProfileReferralInvitedUserResponse {
user_id: user.user_id,
display_name: user.display_name,
avatar_url: user.avatar_url,
bound_at: user.bound_at,
})
.collect(),
has_redeemed_code: record.has_redeemed_code,
bound_inviter_user_id: record.bound_inviter_user_id,
bound_at: record.bound_at,
updated_at: record.updated_at,
}
}
fn build_redeem_profile_referral_invite_code_response(
record: module_runtime::RuntimeReferralRedeemRecord,
) -> RedeemProfileReferralInviteCodeResponse {
RedeemProfileReferralInviteCodeResponse {
center: build_profile_referral_invite_center_response(record.center),
invitee_reward_granted: record.invitee_reward_granted,
inviter_reward_granted: record.inviter_reward_granted,
invitee_balance_after: record.invitee_balance_after,
inviter_balance_after: record.inviter_balance_after,
}
}
fn build_redeem_profile_reward_code_response(
record: RuntimeProfileRewardCodeRedeemRecord,
) -> RedeemProfileRewardCodeResponse {
RedeemProfileRewardCodeResponse {
wallet_balance: record.wallet_balance,
amount_granted: record.amount_granted,
ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry),
}
}
fn build_profile_wallet_ledger_entry_response(
record: module_runtime::RuntimeProfileWalletLedgerEntryRecord,
) -> ProfileWalletLedgerEntryResponse {
ProfileWalletLedgerEntryResponse {
id: record.wallet_ledger_id,
amount_delta: record.amount_delta,
balance_after: record.balance_after,
source_type: format_profile_wallet_ledger_source_type(record.source_type).to_string(),
created_at: record.created_at,
}
}
fn build_profile_task_center_response(
record: RuntimeProfileTaskCenterRecord,
) -> ProfileTaskCenterResponse {
ProfileTaskCenterResponse {
day_key: record.day_key,
wallet_balance: record.wallet_balance,
tasks: record
.tasks
.into_iter()
.map(build_profile_task_item_response)
.collect(),
updated_at: record.updated_at,
}
}
fn build_analytics_metric_query_response(
record: module_runtime::AnalyticsMetricQueryResponse,
) -> AnalyticsMetricQueryResponse {
AnalyticsMetricQueryResponse {
buckets: record
.buckets
.into_iter()
.map(|bucket| AnalyticsBucketMetricResponse {
bucket_key: bucket.bucket_key,
bucket_start_date_key: bucket.bucket_start_date_key,
bucket_end_date_key: bucket.bucket_end_date_key,
value: bucket.value,
})
.collect(),
}
}
fn build_profile_task_item_response(
record: RuntimeProfileTaskItemRecord,
) -> ProfileTaskItemResponse {
ProfileTaskItemResponse {
task_id: record.task_id,
title: record.title,
description: record.description,
event_key: record.event_key,
cycle: format_profile_task_cycle(record.cycle).to_string(),
threshold: record.threshold,
progress_count: record.progress_count,
reward_points: record.reward_points,
status: format_profile_task_status(record.status).to_string(),
day_key: record.day_key,
claimed_at: record.claimed_at,
updated_at: record.updated_at,
}
}
fn build_claim_profile_task_reward_response(
record: RuntimeProfileTaskClaimRecord,
) -> ClaimProfileTaskRewardResponse {
ClaimProfileTaskRewardResponse {
task_id: record.task_id,
day_key: record.day_key,
reward_points: record.reward_points,
wallet_balance: record.wallet_balance,
ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry),
center: build_profile_task_center_response(record.center),
}
}
fn build_profile_task_config_admin_response(
record: RuntimeProfileTaskConfigRecord,
) -> ProfileTaskConfigAdminResponse {
ProfileTaskConfigAdminResponse {
task_id: record.task_id,
title: record.title,
description: record.description,
event_key: record.event_key,
cycle: format_profile_task_cycle(record.cycle).to_string(),
scope_kind: format_tracking_scope_kind(record.scope_kind).to_string(),
threshold: record.threshold,
reward_points: record.reward_points,
enabled: record.enabled,
sort_order: record.sort_order,
created_by: record.created_by,
created_at: record.created_at,
updated_by: record.updated_by,
updated_at: record.updated_at,
}
}
fn build_profile_recharge_product_config_admin_response(
record: RuntimeProfileRechargeProductConfigRecord,
) -> ProfileRechargeProductConfigAdminResponse {
ProfileRechargeProductConfigAdminResponse {
product_id: record.product_id,
title: record.title,
price_cents: record.price_cents,
kind: format_profile_recharge_product_kind(record.kind).to_string(),
points_amount: record.points_amount,
bonus_points: record.bonus_points,
duration_days: record.duration_days,
badge_label: record.badge_label,
description: record.description,
tier: format_profile_membership_tier(record.tier).to_string(),
enabled: record.enabled,
sort_order: record.sort_order,
created_by: record.created_by,
created_at: record.created_at,
updated_by: record.updated_by,
updated_at: record.updated_at,
}
}
fn normalize_admin_invite_code_metadata(metadata: Option<Value>) -> Result<String, AppError> {
let metadata = match metadata {
Some(Value::Null) | None => json!({}),
Some(value) if value.is_object() => value,
Some(_) => {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("邀请码 metadata 必须是 JSON 对象")
.with_details(json!({ "field": "metadata" })));
}
};
let metadata_json = serde_json::to_string(&metadata).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(format!("邀请码 metadata 序列化失败:{error}"))
.with_details(json!({ "field": "metadata" }))
})?;
if metadata_json.len() > 4096 {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("邀请码 metadata 不能超过 4096 bytes")
.with_details(json!({ "field": "metadata" })));
}
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),
"unique" => Ok(RuntimeProfileRedeemCodeMode::Unique),
"private" => Ok(RuntimeProfileRedeemCodeMode::Private),
_ => Err("兑换码类型无效".to_string()),
}
}
fn parse_profile_task_cycle(raw: &str) -> Result<RuntimeProfileTaskCycle, String> {
match raw.trim().to_ascii_lowercase().as_str() {
PROFILE_TASK_CYCLE_DAILY => Ok(RuntimeProfileTaskCycle::Daily),
_ => Err("任务周期无效".to_string()),
}
}
fn parse_profile_recharge_product_kind(
raw: &str,
) -> Result<RuntimeProfileRechargeProductKind, String> {
match raw.trim().to_ascii_lowercase().as_str() {
PROFILE_RECHARGE_PRODUCT_KIND_POINTS => Ok(RuntimeProfileRechargeProductKind::Points),
PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP => {
Ok(RuntimeProfileRechargeProductKind::Membership)
}
_ => Err("充值商品类型无效".to_string()),
}
}
fn parse_profile_membership_tier(raw: &str) -> Result<RuntimeProfileMembershipTier, String> {
match raw.trim().to_ascii_lowercase().as_str() {
PROFILE_MEMBERSHIP_TIER_NORMAL => Ok(RuntimeProfileMembershipTier::Normal),
PROFILE_MEMBERSHIP_TIER_MONTH => Ok(RuntimeProfileMembershipTier::Month),
PROFILE_MEMBERSHIP_TIER_SEASON => Ok(RuntimeProfileMembershipTier::Season),
PROFILE_MEMBERSHIP_TIER_YEAR => Ok(RuntimeProfileMembershipTier::Year),
_ => Err("会员档位无效".to_string()),
}
}
fn parse_tracking_scope_kind(raw: &str) -> Result<RuntimeTrackingScopeKind, String> {
match raw.trim().to_ascii_lowercase().as_str() {
TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site),
TRACKING_SCOPE_KIND_WORK => Ok(RuntimeTrackingScopeKind::Work),
TRACKING_SCOPE_KIND_MODULE => Ok(RuntimeTrackingScopeKind::Module),
TRACKING_SCOPE_KIND_USER => Ok(RuntimeTrackingScopeKind::User),
_ => Err("埋点范围无效".to_string()),
}
}
fn parse_analytics_granularity(raw: &str) -> Result<AnalyticsGranularity, String> {
match raw.trim().to_ascii_lowercase().as_str() {
ANALYTICS_GRANULARITY_DAY => Ok(AnalyticsGranularity::Day),
ANALYTICS_GRANULARITY_WEEK => Ok(AnalyticsGranularity::Week),
ANALYTICS_GRANULARITY_MONTH => Ok(AnalyticsGranularity::Month),
ANALYTICS_GRANULARITY_QUARTER => Ok(AnalyticsGranularity::Quarter),
ANALYTICS_GRANULARITY_YEAR => Ok(AnalyticsGranularity::Year),
_ => Err("统计粒度无效".to_string()),
}
}
fn format_profile_task_cycle(cycle: RuntimeProfileTaskCycle) -> &'static str {
match cycle {
RuntimeProfileTaskCycle::Daily => PROFILE_TASK_CYCLE_DAILY,
}
}
fn format_profile_task_status(status: RuntimeProfileTaskStatus) -> &'static str {
match status {
RuntimeProfileTaskStatus::Incomplete => PROFILE_TASK_STATUS_INCOMPLETE,
RuntimeProfileTaskStatus::Claimable => PROFILE_TASK_STATUS_CLAIMABLE,
RuntimeProfileTaskStatus::Claimed => PROFILE_TASK_STATUS_CLAIMED,
RuntimeProfileTaskStatus::Disabled => PROFILE_TASK_STATUS_DISABLED,
}
}
fn format_profile_recharge_product_kind(kind: RuntimeProfileRechargeProductKind) -> &'static str {
match kind {
RuntimeProfileRechargeProductKind::Points => PROFILE_RECHARGE_PRODUCT_KIND_POINTS,
RuntimeProfileRechargeProductKind::Membership => PROFILE_RECHARGE_PRODUCT_KIND_MEMBERSHIP,
}
}
fn format_profile_membership_tier(tier: RuntimeProfileMembershipTier) -> &'static str {
match tier {
RuntimeProfileMembershipTier::Normal => PROFILE_MEMBERSHIP_TIER_NORMAL,
RuntimeProfileMembershipTier::Month => PROFILE_MEMBERSHIP_TIER_MONTH,
RuntimeProfileMembershipTier::Season => PROFILE_MEMBERSHIP_TIER_SEASON,
RuntimeProfileMembershipTier::Year => PROFILE_MEMBERSHIP_TIER_YEAR,
}
}
fn format_tracking_scope_kind(scope_kind: RuntimeTrackingScopeKind) -> &'static str {
match scope_kind {
RuntimeTrackingScopeKind::Site => TRACKING_SCOPE_KIND_SITE,
RuntimeTrackingScopeKind::Work => TRACKING_SCOPE_KIND_WORK,
RuntimeTrackingScopeKind::Module => TRACKING_SCOPE_KIND_MODULE,
RuntimeTrackingScopeKind::User => TRACKING_SCOPE_KIND_USER,
}
}
fn build_profile_invite_code_admin_response(
record: RuntimeProfileInviteCodeRecord,
) -> ProfileInviteCodeAdminResponse {
let metadata =
serde_json::from_str::<Value>(&record.metadata_json).unwrap_or_else(|_| json!({}));
ProfileInviteCodeAdminResponse {
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,
}
}
fn build_profile_redeem_code_admin_response(
record: RuntimeProfileRedeemCodeRecord,
) -> ProfileRedeemCodeAdminResponse {
ProfileRedeemCodeAdminResponse {
code: record.code,
mode: record.mode.as_str().to_string(),
reward_points: record.reward_points,
max_uses: record.max_uses,
global_used_count: record.global_used_count,
enabled: record.enabled,
allowed_user_ids: record.allowed_user_ids,
created_by: record.created_by,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
#[cfg(test)]
mod tests {
use module_auth::{ResolveWechatLoginInput, WechatIdentityProfile};
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
RuntimeProfileMembershipRecord, RuntimeProfileMembershipStatus,
RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord,
RuntimeProfileWalletLedgerSourceType,
};
use super::{
build_wechat_virtual_pay_params, calc_wechat_virtual_payment_pay_signature_with_key,
calc_wechat_virtual_payment_user_signature_with_key,
format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata,
};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::Value;
use std::time::Duration;
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
#[test]
fn profile_wallet_ledger_source_type_formats_backend_values() {
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD
);
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
);
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
);
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
);
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::DailyTaskReward
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD
);
}
#[tokio::test]
async fn profile_dashboard_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/dashboard")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let confirm_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders/rcgtest001/wechat/confirm")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(confirm_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_wallet_ledger_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/wallet-ledger")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_tasks_require_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/tasks")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let claim_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/tasks/daily_login/claim")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(claim_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_play_stats_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/play-stats")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_recharge_center_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/recharge-center")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_recharge_order_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("content-type", "application/json")
.body(Body::from(r#"{"productId":"points_60"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_recharge_order_rejects_missing_payment_channel_before_spacetime() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"productId":"points_60"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["error"]["message"], "充值支付渠道不能为空");
}
#[tokio::test]
async fn profile_recharge_order_rejects_unknown_payment_channel_before_spacetime() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"card"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["error"]["message"], "充值支付渠道无效");
}
#[tokio::test]
async fn profile_recharge_order_rejects_mock_when_pay_provider_is_real() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_provider: "real".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"mock"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"生产充值不允许使用 mock 支付渠道"
);
}
#[tokio::test]
async fn profile_recharge_order_rejects_non_wechat_device_before_spacetime() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"当前登录设备不支持充值,请在微信环境内登录后重试"
);
}
#[tokio::test]
async fn profile_recharge_order_rejects_mismatched_wechat_channel_before_spacetime() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_mobile_h5");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"当前登录设备不支持充值,请在微信环境内登录后重试"
);
}
#[tokio::test]
async fn profile_recharge_order_allows_desktop_wechat_native_channel_before_provider_check() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token =
issue_wechat_h5_access_token(&state, "windows", "sess_runtime_profile_desktop_wechat");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_native"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"真实微信支付渠道不能使用 mock 支付配置"
);
}
#[tokio::test]
async fn profile_recharge_order_rejects_real_wechat_channel_when_pay_provider_is_mock() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_pay_enabled: true,
wechat_pay_provider: "mock".to_string(),
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
})
.await;
let token = issue_wechat_h5_access_token(&state, "ios", "sess_runtime_profile_wechat_h5");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/recharge/orders")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
r#"{"productId":"points_60","paymentChannel":"wechat_h5"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
"真实微信支付渠道不能使用 mock 支付配置"
);
}
#[tokio::test]
async fn wechat_virtual_pay_params_use_goods_mode_for_membership_products() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
wechat_mini_program_virtual_payment_env: 0,
..fast_spacetime_timeout_config()
})
.await;
let wechat_login = state
.wechat_auth_service()
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "openid-user-00000001".to_string(),
provider_union_id: Some("union-user-00000001".to_string()),
display_name: Some("资料页用户".to_string()),
avatar_url: None,
session_key: Some("session-key-1".to_string()),
},
})
.await
.expect("wechat identity should seed");
let user_id = wechat_login.user.id;
let order = RuntimeProfileRechargeOrderRecord {
order_id: "memberorder01".to_string(),
user_id: user_id.clone(),
product_id: "member_month".to_string(),
product_title: "月卡".to_string(),
kind: RuntimeProfileRechargeProductKind::Membership,
amount_cents: 2800,
status: RuntimeProfileRechargeOrderStatus::Pending,
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
.to_string(),
paid_at: None,
paid_at_micros: None,
provider_transaction_id: None,
created_at: "2026-05-26T10:00:00Z".to_string(),
created_at_micros: 1_779_756_000_000_000,
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
};
let center = RuntimeProfileRechargeCenterRecord {
user_id: user_id.clone(),
wallet_balance: 0,
membership: RuntimeProfileMembershipRecord {
user_id: user_id.clone(),
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at: None,
started_at_micros: None,
expires_at: None,
expires_at_micros: None,
updated_at: None,
updated_at_micros: None,
},
point_products: vec![],
membership_products: vec![RuntimeProfileRechargeProductRecord {
product_id: "member_month".to_string(),
title: "月卡".to_string(),
price_cents: 2800,
kind: RuntimeProfileRechargeProductKind::Membership,
points_amount: 0,
bonus_points: 0,
duration_days: 30,
badge_label: String::new(),
description: "30天会员".to_string(),
tier: RuntimeProfileMembershipTier::Month,
}],
benefits: vec![],
latest_order: None,
has_points_recharged: false,
};
let params =
build_wechat_virtual_pay_params(&state, &center, &order, "openid-user-00000001")
.expect("membership virtual pay params should build");
let sign_data: Value =
serde_json::from_str(&params.sign_data).expect("sign data should be valid json");
let attach: Value = serde_json::from_str(
sign_data["attach"]
.as_str()
.expect("attach should be string json"),
)
.expect("attach should decode");
assert_eq!(params.mode, "short_series_goods");
assert_eq!(sign_data["buyQuantity"], 1);
assert_eq!(sign_data["offerId"], "offer-1");
assert_eq!(sign_data["productId"], "member_month");
assert_eq!(sign_data["goodsPrice"], 2800);
assert_eq!(sign_data["outTradeNo"], "memberorder01");
assert_eq!(attach["paymentChannel"], "wechat_mp_virtual");
assert!(!params.pay_sig.is_empty());
assert!(!params.signature.is_empty());
}
#[tokio::test]
async fn wechat_virtual_pay_params_use_coin_quantity_for_points_products() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
wechat_mini_program_virtual_payment_env: 0,
..fast_spacetime_timeout_config()
})
.await;
let wechat_login = state
.wechat_auth_service()
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "openid-user-points-60".to_string(),
provider_union_id: Some("union-user-points-60".to_string()),
display_name: Some("资料页用户".to_string()),
avatar_url: None,
session_key: Some("session-key-points-60".to_string()),
},
})
.await
.expect("wechat identity should seed");
let user_id = wechat_login.user.id.clone();
let order = RuntimeProfileRechargeOrderRecord {
order_id: "pointsorder60".to_string(),
user_id: user_id.clone(),
product_id: "points_60".to_string(),
product_title: "60泥点".to_string(),
kind: RuntimeProfileRechargeProductKind::Points,
amount_cents: 600,
status: RuntimeProfileRechargeOrderStatus::Pending,
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
.to_string(),
paid_at: None,
paid_at_micros: None,
provider_transaction_id: None,
created_at: "2026-05-30T10:00:00Z".to_string(),
created_at_micros: 1_780_000_000_000_000,
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
};
let center = RuntimeProfileRechargeCenterRecord {
user_id: user_id.clone(),
wallet_balance: 0,
membership: RuntimeProfileMembershipRecord {
user_id: user_id.clone(),
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at: None,
started_at_micros: None,
expires_at: None,
expires_at_micros: None,
updated_at: None,
updated_at_micros: None,
},
point_products: vec![RuntimeProfileRechargeProductRecord {
product_id: "points_60".to_string(),
title: "60泥点".to_string(),
price_cents: 600,
kind: RuntimeProfileRechargeProductKind::Points,
points_amount: 60,
bonus_points: 60,
duration_days: 0,
badge_label: "首充双倍".to_string(),
description: "60+60泥点".to_string(),
tier: RuntimeProfileMembershipTier::Normal,
}],
membership_products: vec![],
benefits: vec![],
latest_order: None,
has_points_recharged: true,
};
let params =
build_wechat_virtual_pay_params(&state, &center, &order, "openid-user-points-60")
.expect("points virtual pay params should build");
let sign_data: Value =
serde_json::from_str(&params.sign_data).expect("sign data should be valid json");
let attach: Value = serde_json::from_str(
sign_data["attach"]
.as_str()
.expect("attach should be string json"),
)
.expect("attach should decode");
assert_eq!(params.mode, "short_series_coin");
assert_eq!(sign_data["buyQuantity"], 60);
assert_eq!(sign_data["offerId"], "offer-1");
assert_eq!(sign_data["outTradeNo"], "pointsorder60");
assert_eq!(attach["paymentChannel"], "wechat_mp_virtual");
assert!(!params.pay_sig.is_empty());
assert!(!params.signature.is_empty());
}
#[tokio::test]
async fn wechat_virtual_pay_params_accept_admin_membership_product_ids() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
wechat_mini_program_virtual_payment_env: 0,
..fast_spacetime_timeout_config()
})
.await;
let wechat_login = state
.wechat_auth_service()
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "openid-user-item01".to_string(),
provider_union_id: Some("union-user-item01".to_string()),
display_name: Some("资料页用户".to_string()),
avatar_url: None,
session_key: Some("session-key-item01".to_string()),
},
})
.await
.expect("wechat identity should seed");
let user_id = wechat_login.user.id.clone();
let order = RuntimeProfileRechargeOrderRecord {
order_id: "item01order01".to_string(),
user_id: user_id.clone(),
product_id: "item01".to_string(),
product_title: "测试道具".to_string(),
kind: RuntimeProfileRechargeProductKind::Membership,
amount_cents: 100,
status: RuntimeProfileRechargeOrderStatus::Pending,
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
.to_string(),
paid_at: None,
paid_at_micros: None,
provider_transaction_id: None,
created_at: "2026-05-27T10:00:00Z".to_string(),
created_at_micros: 1_779_842_400_000_000,
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
};
let center = RuntimeProfileRechargeCenterRecord {
user_id: user_id.clone(),
wallet_balance: 0,
membership: RuntimeProfileMembershipRecord {
user_id: user_id.clone(),
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at: None,
started_at_micros: None,
expires_at: None,
expires_at_micros: None,
updated_at: None,
updated_at_micros: None,
},
point_products: vec![],
membership_products: vec![RuntimeProfileRechargeProductRecord {
product_id: "item01".to_string(),
title: "测试道具".to_string(),
price_cents: 100,
kind: RuntimeProfileRechargeProductKind::Membership,
points_amount: 0,
bonus_points: 0,
duration_days: 30,
badge_label: String::new(),
description: "30天会员".to_string(),
tier: RuntimeProfileMembershipTier::Month,
}],
benefits: vec![],
latest_order: None,
has_points_recharged: false,
};
let params = build_wechat_virtual_pay_params(&state, &center, &order, "openid-user-item01")
.expect("custom membership virtual pay params should build");
let sign_data: Value =
serde_json::from_str(&params.sign_data).expect("sign data should be valid json");
assert_eq!(params.mode, "short_series_goods");
assert_eq!(sign_data["productId"], "item01");
assert_eq!(sign_data["goodsPrice"], 100);
assert_eq!(sign_data["outTradeNo"], "item01order01");
}
#[test]
fn wechat_virtual_payment_signatures_match_official_examples() {
let post_body = r#"{"openid": "xxx", "user_ip": "127.0.0.1", "env": 0}"#;
let pay_sig = calc_wechat_virtual_payment_pay_signature_with_key("12345", post_body)
.expect("pay signature should build");
let signature = calc_wechat_virtual_payment_user_signature_with_key(
"9hAb/NEYUlkaMBEsmFgzig==",
post_body,
)
.expect("user signature should build");
assert_eq!(
pay_sig,
"a1ab2651b927b6a766152cf864033417b85c1448fc3c6e1bedbbd7f49416e92f"
);
assert_eq!(
signature,
"089d9e8dc5d308977360c4b79ec600a93d736802802a807d634192328032f6c7"
);
}
#[tokio::test]
async fn wechat_virtual_payment_sandbox_requires_sandbox_app_key() {
let state = seed_authenticated_state_with_config(AppConfig {
wechat_mini_program_virtual_payment_offer_id: Some("offer-1".to_string()),
wechat_mini_program_virtual_payment_app_key: Some("app-key-1".to_string()),
wechat_mini_program_virtual_payment_sandbox_app_key: None,
wechat_mini_program_virtual_payment_env: 1,
..fast_spacetime_timeout_config()
})
.await;
let wechat_login = state
.wechat_auth_service()
.resolve_login(ResolveWechatLoginInput {
profile: WechatIdentityProfile {
provider_uid: "openid-sandbox-1".to_string(),
provider_union_id: Some("union-sandbox-1".to_string()),
display_name: Some("资料页用户".to_string()),
avatar_url: None,
session_key: Some("session-key-sandbox-1".to_string()),
},
})
.await
.expect("wechat identity should seed");
let user_id = wechat_login.user.id.clone();
let order = RuntimeProfileRechargeOrderRecord {
order_id: "sandboxorder01".to_string(),
user_id: user_id.clone(),
product_id: "points_60".to_string(),
product_title: "60泥点".to_string(),
kind: RuntimeProfileRechargeProductKind::Points,
amount_cents: 600,
status: RuntimeProfileRechargeOrderStatus::Pending,
payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL
.to_string(),
paid_at: None,
paid_at_micros: None,
provider_transaction_id: None,
created_at: "2026-05-30T10:00:00Z".to_string(),
created_at_micros: 1_780_000_000_000_000,
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
};
let center = RuntimeProfileRechargeCenterRecord {
user_id: user_id.clone(),
wallet_balance: 0,
membership: RuntimeProfileMembershipRecord {
user_id: user_id.clone(),
status: RuntimeProfileMembershipStatus::Normal,
tier: RuntimeProfileMembershipTier::Normal,
started_at: None,
started_at_micros: None,
expires_at: None,
expires_at_micros: None,
updated_at: None,
updated_at_micros: None,
},
point_products: vec![RuntimeProfileRechargeProductRecord {
product_id: "points_60".to_string(),
title: "60泥点".to_string(),
price_cents: 600,
kind: RuntimeProfileRechargeProductKind::Points,
points_amount: 60,
bonus_points: 60,
duration_days: 0,
badge_label: "首充双倍".to_string(),
description: "60+60泥点".to_string(),
tier: RuntimeProfileMembershipTier::Normal,
}],
membership_products: vec![],
benefits: vec![],
latest_order: None,
has_points_recharged: false,
};
let error = build_wechat_virtual_pay_params(&state, &center, &order, "openid-sandbox-1")
.expect_err("sandbox pay params should reject missing sandbox app key");
assert!(
error.to_string().contains("沙箱 AppKey 未配置"),
"unexpected error: {error}"
);
}
#[tokio::test]
async fn profile_feedback_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/feedback")
.header("content-type", "application/json")
.body(Body::from(
r#"{"description":"反馈页面上传图片后没有显示预览"}"#,
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_referral_invite_center_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/referrals/invite-center")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_referral_redeem_code_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/referrals/redeem-code")
.header("content-type", "application/json")
.body(Body::from(r#"{"inviteCode":"SY12345678"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_referral_redeem_code_calls_spacetime_for_authenticated_user() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/referrals/redeem-code")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"inviteCode":"SY12345678"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["details"]["provider"],
Value::String("spacetimedb".to_string())
);
}
#[test]
fn admin_invite_code_metadata_accepts_only_json_object() {
assert_eq!(
normalize_admin_invite_code_metadata(None).expect("empty metadata should default"),
"{}"
);
assert_eq!(
normalize_admin_invite_code_metadata(Some(serde_json::json!({
"channel": "spring",
"source": "banner"
})))
.expect("object metadata should serialize"),
r#"{"channel":"spring","source":"banner"}"#
);
let error = normalize_admin_invite_code_metadata(Some(serde_json::json!("spring")))
.expect_err("non-object metadata should reject");
assert_eq!(error.message(), "邀请码 metadata 必须是 JSON 对象");
}
#[tokio::test]
async fn runtime_profile_legacy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
for uri in [
"/api/runtime/profile/dashboard",
"/api/runtime/profile/wallet-ledger",
"/api/runtime/profile/recharge-center",
"/api/runtime/profile/recharge/orders",
"/api/runtime/profile/feedback",
"/api/runtime/profile/referrals/invite-center",
"/api/runtime/profile/referrals/redeem-code",
"/api/runtime/profile/redeem-codes/redeem",
"/api/runtime/profile/play-stats",
"/api/runtime/profile/save-archives",
"/api/runtime/profile/save-archives/world-1",
"/api/runtime/profile/browse-history",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
}
}
#[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 list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/api/profile/tasks")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let upsert_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/profile/tasks")
.header("content-type", "application/json")
.body(Body::from(r#"{"taskId":"daily_login"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
let disable_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/profile/tasks/disable")
.header("content-type", "application/json")
.body(Body::from(r#"{"taskId":"daily_login"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(upsert_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(disable_response.status(), StatusCode::UNAUTHORIZED);
}
#[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"));
for uri in [
"/admin/api/profile/redeem-codes",
"/admin/api/profile/invite-codes",
"/admin/api/profile/recharge-products",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}");
}
}
async fn seed_authenticated_state() -> AppState {
seed_authenticated_state_with_config(fast_spacetime_timeout_config()).await
}
async fn seed_authenticated_state_with_config(config: AppConfig) -> AppState {
let state = AppState::new(config).expect("state should build");
state
.seed_test_phone_user_with_password("13800138104", "secret123")
.await
.id;
state
}
fn fast_spacetime_timeout_config() -> AppConfig {
AppConfig {
spacetime_procedure_timeout: Duration::from_secs(1),
..AppConfig::default()
}
}
fn admin_enabled_test_config() -> AppConfig {
AppConfig {
admin_username: Some("root".to_string()),
admin_password: Some("secret123".to_string()),
..AppConfig::default()
}
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_runtime_profile"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("资料页用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
fn issue_wechat_h5_access_token(
state: &AppState,
client_platform: &str,
session_id: &str,
) -> String {
let claims = AccessTokenClaims::from_input_with_device(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", session_id),
provider: AuthProvider::Wechat,
roles: vec!["user".to_string()],
token_version: 2,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("微信资料页用户".to_string()),
},
Some(platform_auth::AccessTokenDeviceInfo {
client_type: "wechat_h5".to_string(),
client_runtime: "wechat_embedded_browser".to_string(),
client_platform: client_platform.to_string(),
}),
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
}