Add backend feedback submission and image preview
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-08 21:47:45 +08:00
parent b2ac92e0fc
commit 199b44c18c
38 changed files with 1521 additions and 140 deletions

View File

@@ -116,7 +116,7 @@ use crate::{
claim_profile_task_reward, create_profile_recharge_order, get_profile_analytics_metric,
get_profile_dashboard, get_profile_play_stats, get_profile_recharge_center,
get_profile_referral_invite_center, get_profile_task_center, get_profile_wallet_ledger,
redeem_profile_referral_invite_code, redeem_profile_reward_code,
redeem_profile_referral_invite_code, redeem_profile_reward_code, submit_profile_feedback,
},
runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -144,6 +144,7 @@ use crate::{
};
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
const PROFILE_FEEDBACK_BODY_LIMIT_BYTES: usize = 6 * 1024 * 1024;
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
pub fn build_router(state: AppState) -> Router {
@@ -1227,6 +1228,16 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/profile/feedback",
post(submit_profile_feedback)
// 中文注释:反馈首版允许最多四张 1MB Data URL 图片,只给该接口放宽 body limit。
.layer(DefaultBodyLimit::max(PROFILE_FEEDBACK_BODY_LIMIT_BYTES))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/referrals/invite-center",
get(get_profile_referral_invite_center).route_layer(middleware::from_fn_with_state(

View File

@@ -100,9 +100,9 @@ fn run_api_server_with_runtime() -> Result<(), std::io::Error> {
async fn run_api_server() -> Result<(), std::io::Error> {
// 运行本地开发与联调时,优先从仓库根目录加载本地变量,避免手工逐项导出 OSS / APIMart 配置。
let _ = dotenvy::from_filename(".env");
let _ = dotenvy::from_filename(".env.local");
let _ = dotenvy::from_filename(".env.secrets.local");
let _ = dotenvy::from_filename(".env.local");
let _ = dotenvy::from_filename(".env");
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
let config = AppConfig::from_env();

View File

@@ -5,7 +5,9 @@ use axum::{
response::Response,
};
use module_runtime::{
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord,
AnalyticsGranularity, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK,
RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot,
RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord,
RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
@@ -23,8 +25,8 @@ use shared_contracts::runtime::{
AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest,
AnalyticsBucketMetricResponse, AnalyticsMetricQueryResponse, ClaimProfileTaskRewardResponse,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED,
PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
PROFILE_FEEDBACK_STATUS_OPEN, 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,
@@ -35,6 +37,7 @@ use shared_contracts::runtime::{
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,
@@ -44,8 +47,9 @@ use shared_contracts::runtime::{
ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, ProfileTaskItemResponse,
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, TRACKING_SCOPE_KIND_MODULE,
TRACKING_SCOPE_KIND_SITE, TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK,
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;
@@ -208,6 +212,51 @@ pub async fn create_profile_recharge_order(
))
}
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>,
@@ -782,6 +831,36 @@ fn build_profile_recharge_order_response(
}
}
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 {
@@ -1245,6 +1324,27 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[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"));
@@ -1345,6 +1445,7 @@ mod tests {
"/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",

View File

@@ -164,6 +164,36 @@ pub fn build_runtime_profile_recharge_order_record(
}
}
pub fn build_runtime_profile_feedback_submission_record(
snapshot: RuntimeProfileFeedbackSubmissionSnapshot,
) -> Result<RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileFieldError> {
let evidence_items = serde_json::from_str::<Vec<RuntimeProfileFeedbackEvidenceSnapshot>>(
&snapshot.evidence_json,
)
.map_err(|_| RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl)?
.into_iter()
.map(|item| RuntimeProfileFeedbackEvidenceRecord {
evidence_id: item.evidence_id,
file_name: item.file_name,
content_type: item.content_type,
size_bytes: item.size_bytes,
})
.collect();
Ok(RuntimeProfileFeedbackSubmissionRecord {
feedback_id: snapshot.feedback_id,
user_id: snapshot.user_id,
description: snapshot.description,
contact_phone: snapshot.contact_phone,
evidence_items,
status: snapshot.status,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
})
}
pub fn build_runtime_referral_invite_center_record(
snapshot: RuntimeReferralInviteCenterSnapshot,
) -> RuntimeReferralInviteCenterRecord {

View File

@@ -262,6 +262,83 @@ pub fn build_runtime_profile_recharge_order_create_input(
})
}
pub fn build_runtime_profile_feedback_submission_input(
user_id: String,
description: String,
contact_phone: Option<String>,
evidence_items: Vec<RuntimeProfileFeedbackEvidenceSnapshot>,
created_at_micros: i64,
) -> Result<RuntimeProfileFeedbackSubmissionInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let description = normalize_required_string(description)
.ok_or(RuntimeProfileFieldError::MissingFeedbackDescription)?;
let description_chars = description.chars().count();
if description_chars < PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS {
return Err(RuntimeProfileFieldError::FeedbackDescriptionTooShort);
}
if description_chars > PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS {
return Err(RuntimeProfileFieldError::FeedbackDescriptionTooLong);
}
let contact_phone = normalize_optional_string(contact_phone);
if contact_phone
.as_deref()
.map(|value| value.chars().count() > PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS)
.unwrap_or(false)
{
return Err(RuntimeProfileFieldError::FeedbackContactPhoneTooLong);
}
if evidence_items.len() > PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT {
return Err(RuntimeProfileFieldError::TooManyFeedbackEvidenceItems);
}
let feedback_id = build_runtime_profile_feedback_submission_id(&user_id, created_at_micros);
let mut total_size_bytes = 0u64;
let mut normalized_evidence_items = Vec::with_capacity(evidence_items.len());
for (index, item) in evidence_items.into_iter().enumerate() {
let content_type = normalize_required_string(item.content_type)
.map(|value| value.to_ascii_lowercase())
.ok_or(RuntimeProfileFieldError::InvalidFeedbackEvidenceContentType)?;
if !is_profile_feedback_image_content_type(&content_type) {
return Err(RuntimeProfileFieldError::InvalidFeedbackEvidenceContentType);
}
if item.size_bytes == 0 || item.size_bytes > PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES {
return Err(RuntimeProfileFieldError::FeedbackEvidenceTooLarge);
}
total_size_bytes = total_size_bytes
.checked_add(item.size_bytes)
.ok_or(RuntimeProfileFieldError::FeedbackEvidenceTotalTooLarge)?;
if total_size_bytes > PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES {
return Err(RuntimeProfileFieldError::FeedbackEvidenceTotalTooLarge);
}
let data_url = normalize_required_string(item.data_url)
.ok_or(RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl)?;
if !has_profile_feedback_data_url_prefix(&data_url, &content_type) {
return Err(RuntimeProfileFieldError::InvalidFeedbackEvidenceDataUrl);
}
let file_name = normalize_optional_string(Some(item.file_name))
.unwrap_or_else(|| format!("feedback-image-{}", index + 1));
normalized_evidence_items.push(RuntimeProfileFeedbackEvidenceSnapshot {
evidence_id: build_runtime_profile_feedback_evidence_id(&feedback_id, index),
file_name,
content_type,
size_bytes: item.size_bytes,
data_url,
});
}
Ok(RuntimeProfileFeedbackSubmissionInput {
user_id,
description,
contact_phone,
evidence_items: normalized_evidence_items,
created_at_micros,
})
}
pub fn build_runtime_referral_invite_center_get_input(
user_id: String,
) -> Result<RuntimeReferralInviteCenterGetInput, RuntimeProfileFieldError> {
@@ -595,6 +672,17 @@ pub fn build_runtime_browse_history_id(
format!("{user_id}:{owner_user_id}:{profile_id}")
}
pub fn build_runtime_profile_feedback_submission_id(
user_id: &str,
created_at_micros: i64,
) -> String {
format!("feedback:{}:{}", user_id.trim(), created_at_micros)
}
pub fn build_runtime_profile_feedback_evidence_id(feedback_id: &str, index: usize) -> String {
format!("{}:evidence:{:02}", feedback_id.trim(), index + 1)
}
fn parse_utc_rfc3339_to_micros(value: &str) -> Option<i64> {
let trimmed = value.trim();
if trimmed.is_empty() {
@@ -630,6 +718,19 @@ fn normalize_current_story_json(
.map_err(|_| RuntimeProfileFieldError::InvalidCurrentStoryJson)
}
fn is_profile_feedback_image_content_type(value: &str) -> bool {
matches!(
value,
"image/png" | "image/jpeg" | "image/jpg" | "image/webp" | "image/gif"
)
}
fn has_profile_feedback_data_url_prefix(data_url: &str, content_type: &str) -> bool {
data_url
.to_ascii_lowercase()
.starts_with(&format!("data:{content_type};base64,"))
}
pub fn normalize_invite_code(value: String) -> Option<String> {
let normalized = value
.trim()

View File

@@ -33,6 +33,12 @@ pub const PROFILE_TASK_DEFAULT_THRESHOLD: u32 = 1;
pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
pub const PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS: usize = 10;
pub const PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS: usize = 200;
pub const PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS: usize = 40;
pub const PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT: usize = 4;
pub const PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES: u64 = 1_048_576;
pub const PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES: u64 = 4_194_304;
/// 分析日期维表的纯领域快照。
///
@@ -440,6 +446,83 @@ pub struct RuntimeProfileDashboardGetInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileFeedbackStatus {
Open,
}
impl RuntimeProfileFeedbackStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Open => "open",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeProfileFeedbackEvidenceSnapshot {
pub evidence_id: String,
pub file_name: String,
pub content_type: String,
pub size_bytes: u64,
pub data_url: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeProfileFeedbackSubmissionInput {
pub user_id: String,
pub description: String,
pub contact_phone: Option<String>,
pub evidence_items: Vec<RuntimeProfileFeedbackEvidenceSnapshot>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeProfileFeedbackSubmissionSnapshot {
pub feedback_id: String,
pub user_id: String,
pub description: String,
pub contact_phone: Option<String>,
pub evidence_json: String,
pub status: RuntimeProfileFeedbackStatus,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeProfileFeedbackSubmissionProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileFeedbackSubmissionSnapshot>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeProfileFeedbackEvidenceRecord {
pub evidence_id: String,
pub file_name: String,
pub content_type: String,
pub size_bytes: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeProfileFeedbackSubmissionRecord {
pub feedback_id: String,
pub user_id: String,
pub description: String,
pub contact_phone: Option<String>,
pub evidence_items: Vec<RuntimeProfileFeedbackEvidenceRecord>,
pub status: RuntimeProfileFeedbackStatus,
pub created_at: String,
pub created_at_micros: i64,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeTrackingScopeKind {

View File

@@ -2,7 +2,12 @@
//!
//! 错误保持运行时业务语义,例如快照版本非法、兑换码不可用或钱包余额不足。
use crate::MAX_BROWSE_HISTORY_BATCH_SIZE;
use crate::{
MAX_BROWSE_HISTORY_BATCH_SIZE, PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS,
PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS, PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS,
PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES, PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT,
PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeSettingsFieldError {
@@ -80,6 +85,15 @@ pub enum RuntimeProfileFieldError {
},
NonPersistentRuntimeSnapshot,
InvalidAnalyticsCalendarDate,
MissingFeedbackDescription,
FeedbackDescriptionTooShort,
FeedbackDescriptionTooLong,
FeedbackContactPhoneTooLong,
TooManyFeedbackEvidenceItems,
InvalidFeedbackEvidenceContentType,
InvalidFeedbackEvidenceDataUrl,
FeedbackEvidenceTooLarge,
FeedbackEvidenceTotalTooLarge,
}
impl std::fmt::Display for RuntimeProfileFieldError {
@@ -140,6 +154,37 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::InvalidAnalyticsCalendarDate => {
f.write_str("analytics_date_dimension.calendar_date 必须是合法 YYYY-MM-DD 日期")
}
Self::MissingFeedbackDescription => f.write_str("反馈问题描述不能为空"),
Self::FeedbackDescriptionTooShort => write!(
f,
"请填写{}个字以上的问题描述",
PROFILE_FEEDBACK_DESCRIPTION_MIN_CHARS
),
Self::FeedbackDescriptionTooLong => write!(
f,
"问题描述不能超过 {} 字",
PROFILE_FEEDBACK_DESCRIPTION_MAX_CHARS
),
Self::FeedbackContactPhoneTooLong => write!(
f,
"联系电话不能超过 {} 字",
PROFILE_FEEDBACK_CONTACT_PHONE_MAX_CHARS
),
Self::TooManyFeedbackEvidenceItems => {
write!(f, "最多上传{}张凭证", PROFILE_FEEDBACK_EVIDENCE_MAX_COUNT)
}
Self::InvalidFeedbackEvidenceContentType => f.write_str("反馈凭证只支持图片类型"),
Self::InvalidFeedbackEvidenceDataUrl => f.write_str("反馈凭证图片数据无效"),
Self::FeedbackEvidenceTooLarge => write!(
f,
"单张反馈凭证不能超过 {} bytes",
PROFILE_FEEDBACK_EVIDENCE_MAX_BYTES
),
Self::FeedbackEvidenceTotalTooLarge => write!(
f,
"反馈凭证总大小不能超过 {} bytes",
PROFILE_FEEDBACK_EVIDENCE_TOTAL_MAX_BYTES
),
}
}
}

View File

@@ -21,6 +21,7 @@ pub const PROFILE_TASK_STATUS_INCOMPLETE: &str = "incomplete";
pub const PROFILE_TASK_STATUS_CLAIMABLE: &str = "claimable";
pub const PROFILE_TASK_STATUS_CLAIMED: &str = "claimed";
pub const PROFILE_TASK_STATUS_DISABLED: &str = "disabled";
pub const PROFILE_FEEDBACK_STATUS_OPEN: &str = "open";
pub const TRACKING_SCOPE_KIND_SITE: &str = "site";
pub const TRACKING_SCOPE_KIND_WORK: &str = "work";
pub const TRACKING_SCOPE_KIND_MODULE: &str = "module";
@@ -254,6 +255,49 @@ pub struct CreateProfileRechargeOrderResponse {
pub center: ProfileRechargeCenterResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileFeedbackEvidenceItemRequest {
pub file_name: String,
pub content_type: String,
pub size_bytes: u64,
pub data_url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SubmitProfileFeedbackRequest {
pub description: String,
#[serde(default)]
pub contact_phone: Option<String>,
#[serde(default)]
pub evidence_items: Vec<ProfileFeedbackEvidenceItemRequest>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileFeedbackEvidenceItemResponse {
pub evidence_id: String,
pub file_name: String,
pub content_type: String,
pub size_bytes: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileFeedbackSubmissionResponse {
pub feedback_id: String,
pub status: String,
pub created_at: String,
pub evidence_items: Vec<ProfileFeedbackEvidenceItemResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SubmitProfileFeedbackResponse {
pub feedback: ProfileFeedbackSubmissionResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileReferralInvitedUserResponse {
@@ -1263,6 +1307,41 @@ mod tests {
assert_eq!(payload.payment_channel, None);
}
#[test]
fn profile_feedback_response_uses_camel_case_fields() {
let payload = serde_json::to_value(SubmitProfileFeedbackResponse {
feedback: ProfileFeedbackSubmissionResponse {
feedback_id: "feedback:user-1:1".to_string(),
status: PROFILE_FEEDBACK_STATUS_OPEN.to_string(),
created_at: "2026-05-08T10:00:00Z".to_string(),
evidence_items: vec![ProfileFeedbackEvidenceItemResponse {
evidence_id: "feedback:user-1:1:evidence:01".to_string(),
file_name: "问题截图.png".to_string(),
content_type: "image/png".to_string(),
size_bytes: 128,
}],
},
})
.expect("payload should serialize");
assert_eq!(
payload["feedback"]["feedbackId"],
json!("feedback:user-1:1")
);
assert_eq!(
payload["feedback"]["status"],
json!(PROFILE_FEEDBACK_STATUS_OPEN)
);
assert_eq!(
payload["feedback"]["evidenceItems"][0]["contentType"],
json!("image/png")
);
assert_eq!(
payload["feedback"]["evidenceItems"][0]["sizeBytes"],
json!(128)
);
}
#[test]
fn profile_play_stats_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ProfilePlayStatsResponse {

View File

@@ -146,8 +146,9 @@ use module_puzzle::{
use module_runtime::{
AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, RuntimeBrowseHistoryRecord,
RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord,
RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord,
RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileSaveArchiveRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord,
@@ -159,6 +160,8 @@ use module_runtime::{
build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input,
build_runtime_browse_history_record, build_runtime_browse_history_sync_input,
build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record,
build_runtime_profile_feedback_submission_input,
build_runtime_profile_feedback_submission_record,
build_runtime_profile_invite_code_admin_list_input,
build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record,
build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record,

View File

@@ -161,6 +161,34 @@ impl From<module_runtime::RuntimeProfileRechargeOrderCreateInput>
}
}
impl From<module_runtime::RuntimeProfileFeedbackSubmissionInput>
for RuntimeProfileFeedbackSubmissionInput
{
fn from(input: module_runtime::RuntimeProfileFeedbackSubmissionInput) -> Self {
Self {
user_id: input.user_id,
description: input.description,
contact_phone: input.contact_phone,
evidence_items: input.evidence_items.into_iter().map(Into::into).collect(),
created_at_micros: input.created_at_micros,
}
}
}
impl From<module_runtime::RuntimeProfileFeedbackEvidenceSnapshot>
for RuntimeProfileFeedbackEvidenceSnapshot
{
fn from(input: module_runtime::RuntimeProfileFeedbackEvidenceSnapshot) -> Self {
Self {
evidence_id: input.evidence_id,
file_name: input.file_name,
content_type: input.content_type,
size_bytes: input.size_bytes,
data_url: input.data_url,
}
}
}
impl From<module_runtime::RuntimeProfileRewardCodeRedeemInput>
for RuntimeProfileRewardCodeRedeemInput
{
@@ -846,6 +874,23 @@ pub(crate) fn map_runtime_profile_recharge_order_procedure_result(
))
}
pub(crate) fn map_runtime_profile_feedback_submission_procedure_result(
result: RuntimeProfileFeedbackSubmissionProcedureResult,
) -> Result<RuntimeProfileFeedbackSubmissionRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let snapshot = result
.record
.ok_or_else(|| SpacetimeClientError::missing_snapshot("profile feedback 快照"))?;
build_runtime_profile_feedback_submission_record(
map_runtime_profile_feedback_submission_snapshot(snapshot),
)
.map_err(SpacetimeClientError::validation_failed)
}
pub(crate) fn map_runtime_referral_invite_center_procedure_result(
result: RuntimeReferralInviteCenterProcedureResult,
) -> Result<RuntimeReferralInviteCenterRecord, SpacetimeClientError> {
@@ -1999,6 +2044,21 @@ pub(crate) fn map_runtime_profile_recharge_order_snapshot(
}
}
pub(crate) fn map_runtime_profile_feedback_submission_snapshot(
snapshot: RuntimeProfileFeedbackSubmissionSnapshot,
) -> module_runtime::RuntimeProfileFeedbackSubmissionSnapshot {
module_runtime::RuntimeProfileFeedbackSubmissionSnapshot {
feedback_id: snapshot.feedback_id,
user_id: snapshot.user_id,
description: snapshot.description,
contact_phone: snapshot.contact_phone,
evidence_json: snapshot.evidence_json,
status: map_runtime_profile_feedback_status_back(snapshot.status),
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_referral_invite_center_snapshot(
snapshot: RuntimeReferralInviteCenterSnapshot,
) -> module_runtime::RuntimeReferralInviteCenterSnapshot {
@@ -4644,6 +4704,16 @@ pub(crate) fn map_runtime_profile_recharge_order_status_back(
}
}
pub(crate) fn map_runtime_profile_feedback_status_back(
value: crate::module_bindings::RuntimeProfileFeedbackStatus,
) -> module_runtime::RuntimeProfileFeedbackStatus {
match value {
crate::module_bindings::RuntimeProfileFeedbackStatus::Open => {
module_runtime::RuntimeProfileFeedbackStatus::Open
}
}
}
pub(crate) fn map_story_session_status(value: StorySessionStatus) -> DomainStorySessionStatus {
match value {
StorySessionStatus::Active => DomainStorySessionStatus::Active,

View File

@@ -1,7 +1,7 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
// This was generated using spacetimedb cli version 2.1.0 (commit 10a4779b1338eff3708493d87496b51842a7c412).
// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6).
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
@@ -399,6 +399,8 @@ pub mod player_progression_table;
pub mod player_progression_type;
pub mod profile_dashboard_state_table;
pub mod profile_dashboard_state_type;
pub mod profile_feedback_submission_table;
pub mod profile_feedback_submission_type;
pub mod profile_invite_code_table;
pub mod profile_invite_code_type;
pub mod profile_membership_table;
@@ -567,6 +569,11 @@ pub mod runtime_platform_theme_type;
pub mod runtime_profile_dashboard_get_input_type;
pub mod runtime_profile_dashboard_procedure_result_type;
pub mod runtime_profile_dashboard_snapshot_type;
pub mod runtime_profile_feedback_evidence_snapshot_type;
pub mod runtime_profile_feedback_status_type;
pub mod runtime_profile_feedback_submission_input_type;
pub mod runtime_profile_feedback_submission_procedure_result_type;
pub mod runtime_profile_feedback_submission_snapshot_type;
pub mod runtime_profile_invite_code_admin_list_input_type;
pub mod runtime_profile_invite_code_admin_list_procedure_result_type;
pub mod runtime_profile_invite_code_admin_procedure_result_type;
@@ -643,8 +650,8 @@ pub mod runtime_snapshot_row_type;
pub mod runtime_snapshot_table;
pub mod runtime_snapshot_type;
pub mod runtime_snapshot_upsert_input_type;
pub mod runtime_tracking_scope_kind_type;
pub mod runtime_tracking_event_procedure_result_type;
pub mod runtime_tracking_scope_kind_type;
pub mod save_puzzle_form_draft_procedure;
pub mod save_puzzle_generated_images_procedure;
pub mod seed_analytics_date_dimensions_reducer;
@@ -703,6 +710,7 @@ pub mod submit_big_fish_input_procedure;
pub mod submit_big_fish_message_procedure;
pub mod submit_custom_world_agent_message_procedure;
pub mod submit_match_3_d_agent_message_procedure;
pub mod submit_profile_feedback_and_return_procedure;
pub mod submit_puzzle_agent_message_procedure;
pub mod submit_puzzle_leaderboard_entry_procedure;
pub mod submit_square_hole_agent_message_procedure;
@@ -1135,6 +1143,8 @@ pub use player_progression_table::*;
pub use player_progression_type::PlayerProgression;
pub use profile_dashboard_state_table::*;
pub use profile_dashboard_state_type::ProfileDashboardState;
pub use profile_feedback_submission_table::*;
pub use profile_feedback_submission_type::ProfileFeedbackSubmission;
pub use profile_invite_code_table::*;
pub use profile_invite_code_type::ProfileInviteCode;
pub use profile_membership_table::*;
@@ -1303,6 +1313,11 @@ pub use runtime_platform_theme_type::RuntimePlatformTheme;
pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput;
pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult;
pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot;
pub use runtime_profile_feedback_evidence_snapshot_type::RuntimeProfileFeedbackEvidenceSnapshot;
pub use runtime_profile_feedback_status_type::RuntimeProfileFeedbackStatus;
pub use runtime_profile_feedback_submission_input_type::RuntimeProfileFeedbackSubmissionInput;
pub use runtime_profile_feedback_submission_procedure_result_type::RuntimeProfileFeedbackSubmissionProcedureResult;
pub use runtime_profile_feedback_submission_snapshot_type::RuntimeProfileFeedbackSubmissionSnapshot;
pub use runtime_profile_invite_code_admin_list_input_type::RuntimeProfileInviteCodeAdminListInput;
pub use runtime_profile_invite_code_admin_list_procedure_result_type::RuntimeProfileInviteCodeAdminListProcedureResult;
pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult;
@@ -1379,8 +1394,8 @@ pub use runtime_snapshot_row_type::RuntimeSnapshotRow;
pub use runtime_snapshot_table::*;
pub use runtime_snapshot_type::RuntimeSnapshot;
pub use runtime_snapshot_upsert_input_type::RuntimeSnapshotUpsertInput;
pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
pub use runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProcedureResult;
pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft;
pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images;
pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions;
@@ -1439,6 +1454,7 @@ pub use submit_big_fish_input_procedure::submit_big_fish_input;
pub use submit_big_fish_message_procedure::submit_big_fish_message;
pub use submit_custom_world_agent_message_procedure::submit_custom_world_agent_message;
pub use submit_match_3_d_agent_message_procedure::submit_match_3_d_agent_message;
pub use submit_profile_feedback_and_return_procedure::submit_profile_feedback_and_return;
pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message;
pub use submit_puzzle_leaderboard_entry_procedure::submit_puzzle_leaderboard_entry;
pub use submit_square_hole_agent_message_procedure::submit_square_hole_agent_message;
@@ -1785,6 +1801,7 @@ pub struct DbUpdate {
npc_state: __sdk::TableUpdate<NpcState>,
player_progression: __sdk::TableUpdate<PlayerProgression>,
profile_dashboard_state: __sdk::TableUpdate<ProfileDashboardState>,
profile_feedback_submission: __sdk::TableUpdate<ProfileFeedbackSubmission>,
profile_invite_code: __sdk::TableUpdate<ProfileInviteCode>,
profile_membership: __sdk::TableUpdate<ProfileMembership>,
profile_played_world: __sdk::TableUpdate<ProfilePlayedWorld>,
@@ -1936,6 +1953,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
"profile_dashboard_state" => db_update.profile_dashboard_state.append(
profile_dashboard_state_table::parse_table_update(table_update)?,
),
"profile_feedback_submission" => db_update.profile_feedback_submission.append(
profile_feedback_submission_table::parse_table_update(table_update)?,
),
"profile_invite_code" => db_update
.profile_invite_code
.append(profile_invite_code_table::parse_table_update(table_update)?),
@@ -2241,6 +2261,12 @@ impl __sdk::DbUpdate for DbUpdate {
&self.profile_dashboard_state,
)
.with_updates_by_pk(|row| &row.user_id);
diff.profile_feedback_submission = cache
.apply_diff_to_table::<ProfileFeedbackSubmission>(
"profile_feedback_submission",
&self.profile_feedback_submission,
)
.with_updates_by_pk(|row| &row.feedback_id);
diff.profile_invite_code = cache
.apply_diff_to_table::<ProfileInviteCode>(
"profile_invite_code",
@@ -2531,6 +2557,9 @@ impl __sdk::DbUpdate for DbUpdate {
"profile_dashboard_state" => db_update
.profile_dashboard_state
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_feedback_submission" => db_update
.profile_feedback_submission
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_invite_code" => db_update
.profile_invite_code
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -2757,6 +2786,9 @@ impl __sdk::DbUpdate for DbUpdate {
"profile_dashboard_state" => db_update
.profile_dashboard_state
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_feedback_submission" => db_update
.profile_feedback_submission
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_invite_code" => db_update
.profile_invite_code
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -2915,6 +2947,7 @@ pub struct AppliedDiff<'r> {
npc_state: __sdk::TableAppliedDiff<'r, NpcState>,
player_progression: __sdk::TableAppliedDiff<'r, PlayerProgression>,
profile_dashboard_state: __sdk::TableAppliedDiff<'r, ProfileDashboardState>,
profile_feedback_submission: __sdk::TableAppliedDiff<'r, ProfileFeedbackSubmission>,
profile_invite_code: __sdk::TableAppliedDiff<'r, ProfileInviteCode>,
profile_membership: __sdk::TableAppliedDiff<'r, ProfileMembership>,
profile_played_world: __sdk::TableAppliedDiff<'r, ProfilePlayedWorld>,
@@ -3127,6 +3160,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.profile_dashboard_state,
event,
);
callbacks.invoke_table_row_callbacks::<ProfileFeedbackSubmission>(
"profile_feedback_submission",
&self.profile_feedback_submission,
event,
);
callbacks.invoke_table_row_callbacks::<ProfileInviteCode>(
"profile_invite_code",
&self.profile_invite_code,
@@ -3994,6 +4032,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
npc_state_table::register_table(client_cache);
player_progression_table::register_table(client_cache);
profile_dashboard_state_table::register_table(client_cache);
profile_feedback_submission_table::register_table(client_cache);
profile_invite_code_table::register_table(client_cache);
profile_membership_table::register_table(client_cache);
profile_played_world_table::register_table(client_cache);
@@ -4067,6 +4106,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
"npc_state",
"player_progression",
"profile_dashboard_state",
"profile_feedback_submission",
"profile_invite_code",
"profile_membership",
"profile_played_world",

View File

@@ -0,0 +1,167 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::profile_feedback_submission_type::ProfileFeedbackSubmission;
use super::runtime_profile_feedback_status_type::RuntimeProfileFeedbackStatus;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `profile_feedback_submission`.
///
/// Obtain a handle from the [`ProfileFeedbackSubmissionTableAccess::profile_feedback_submission`] method on [`super::RemoteTables`],
/// like `ctx.db.profile_feedback_submission()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_feedback_submission().on_insert(...)`.
pub struct ProfileFeedbackSubmissionTableHandle<'ctx> {
imp: __sdk::TableHandle<ProfileFeedbackSubmission>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `profile_feedback_submission`.
///
/// Implemented for [`super::RemoteTables`].
pub trait ProfileFeedbackSubmissionTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`ProfileFeedbackSubmissionTableHandle`], which mediates access to the table `profile_feedback_submission`.
fn profile_feedback_submission(&self) -> ProfileFeedbackSubmissionTableHandle<'_>;
}
impl ProfileFeedbackSubmissionTableAccess for super::RemoteTables {
fn profile_feedback_submission(&self) -> ProfileFeedbackSubmissionTableHandle<'_> {
ProfileFeedbackSubmissionTableHandle {
imp: self
.imp
.get_table::<ProfileFeedbackSubmission>("profile_feedback_submission"),
ctx: std::marker::PhantomData,
}
}
}
pub struct ProfileFeedbackSubmissionInsertCallbackId(__sdk::CallbackId);
pub struct ProfileFeedbackSubmissionDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for ProfileFeedbackSubmissionTableHandle<'ctx> {
type Row = ProfileFeedbackSubmission;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = ProfileFeedbackSubmission> + '_ {
self.imp.iter()
}
type InsertCallbackId = ProfileFeedbackSubmissionInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileFeedbackSubmissionInsertCallbackId {
ProfileFeedbackSubmissionInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: ProfileFeedbackSubmissionInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = ProfileFeedbackSubmissionDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> ProfileFeedbackSubmissionDeleteCallbackId {
ProfileFeedbackSubmissionDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: ProfileFeedbackSubmissionDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
pub struct ProfileFeedbackSubmissionUpdateCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::TableWithPrimaryKey for ProfileFeedbackSubmissionTableHandle<'ctx> {
type UpdateCallbackId = ProfileFeedbackSubmissionUpdateCallbackId;
fn on_update(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static,
) -> ProfileFeedbackSubmissionUpdateCallbackId {
ProfileFeedbackSubmissionUpdateCallbackId(self.imp.on_update(Box::new(callback)))
}
fn remove_on_update(&self, callback: ProfileFeedbackSubmissionUpdateCallbackId) {
self.imp.remove_on_update(callback.0)
}
}
/// Access to the `feedback_id` unique index on the table `profile_feedback_submission`,
/// which allows point queries on the field of the same name
/// via the [`ProfileFeedbackSubmissionFeedbackIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.profile_feedback_submission().feedback_id().find(...)`.
pub struct ProfileFeedbackSubmissionFeedbackIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<ProfileFeedbackSubmission, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> ProfileFeedbackSubmissionTableHandle<'ctx> {
/// Get a handle on the `feedback_id` unique index on the table `profile_feedback_submission`.
pub fn feedback_id(&self) -> ProfileFeedbackSubmissionFeedbackIdUnique<'ctx> {
ProfileFeedbackSubmissionFeedbackIdUnique {
imp: self.imp.get_unique_constraint::<String>("feedback_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> ProfileFeedbackSubmissionFeedbackIdUnique<'ctx> {
/// Find the subscribed row whose `feedback_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<ProfileFeedbackSubmission> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table =
client_cache.get_or_make_table::<ProfileFeedbackSubmission>("profile_feedback_submission");
_table.add_unique_constraint::<String>("feedback_id", |row| &row.feedback_id);
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<ProfileFeedbackSubmission>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<ProfileFeedbackSubmission>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `ProfileFeedbackSubmission`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait profile_feedback_submissionQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `ProfileFeedbackSubmission`.
fn profile_feedback_submission(
&self,
) -> __sdk::__query_builder::Table<ProfileFeedbackSubmission>;
}
impl profile_feedback_submissionQueryTableAccess for __sdk::QueryTableAccessor {
fn profile_feedback_submission(
&self,
) -> __sdk::__query_builder::Table<ProfileFeedbackSubmission> {
__sdk::__query_builder::Table::new("profile_feedback_submission")
}
}

View File

@@ -0,0 +1,75 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_feedback_status_type::RuntimeProfileFeedbackStatus;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileFeedbackSubmission {
pub feedback_id: String,
pub user_id: String,
pub description: String,
pub contact_phone: Option<String>,
pub evidence_json: String,
pub status: RuntimeProfileFeedbackStatus,
pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileFeedbackSubmission {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileFeedbackSubmission`.
///
/// Provides typed access to columns for query building.
pub struct ProfileFeedbackSubmissionCols {
pub feedback_id: __sdk::__query_builder::Col<ProfileFeedbackSubmission, String>,
pub user_id: __sdk::__query_builder::Col<ProfileFeedbackSubmission, String>,
pub description: __sdk::__query_builder::Col<ProfileFeedbackSubmission, String>,
pub contact_phone: __sdk::__query_builder::Col<ProfileFeedbackSubmission, Option<String>>,
pub evidence_json: __sdk::__query_builder::Col<ProfileFeedbackSubmission, String>,
pub status:
__sdk::__query_builder::Col<ProfileFeedbackSubmission, RuntimeProfileFeedbackStatus>,
pub created_at: __sdk::__query_builder::Col<ProfileFeedbackSubmission, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<ProfileFeedbackSubmission, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileFeedbackSubmission {
type Cols = ProfileFeedbackSubmissionCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileFeedbackSubmissionCols {
feedback_id: __sdk::__query_builder::Col::new(table_name, "feedback_id"),
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
description: __sdk::__query_builder::Col::new(table_name, "description"),
contact_phone: __sdk::__query_builder::Col::new(table_name, "contact_phone"),
evidence_json: __sdk::__query_builder::Col::new(table_name, "evidence_json"),
status: __sdk::__query_builder::Col::new(table_name, "status"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileFeedbackSubmission`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileFeedbackSubmissionIxCols {
pub feedback_id: __sdk::__query_builder::IxCol<ProfileFeedbackSubmission, String>,
pub user_id: __sdk::__query_builder::IxCol<ProfileFeedbackSubmission, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileFeedbackSubmission {
type IxCols = ProfileFeedbackSubmissionIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileFeedbackSubmissionIxCols {
feedback_id: __sdk::__query_builder::IxCol::new(table_name, "feedback_id"),
user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileFeedbackSubmission {}

View File

@@ -34,10 +34,10 @@ pub trait record_daily_login_tracking_event_and_return {
input: RuntimeProfileTaskCenterGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -47,10 +47,10 @@ impl record_daily_login_tracking_event_and_return for super::RemoteProcedures {
input: RuntimeProfileTaskCenterGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>(

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileFeedbackEvidenceSnapshot {
pub evidence_id: String,
pub file_name: String,
pub content_type: String,
pub size_bytes: u64,
pub data_url: String,
}
impl __sdk::InModule for RuntimeProfileFeedbackEvidenceSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeProfileFeedbackStatus {
Open,
}
impl __sdk::InModule for RuntimeProfileFeedbackStatus {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,21 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_feedback_evidence_snapshot_type::RuntimeProfileFeedbackEvidenceSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileFeedbackSubmissionInput {
pub user_id: String,
pub description: String,
pub contact_phone: Option<String>,
pub evidence_items: Vec<RuntimeProfileFeedbackEvidenceSnapshot>,
pub created_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileFeedbackSubmissionInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_feedback_submission_snapshot_type::RuntimeProfileFeedbackSubmissionSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileFeedbackSubmissionProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileFeedbackSubmissionSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileFeedbackSubmissionProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,24 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_feedback_status_type::RuntimeProfileFeedbackStatus;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileFeedbackSubmissionSnapshot {
pub feedback_id: String,
pub user_id: String,
pub description: String,
pub contact_phone: Option<String>,
pub evidence_json: String,
pub status: RuntimeProfileFeedbackStatus,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileFeedbackSubmissionSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_feedback_submission_input_type::RuntimeProfileFeedbackSubmissionInput;
use super::runtime_profile_feedback_submission_procedure_result_type::RuntimeProfileFeedbackSubmissionProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct SubmitProfileFeedbackAndReturnArgs {
pub input: RuntimeProfileFeedbackSubmissionInput,
}
impl __sdk::InModule for SubmitProfileFeedbackAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `submit_profile_feedback_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait submit_profile_feedback_and_return {
fn submit_profile_feedback_and_return(&self, input: RuntimeProfileFeedbackSubmissionInput) {
self.submit_profile_feedback_and_return_then(input, |_, _| {});
}
fn submit_profile_feedback_and_return_then(
&self,
input: RuntimeProfileFeedbackSubmissionInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileFeedbackSubmissionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl submit_profile_feedback_and_return for super::RemoteProcedures {
fn submit_profile_feedback_and_return_then(
&self,
input: RuntimeProfileFeedbackSubmissionInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileFeedbackSubmissionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileFeedbackSubmissionProcedureResult>(
"submit_profile_feedback_and_return",
SubmitProfileFeedbackAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -234,6 +234,37 @@ impl SpacetimeClient {
.await
}
pub async fn submit_profile_feedback(
&self,
user_id: String,
description: String,
contact_phone: Option<String>,
evidence_items: Vec<module_runtime::RuntimeProfileFeedbackEvidenceSnapshot>,
created_at_micros: i64,
) -> Result<RuntimeProfileFeedbackSubmissionRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_feedback_submission_input(
user_id,
description,
contact_phone,
evidence_items,
created_at_micros,
)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.submit_profile_feedback_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_feedback_submission_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_profile_referral_invite_center(
&self,
user_id: String,
@@ -385,14 +416,15 @@ impl SpacetimeClient {
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.query_analytics_metric_then(procedure_input, move |_, result| {
connection.procedures().query_analytics_metric_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_analytics_metric_query_procedure_result);
send_once(&sender, mapped);
});
},
);
})
.await
}

View File

@@ -181,6 +181,7 @@ macro_rules! migration_tables {
public_work_like,
profile_membership,
profile_recharge_order,
profile_feedback_submission,
profile_save_archive,
player_progression,
chapter_progression,

View File

@@ -351,6 +351,27 @@ pub struct ProfileRechargeOrder {
pub(crate) membership_expires_at: Option<Timestamp>,
}
#[spacetimedb::table(
accessor = profile_feedback_submission,
index(accessor = by_profile_feedback_user_id, btree(columns = [user_id])),
index(
accessor = by_profile_feedback_user_created_at,
btree(columns = [user_id, created_at])
)
)]
pub struct ProfileFeedbackSubmission {
#[primary_key]
pub(crate) feedback_id: String,
pub(crate) user_id: String,
pub(crate) description: String,
pub(crate) contact_phone: Option<String>,
// 中文注释:首版凭证以 Data URL 写入私有表HTTP 回包只返回元数据,后续迁 OSS 不改变外部契约。
pub(crate) evidence_json: String,
pub(crate) status: RuntimeProfileFeedbackStatus,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_save_archive,
index(accessor = by_profile_save_archive_user_id, btree(columns = [user_id])),
@@ -749,6 +770,25 @@ pub fn create_profile_recharge_order_and_return(
}
}
#[spacetimedb::procedure]
pub fn submit_profile_feedback_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileFeedbackSubmissionInput,
) -> RuntimeProfileFeedbackSubmissionProcedureResult {
match ctx.try_with_tx(|tx| submit_profile_feedback_record(tx, input.clone())) {
Ok(record) => RuntimeProfileFeedbackSubmissionProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileFeedbackSubmissionProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 邀请中心会在首次打开时为账号创建稳定邀请码,前端只展示这里返回的后端状态。
#[spacetimedb::procedure]
pub fn get_profile_referral_invite_center(
@@ -1906,6 +1946,47 @@ fn create_profile_recharge_order_record(
))
}
fn submit_profile_feedback_record(
ctx: &ReducerContext,
input: RuntimeProfileFeedbackSubmissionInput,
) -> Result<RuntimeProfileFeedbackSubmissionSnapshot, String> {
let validated_input = build_runtime_profile_feedback_submission_input(
input.user_id,
input.description,
input.contact_phone,
input.evidence_items,
input.created_at_micros,
)
.map_err(|error| error.to_string())?;
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
let feedback_id = build_runtime_profile_feedback_submission_id(
&validated_input.user_id,
validated_input.created_at_micros,
);
let evidence_json = serde_json::to_string(&validated_input.evidence_items)
.map_err(|error| format!("反馈凭证序列化失败: {error}"))?;
let row = ProfileFeedbackSubmission {
feedback_id: feedback_id.clone(),
user_id: validated_input.user_id,
description: validated_input.description,
contact_phone: validated_input.contact_phone,
evidence_json,
status: RuntimeProfileFeedbackStatus::Open,
created_at,
updated_at: created_at,
};
ctx.db.profile_feedback_submission().insert(row);
let latest = ctx
.db
.profile_feedback_submission()
.feedback_id()
.find(&feedback_id)
.ok_or_else(|| "profile_feedback_submission 写入后未能读取".to_string())?;
Ok(build_profile_feedback_submission_snapshot_from_row(&latest))
}
fn get_profile_referral_invite_center_snapshot(
ctx: &ReducerContext,
input: RuntimeReferralInviteCenterGetInput,
@@ -3440,6 +3521,21 @@ fn build_profile_recharge_order_snapshot_from_row(
}
}
fn build_profile_feedback_submission_snapshot_from_row(
row: &ProfileFeedbackSubmission,
) -> RuntimeProfileFeedbackSubmissionSnapshot {
RuntimeProfileFeedbackSubmissionSnapshot {
feedback_id: row.feedback_id.clone(),
user_id: row.user_id.clone(),
description: row.description.clone(),
contact_phone: row.contact_phone.clone(),
evidence_json: row.evidence_json.clone(),
status: row.status,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_played_world_snapshot_from_row(
row: &ProfilePlayedWorld,
) -> RuntimeProfilePlayedWorldSnapshot {