# Conflicts: # .hermes/shared-memory/decision-log.md # docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md # docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md # server-rs/crates/module-runtime/src/errors.rs # src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx
2381 lines
86 KiB
Rust
2381 lines
86 KiB
Rust
use axum::{
|
||
Json,
|
||
extract::{Extension, Path, Query, State},
|
||
http::{HeaderMap, StatusCode},
|
||
response::Response,
|
||
};
|
||
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_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 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,
|
||
};
|
||
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,
|
||
},
|
||
};
|
||
|
||
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(
|
||
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 {
|
||
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 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()
|
||
.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_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_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 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_runtime::RuntimeProfileWalletLedgerSourceType;
|
||
|
||
use super::{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 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")
|
||
}
|
||
}
|