Merge remote-tracking branch 'origin/master' into feat/video-BGfilter
This commit is contained in:
@@ -394,7 +394,7 @@ button:disabled {
|
||||
.admin-dashboard-bars {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(18px, 1fr);
|
||||
grid-auto-columns: minmax(36px, 1fr);
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
min-height: 196px;
|
||||
@@ -404,7 +404,7 @@ button:disabled {
|
||||
|
||||
.admin-dashboard-bar-item {
|
||||
display: grid;
|
||||
min-width: 18px;
|
||||
min-width: 36px;
|
||||
gap: 7px;
|
||||
align-items: end;
|
||||
justify-items: center;
|
||||
@@ -413,7 +413,8 @@ button:disabled {
|
||||
.admin-dashboard-bar-track {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
width: 18px;
|
||||
max-width: 100%;
|
||||
min-width: 18px;
|
||||
height: 160px;
|
||||
align-items: flex-end;
|
||||
@@ -430,10 +431,12 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-item small {
|
||||
max-width: 48px;
|
||||
width: 100%;
|
||||
max-width: 44px;
|
||||
overflow: hidden;
|
||||
color: #8f7868;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
## 2026-07-09 会员有效期与周期泥点重置分离
|
||||
|
||||
- 背景:账户会员制度新增 Starter / Basic / Pro / Ultimate 四档后,会员有效期、周期限时泥点和普通永久泥点容易被混成同一条时间线;升级场景尤其容易误把“补差额”实现成延长会员或重算 reset time。
|
||||
- 决策:`profile_membership.expires_at` 只表示会员是否生效,`cycle_resets_at/cycle_period_days` 只表示会员周期限时泥点重置时间。同级会员购买只延长 `expires_at`,不发当前周期额外泥点,不移动 reset time;升级只补齐当前周期应发泥点差额并更新档位,不延长 `expires_at`,不移动 reset time。周期刷新由后端在个人中心、充值中心、任务中心、账单读取和钱包扣费入口执行,先清上周期剩余限时泥点,再发当前档位周期额度;资产操作退款按原消费流水恢复同一周期限时泥点,避免把限时泥点退成永久泥点。
|
||||
- 决策:`profile_membership.expires_at` 只表示会员是否生效,`cycle_resets_at/cycle_period_days` 只表示会员周期限时泥点重置时间。同级会员购买只延长 `expires_at`,不发当前周期额外泥点,不移动 reset time;升级只补齐当前周期应发泥点差额并更新档位,不延长 `expires_at`,不移动 reset time,但后续周期天数切换为新商品配置。旧月 / 季 / 年卡购买同等级新会员档位时按同级迁移购买处理,延长有效期并切到 Starter / Basic / Pro,避免存量会员无法迁移;购买更高等级新档位仍按升级处理。周期刷新由后端在个人中心、充值中心、任务中心、账单读取和钱包扣费入口执行,先清上周期剩余限时泥点,再发当前档位周期额度;资产操作退款按原消费流水恢复同一周期限时泥点,避免把限时泥点退成永久泥点。
|
||||
- 影响范围:`profile_membership`、`profile_recharge_product_config`、`profile_wallet_ledger`、充值中心、后台充值商品配置、个人资金 ViewModel、钱包扣费入口。
|
||||
- 验证方式:`npm run spacetime:generate`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml wechat_virtual_pay_params`、`npm run typecheck`、充值弹窗和资金 ViewModel 定向测试。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
@@ -1495,6 +1495,14 @@
|
||||
- 验证:发布前运行 `npm run check:spacetime-schema`,完成 schema 检查、bindings 生成、表目录更新和相关 smoke。
|
||||
- 关联:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。
|
||||
|
||||
## SpacetimeDB 持久化 enum 新 variant 只能末尾追加
|
||||
|
||||
- 现象:生产发布时 schema 迁移失败,或旧数据中的 enum 判别序号被新代码解释成其它业务枚举值。
|
||||
- 原因:SpacetimeDB schema 会保存 enum variant 顺序;在已有持久化 enum 中间插入新 variant,会让后续 variant 的判别序号整体移动。即使 Rust 代码能编译,发布到已有数据库也可能炸。
|
||||
- 处理:给已发布并持久化的 enum 增加 variant 时,只能追加到 enum 末尾;同步运行 `npm run spacetime:generate` 刷新 bindings,不能手工把 generated bindings 改成另一套顺序。需要调整既有 variant 顺序、删除或重命名时,必须先确认数据迁移方案。
|
||||
- 验证:`npm run check:spacetime-schema` 应通过;对照本次修改前后的 enum,所有旧 variant 顺序必须完全不变,新 variant 只出现在末尾。
|
||||
- 关联:`server-rs/crates/module-runtime/src/domain.rs`、`server-rs/crates/spacetime-client/src/module_bindings/`、`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md`。
|
||||
|
||||
## SpacetimeDB publish 报 wasm-bindgen 时先查 shared-contracts feature
|
||||
|
||||
- 现象:发布 `spacetime-module` 时报 `wasm-bindgen detected`,提示 `wasm-bindgen is only for webassembly modules that target the web platform`。
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”按当天真实日期自动填充对应自然日 / 自然周 / 自然月范围并刷新,手动修改起止日期后按当前日期范围查询。
|
||||
- 前端默认日期和“本日 / 本周 / 本月”快捷入口都按 `Asia/Shanghai` 计算,不使用浏览器本地时区。
|
||||
- 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。
|
||||
- 图表横轴按后端返回的 bucket label 展示;本月和较长自定义时段使用固定最小日期列宽并允许横向滚动,避免日期刻度互相挤压。
|
||||
|
||||
## 指标口径
|
||||
|
||||
|
||||
@@ -168,13 +168,14 @@ npm run check:server-rs-ddd
|
||||
|
||||
1. 任何 table、view、reducer、procedure、row shape 或 bindings 变化,都必须同步本文件表 / view 目录和生成绑定;真实 table 变化还必须同步 `server-rs/crates/spacetime-module/src/migration.rs`,view 属于派生投影,不写入迁移导入导出表清单。
|
||||
2. 已有表新增字段必须放在 Rust 表结构体最后,并设置明确 `#[default(...)]`。
|
||||
3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。
|
||||
4. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option<Vec<T>>` 加 `#[default(None::<Vec<T>>)]`,业务层归一为空数组。
|
||||
5. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。
|
||||
6. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry` 和 `public_work_detail_entry`;各玩法既有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 保留为 source view 和兼容路径。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅 `public_work_play_daily_stat` 后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走玩法 procedure / reducer。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 维持。
|
||||
7. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。
|
||||
8. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option<String>` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。
|
||||
9. 修改后运行:
|
||||
3. 已发布并持久化的 enum 新增 variant 只能追加到末尾,不能插入中间、删除、重命名或重排;否则会移动既有 variant 的判别序号,导致旧数据解释错误或生产发布 schema 迁移失败。
|
||||
4. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。
|
||||
5. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option<Vec<T>>` 加 `#[default(None::<Vec<T>>)]`,业务层归一为空数组。
|
||||
6. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。
|
||||
7. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。跨玩法公开作品统一主读模型是 `public_work_gallery_entry` 和 `public_work_detail_entry`;各玩法既有 `*_gallery_card_view` / `*_gallery_view` / `custom_world_gallery_entry` 保留为 source view 和兼容路径。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅 `public_work_play_daily_stat` 后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍走玩法 procedure / reducer。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 由 `api-server` BFF 维持。
|
||||
8. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。
|
||||
9. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option<String>` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。
|
||||
10. 修改后运行:
|
||||
|
||||
```bash
|
||||
npm run spacetime:generate
|
||||
|
||||
@@ -1070,13 +1070,13 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
||||
InviteInviterReward,
|
||||
InviteInviteeReward,
|
||||
PointsRecharge,
|
||||
MembershipPeriodGrant,
|
||||
MembershipPeriodReset,
|
||||
AssetOperationConsume,
|
||||
AssetOperationRefund,
|
||||
RedeemCodeReward,
|
||||
PuzzleAuthorIncentiveClaim,
|
||||
DailyTaskReward,
|
||||
MembershipPeriodGrant,
|
||||
MembershipPeriodReset,
|
||||
}
|
||||
|
||||
impl RuntimeProfileWalletLedgerSourceType {
|
||||
|
||||
+4
-4
@@ -18,10 +18,6 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
||||
|
||||
PointsRecharge,
|
||||
|
||||
MembershipPeriodGrant,
|
||||
|
||||
MembershipPeriodReset,
|
||||
|
||||
AssetOperationConsume,
|
||||
|
||||
AssetOperationRefund,
|
||||
@@ -31,6 +27,10 @@ pub enum RuntimeProfileWalletLedgerSourceType {
|
||||
PuzzleAuthorIncentiveClaim,
|
||||
|
||||
DailyTaskReward,
|
||||
|
||||
MembershipPeriodGrant,
|
||||
|
||||
MembershipPeriodReset,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {
|
||||
|
||||
@@ -1827,6 +1827,154 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_membership_tiers_can_migrate_to_new_equivalent_tiers_as_renewals() {
|
||||
assert_eq!(
|
||||
resolve_active_membership_purchase_mode(
|
||||
RuntimeProfileMembershipTier::Month,
|
||||
RuntimeProfileMembershipTier::Starter,
|
||||
),
|
||||
Ok(ActiveMembershipPurchaseMode::Renew),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_active_membership_purchase_mode(
|
||||
RuntimeProfileMembershipTier::Season,
|
||||
RuntimeProfileMembershipTier::Basic,
|
||||
),
|
||||
Ok(ActiveMembershipPurchaseMode::Renew),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_active_membership_purchase_mode(
|
||||
RuntimeProfileMembershipTier::Year,
|
||||
RuntimeProfileMembershipTier::Pro,
|
||||
),
|
||||
Ok(ActiveMembershipPurchaseMode::Renew),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_active_membership_purchase_mode(
|
||||
RuntimeProfileMembershipTier::Starter,
|
||||
RuntimeProfileMembershipTier::Starter,
|
||||
),
|
||||
Ok(ActiveMembershipPurchaseMode::Renew),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_active_membership_purchase_mode(
|
||||
RuntimeProfileMembershipTier::Month,
|
||||
RuntimeProfileMembershipTier::Basic,
|
||||
),
|
||||
Ok(ActiveMembershipPurchaseMode::Upgrade),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_active_membership_purchase_mode(
|
||||
RuntimeProfileMembershipTier::Season,
|
||||
RuntimeProfileMembershipTier::Pro,
|
||||
),
|
||||
Ok(ActiveMembershipPurchaseMode::Upgrade),
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_active_membership_purchase_mode(
|
||||
RuntimeProfileMembershipTier::Year,
|
||||
RuntimeProfileMembershipTier::Ultimate,
|
||||
),
|
||||
Ok(ActiveMembershipPurchaseMode::Upgrade),
|
||||
);
|
||||
assert!(
|
||||
resolve_active_membership_purchase_mode(
|
||||
RuntimeProfileMembershipTier::Basic,
|
||||
RuntimeProfileMembershipTier::Starter,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
resolve_active_membership_purchase_mode(
|
||||
RuntimeProfileMembershipTier::Year,
|
||||
RuntimeProfileMembershipTier::Basic,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
resolve_active_membership_purchase_mode(
|
||||
RuntimeProfileMembershipTier::Basic,
|
||||
RuntimeProfileMembershipTier::Season,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_membership_migration_renews_and_preserves_cycle_timing() {
|
||||
let started_at = Timestamp::from_micros_since_unix_epoch(PROFILE_RUNTIME_DAY_MICROS);
|
||||
let expires_at = Timestamp::from_micros_since_unix_epoch(10 * PROFILE_RUNTIME_DAY_MICROS);
|
||||
let current_reset_at =
|
||||
Timestamp::from_micros_since_unix_epoch(5 * PROFILE_RUNTIME_DAY_MICROS);
|
||||
let purchased_at = Timestamp::from_micros_since_unix_epoch(2 * PROFILE_RUNTIME_DAY_MICROS);
|
||||
let row = apply_active_membership_renew_row(
|
||||
ProfileMembership {
|
||||
user_id: "user-1".to_string(),
|
||||
status: RuntimeProfileMembershipStatus::Active,
|
||||
tier: RuntimeProfileMembershipTier::Month,
|
||||
started_at,
|
||||
expires_at,
|
||||
updated_at: started_at,
|
||||
cycle_started_at: Some(started_at),
|
||||
cycle_resets_at: Some(current_reset_at),
|
||||
cycle_granted_points: 0,
|
||||
cycle_remaining_points: 0,
|
||||
cycle_period_days: 90,
|
||||
},
|
||||
RuntimeProfileMembershipTier::Starter,
|
||||
30,
|
||||
purchased_at,
|
||||
);
|
||||
|
||||
assert_eq!(row.tier, RuntimeProfileMembershipTier::Starter);
|
||||
assert_eq!(
|
||||
row.expires_at,
|
||||
Timestamp::from_micros_since_unix_epoch(40 * PROFILE_RUNTIME_DAY_MICROS),
|
||||
);
|
||||
assert_eq!(row.cycle_resets_at, Some(current_reset_at));
|
||||
assert_eq!(row.cycle_period_days, 90);
|
||||
assert_eq!(row.cycle_granted_points, 0);
|
||||
assert_eq!(row.cycle_remaining_points, 0);
|
||||
assert_eq!(row.updated_at, purchased_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_membership_upgrade_switches_next_cycle_period_without_moving_reset() {
|
||||
let started_at = Timestamp::from_micros_since_unix_epoch(1_000);
|
||||
let expires_at = Timestamp::from_micros_since_unix_epoch(90_000);
|
||||
let current_reset_at = Timestamp::from_micros_since_unix_epoch(30_000);
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(2_000);
|
||||
let (row, period_points_delta) = apply_active_membership_upgrade_row(
|
||||
ProfileMembership {
|
||||
user_id: "user-1".to_string(),
|
||||
status: RuntimeProfileMembershipStatus::Active,
|
||||
tier: RuntimeProfileMembershipTier::Basic,
|
||||
started_at,
|
||||
expires_at,
|
||||
updated_at: started_at,
|
||||
cycle_started_at: Some(started_at),
|
||||
cycle_resets_at: Some(current_reset_at),
|
||||
cycle_granted_points: 800,
|
||||
cycle_remaining_points: 120,
|
||||
cycle_period_days: 90,
|
||||
},
|
||||
RuntimeProfileMembershipTier::Pro,
|
||||
7,
|
||||
2_500,
|
||||
updated_at,
|
||||
);
|
||||
|
||||
assert_eq!(row.tier, RuntimeProfileMembershipTier::Pro);
|
||||
assert_eq!(row.expires_at, expires_at);
|
||||
assert_eq!(row.cycle_resets_at, Some(current_reset_at));
|
||||
assert_eq!(row.cycle_period_days, 7);
|
||||
assert_eq!(row.cycle_granted_points, 2_500);
|
||||
assert_eq!(row.cycle_remaining_points, 1_820);
|
||||
assert_eq!(row.updated_at, updated_at);
|
||||
assert_eq!(period_points_delta, 1_700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_tracking_event_ids_are_treated_as_idempotent_replays() {
|
||||
assert!(should_skip_existing_tracking_event_id(true));
|
||||
@@ -2327,6 +2475,12 @@ fn create_profile_recharge_order_record(
|
||||
let created_at = Timestamp::from_micros_since_unix_epoch(validated_input.created_at_micros);
|
||||
let should_settle_immediately =
|
||||
validated_input.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK;
|
||||
let amount_cents = resolve_profile_recharge_order_amount_cents(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
&product,
|
||||
created_at,
|
||||
)?;
|
||||
let (status, paid_at, points_delta, membership_expires_at) = if should_settle_immediately {
|
||||
let (points_delta, membership_expires_at) = apply_profile_recharge_purchase(
|
||||
ctx,
|
||||
@@ -2345,13 +2499,6 @@ fn create_profile_recharge_order_record(
|
||||
(RuntimeProfileRechargeOrderStatus::Pending, None, 0, None)
|
||||
};
|
||||
|
||||
let amount_cents = resolve_profile_recharge_order_amount_cents(
|
||||
ctx,
|
||||
&validated_input.user_id,
|
||||
&product,
|
||||
created_at,
|
||||
)?;
|
||||
|
||||
let order = ProfileRechargeOrder {
|
||||
order_id: build_runtime_profile_recharge_order_id(
|
||||
&validated_input.user_id,
|
||||
@@ -4316,6 +4463,12 @@ struct MembershipCycleInitialization {
|
||||
cycle_resets_at: Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ActiveMembershipPurchaseMode {
|
||||
Renew,
|
||||
Upgrade,
|
||||
}
|
||||
|
||||
fn normalized_membership_period_days(period_days: u32) -> u32 {
|
||||
if period_days == 0 {
|
||||
PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS
|
||||
@@ -4364,6 +4517,44 @@ fn canonical_membership_pricing_tier(
|
||||
}
|
||||
}
|
||||
|
||||
fn is_legacy_membership_tier(tier: RuntimeProfileMembershipTier) -> bool {
|
||||
matches!(
|
||||
tier,
|
||||
RuntimeProfileMembershipTier::Month
|
||||
| RuntimeProfileMembershipTier::Season
|
||||
| RuntimeProfileMembershipTier::Year
|
||||
)
|
||||
}
|
||||
|
||||
fn active_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> u8 {
|
||||
runtime_profile_membership_tier_rank(tier).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn resolve_active_membership_purchase_mode(
|
||||
current_tier: RuntimeProfileMembershipTier,
|
||||
next_tier: RuntimeProfileMembershipTier,
|
||||
) -> Result<ActiveMembershipPurchaseMode, String> {
|
||||
if current_tier == next_tier {
|
||||
return Ok(ActiveMembershipPurchaseMode::Renew);
|
||||
}
|
||||
|
||||
let current_rank = active_membership_tier_rank(current_tier);
|
||||
let next_rank = active_membership_tier_rank(next_tier);
|
||||
let is_legacy_equivalent_migration = current_rank == next_rank
|
||||
&& is_legacy_membership_tier(current_tier)
|
||||
&& canonical_membership_pricing_tier(current_tier) == next_tier;
|
||||
|
||||
if is_legacy_equivalent_migration {
|
||||
return Ok(ActiveMembershipPurchaseMode::Renew);
|
||||
}
|
||||
|
||||
if next_rank > current_rank {
|
||||
return Ok(ActiveMembershipPurchaseMode::Upgrade);
|
||||
}
|
||||
|
||||
Err("当前会员有效期内不能购买更低档会员".to_string())
|
||||
}
|
||||
|
||||
fn membership_upgrade_amount_cents(target_price_cents: u64, current_price_cents: u64) -> u64 {
|
||||
target_price_cents.saturating_sub(current_price_cents)
|
||||
}
|
||||
@@ -4404,16 +4595,11 @@ fn resolve_profile_membership_order_amount_cents(
|
||||
return Ok(product.price_cents);
|
||||
};
|
||||
|
||||
if row.tier == product.tier {
|
||||
let purchase_mode = resolve_active_membership_purchase_mode(row.tier, product.tier)?;
|
||||
if purchase_mode == ActiveMembershipPurchaseMode::Renew {
|
||||
return Ok(product.price_cents);
|
||||
}
|
||||
|
||||
let current_rank = runtime_profile_membership_tier_rank(row.tier).unwrap_or(0);
|
||||
let next_rank = runtime_profile_membership_tier_rank(product.tier).unwrap_or(0);
|
||||
if next_rank <= current_rank {
|
||||
return Err("当前会员有效期内不能购买更低档会员".to_string());
|
||||
}
|
||||
|
||||
let current_price_cents = membership_product_price_cents_by_tier(ctx, row.tier).unwrap_or(0);
|
||||
Ok(membership_upgrade_amount_cents(
|
||||
product.price_cents,
|
||||
@@ -4435,6 +4621,40 @@ fn resolve_profile_recharge_order_amount_cents(
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_active_membership_upgrade_row(
|
||||
mut row: ProfileMembership,
|
||||
tier: RuntimeProfileMembershipTier,
|
||||
period_days: u32,
|
||||
period_points: u64,
|
||||
updated_at: Timestamp,
|
||||
) -> (ProfileMembership, u64) {
|
||||
let period_points_delta = period_points.saturating_sub(row.cycle_granted_points);
|
||||
row.tier = tier;
|
||||
row.cycle_granted_points = row.cycle_granted_points.max(period_points);
|
||||
row.cycle_remaining_points = row
|
||||
.cycle_remaining_points
|
||||
.saturating_add(period_points_delta);
|
||||
row.cycle_period_days = period_days;
|
||||
row.updated_at = updated_at;
|
||||
(row, period_points_delta)
|
||||
}
|
||||
|
||||
fn apply_active_membership_renew_row(
|
||||
mut row: ProfileMembership,
|
||||
tier: RuntimeProfileMembershipTier,
|
||||
duration_days: u32,
|
||||
purchased_at: Timestamp,
|
||||
) -> ProfileMembership {
|
||||
row.tier = tier;
|
||||
row.expires_at = Timestamp::from_micros_since_unix_epoch(
|
||||
row.expires_at
|
||||
.to_micros_since_unix_epoch()
|
||||
.saturating_add(i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS)),
|
||||
);
|
||||
row.updated_at = purchased_at;
|
||||
row
|
||||
}
|
||||
|
||||
fn upsert_profile_membership_row(ctx: &ReducerContext, row: ProfileMembership) {
|
||||
ctx.db.profile_membership().user_id().delete(&row.user_id);
|
||||
ctx.db.profile_membership().insert(row);
|
||||
@@ -4766,30 +4986,21 @@ fn apply_profile_membership_purchase(
|
||||
let duration_micros = i64::from(duration_days).saturating_mul(PROFILE_RUNTIME_DAY_MICROS);
|
||||
|
||||
let (next_row, period_points_delta) = match current {
|
||||
Some(mut row) if active_membership_row_at(&row, purchased_at) => {
|
||||
if row.tier == tier {
|
||||
row.expires_at = Timestamp::from_micros_since_unix_epoch(
|
||||
row.expires_at
|
||||
.to_micros_since_unix_epoch()
|
||||
.saturating_add(duration_micros),
|
||||
);
|
||||
row.updated_at = purchased_at;
|
||||
(row, 0)
|
||||
Some(row) if active_membership_row_at(&row, purchased_at) => {
|
||||
let purchase_mode = resolve_active_membership_purchase_mode(row.tier, tier)?;
|
||||
if purchase_mode == ActiveMembershipPurchaseMode::Renew {
|
||||
(
|
||||
apply_active_membership_renew_row(row, tier, duration_days, purchased_at),
|
||||
0,
|
||||
)
|
||||
} else {
|
||||
let current_rank = runtime_profile_membership_tier_rank(row.tier).unwrap_or(0);
|
||||
let next_rank = runtime_profile_membership_tier_rank(tier).unwrap_or(0);
|
||||
if next_rank <= current_rank {
|
||||
return Err("当前会员有效期内不能购买更低档会员".to_string());
|
||||
}
|
||||
let period_points_delta = period_points.saturating_sub(row.cycle_granted_points);
|
||||
row.tier = tier;
|
||||
row.cycle_granted_points = row.cycle_granted_points.max(period_points);
|
||||
row.cycle_remaining_points = row
|
||||
.cycle_remaining_points
|
||||
.saturating_add(period_points_delta);
|
||||
row.cycle_period_days = normalized_membership_period_days(row.cycle_period_days);
|
||||
row.updated_at = purchased_at;
|
||||
(row, period_points_delta)
|
||||
apply_active_membership_upgrade_row(
|
||||
row,
|
||||
tier,
|
||||
period_days,
|
||||
period_points,
|
||||
purchased_at,
|
||||
)
|
||||
}
|
||||
}
|
||||
Some(row) => {
|
||||
|
||||
@@ -152,6 +152,42 @@ test('profile funds ViewModel formats recharge product and membership labels', (
|
||||
}),
|
||||
),
|
||||
).toBe('每月200泥点');
|
||||
expect(
|
||||
buildRechargeProductValueLabel(
|
||||
buildRechargeProduct({
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
bonusPoints: 0,
|
||||
durationDays: 7,
|
||||
membershipPeriodPoints: 80,
|
||||
membershipPeriodDays: 7,
|
||||
}),
|
||||
),
|
||||
).toBe('每周80泥点');
|
||||
expect(
|
||||
buildRechargeProductValueLabel(
|
||||
buildRechargeProduct({
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
bonusPoints: 0,
|
||||
durationDays: 14,
|
||||
membershipPeriodPoints: 300,
|
||||
membershipPeriodDays: 14,
|
||||
}),
|
||||
),
|
||||
).toBe('每14天300泥点');
|
||||
expect(
|
||||
buildRechargeProductValueLabel(
|
||||
buildRechargeProduct({
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
bonusPoints: 0,
|
||||
durationDays: 90,
|
||||
membershipPeriodPoints: 1200,
|
||||
membershipPeriodDays: 90,
|
||||
}),
|
||||
),
|
||||
).toBe('每90天1200泥点');
|
||||
expect(buildMembershipLabel(buildMembership(), (value) => value)).toBe(
|
||||
'普通用户',
|
||||
);
|
||||
|
||||
@@ -86,11 +86,28 @@ export function formatRechargePrice(priceCents: number) {
|
||||
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatMembershipPeriodPrefix(periodDays: number) {
|
||||
if (periodDays === 30) {
|
||||
return '每月';
|
||||
}
|
||||
if (periodDays === 7) {
|
||||
return '每周';
|
||||
}
|
||||
if (periodDays > 0) {
|
||||
return `每${periodDays}天`;
|
||||
}
|
||||
return '每周期';
|
||||
}
|
||||
|
||||
export function buildRechargeProductValueLabel(product: ProfileRechargeProduct) {
|
||||
if (product.kind === 'membership') {
|
||||
const membershipPeriodPoints = product.membershipPeriodPoints;
|
||||
const membershipPeriodDays =
|
||||
product.membershipPeriodDays > 0
|
||||
? product.membershipPeriodDays
|
||||
: product.durationDays;
|
||||
return membershipPeriodPoints > 0
|
||||
? `每月${membershipPeriodPoints}泥点`
|
||||
? `${formatMembershipPeriodPrefix(membershipPeriodDays)}${membershipPeriodPoints}泥点`
|
||||
: `${product.durationDays}天`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user