Add backend feedback submission and image preview
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user