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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user