feat(payment): 新增「售后策略」配置与前台售后弹窗链路

- 后台「支付&付费 → 售后策略」:售后功能开关、申请时效、超时效提示(HTML)、
  预置原因、申请售后说明、所需材料、申请说明占位文案、可上传图片开关;
  PHP schema 与 SPA schema 1:1 对齐,group 字段支持拖拽排序
- PaymentRuntime 新增售后设置解析(缺键回退 schema 默认、HTML 走 kses、
  中文原因稳定 slug)与 after_sale_expired 时效判定(支付时间起算)
- 售后工单支持举证图片(order_aftersale.images 列,最多 3 张,
  复用评论图片上传校验)与上传路由;订单视图模型输出 after_sale 按钮状态
- 前台申请 / 超时效提示 / 售后详细三个独立弹窗(modal-mini + 蓝色炫彩头),
  订单详情弹窗不再内嵌售后表单并清理相关死代码
- 新增 after-sale-policy 契约测试与运行时 harness,527 项契约测试通过
This commit is contained in:
kate522 committed 2026-10-06 22:21:47 +08:00
1 parent b447c41f13
commit 1a9a2762f3
19 files changed
+1835 -86

No files matched your search

+82
View File
@@ -553,6 +553,7 @@ class PaymentRuntime {
$wechat = 'null';
}
$alipay = sanitize_key((string) ($settings['pay_alipay_sdk_options'] ?? 'null'));
if (!in_array($alipay, ['official_alipay', 'epay', 'null'], true)) {
$alipay = 'null';
}
@@ -583,6 +584,87 @@ class PaymentRuntime {
return false;
}
/**
* 售后策略(后台「支付&付费 → 售后策略」分区)的运行时读取。
*
* read_domain 走 SchemaProvider 的带默认读取:用户从未保存过该分区时,
* 预置原因 / 所需材料 / 文案等键仍取 schema 默认值(语义对齐
* UserRuntime::ban_settings 对禁封/举报分区的读取)。
*
* @return array{
* enabled:bool,time_limit:int,expired_text:string,
* reasons:array<int,array{slug:string,name:string}>,materials:string[],
* desc:string,img_enabled:bool,reason_placeholder:string
* }
*/
public static function get_after_sale_settings(): array {
$s = class_exists('\AKSRT\Admin\SchemaProvider')
? Settings::read_domain('payment', 'after_sale')
: Settings::get_all();
if (!is_array($s)) {
$s = [];
}
$reasons = [];
foreach ((array) ($s['after_sale_reasons'] ?? []) as $row) {
$text = trim((string) (is_array($row) ? ($row['t'] ?? '') : $row));
if ($text === '') {
continue;
}
$reasons[] = ['slug' => self::after_sale_slug($text), 'name' => sanitize_text_field($text)];
}
$materials = [];
foreach ((array) ($s['after_sale_materials'] ?? []) as $row) {
$text = trim((string) (is_array($row) ? ($row['t'] ?? '') : $row));
if ($text !== '') {
$materials[] = sanitize_text_field($text);
}
}
return [
'enabled' => !isset($s['after_sale_s']) || !empty($s['after_sale_s']),
'time_limit' => max(0, min(3650, (int) ($s['after_sale_time_limit'] ?? 7))),
'expired_text' => wp_kses_post((string) ($s['after_sale_expired_text'] ?? '')),
'reasons' => $reasons,
'materials' => $materials,
'desc' => wp_kses_post((string) ($s['after_sale_desc'] ?? '')),
'img_enabled' => !empty($s['after_sale_img_s']),
'reason_placeholder' => sanitize_text_field((string) ($s['after_sale_reason_placeholder'] ?? '')),
];
}
/**
* 订单是否已超过售后申请时效(自支付时间起算;时效 <=0 或缺支付时间视为未过期)。
*
* 时区口径与 UserOrders::get_order_countdown 保持一致:均以站点本地时间
* 比较存储的 DATETIME 与当前时间。
*/
public static function after_sale_expired(string $paid_at): bool {
$limit = (int) (self::get_after_sale_settings()['time_limit'] ?? 0);
if ($limit <= 0) {
return false;
}
$paid_at = trim($paid_at);
if ($paid_at === '') {
return false;
}
$paid_timestamp = strtotime($paid_at);
if ($paid_timestamp === false) {
return false;
}
return current_time('timestamp') > $paid_timestamp + $limit * DAY_IN_SECONDS;
}
/**
* 预置原因 slug:sanitize_key 无法处理中文标签(会被清洗成空串),
* 与 UserRuntime::ban_settings 同策略——ASCII 标签取 key,其余取稳定短哈希。
*/
private static function after_sale_slug(string $text): string {
$key = sanitize_key($text);
return $key !== '' ? $key : substr(md5($text), 0, 12);
}
private static function count_structured_lines(string $raw): int {
$lines = preg_split('/[\r\n]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
$count = 0;