feat: wave 2 migration (P3, P5, P7 openspec changes)

Implements, verifies, and archives the three remaining Wave 2 changes from
openspec/MIGRATION-PLAN.md.

- add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and
  one-time admin review, paginated own fund entries, idempotent per-shop
  weekly/monthly settlement statements with commission rate and one-time
  payout confirmation.
- add-merchant-onboarding (P5): personal/enterprise applications with one live
  application per user, guarded review with mandatory rejection reason, and
  transactional shop + owner provisioning returning one-time credentials;
  mall onboarding/status pages and an admin review console.
- add-membership-messaging (P7): platform member levels, append-only growth
  accrual on order completion with guarded one-way leveling, order/shipment/
  refund system messages with unread/read state and soft deletion, plus the
  mall header unread badge.

Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding,
membership and messaging modules, event hooks in order/fulfillment/aftersale,
and integration suites for each. Shared contract extended and all three
frontends updated; code indexes, domain docs, backend guidelines and the
migration tracker synced.

Verification: cargo test -p vmall-api green twice consecutively; mall, admin
and shop-admin builds pass; browser smoke on every new surface; openspec
validate --all --strict green (33 passed).

The three changes share the @vmall/shared contract, the mall mock adapter and
per-app locale/nav files, so they are committed together to keep every commit
buildable.
This commit is contained in:
2026-09-25 15:25:29 +00:00
parent 772aafa3fb
commit 9904696e76
120 changed files with 14097 additions and 125 deletions
+343
View File
@@ -12,6 +12,10 @@ export const enExtra = {
brands: "Brands",
aftersales: "After-sales",
reviews: "Reviews",
withdrawals: "Withdrawals",
settlements: "Settlements",
merchantApplications: "Merchant applications",
memberLevels: "Member levels",
},
admin: {
dashboardTitle: "Platform overview",
@@ -149,6 +153,91 @@ export const enExtra = {
merchant: "Merchant",
platform: "Platform",
},
withdrawalReviewTitle: "Withdrawal applications",
withdrawalId: "Application",
withdrawalBuyer: "Buyer email",
withdrawalUser: "User ID",
withdrawalAmount: "Amount",
withdrawalPayout: "Payout details",
withdrawalMethod: "Channel",
withdrawalAccount: "Account",
withdrawalHolder: "Holder",
withdrawalReviewedAt: "Reviewed",
withdrawalReview: "Review",
withdrawalClose: "Close",
withdrawalNote: "Review note (optional)",
withdrawalNotePlaceholder: "Reason kept with the decision",
withdrawalReviewNote: "Note",
withdrawalApprove: "Approve",
withdrawalReject: "Reject",
withdrawalConfirmApprove:
"Approve this withdrawal? The frozen amount is paid out and cannot be undone.",
withdrawalConfirmReject:
"Reject this withdrawal? The frozen amount returns to the buyer's available balance.",
withdrawalApproved: "Withdrawal approved; the frozen balance is settled.",
withdrawalRejected: "Withdrawal rejected; the amount is back in the buyer's balance.",
withdrawalConflict: "This application was already reviewed; the list was refreshed",
withdrawalStatuses: {
pending: "Pending review",
approved: "Approved",
rejected: "Rejected",
},
commissionRateTitle: "Commission rate",
commissionRate: "Commission rate (basis points)",
commissionRateHint: "Integer basis points between 0 and 10000; 100 bps = 1%.",
commissionRateDisplay: "{bps} bps ({percent}%)",
commissionRateCurrent: "Effective rate",
commissionRateSave: "Save rate",
commissionRateInvalid: "Enter an integer between 0 and 10000 basis points.",
commissionRateSaved: "Commission rate saved. New statements snapshot it.",
settlementGenerateTitle: "Manual generation",
settlementGenerateHint:
"Generation is idempotent per shop and closed period: an existing statement is returned instead of a duplicate.",
settlementShop: "Shop",
settlementPeriodKind: "Period",
settlementPeriodKinds: {
week: "Week",
month: "Month",
},
settlementPeriodDate: "Date inside the period",
settlementPeriodDateHint:
"Any date inside the target week/month; the server normalizes it to the period bounds.",
settlementGenerate: "Generate statement",
settlementGenerated: "Statement generated.",
settlementGeneratedExisting:
"An existing statement for this shop and period was returned; nothing was duplicated.",
settlementGeneratedResult: "Returned statement",
settlementPickShop: "Select a shop first.",
settlementPeriodDateInvalid: "Enter a valid date (YYYY-MM-DD).",
settlementPeriodNotClosed: "The period is not closed yet; generation was refused",
settlementListTitle: "Statements",
settlementPeriod: "Period",
settlementOrderCount: "Orders",
settlementGross: "Gross",
settlementRefunds: "Refunds",
settlementCommission: "Commission",
settlementPayable: "Payable",
settlementDetail: "Detail",
settlementClose: "Close",
settlementStatuses: {
pending: "Pending payout",
confirmed: "Confirmed",
},
settlementDetailTitle: "Statement detail",
settlementSnapshot: "Snapshot",
settlementOrders: "Contributing orders",
settlementNoOrders: "No contributing orders in this period.",
settlementOrderNo: "Order no.",
settlementOrderCurrency: "Order currency",
settlementConfirmedAt: "Confirmed",
settlementUpdated: "Updated",
settlementConfirmPayout: "Confirm payout",
settlementConfirmPayoutHint:
"Confirming credits the payable amount to the shop owner's balance exactly once.",
settlementConfirmDialog:
"Confirm this payout? The shop owner's balance is credited and the confirmation cannot be repeated.",
settlementPayoutConfirmed: "Payout confirmed; the shop owner's ledger is credited.",
settlementConflict: "This statement was already confirmed; the list was refreshed",
reviewProduct: "Product",
reviewShop: "Shop",
reviewBuyer: "Buyer",
@@ -167,6 +256,94 @@ export const enExtra = {
visible: "Visible",
hidden: "Hidden",
},
merchantApplication: "Application",
merchantApplicant: "Applicant email",
merchantEntityType: "Entity type",
merchantEntityName: "Entity name",
merchantSubmittedAt: "Submitted",
merchantUpdated: "Updated",
merchantReviewedAt: "Reviewed",
merchantCreatedShop: "Created shop",
merchantView: "Detail",
merchantClose: "Close",
merchantDetail: "Merchant application detail",
merchantEntityInfo: "Entity information",
merchantRealName: "Real name",
merchantCompanyName: "Company name",
merchantBusinessLicenseNo: "Business license no.",
merchantContact: "Contact",
merchantContactName: "Contact name",
merchantContactPhone: "Phone",
merchantContactEmail: "Contact email",
merchantContactAddress: "Address",
merchantCategories: "Operating categories",
merchantNoCategories: "No categories submitted",
merchantQualification: "Qualification",
merchantIdentityDocument: "Identity document",
merchantBusinessLicense: "Business license",
merchantExtraMaterials: "Extra materials",
merchantNoQualification: "No qualification material provided",
merchantRejectionReason: "Rejection reason",
merchantConfirmApprove:
"Approve this application? A shop and a dedicated shop-owner login are provisioned; the initial password is shown once and cannot be retrieved again.",
merchantConfirmReject:
"Reject this application? The applicant can submit a new application afterwards.",
merchantApprove: "Approve",
merchantReject: "Reject",
merchantRejectReason: "Rejection reason (required)",
merchantRejectReasonPlaceholder: "Tell the applicant why the application was rejected",
merchantRejectReasonRequired: "A rejection reason is required.",
merchantApprovedNotice: "Application approved; the shop and its owner login were provisioned.",
merchantRejectedNotice: "Application rejected.",
merchantOutcomeApproved: "Approved: the shop and owner login were provisioned.",
merchantOutcomeRejected: "Rejected: the applicant can submit a new application.",
merchantConflict: "This application was already reviewed; the list was refreshed",
merchantCredentialsTitle: "One-time initial credentials",
merchantCredentialsWarning:
"Copy these credentials now. The initial password is shown only once and cannot be retrieved again after this dialog is closed.",
merchantCredentialsEmail: "Owner login",
merchantCredentialsPassword: "Initial password",
merchantCredentialsShopSlug: "Shop slug",
merchantCredentialsShopId: "Shop ID",
merchantCredentialsClose: "I have saved the credentials",
merchantStatuses: {
pending: "Pending review",
approved: "Approved",
rejected: "Rejected",
},
merchantEntityTypes: {
personal: "Personal",
enterprise: "Enterprise",
},
memberLevelCreateTitle: "Add member level",
memberLevelEditTitle: "Edit member level",
memberLevelHint:
"Levels list in growth order. Both languages are required, and a growth threshold can be used by exactly one level.",
memberLevelNameEn: "Level name (English)",
memberLevelNameZh: "Level name (中文)",
memberLevelIcon: "Icon",
memberLevelGrowth: "Growth threshold",
memberLevelBenefitsEn: "Benefits (English)",
memberLevelBenefitsZh: "Benefits (中文)",
memberLevelCreate: "Add level",
memberLevelCreated: "Member level created.",
memberLevelUpdated: "Member level updated.",
memberLevelUpdatedAt: "Updated",
memberLevelDeleted: "Member level deleted.",
memberLevelRequired: "Both language names and benefits are required.",
memberLevelIconRequired: "An icon is required.",
memberLevelThresholdInvalid: "Use a growth threshold of zero or greater.",
memberLevelThresholdDuplicate:
"Another level already uses that growth threshold.",
memberLevelConflict: "Growth threshold already in use",
memberLevelInvalid: "The level was rejected by the server",
memberLevelConfirmDelete:
'Delete the level "{name}"? This cannot be undone.',
memberLevelDeleteInUse:
"This level is held by at least one customer, so it cannot be deleted",
memberLevelDiscardEdit: "Discard the unsaved level edits and refresh?",
memberLevelGone:
"That member level no longer exists; the list was refreshed.",
},
} as const;
@@ -182,6 +359,10 @@ export const zhExtra = {
brands: "品牌",
aftersales: "售后仲裁",
reviews: "评价管理",
withdrawals: "提现审核",
settlements: "结算对账",
merchantApplications: "商家入驻审核",
memberLevels: "会员等级",
},
admin: {
dashboardTitle: "平台概览",
@@ -316,6 +497,85 @@ export const zhExtra = {
merchant: "商家",
platform: "平台",
},
withdrawalReviewTitle: "提现申请",
withdrawalId: "申请单",
withdrawalBuyer: "买家邮箱",
withdrawalUser: "用户 ID",
withdrawalAmount: "金额",
withdrawalPayout: "收款信息",
withdrawalMethod: "渠道",
withdrawalAccount: "账号",
withdrawalHolder: "户名",
withdrawalReviewedAt: "审核时间",
withdrawalReview: "审核",
withdrawalClose: "收起",
withdrawalNote: "审核备注(可选)",
withdrawalNotePlaceholder: "随审核结果保留的说明",
withdrawalReviewNote: "备注",
withdrawalApprove: "通过",
withdrawalReject: "驳回",
withdrawalConfirmApprove: "确定通过该提现申请吗?冻结金额将打款且不可撤销。",
withdrawalConfirmReject: "确定驳回该提现申请吗?冻结金额将退回买家可用余额。",
withdrawalApproved: "提现已通过,冻结金额已结算。",
withdrawalRejected: "提现已驳回,金额已退回买家余额。",
withdrawalConflict: "该申请已被审核,列表已刷新",
withdrawalStatuses: {
pending: "待审核",
approved: "已通过",
rejected: "已驳回",
},
commissionRateTitle: "佣金比例",
commissionRate: "佣金比例(基点)",
commissionRateHint: "取 0 到 10000 的整数基点,100 基点 = 1%。",
commissionRateDisplay: "{bps} 基点({percent}%)",
commissionRateCurrent: "当前比例",
commissionRateSave: "保存比例",
commissionRateInvalid: "请输入 0 到 10000 之间的整数基点。",
commissionRateSaved: "佣金比例已保存,之后生成的账单将采用新比例。",
settlementGenerateTitle: "手动生成",
settlementGenerateHint:
"同一店铺同一周期幂等:已存在时返回原账单,不会重复生成。",
settlementShop: "店铺",
settlementPeriodKind: "周期",
settlementPeriodKinds: {
week: "周",
month: "月",
},
settlementPeriodDate: "周期内日期",
settlementPeriodDateHint: "填写目标周/月内的任意日期,服务端会归一化为周期边界。",
settlementGenerate: "生成账单",
settlementGenerated: "账单已生成。",
settlementGeneratedExisting: "该店铺该周期已存在账单,已返回原账单,未重复生成。",
settlementGeneratedResult: "返回的账单",
settlementPickShop: "请先选择店铺。",
settlementPeriodDateInvalid: "请输入有效日期(YYYY-MM-DD)。",
settlementPeriodNotClosed: "该周期尚未结束,生成被拒绝",
settlementListTitle: "结算账单",
settlementPeriod: "周期",
settlementOrderCount: "订单数",
settlementGross: "交易总额",
settlementRefunds: "退款",
settlementCommission: "佣金",
settlementPayable: "应付",
settlementDetail: "详情",
settlementClose: "收起",
settlementStatuses: {
pending: "待打款",
confirmed: "已确认",
},
settlementDetailTitle: "账单详情",
settlementSnapshot: "账单快照",
settlementOrders: "关联订单",
settlementNoOrders: "该周期没有关联订单。",
settlementOrderNo: "订单号",
settlementOrderCurrency: "下单币种",
settlementConfirmedAt: "确认时间",
settlementUpdated: "更新时间",
settlementConfirmPayout: "确认打款",
settlementConfirmPayoutHint: "确认后应付金额将一次性计入店主余额。",
settlementConfirmDialog: "确定确认打款吗?店主余额将入账,且不可重复确认。",
settlementPayoutConfirmed: "打款已确认,店主账户已入账。",
settlementConflict: "该账单已确认,列表已刷新",
reviewProduct: "商品",
reviewShop: "店铺",
reviewBuyer: "买家",
@@ -334,5 +594,88 @@ export const zhExtra = {
visible: "可见",
hidden: "已隐藏",
},
merchantApplication: "申请单",
merchantApplicant: "申请人邮箱",
merchantEntityType: "主体类型",
merchantEntityName: "主体名称",
merchantSubmittedAt: "提交时间",
merchantUpdated: "更新时间",
merchantReviewedAt: "审核时间",
merchantCreatedShop: "创建店铺",
merchantView: "详情",
merchantClose: "收起",
merchantDetail: "商家入驻申请详情",
merchantEntityInfo: "主体信息",
merchantRealName: "真实姓名",
merchantCompanyName: "企业名称",
merchantBusinessLicenseNo: "营业执照号",
merchantContact: "联系方式",
merchantContactName: "联系人",
merchantContactPhone: "联系电话",
merchantContactEmail: "联系邮箱",
merchantContactAddress: "联系地址",
merchantCategories: "经营类目",
merchantNoCategories: "未选择类目",
merchantQualification: "资质材料",
merchantIdentityDocument: "身份证明",
merchantBusinessLicense: "营业执照",
merchantExtraMaterials: "补充材料",
merchantNoQualification: "未提供资质材料",
merchantRejectionReason: "驳回原因",
merchantConfirmApprove:
"确定通过该入驻申请吗?将开通店铺并创建专属店主账号,初始密码仅显示一次且无法再次获取。",
merchantConfirmReject: "确定驳回该入驻申请吗?申请人之后可以重新提交。",
merchantApprove: "通过",
merchantReject: "驳回",
merchantRejectReason: "驳回原因(必填)",
merchantRejectReasonPlaceholder: "请说明驳回原因,供申请人查看",
merchantRejectReasonRequired: "驳回原因不能为空。",
merchantApprovedNotice: "申请已通过,店铺与店主账号已开通。",
merchantRejectedNotice: "申请已驳回。",
merchantOutcomeApproved: "已通过:店铺与店主账号已开通。",
merchantOutcomeRejected: "已驳回:申请人可重新提交。",
merchantConflict: "该申请已被审核,列表已刷新",
merchantCredentialsTitle: "一次性初始凭据",
merchantCredentialsWarning:
"请立即保存以下凭据。初始密码仅显示一次,关闭本弹窗后无法再次获取。",
merchantCredentialsEmail: "店主登录账号",
merchantCredentialsPassword: "初始密码",
merchantCredentialsShopSlug: "店铺别名",
merchantCredentialsShopId: "店铺 ID",
merchantCredentialsClose: "我已保存凭据",
merchantStatuses: {
pending: "待审核",
approved: "已通过",
rejected: "已驳回",
},
merchantEntityTypes: {
personal: "个人",
enterprise: "企业",
},
memberLevelCreateTitle: "新增会员等级",
memberLevelEditTitle: "编辑会员等级",
memberLevelHint:
"等级按成长值升序排列;中英文均为必填,且同一成长值只能用于一个等级。",
memberLevelNameEn: "等级名称(英文)",
memberLevelNameZh: "等级名称(中文)",
memberLevelIcon: "图标",
memberLevelGrowth: "成长值门槛",
memberLevelBenefitsEn: "等级权益(英文)",
memberLevelBenefitsZh: "等级权益(中文)",
memberLevelCreate: "新增等级",
memberLevelCreated: "会员等级已创建。",
memberLevelUpdated: "会员等级已更新。",
memberLevelUpdatedAt: "更新时间",
memberLevelDeleted: "会员等级已删除。",
memberLevelRequired: "中英文等级名称与权益均为必填项。",
memberLevelIconRequired: "图标为必填项。",
memberLevelThresholdInvalid: "成长值门槛必须为不小于 0 的整数。",
memberLevelThresholdDuplicate: "已有其他等级使用该成长值门槛。",
memberLevelConflict: "成长值门槛已被占用",
memberLevelInvalid: "等级被服务端拒绝",
memberLevelConfirmDelete: "确定删除等级“{name}”吗?此操作不可撤销。",
memberLevelDeleteInUse: "该等级已被至少一位客户持有,无法删除",
memberLevelDiscardEdit: "确定放弃未保存的等级修改并刷新吗?",
memberLevelGone: "该会员等级已不存在,列表已刷新。",
},
} as const;