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:
@@ -30,6 +30,9 @@ final class AfterSaleService {
|
||||
['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_submit'], 'permission_callback' => [$this, 'require_user'], 'args' => [
|
||||
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'reason' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
'images' => ['required' => false, 'type' => 'array', 'validate_callback' => static function ($value): bool {
|
||||
return is_array($value) && count($value) <= 3;
|
||||
}],
|
||||
]],
|
||||
['methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_list'], 'permission_callback' => [$this, 'require_user'], 'args' => [
|
||||
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
@@ -38,6 +41,37 @@ final class AfterSaleService {
|
||||
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
]],
|
||||
]);
|
||||
// 售后举证图片上传:复用评论图片上传链路(大小 / 类型 / 内容审核同一套校验)。
|
||||
register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale/upload-image', [
|
||||
['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_upload_image'], 'permission_callback' => [$this, 'require_after_sale_image']],
|
||||
]);
|
||||
}
|
||||
|
||||
/** 售后举证图片上传权限:登录 + nonce + 售后开启 + 图片开关开启 + 订单归属本人。 */
|
||||
public function require_after_sale_image(\WP_REST_Request $request) {
|
||||
$user = $this->require_user($request);
|
||||
if (is_wp_error($user)) {
|
||||
return $user;
|
||||
}
|
||||
if (!class_exists('\AKSRT\Settings\PaymentRuntime')) {
|
||||
return new \WP_Error('aksrt_after_sale_forbidden', __('售后功能暂未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$settings = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
|
||||
if (empty($settings['enabled']) || empty($settings['img_enabled'])) {
|
||||
return new \WP_Error('aksrt_after_sale_forbidden', __('售后图片上传未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) {
|
||||
return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rest_upload_image(\WP_REST_Request $request) {
|
||||
if (!class_exists('\AKSRT\Admin\UserController')) {
|
||||
return new \WP_Error('aksrt_after_sale_upload', __('上传服务不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return \AKSRT\Admin\UserController::get_instance()->rest_upload_comment_image($request);
|
||||
}
|
||||
|
||||
public function require_user(\WP_REST_Request $request) {
|
||||
@@ -47,7 +81,8 @@ final class AfterSaleService {
|
||||
}
|
||||
|
||||
public function rest_submit(\WP_REST_Request $request) {
|
||||
$result = $this->create(get_current_user_id(), (string) $request->get_param('order_number'), (string) $request->get_param('reason'));
|
||||
$images = $request->get_param('images');
|
||||
$result = $this->create(get_current_user_id(), (string) $request->get_param('order_number'), (string) $request->get_param('reason'), is_array($images) ? $images : []);
|
||||
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
@@ -62,8 +97,9 @@ final class AfterSaleService {
|
||||
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
public function create(int $user_id, string $order_number, string $reason) {
|
||||
public function create(int $user_id, string $order_number, string $reason, array $images = []) {
|
||||
$reason = sanitize_textarea_field($reason);
|
||||
$images = $this->sanitize_images($images);
|
||||
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number) || $reason === '' || mb_strlen($reason) > 2000) {
|
||||
return new \WP_Error('aksrt_after_sale_invalid', __('请填写有效的售后原因', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
@@ -84,15 +120,37 @@ final class AfterSaleService {
|
||||
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
|
||||
$inserted = $wpdb->insert($this->table(), [
|
||||
'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund',
|
||||
'reason' => $reason, 'refund_amount' => $amount, 'status' => self::STATUS_PENDING,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s']);
|
||||
'reason' => $reason, 'images' => wp_json_encode($images), 'refund_amount' => $amount,
|
||||
'status' => self::STATUS_PENDING, 'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s']);
|
||||
return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]);
|
||||
} finally {
|
||||
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
|
||||
}
|
||||
}
|
||||
|
||||
/** 售后举证图片:最多 3 条,统一走 esc_url_raw;非字符串与空值直接丢弃。 */
|
||||
private function sanitize_images(array $images): array {
|
||||
$clean = [];
|
||||
foreach ($images as $url) {
|
||||
if (!is_string($url) || count($clean) >= 3) {
|
||||
continue;
|
||||
}
|
||||
$url = esc_url_raw(trim($url));
|
||||
if ($url !== '') {
|
||||
$clean[] = $url;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/** 最新一条售后工单(列表视图只取最新态,避免整表拉取)。 */
|
||||
public function latest_for_order(string $order_number): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC LIMIT 1", $order_number), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
public function cancel_for_user(int $user_id, string $order_number) {
|
||||
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number)) return new \WP_Error('aksrt_after_sale_invalid', __('售后申请无效', 'aksrt'), ['status' => 400]);
|
||||
global $wpdb;
|
||||
@@ -550,5 +608,13 @@ final class AfterSaleService {
|
||||
private function table(): string { return \AKSRT\Core\DBMigrator::table('order_aftersale'); }
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function normalize(array $row): array { $row['id'] = (int) ($row['id'] ?? 0); $row['user_id'] = (int) ($row['user_id'] ?? 0); return $row; }
|
||||
private function normalize(array $row): array {
|
||||
$row['id'] = (int) ($row['id'] ?? 0);
|
||||
$row['user_id'] = (int) ($row['user_id'] ?? 0);
|
||||
$images = json_decode((string) ($row['images'] ?? ''), true);
|
||||
$row['images'] = is_array($images)
|
||||
? array_values(array_filter($images, static fn($url): bool => is_string($url) && $url !== ''))
|
||||
: [];
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user