feat: add invite code validity controls
- Add invite code starts/expires fields across contracts, API, Spacetime bindings, and admin UI - Enforce pending/expired invite code redemption behavior and expose admin status - Add admin write-operation confirmation guard and documentation - Add invite code contract/runtime tests
This commit is contained in:
@@ -44,6 +44,7 @@ use shared_contracts::runtime::{
|
||||
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, 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;
|
||||
|
||||
@@ -369,6 +370,14 @@ pub async fn admin_upsert_profile_task_config(
|
||||
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()
|
||||
@@ -558,6 +567,10 @@ pub async fn admin_upsert_profile_invite_code(
|
||||
) -> 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()
|
||||
@@ -565,6 +578,8 @@ pub async fn 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
|
||||
@@ -873,6 +888,27 @@ fn normalize_admin_invite_code_metadata(metadata: Option<Value>) -> Result<Strin
|
||||
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),
|
||||
@@ -932,6 +968,9 @@ fn build_profile_invite_code_admin_response(
|
||||
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,
|
||||
}
|
||||
@@ -1256,9 +1295,8 @@ mod tests {
|
||||
|
||||
#[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 app =
|
||||
build_router(AppState::new(admin_enabled_test_config()).expect("state should build"));
|
||||
|
||||
let list_response = app
|
||||
.clone()
|
||||
@@ -1302,9 +1340,8 @@ mod tests {
|
||||
|
||||
#[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"),
|
||||
);
|
||||
let app =
|
||||
build_router(AppState::new(admin_enabled_test_config()).expect("state should build"));
|
||||
|
||||
for uri in [
|
||||
"/admin/api/profile/redeem-codes",
|
||||
|
||||
@@ -223,6 +223,189 @@ pub fn runtime_profile_beijing_day_key(now_micros: i64) -> i64 {
|
||||
.div_euclid(PROFILE_RUNTIME_DAY_MICROS)
|
||||
}
|
||||
|
||||
/// 从 YYYY-MM-DD 解析分析业务日 date_key。
|
||||
///
|
||||
/// 这里故意不引入时区库:date_key 本身就是“北京时间日历日期自 Unix 纪元起的天数”。
|
||||
pub fn parse_analytics_calendar_date_key(
|
||||
calendar_date: &str,
|
||||
) -> Result<i64, RuntimeProfileFieldError> {
|
||||
let (year, month, day) = parse_calendar_date_parts(calendar_date)?;
|
||||
validate_calendar_date(year, month, day)?;
|
||||
let date_key = days_from_civil(year, month, day);
|
||||
validate_analytics_date_dimension_date_key(date_key)?;
|
||||
Ok(date_key)
|
||||
}
|
||||
|
||||
/// 校验分析日期维表 date_key 是否位于业务允许范围内。
|
||||
///
|
||||
/// 裸 i64 date_key 可由 reducer 直接传入,因此在进入日历算法前先限制范围,避免极端输入
|
||||
/// 生成无意义日期或触发整数边界风险。
|
||||
pub fn validate_analytics_date_dimension_date_key(
|
||||
date_key: i64,
|
||||
) -> Result<(), RuntimeProfileFieldError> {
|
||||
let min_date_key = days_from_civil(2000, 1, 1);
|
||||
let max_date_key = days_from_civil(2100, 12, 31);
|
||||
if date_key < min_date_key || date_key > max_date_key {
|
||||
return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_analytics_date_dimension_from_date_key(
|
||||
date_key: i64,
|
||||
) -> AnalyticsDateDimensionSnapshot {
|
||||
let (year, month, day) = civil_from_days(date_key);
|
||||
let weekday = weekday_from_date_key(date_key);
|
||||
let iso_week_key = iso_week_key(year, month, day, weekday);
|
||||
let week_start_date_key = date_key - i64::from(weekday - 1);
|
||||
let week_end_date_key = week_start_date_key + 6;
|
||||
let month_start_date_key = days_from_civil(year, month, 1);
|
||||
let month_end_date_key = days_from_civil(year, month, days_in_month(year, month));
|
||||
let quarter = (month - 1) / 3 + 1;
|
||||
let quarter_start_month = (quarter - 1) * 3 + 1;
|
||||
let quarter_end_month = quarter_start_month + 2;
|
||||
let quarter_start_date_key = days_from_civil(year, quarter_start_month, 1);
|
||||
let quarter_end_date_key = days_from_civil(
|
||||
year,
|
||||
quarter_end_month,
|
||||
days_in_month(year, quarter_end_month),
|
||||
);
|
||||
let year_start_date_key = days_from_civil(year, 1, 1);
|
||||
let year_end_date_key = days_from_civil(year, 12, 31);
|
||||
|
||||
AnalyticsDateDimensionSnapshot {
|
||||
date_key,
|
||||
calendar_date: format!("{year:04}-{month:02}-{day:02}"),
|
||||
weekday,
|
||||
iso_week_key,
|
||||
week_start_date_key,
|
||||
week_end_date_key,
|
||||
month_key: year * 100 + i32::from(month),
|
||||
month_start_date_key,
|
||||
month_end_date_key,
|
||||
quarter_key: year * 10 + i32::from(quarter),
|
||||
quarter_start_date_key,
|
||||
quarter_end_date_key,
|
||||
year_key: year,
|
||||
year_start_date_key,
|
||||
year_end_date_key,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_calendar_date_parts(
|
||||
calendar_date: &str,
|
||||
) -> Result<(i32, u8, u8), RuntimeProfileFieldError> {
|
||||
let mut parts = calendar_date.trim().split('-');
|
||||
let year = parts
|
||||
.next()
|
||||
.and_then(|value| value.parse::<i32>().ok())
|
||||
.ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?;
|
||||
let month = parts
|
||||
.next()
|
||||
.and_then(|value| value.parse::<u8>().ok())
|
||||
.ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?;
|
||||
let day = parts
|
||||
.next()
|
||||
.and_then(|value| value.parse::<u8>().ok())
|
||||
.ok_or(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate)?;
|
||||
if parts.next().is_some() {
|
||||
return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate);
|
||||
}
|
||||
Ok((year, month, day))
|
||||
}
|
||||
|
||||
fn validate_calendar_date(year: i32, month: u8, day: u8) -> Result<(), RuntimeProfileFieldError> {
|
||||
if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
|
||||
return Err(RuntimeProfileFieldError::InvalidAnalyticsCalendarDate);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn days_in_month(year: i32, month: u8) -> u8 {
|
||||
match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if is_leap_year(year) => 29,
|
||||
2 => 28,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_leap_year(year: i32) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
fn weekday_from_date_key(date_key: i64) -> u8 {
|
||||
// 中文注释:1970-01-01 是周四;这里返回 ISO weekday,周一=1,周日=7。
|
||||
(date_key + 3).rem_euclid(7) as u8 + 1
|
||||
}
|
||||
|
||||
fn iso_week_key(year: i32, month: u8, day: u8, weekday: u8) -> i32 {
|
||||
let ordinal = ordinal_day(year, month, day);
|
||||
let week = (i32::from(ordinal) - i32::from(weekday) + 10).div_euclid(7);
|
||||
let iso_year = if week < 1 {
|
||||
year - 1
|
||||
} else if week > iso_weeks_in_year(year) {
|
||||
year + 1
|
||||
} else {
|
||||
year
|
||||
};
|
||||
let iso_week = if week < 1 {
|
||||
iso_weeks_in_year(year - 1)
|
||||
} else if week > iso_weeks_in_year(year) {
|
||||
1
|
||||
} else {
|
||||
week
|
||||
};
|
||||
iso_year * 100 + iso_week
|
||||
}
|
||||
|
||||
fn ordinal_day(year: i32, month: u8, day: u8) -> u16 {
|
||||
(1..month)
|
||||
.map(|current_month| u16::from(days_in_month(year, current_month)))
|
||||
.sum::<u16>()
|
||||
+ u16::from(day)
|
||||
}
|
||||
|
||||
fn iso_weeks_in_year(year: i32) -> i32 {
|
||||
let jan_first_weekday = weekday_from_date_key(days_from_civil(year, 1, 1));
|
||||
if jan_first_weekday == 4 || (jan_first_weekday == 3 && is_leap_year(year)) {
|
||||
53
|
||||
} else {
|
||||
52
|
||||
}
|
||||
}
|
||||
|
||||
fn days_from_civil(year: i32, month: u8, day: u8) -> i64 {
|
||||
// 中文注释:Howard Hinnant civil calendar 算法,返回 1970-01-01 起的日序号。
|
||||
let adjusted_year = year - if month <= 2 { 1 } else { 0 };
|
||||
let era = adjusted_year.div_euclid(400);
|
||||
let year_of_era = adjusted_year - era * 400;
|
||||
let month = i32::from(month);
|
||||
let day = i32::from(day);
|
||||
let month_prime = month + if month > 2 { -3 } else { 9 };
|
||||
let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
|
||||
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
|
||||
i64::from(era * 146_097 + day_of_era - 719_468)
|
||||
}
|
||||
|
||||
fn civil_from_days(date_key: i64) -> (i32, u8, u8) {
|
||||
// 中文注释:days_from_civil 的反向算法,避免依赖运行环境时区。
|
||||
let z = date_key + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let day_of_era = z - era * 146_097;
|
||||
let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524
|
||||
- day_of_era / 146_096)
|
||||
.div_euclid(365);
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2).div_euclid(153);
|
||||
let day = day_of_year - (153 * month_prime + 2).div_euclid(5) + 1;
|
||||
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
|
||||
year += if month <= 2 { 1 } else { 0 };
|
||||
(year as i32, month as u8, day as u8)
|
||||
}
|
||||
|
||||
pub fn build_default_runtime_profile_task_config(
|
||||
updated_at_micros: i64,
|
||||
updated_by: String,
|
||||
@@ -416,10 +599,21 @@ pub fn build_runtime_profile_redeem_code_record(
|
||||
pub fn build_runtime_profile_invite_code_record(
|
||||
snapshot: RuntimeProfileInviteCodeSnapshot,
|
||||
) -> RuntimeProfileInviteCodeRecord {
|
||||
let status = crate::commands::resolve_runtime_profile_invite_code_status(
|
||||
snapshot.starts_at_micros,
|
||||
snapshot.expires_at_micros,
|
||||
snapshot.updated_at_micros,
|
||||
);
|
||||
|
||||
RuntimeProfileInviteCodeRecord {
|
||||
user_id: snapshot.user_id,
|
||||
invite_code: snapshot.invite_code,
|
||||
metadata_json: snapshot.metadata_json,
|
||||
starts_at: snapshot.starts_at_micros.map(format_utc_micros),
|
||||
starts_at_micros: snapshot.starts_at_micros,
|
||||
expires_at: snapshot.expires_at_micros.map(format_utc_micros),
|
||||
expires_at_micros: snapshot.expires_at_micros,
|
||||
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),
|
||||
|
||||
@@ -89,8 +89,8 @@ pub fn build_runtime_tracking_event_input(
|
||||
) -> Result<RuntimeTrackingEventInput, RuntimeProfileFieldError> {
|
||||
let event_id = normalize_required_string(event_id)
|
||||
.ok_or(RuntimeProfileFieldError::MissingTrackingEventId)?;
|
||||
let event_key =
|
||||
normalize_required_string(event_key).ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
|
||||
let event_key = normalize_required_string(event_key)
|
||||
.ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
|
||||
let scope_id = normalize_required_string(scope_id)
|
||||
.ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?;
|
||||
let metadata_json = normalize_tracking_metadata_json(metadata_json)?;
|
||||
@@ -151,8 +151,12 @@ pub fn build_runtime_profile_task_config_admin_upsert_input(
|
||||
let task_id = normalize_profile_task_id(task_id)?;
|
||||
let title =
|
||||
normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingTaskTitle)?;
|
||||
let event_key =
|
||||
normalize_required_string(event_key).ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
|
||||
let event_key = normalize_required_string(event_key)
|
||||
.ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
|
||||
// 中文注释:个人任务首版只按用户维度累计,避免 site/work/module 误复用用户桶。
|
||||
if scope_kind != RuntimeTrackingScopeKind::User {
|
||||
return Err(RuntimeProfileFieldError::UnsupportedProfileTaskScopeKind);
|
||||
}
|
||||
if threshold == 0 {
|
||||
return Err(RuntimeProfileFieldError::InvalidTaskThreshold);
|
||||
}
|
||||
@@ -326,17 +330,25 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input(
|
||||
admin_user_id: String,
|
||||
invite_code: String,
|
||||
metadata_json: String,
|
||||
starts_at_micros: Option<i64>,
|
||||
expires_at_micros: Option<i64>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeProfileInviteCodeAdminUpsertInput, RuntimeProfileFieldError> {
|
||||
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
|
||||
let invite_code =
|
||||
normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?;
|
||||
let metadata_json = normalize_invite_code_metadata_json(metadata_json)?;
|
||||
crate::commands::validate_runtime_profile_invite_code_validity_window(
|
||||
starts_at_micros,
|
||||
expires_at_micros,
|
||||
)?;
|
||||
|
||||
Ok(RuntimeProfileInviteCodeAdminUpsertInput {
|
||||
admin_user_id,
|
||||
invite_code,
|
||||
metadata_json,
|
||||
starts_at_micros,
|
||||
expires_at_micros,
|
||||
updated_at_micros,
|
||||
})
|
||||
}
|
||||
@@ -639,6 +651,40 @@ pub fn normalize_invite_code_metadata_json(
|
||||
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
|
||||
}
|
||||
|
||||
pub fn validate_runtime_profile_invite_code_validity_window(
|
||||
starts_at_micros: Option<i64>,
|
||||
expires_at_micros: Option<i64>,
|
||||
) -> Result<(), RuntimeProfileFieldError> {
|
||||
if matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at > expires_at)
|
||||
{
|
||||
return Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resolve_runtime_profile_invite_code_status(
|
||||
starts_at_micros: Option<i64>,
|
||||
expires_at_micros: Option<i64>,
|
||||
now_micros: i64,
|
||||
) -> RuntimeProfileInviteCodeStatus {
|
||||
if starts_at_micros
|
||||
.map(|starts_at| now_micros < starts_at)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return RuntimeProfileInviteCodeStatus::Pending;
|
||||
}
|
||||
|
||||
if expires_at_micros
|
||||
.map(|expires_at| now_micros >= expires_at)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return RuntimeProfileInviteCodeStatus::Expired;
|
||||
}
|
||||
|
||||
RuntimeProfileInviteCodeStatus::Active
|
||||
}
|
||||
|
||||
fn normalize_tracking_metadata_json(value: String) -> Result<String, RuntimeProfileFieldError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
|
||||
@@ -21,6 +21,10 @@ pub const PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON: &str = "{}";
|
||||
pub const PROFILE_INVITE_CODE_METADATA_MAX_BYTES: usize = 4096;
|
||||
pub const PROFILE_RUNTIME_DAY_MICROS: i64 = 86_400_000_000;
|
||||
pub const PROFILE_TASK_BEIJING_OFFSET_MICROS: i64 = 28_800_000_000;
|
||||
pub const ANALYTICS_DATE_DIMENSION_MAX_SEED_DAYS: i64 = 3_660;
|
||||
// 中文注释:日期维表当前只预置运营统计可接受的业务日期范围,避免裸 date_key 极值进入日历算法。
|
||||
pub const ANALYTICS_DATE_DIMENSION_MIN_DATE: &str = "2000-01-01";
|
||||
pub const ANALYTICS_DATE_DIMENSION_MAX_DATE: &str = "2100-12-31";
|
||||
pub const PROFILE_TASK_ID_DAILY_LOGIN: &str = "daily_login";
|
||||
pub const PROFILE_TASK_EVENT_KEY_DAILY_LOGIN: &str = "daily_login";
|
||||
pub const PROFILE_TASK_DEFAULT_TITLE_DAILY_LOGIN: &str = "每日登录";
|
||||
@@ -30,6 +34,30 @@ pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
|
||||
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
|
||||
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
|
||||
|
||||
/// 分析日期维表的纯领域快照。
|
||||
///
|
||||
/// date_key 沿用现有北京时间自然日桶:floor((occurred_at_micros + 8h) / 1d)。
|
||||
/// calendar_date 使用该业务日对应的公历日期,格式固定为 YYYY-MM-DD。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AnalyticsDateDimensionSnapshot {
|
||||
pub date_key: i64,
|
||||
pub calendar_date: String,
|
||||
pub weekday: u8,
|
||||
pub iso_week_key: i32,
|
||||
pub week_start_date_key: i64,
|
||||
pub week_end_date_key: i64,
|
||||
pub month_key: i32,
|
||||
pub month_start_date_key: i64,
|
||||
pub month_end_date_key: i64,
|
||||
pub quarter_key: i32,
|
||||
pub quarter_start_date_key: i64,
|
||||
pub quarter_end_date_key: i64,
|
||||
pub year_key: i32,
|
||||
pub year_start_date_key: i64,
|
||||
pub year_end_date_key: i64,
|
||||
}
|
||||
|
||||
/// 运行时平台主题。
|
||||
///
|
||||
/// 当前只冻结 light/dark 两种主题,避免各层散落字符串字面量。
|
||||
@@ -411,6 +439,24 @@ impl RuntimeProfileTaskStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RuntimeProfileInviteCodeStatus {
|
||||
Pending,
|
||||
Active,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl RuntimeProfileInviteCodeStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Active => "active",
|
||||
Self::Expired => "expired",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeTrackingEventInput {
|
||||
@@ -904,6 +950,8 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput {
|
||||
pub admin_user_id: String,
|
||||
pub invite_code: String,
|
||||
pub metadata_json: String,
|
||||
pub starts_at_micros: Option<i64>,
|
||||
pub expires_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -919,6 +967,8 @@ pub struct RuntimeProfileInviteCodeSnapshot {
|
||||
pub user_id: String,
|
||||
pub invite_code: String,
|
||||
pub metadata_json: String,
|
||||
pub starts_at_micros: Option<i64>,
|
||||
pub expires_at_micros: Option<i64>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
@@ -1289,6 +1339,11 @@ pub struct RuntimeProfileInviteCodeRecord {
|
||||
pub user_id: String,
|
||||
pub invite_code: String,
|
||||
pub metadata_json: String,
|
||||
pub starts_at: Option<String>,
|
||||
pub starts_at_micros: Option<i64>,
|
||||
pub expires_at: Option<String>,
|
||||
pub expires_at_micros: Option<i64>,
|
||||
pub status: RuntimeProfileInviteCodeStatus,
|
||||
pub created_at: String,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at: String,
|
||||
|
||||
@@ -52,6 +52,7 @@ pub enum RuntimeProfileFieldError {
|
||||
InvalidRedeemCodeReward,
|
||||
InvalidRedeemCodeMaxUses,
|
||||
InvalidInviteCodeMetadata,
|
||||
InvalidInviteCodeValidityWindow,
|
||||
MissingTaskId,
|
||||
MissingTaskTitle,
|
||||
MissingTaskEventKey,
|
||||
@@ -59,6 +60,7 @@ pub enum RuntimeProfileFieldError {
|
||||
MissingTrackingScopeId,
|
||||
InvalidTaskCycle,
|
||||
InvalidTaskScopeKind,
|
||||
UnsupportedProfileTaskScopeKind,
|
||||
InvalidTaskThreshold,
|
||||
InvalidTaskReward,
|
||||
TaskDisabled,
|
||||
@@ -77,6 +79,7 @@ pub enum RuntimeProfileFieldError {
|
||||
actual_session_id: String,
|
||||
},
|
||||
NonPersistentRuntimeSnapshot,
|
||||
InvalidAnalyticsCalendarDate,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
@@ -98,6 +101,7 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
Self::InvalidInviteCodeMetadata => {
|
||||
f.write_str("邀请码 metadata 必须是合法 JSON object")
|
||||
}
|
||||
Self::InvalidInviteCodeValidityWindow => f.write_str("邀请码开始时间不能晚于截止时间"),
|
||||
Self::MissingTaskId => f.write_str("profile_task.task_id 不能为空"),
|
||||
Self::MissingTaskTitle => f.write_str("profile_task.title 不能为空"),
|
||||
Self::MissingTaskEventKey => f.write_str("profile_task.event_key 不能为空"),
|
||||
@@ -105,6 +109,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
Self::MissingTrackingScopeId => f.write_str("tracking_event.scope_id 不能为空"),
|
||||
Self::InvalidTaskCycle => f.write_str("profile_task.cycle 无效"),
|
||||
Self::InvalidTaskScopeKind => f.write_str("profile_task.scope_kind 无效"),
|
||||
Self::UnsupportedProfileTaskScopeKind => {
|
||||
f.write_str("个人任务 scope_kind 首版仅支持 user")
|
||||
}
|
||||
Self::InvalidTaskThreshold => f.write_str("profile_task.threshold 必须大于 0"),
|
||||
Self::InvalidTaskReward => f.write_str("profile_task.reward_points 必须大于 0"),
|
||||
Self::TaskDisabled => f.write_str("任务已停用"),
|
||||
@@ -130,6 +137,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
|
||||
Self::NonPersistentRuntimeSnapshot => {
|
||||
f.write_str("预览或测试运行态不能创建正式 checkpoint")
|
||||
}
|
||||
Self::InvalidAnalyticsCalendarDate => {
|
||||
f.write_str("analytics_date_dimension.calendar_date 必须是合法 YYYY-MM-DD 日期")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod application;
|
||||
mod application;
|
||||
mod commands;
|
||||
mod domain;
|
||||
mod errors;
|
||||
@@ -465,6 +465,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytics_date_dimension_handles_iso_week_across_year() {
|
||||
let date_key = parse_analytics_calendar_date_key("2024-12-31").unwrap();
|
||||
let dimension = build_analytics_date_dimension_from_date_key(date_key);
|
||||
|
||||
assert_eq!(dimension.calendar_date, "2024-12-31");
|
||||
assert_eq!(dimension.weekday, 2);
|
||||
assert_eq!(dimension.iso_week_key, 202501);
|
||||
assert_eq!(
|
||||
dimension.week_start_date_key,
|
||||
parse_analytics_calendar_date_key("2024-12-30").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
dimension.week_end_date_key,
|
||||
parse_analytics_calendar_date_key("2025-01-05").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytics_date_dimension_handles_leap_day() {
|
||||
let date_key = parse_analytics_calendar_date_key("2024-02-29").unwrap();
|
||||
let dimension = build_analytics_date_dimension_from_date_key(date_key);
|
||||
|
||||
assert_eq!(dimension.calendar_date, "2024-02-29");
|
||||
assert_eq!(dimension.weekday, 4);
|
||||
assert_eq!(dimension.month_key, 202402);
|
||||
assert_eq!(dimension.month_end_date_key, date_key);
|
||||
assert_eq!(dimension.quarter_key, 20241);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analytics_date_dimension_handles_quarter_boundary() {
|
||||
let date_key = parse_analytics_calendar_date_key("2024-04-01").unwrap();
|
||||
let dimension = build_analytics_date_dimension_from_date_key(date_key);
|
||||
|
||||
assert_eq!(dimension.quarter_key, 20242);
|
||||
assert_eq!(dimension.quarter_start_date_key, date_key);
|
||||
assert_eq!(
|
||||
dimension.quarter_end_date_key,
|
||||
parse_analytics_calendar_date_key("2024-06-30").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
dimension.year_start_date_key,
|
||||
parse_analytics_calendar_date_key("2024-01-01").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
dimension.year_end_date_key,
|
||||
parse_analytics_calendar_date_key("2024-12-31").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_profile_task_status_matches_progress_and_claim() {
|
||||
assert_eq!(
|
||||
@@ -525,6 +576,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_task_config_input_accepts_only_user_scope() {
|
||||
let input = build_runtime_profile_task_config_admin_upsert_input(
|
||||
"admin".to_string(),
|
||||
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
|
||||
"每日登录".to_string(),
|
||||
"".to_string(),
|
||||
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
|
||||
RuntimeProfileTaskCycle::Daily,
|
||||
RuntimeTrackingScopeKind::User,
|
||||
1,
|
||||
10,
|
||||
true,
|
||||
10,
|
||||
1,
|
||||
)
|
||||
.expect("user scope should be accepted");
|
||||
assert_eq!(input.scope_kind, RuntimeTrackingScopeKind::User);
|
||||
|
||||
for scope_kind in [
|
||||
RuntimeTrackingScopeKind::Site,
|
||||
RuntimeTrackingScopeKind::Module,
|
||||
RuntimeTrackingScopeKind::Work,
|
||||
] {
|
||||
assert_eq!(
|
||||
build_runtime_profile_task_config_admin_upsert_input(
|
||||
"admin".to_string(),
|
||||
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
|
||||
"每日登录".to_string(),
|
||||
"".to_string(),
|
||||
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
|
||||
RuntimeProfileTaskCycle::Daily,
|
||||
scope_kind,
|
||||
1,
|
||||
10,
|
||||
true,
|
||||
10,
|
||||
1,
|
||||
)
|
||||
.expect_err("non-user scope should fail"),
|
||||
RuntimeProfileFieldError::UnsupportedProfileTaskScopeKind
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recharge_product_catalog_matches_reference_prices() {
|
||||
let point_products = runtime_profile_recharge_point_products();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use module_runtime::{
|
||||
RuntimeProfileFieldError, RuntimeProfileInviteCodeSnapshot, RuntimeProfileInviteCodeStatus,
|
||||
build_runtime_profile_invite_code_record, resolve_runtime_profile_invite_code_status,
|
||||
validate_runtime_profile_invite_code_validity_window,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn invite_code_validity_window_rejects_start_after_expire() {
|
||||
let result = validate_runtime_profile_invite_code_validity_window(Some(20), Some(10));
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_code_validity_window_allows_open_ended_or_equal_boundary() {
|
||||
assert!(validate_runtime_profile_invite_code_validity_window(None, None).is_ok());
|
||||
assert!(validate_runtime_profile_invite_code_validity_window(Some(10), None).is_ok());
|
||||
assert!(validate_runtime_profile_invite_code_validity_window(None, Some(10)).is_ok());
|
||||
assert!(validate_runtime_profile_invite_code_validity_window(Some(10), Some(10)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_code_status_uses_inclusive_start_and_exclusive_expire_boundary() {
|
||||
assert_eq!(
|
||||
resolve_runtime_profile_invite_code_status(Some(20), None, 19),
|
||||
RuntimeProfileInviteCodeStatus::Pending
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_runtime_profile_invite_code_status(Some(20), Some(30), 20),
|
||||
RuntimeProfileInviteCodeStatus::Active
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_runtime_profile_invite_code_status(Some(20), Some(30), 29),
|
||||
RuntimeProfileInviteCodeStatus::Active
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_runtime_profile_invite_code_status(Some(20), Some(30), 30),
|
||||
RuntimeProfileInviteCodeStatus::Expired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_code_record_formats_window_and_status() {
|
||||
let record = build_runtime_profile_invite_code_record(RuntimeProfileInviteCodeSnapshot {
|
||||
user_id: "user-1".to_string(),
|
||||
invite_code: "SY00000001".to_string(),
|
||||
metadata_json: "{}".to_string(),
|
||||
starts_at_micros: Some(0),
|
||||
expires_at_micros: Some(1_000_000),
|
||||
created_at_micros: 0,
|
||||
updated_at_micros: 1_000_000,
|
||||
});
|
||||
|
||||
assert_eq!(record.starts_at.as_deref(), Some("1970-01-01T00:00:00Z"));
|
||||
assert_eq!(record.expires_at.as_deref(), Some("1970-01-01T00:00:01Z"));
|
||||
assert_eq!(record.status, RuntimeProfileInviteCodeStatus::Expired);
|
||||
}
|
||||
@@ -412,6 +412,10 @@ pub struct AdminUpsertProfileInviteCodeRequest {
|
||||
pub invite_code: String,
|
||||
#[serde(default)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub starts_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -447,6 +451,9 @@ pub struct ProfileInviteCodeAdminResponse {
|
||||
pub user_id: String,
|
||||
pub invite_code: String,
|
||||
pub metadata: serde_json::Value,
|
||||
pub starts_at: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
use serde_json::json;
|
||||
use shared_contracts::runtime::{
|
||||
AdminUpsertProfileInviteCodeRequest, ProfileInviteCodeAdminResponse,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn admin_upsert_invite_code_request_accepts_optional_validity_window() {
|
||||
let request: AdminUpsertProfileInviteCodeRequest = serde_json::from_value(json!({
|
||||
"inviteCode": "SY00000001",
|
||||
"metadata": { "note": "测试" },
|
||||
"startsAt": "2026-05-04T00:00:00Z",
|
||||
"expiresAt": null
|
||||
}))
|
||||
.expect("邀请码管理请求应接受 startsAt/expiresAt");
|
||||
|
||||
assert_eq!(request.starts_at.as_deref(), Some("2026-05-04T00:00:00Z"));
|
||||
assert_eq!(request.expires_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_invite_code_response_serializes_window_and_status_as_camel_case() {
|
||||
let response = ProfileInviteCodeAdminResponse {
|
||||
user_id: "user-1".to_string(),
|
||||
invite_code: "SY00000001".to_string(),
|
||||
metadata: json!({}),
|
||||
starts_at: Some("2026-05-04T00:00:00Z".to_string()),
|
||||
expires_at: None,
|
||||
status: "active".to_string(),
|
||||
created_at: "2026-05-04T00:00:00Z".to_string(),
|
||||
updated_at: "2026-05-04T00:00:00Z".to_string(),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(response).expect("邀请码管理响应应可序列化");
|
||||
assert_eq!(value["startsAt"], json!("2026-05-04T00:00:00Z"));
|
||||
assert_eq!(value["expiresAt"], json!(null));
|
||||
assert_eq!(value["status"], json!("active"));
|
||||
}
|
||||
@@ -281,6 +281,8 @@ impl From<module_runtime::RuntimeProfileInviteCodeAdminUpsertInput>
|
||||
admin_user_id: input.admin_user_id,
|
||||
invite_code: input.invite_code,
|
||||
metadata_json: input.metadata_json,
|
||||
starts_at_micros: input.starts_at_micros,
|
||||
expires_at_micros: input.expires_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
@@ -1997,6 +1999,8 @@ pub(crate) fn map_runtime_profile_invite_code_snapshot(
|
||||
user_id: snapshot.user_id,
|
||||
invite_code: snapshot.invite_code,
|
||||
metadata_json: snapshot.metadata_json,
|
||||
starts_at_micros: snapshot.starts_at_micros,
|
||||
expires_at_micros: snapshot.expires_at_micros,
|
||||
created_at_micros: snapshot.created_at_micros,
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ pub struct ProfileInviteCode {
|
||||
pub metadata_json: String,
|
||||
pub created_at: __sdk::Timestamp,
|
||||
pub updated_at: __sdk::Timestamp,
|
||||
pub starts_at: Option<__sdk::Timestamp>,
|
||||
pub expires_at: Option<__sdk::Timestamp>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ProfileInviteCode {
|
||||
@@ -27,6 +29,8 @@ pub struct ProfileInviteCodeCols {
|
||||
pub metadata_json: __sdk::__query_builder::Col<ProfileInviteCode, String>,
|
||||
pub created_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>,
|
||||
pub updated_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>,
|
||||
pub starts_at: __sdk::__query_builder::Col<ProfileInviteCode, Option<__sdk::Timestamp>>,
|
||||
pub expires_at: __sdk::__query_builder::Col<ProfileInviteCode, Option<__sdk::Timestamp>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for ProfileInviteCode {
|
||||
@@ -38,6 +42,8 @@ impl __sdk::__query_builder::HasCols for ProfileInviteCode {
|
||||
metadata_json: __sdk::__query_builder::Col::new(table_name, "metadata_json"),
|
||||
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
|
||||
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
|
||||
starts_at: __sdk::__query_builder::Col::new(table_name, "starts_at"),
|
||||
expires_at: __sdk::__query_builder::Col::new(table_name, "expires_at"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput {
|
||||
pub admin_user_id: String,
|
||||
pub invite_code: String,
|
||||
pub metadata_json: String,
|
||||
pub starts_at_micros: Option<i64>,
|
||||
pub expires_at_micros: Option<i64>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ pub struct RuntimeProfileInviteCodeSnapshot {
|
||||
pub user_id: String,
|
||||
pub invite_code: String,
|
||||
pub metadata_json: String,
|
||||
pub starts_at_micros: Option<i64>,
|
||||
pub expires_at_micros: Option<i64>,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
@@ -533,12 +533,16 @@ impl SpacetimeClient {
|
||||
admin_user_id: String,
|
||||
invite_code: String,
|
||||
metadata_json: String,
|
||||
starts_at_micros: Option<i64>,
|
||||
expires_at_micros: Option<i64>,
|
||||
updated_at_micros: i64,
|
||||
) -> Result<RuntimeProfileInviteCodeRecord, SpacetimeClientError> {
|
||||
let procedure_input = build_runtime_profile_invite_code_admin_upsert_input(
|
||||
admin_user_id,
|
||||
invite_code,
|
||||
metadata_json,
|
||||
starts_at_micros,
|
||||
expires_at_micros,
|
||||
updated_at_micros,
|
||||
)
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::runtime::analytics_date_dimension::analytics_date_dimension;
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use spacetimedb_lib::sats::de::serde::DeserializeWrapper;
|
||||
@@ -161,6 +162,7 @@ macro_rules! migration_tables {
|
||||
user_browse_history,
|
||||
profile_dashboard_state,
|
||||
profile_wallet_ledger,
|
||||
analytics_date_dimension,
|
||||
tracking_event,
|
||||
tracking_daily_stat,
|
||||
profile_task_config,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
pub mod analytics_date_dimension;
|
||||
mod browse_history;
|
||||
mod profile;
|
||||
mod settings;
|
||||
mod snapshots;
|
||||
|
||||
pub use analytics_date_dimension::*;
|
||||
pub use browse_history::*;
|
||||
pub use profile::*;
|
||||
pub use settings::*;
|
||||
|
||||
@@ -6,7 +6,6 @@ const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20;
|
||||
const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration";
|
||||
const PROFILE_TASK_SYSTEM_USER_ID: &str = "system:profile-task";
|
||||
const PROFILE_TASK_LOGIN_EVENT_ID_PREFIX: &str = "daily-login";
|
||||
const PROFILE_TRACKING_SITE_SCOPE_ID: &str = "site";
|
||||
const PROFILE_TRACKING_PROFILE_MODULE_KEY: &str = "profile";
|
||||
|
||||
#[spacetimedb::table(accessor = profile_dashboard_state)]
|
||||
@@ -188,6 +187,10 @@ pub struct ProfileInviteCode {
|
||||
pub(crate) metadata_json: String,
|
||||
pub(crate) created_at: Timestamp,
|
||||
pub(crate) updated_at: Timestamp,
|
||||
#[default(None::<Timestamp>)]
|
||||
pub(crate) starts_at: Option<Timestamp>,
|
||||
#[default(None::<Timestamp>)]
|
||||
pub(crate) expires_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
@@ -1902,6 +1905,7 @@ fn redeem_profile_referral_invite_code_record(
|
||||
.invite_code()
|
||||
.find(&invite_code)
|
||||
.ok_or_else(|| "邀请码不存在".to_string())?;
|
||||
validate_profile_invite_code_redeem_time(&inviter_code, validated_input.updated_at_micros)?;
|
||||
if inviter_code.user_id == invitee_user_id {
|
||||
return Err("不能填写自己的邀请码".to_string());
|
||||
}
|
||||
@@ -2124,6 +2128,8 @@ fn admin_upsert_profile_invite_code_record(
|
||||
input.admin_user_id,
|
||||
input.invite_code,
|
||||
input.metadata_json,
|
||||
input.starts_at_micros,
|
||||
input.expires_at_micros,
|
||||
input.updated_at_micros,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
@@ -2152,6 +2158,12 @@ fn admin_upsert_profile_invite_code_record(
|
||||
metadata_json: validated_input.metadata_json,
|
||||
created_at: existing.created_at,
|
||||
updated_at,
|
||||
starts_at: validated_input
|
||||
.starts_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
expires_at: validated_input
|
||||
.expires_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
});
|
||||
return Ok(build_profile_invite_code_snapshot_from_row(&inserted));
|
||||
}
|
||||
@@ -2162,6 +2174,12 @@ fn admin_upsert_profile_invite_code_record(
|
||||
metadata_json: validated_input.metadata_json,
|
||||
created_at: updated_at,
|
||||
updated_at,
|
||||
starts_at: validated_input
|
||||
.starts_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
expires_at: validated_input
|
||||
.expires_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
});
|
||||
Ok(build_profile_invite_code_snapshot_from_row(&inserted))
|
||||
}
|
||||
@@ -2286,9 +2304,34 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
|
||||
metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(),
|
||||
created_at: ctx.timestamp,
|
||||
updated_at: ctx.timestamp,
|
||||
starts_at: None,
|
||||
expires_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_profile_invite_code_redeem_time(
|
||||
invite_code: &ProfileInviteCode,
|
||||
now_micros: i64,
|
||||
) -> Result<(), String> {
|
||||
if invite_code
|
||||
.starts_at
|
||||
.map(|starts_at| now_micros < starts_at.to_micros_since_unix_epoch())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err("邀请码未生效".to_string());
|
||||
}
|
||||
|
||||
if invite_code
|
||||
.expires_at
|
||||
.map(|expires_at| now_micros >= expires_at.to_micros_since_unix_epoch())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err("邀请码已过期".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn count_today_profile_referral_inviter_rewards(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
@@ -2397,11 +2440,7 @@ fn get_profile_task_center_snapshot(
|
||||
record_daily_login_tracking_event(ctx, &validated_input.user_id)?;
|
||||
}
|
||||
|
||||
Ok(build_profile_task_center_snapshot(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
ctx.timestamp,
|
||||
))
|
||||
build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp)
|
||||
}
|
||||
|
||||
fn claim_profile_task_reward_record(
|
||||
@@ -2438,7 +2477,7 @@ fn claim_profile_task_reward_record(
|
||||
return Err(RuntimeProfileFieldError::TaskAlreadyClaimed.to_string());
|
||||
}
|
||||
|
||||
let progress_count = profile_task_progress_count(ctx, &validated_input.user_id, &config);
|
||||
let progress_count = profile_task_progress_count(ctx, &validated_input.user_id, &config)?;
|
||||
if progress_count < config.threshold {
|
||||
return Err(RuntimeProfileFieldError::TaskNotClaimable.to_string());
|
||||
}
|
||||
@@ -2469,7 +2508,7 @@ fn claim_profile_task_reward_record(
|
||||
claimed_at: ctx.timestamp,
|
||||
});
|
||||
|
||||
refresh_profile_task_progress(ctx, &validated_input.user_id, &config, day_key);
|
||||
refresh_profile_task_progress(ctx, &validated_input.user_id, &config, day_key)?;
|
||||
let ledger_entry = ctx
|
||||
.db
|
||||
.profile_wallet_ledger()
|
||||
@@ -2484,7 +2523,7 @@ fn claim_profile_task_reward_record(
|
||||
reward_points: claim.reward_points,
|
||||
wallet_balance,
|
||||
ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry),
|
||||
center: build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp),
|
||||
center: build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2640,7 +2679,7 @@ fn build_profile_task_center_snapshot(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
updated_at: Timestamp,
|
||||
) -> RuntimeProfileTaskCenterSnapshot {
|
||||
) -> Result<RuntimeProfileTaskCenterSnapshot, String> {
|
||||
ensure_default_profile_task_config(ctx);
|
||||
let day_key = runtime_profile_beijing_day_key(updated_at.to_micros_since_unix_epoch());
|
||||
let mut configs = ctx.db.profile_task_config().iter().collect::<Vec<_>>();
|
||||
@@ -2649,43 +2688,42 @@ fn build_profile_task_center_snapshot(
|
||||
.cmp(&right.sort_order)
|
||||
.then_with(|| left.task_id.cmp(&right.task_id))
|
||||
});
|
||||
let tasks = configs
|
||||
.into_iter()
|
||||
.map(|config| {
|
||||
let progress_count = profile_task_progress_count(ctx, user_id, &config);
|
||||
refresh_profile_task_progress(ctx, user_id, &config, day_key);
|
||||
let claim = ctx.db.profile_task_reward_claim().claim_id().find(
|
||||
&build_runtime_profile_task_claim_id(user_id, &config.task_id, day_key),
|
||||
);
|
||||
RuntimeProfileTaskItemSnapshot {
|
||||
task_id: config.task_id,
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
event_key: config.event_key,
|
||||
cycle: config.cycle,
|
||||
threshold: config.threshold,
|
||||
let mut tasks = Vec::with_capacity(configs.len());
|
||||
for config in configs {
|
||||
validate_profile_task_user_scope(&config)?;
|
||||
let progress_count = profile_task_progress_count(ctx, user_id, &config)?;
|
||||
refresh_profile_task_progress(ctx, user_id, &config, day_key)?;
|
||||
let claim = ctx.db.profile_task_reward_claim().claim_id().find(
|
||||
&build_runtime_profile_task_claim_id(user_id, &config.task_id, day_key),
|
||||
);
|
||||
tasks.push(RuntimeProfileTaskItemSnapshot {
|
||||
task_id: config.task_id,
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
event_key: config.event_key,
|
||||
cycle: config.cycle,
|
||||
threshold: config.threshold,
|
||||
progress_count,
|
||||
reward_points: config.reward_points,
|
||||
status: resolve_runtime_profile_task_status(
|
||||
config.enabled,
|
||||
progress_count,
|
||||
reward_points: config.reward_points,
|
||||
status: resolve_runtime_profile_task_status(
|
||||
config.enabled,
|
||||
progress_count,
|
||||
config.threshold,
|
||||
claim.is_some(),
|
||||
),
|
||||
day_key,
|
||||
claimed_at_micros: claim.map(|row| row.claimed_at.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
config.threshold,
|
||||
claim.is_some(),
|
||||
),
|
||||
day_key,
|
||||
claimed_at_micros: claim.map(|row| row.claimed_at.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
|
||||
});
|
||||
}
|
||||
|
||||
RuntimeProfileTaskCenterSnapshot {
|
||||
Ok(RuntimeProfileTaskCenterSnapshot {
|
||||
user_id: user_id.to_string(),
|
||||
day_key,
|
||||
wallet_balance: profile_wallet_balance(ctx, user_id),
|
||||
tasks,
|
||||
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh_profile_task_progress(
|
||||
@@ -2693,7 +2731,7 @@ fn refresh_profile_task_progress(
|
||||
user_id: &str,
|
||||
config: &ProfileTaskConfig,
|
||||
day_key: i64,
|
||||
) -> ProfileTaskProgress {
|
||||
) -> Result<ProfileTaskProgress, String> {
|
||||
let progress_id = build_runtime_profile_task_progress_id(user_id, &config.task_id, day_key);
|
||||
if let Some(existing) = ctx
|
||||
.db
|
||||
@@ -2706,7 +2744,7 @@ fn refresh_profile_task_progress(
|
||||
.progress_id()
|
||||
.delete(&existing.progress_id);
|
||||
}
|
||||
let progress_count = profile_task_progress_count(ctx, user_id, config);
|
||||
let progress_count = profile_task_progress_count(ctx, user_id, config)?;
|
||||
let claimed = ctx
|
||||
.db
|
||||
.profile_task_reward_claim()
|
||||
@@ -2717,7 +2755,7 @@ fn refresh_profile_task_progress(
|
||||
day_key,
|
||||
))
|
||||
.is_some();
|
||||
ctx.db.profile_task_progress().insert(ProfileTaskProgress {
|
||||
Ok(ctx.db.profile_task_progress().insert(ProfileTaskProgress {
|
||||
progress_id,
|
||||
user_id: user_id.to_string(),
|
||||
task_id: config.task_id.clone(),
|
||||
@@ -2731,17 +2769,19 @@ fn refresh_profile_task_progress(
|
||||
claimed,
|
||||
),
|
||||
updated_at: ctx.timestamp,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
fn profile_task_progress_count(
|
||||
ctx: &ReducerContext,
|
||||
user_id: &str,
|
||||
config: &ProfileTaskConfig,
|
||||
) -> u32 {
|
||||
) -> Result<u32, String> {
|
||||
validate_profile_task_user_scope(config)?;
|
||||
let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch());
|
||||
let scope_id = profile_task_tracking_scope_id(user_id, config);
|
||||
ctx.db
|
||||
let scope_id = profile_task_tracking_scope_id(user_id, config)?;
|
||||
Ok(ctx
|
||||
.db
|
||||
.tracking_daily_stat()
|
||||
.stat_id()
|
||||
.find(&build_runtime_tracking_daily_stat_id(
|
||||
@@ -2751,15 +2791,26 @@ fn profile_task_progress_count(
|
||||
day_key,
|
||||
))
|
||||
.map(|row| row.count)
|
||||
.unwrap_or(0)
|
||||
.unwrap_or(0))
|
||||
}
|
||||
|
||||
fn profile_task_tracking_scope_id(user_id: &str, config: &ProfileTaskConfig) -> String {
|
||||
match config.scope_kind {
|
||||
RuntimeTrackingScopeKind::Site => PROFILE_TRACKING_SITE_SCOPE_ID.to_string(),
|
||||
RuntimeTrackingScopeKind::Module => PROFILE_TRACKING_PROFILE_MODULE_KEY.to_string(),
|
||||
RuntimeTrackingScopeKind::User => user_id.to_string(),
|
||||
RuntimeTrackingScopeKind::Work => user_id.to_string(),
|
||||
fn profile_task_tracking_scope_id(
|
||||
user_id: &str,
|
||||
config: &ProfileTaskConfig,
|
||||
) -> Result<String, String> {
|
||||
validate_profile_task_user_scope(config)?;
|
||||
Ok(user_id.to_string())
|
||||
}
|
||||
|
||||
fn validate_profile_task_user_scope(config: &ProfileTaskConfig) -> Result<(), String> {
|
||||
if config.scope_kind == RuntimeTrackingScopeKind::User {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"个人任务 scope_kind 首版仅支持 user,当前 task_id={} scope_kind={}",
|
||||
config.task_id,
|
||||
config.scope_kind.as_str()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3242,6 +3293,12 @@ fn build_profile_invite_code_snapshot_from_row(
|
||||
user_id: row.user_id.clone(),
|
||||
invite_code: row.invite_code.clone(),
|
||||
metadata_json: row.metadata_json.clone(),
|
||||
starts_at_micros: row
|
||||
.starts_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
expires_at_micros: row
|
||||
.expires_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user