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

@@ -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()