展开的操作面板在状态下拉与备注旁新增「标记订单已退款」勾选框:勾选后点击确认,会先把关联订单推到已退款,再更新售后工单状态。订单本已退款时勾选框呈现为不可改的已选状态,且不提交该标记。 顺序不能反:OrderRepository::update_status 的退款闸门要求售后工单仍为待审核/处理中,一旦 set_status 先执行就不再放行。因此退款失败时整次操作中止,避免出现「工单已关、订单未退」的半完成状态。订单号由服务端按 after_sale_id 反查,不经客户端传递。 AfterSaleService::get() 由 private 改为 public 以支撑该反查。dev harness 扩展为可驱动 handler 的六个场景,覆盖勾选/未勾选、重复退款、非已支付订单、已关闭工单与非法状态。
351 lines
18 KiB
PHP
351 lines
18 KiB
PHP
<?php
|
|
/** User applications and administrator status management for order after-sales. */
|
|
|
|
namespace AKSRT\Payment;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
final class AfterSaleService {
|
|
public const STATUS_PENDING = 'pending';
|
|
public const STATUS_PROCESSING = 'processing';
|
|
public const STATUS_REJECTED = 'rejected';
|
|
public const STATUS_CANCELLED = 'cancelled';
|
|
public const STATUS_COMPLETED = 'completed';
|
|
private static ?self $instance = null;
|
|
|
|
public static function get_instance(): self { return self::$instance ??= new self(); }
|
|
|
|
public function init(): void { add_action('rest_api_init', [$this, 'register_routes']); }
|
|
|
|
public function register_routes(): void {
|
|
$number = '(?P<order_number>[0-9]{20}|[0-9]{22})';
|
|
register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale', [
|
|
['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'],
|
|
'reason_label' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
|
'materials' => ['required' => false, 'type' => 'array', 'validate_callback' => static function ($value): bool {
|
|
if (!is_array($value) || count($value) > 10) return false;
|
|
foreach ($value as $item) {
|
|
if (!is_array($item)) return false;
|
|
foreach (['label', 'value'] as $key) {
|
|
if (isset($item[$key]) && !is_string($item[$key])) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}],
|
|
'description' => ['required' => false, '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'],
|
|
]],
|
|
['methods' => 'DELETE', 'callback' => [$this, 'rest_cancel'], 'permission_callback' => [$this, 'require_user'], 'args' => [
|
|
'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) {
|
|
if (!is_user_logged_in()) return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
|
$nonce = (string) $request->get_header('X-WP-Nonce');
|
|
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest') ? true : new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
|
}
|
|
|
|
public function rest_submit(\WP_REST_Request $request) {
|
|
$captcha = $this->require_captcha($request, 'after_sale_apply');
|
|
if (is_wp_error($captcha)) {
|
|
return $captcha;
|
|
}
|
|
$images = $request->get_param('images');
|
|
$materials = $request->get_param('materials');
|
|
$result = $this->create(
|
|
get_current_user_id(),
|
|
(string) $request->get_param('order_number'),
|
|
(string) $request->get_param('reason'),
|
|
is_array($images) ? $images : [],
|
|
[
|
|
'reason_label' => (string) $request->get_param('reason_label'),
|
|
'materials' => is_array($materials) ? $materials : [],
|
|
'description' => (string) $request->get_param('description'),
|
|
]
|
|
);
|
|
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
|
|
}
|
|
|
|
public function rest_list(\WP_REST_Request $request) {
|
|
$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 rest_ensure_response(['success' => true, 'data' => $this->list_for_order($number)]);
|
|
}
|
|
|
|
public function rest_cancel(\WP_REST_Request $request) {
|
|
$captcha = $this->require_captcha($request, 'after_sale_cancel');
|
|
if (is_wp_error($captcha)) {
|
|
return $captcha;
|
|
}
|
|
$result = $this->cancel_for_user(get_current_user_id(), (string) $request->get_param('order_number'));
|
|
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
|
|
}
|
|
|
|
/** 人机验证(申请售后 / 撤销售后):场景未启用时 aksrt_require_captcha 直接放行。 */
|
|
private function require_captcha(\WP_REST_Request $request, string $scene) {
|
|
if (!function_exists('aksrt_require_captcha')) {
|
|
return true;
|
|
}
|
|
$params = $request->get_json_params();
|
|
return aksrt_require_captcha($scene, is_array($params) ? $params : []);
|
|
}
|
|
|
|
/** @param array<string,mixed> $apply_form */
|
|
public function create(int $user_id, string $order_number, string $reason, array $images = [], array $apply_form = []) {
|
|
$reason = sanitize_textarea_field($reason);
|
|
$images = $this->sanitize_images($images);
|
|
$apply_form = $this->sanitize_apply_form($apply_form);
|
|
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]);
|
|
}
|
|
global $wpdb;
|
|
$lock = 'aksrt_after_sale_' . md5($order_number);
|
|
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
|
|
try {
|
|
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number);
|
|
if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('只有已支付订单可以申请售后', 'aksrt'), ['status' => 409]);
|
|
$active = $this->find_active($order_number);
|
|
if ($active) {
|
|
if ((int) ($active['user_id'] ?? 0) === $user_id && hash_equals((string) ($active['reason'] ?? ''), $reason)) {
|
|
return $active;
|
|
}
|
|
return new \WP_Error('aksrt_after_sale_exists', __('该订单已有未结束售后申请', 'aksrt'), ['status' => 409]);
|
|
}
|
|
$amount = $this->refund_amount($order);
|
|
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,
|
|
'apply_form' => $apply_form === [] ? null : wp_json_encode($apply_form, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'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', '%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;
|
|
}
|
|
|
|
/**
|
|
* 结构化申请表单(预置原因 / 申请资料 / 申请说明),供售后详细弹窗分行展示;
|
|
* 融合字符串 reason 保持原样入库,保证后台列表与历史数据兼容。
|
|
*
|
|
* @param array<string,mixed> $form
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function sanitize_apply_form(array $form): array {
|
|
$reason_label = sanitize_text_field((string) ($form['reason_label'] ?? ''));
|
|
if (mb_strlen($reason_label) > 120) $reason_label = mb_substr($reason_label, 0, 120);
|
|
$materials = [];
|
|
if (is_array($form['materials'] ?? null)) {
|
|
foreach ($form['materials'] as $item) {
|
|
if (!is_array($item) || count($materials) >= 10) continue;
|
|
$label = sanitize_text_field((string) ($item['label'] ?? ''));
|
|
$value = sanitize_text_field((string) ($item['value'] ?? ''));
|
|
if ($label === '' || $value === '') continue;
|
|
if (mb_strlen($label) > 120) $label = mb_substr($label, 0, 120);
|
|
if (mb_strlen($value) > 500) $value = mb_substr($value, 0, 500);
|
|
$materials[] = ['label' => $label, 'value' => $value];
|
|
}
|
|
}
|
|
$description = sanitize_textarea_field((string) ($form['description'] ?? ''));
|
|
if (mb_strlen($description) > 2000) $description = mb_substr($description, 0, 2000);
|
|
$clean = [];
|
|
if ($reason_label !== '') $clean['reason_label'] = $reason_label;
|
|
if ($materials !== []) $clean['materials'] = $materials;
|
|
if ($description !== '') $clean['description'] = $description;
|
|
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;
|
|
$lock = 'aksrt_after_sale_' . md5($order_number);
|
|
if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]);
|
|
try {
|
|
$row = $this->find_active($order_number);
|
|
if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]);
|
|
if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]);
|
|
if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]);
|
|
return $this->get((int) $row['id']);
|
|
} finally {
|
|
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
|
|
}
|
|
}
|
|
|
|
/** @return list<array<string,mixed>> */
|
|
public function list_for_order(string $order_number): array {
|
|
global $wpdb;
|
|
$rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC", $order_number), ARRAY_A);
|
|
return array_map([$this, 'normalize'], (array) $rows);
|
|
}
|
|
|
|
/** @return list<array<string,mixed>> */
|
|
public function admin_list(array $args = []): array {
|
|
global $wpdb;
|
|
$where = [];
|
|
$params = [];
|
|
$status = sanitize_key((string) ($args['status'] ?? ''));
|
|
$valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_REJECTED, self::STATUS_CANCELLED, self::STATUS_COMPLETED];
|
|
if ($status !== '' && in_array($status, $valid, true)) { $where[] = 'status = %s'; $params[] = $status; }
|
|
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
|
|
$date = sanitize_text_field((string) ($args[$key] ?? ''));
|
|
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
|
$where[] = 'created_at ' . $operator . ' %s';
|
|
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
|
|
}
|
|
}
|
|
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
|
$offset = max(0, (int) ($args['offset'] ?? 0));
|
|
$sql = "SELECT * FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' ORDER BY id DESC LIMIT %d OFFSET %d';
|
|
$params[] = $limit; $params[] = $offset;
|
|
return array_map([$this, 'normalize'], (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A));
|
|
}
|
|
|
|
public function count(array $args = []): int {
|
|
global $wpdb;
|
|
$status = sanitize_key((string) ($args['status'] ?? ''));
|
|
$where = [];
|
|
$params = [];
|
|
if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; }
|
|
foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) {
|
|
$date = sanitize_text_field((string) ($args[$key] ?? ''));
|
|
if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
|
$where[] = 'created_at ' . $operator . ' %s';
|
|
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
|
|
}
|
|
}
|
|
$sql = "SELECT COUNT(*) FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '');
|
|
return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql));
|
|
}
|
|
|
|
public function count_user_open(int $user_id): int {
|
|
if ($user_id <= 0) return 0;
|
|
global $wpdb;
|
|
return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing')", $user_id));
|
|
}
|
|
|
|
/** A refund status transition is valid only for its own active application. */
|
|
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
|
|
if ($after_sale_id <= 0 || $order_number === '') return false;
|
|
$row = $this->get($after_sale_id);
|
|
return is_array($row)
|
|
&& hash_equals((string) ($row['order_number'] ?? ''), $order_number)
|
|
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true);
|
|
}
|
|
|
|
/**
|
|
* 管理员直接设置售后工单状态:纯状态更新,不触发订单、资产与支付渠道联动。
|
|
*/
|
|
public function set_status(int $id, string $status, string $note = '') {
|
|
if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
|
|
$status = sanitize_key($status);
|
|
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) return new \WP_Error('aksrt_after_sale_status', __('售后状态无效', 'aksrt'), ['status' => 400]);
|
|
$row = $this->get($id);
|
|
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
|
|
$note = sanitize_textarea_field($note);
|
|
$data = ['status' => $status];
|
|
if ($note !== '') $data['admin_note'] = $note;
|
|
if ($status !== self::STATUS_PENDING) $data['reviewed_at'] = current_time('mysql');
|
|
if (!$this->update($id, $data)) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
|
|
return $this->get($id);
|
|
}
|
|
|
|
/** @param array<int,array<string,mixed>> $entries */
|
|
private function find_active(string $number): ?array {
|
|
global $wpdb;
|
|
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing') ORDER BY id DESC LIMIT 1", $number), ARRAY_A);
|
|
return is_array($row) ? $this->normalize($row) : null;
|
|
}
|
|
|
|
/** 按 ID 读取售后申请(含关联订单号),供后台操作面板解析关联订单。 */
|
|
public function get(int $id): ?array {
|
|
global $wpdb;
|
|
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $id), ARRAY_A);
|
|
return is_array($row) ? $this->normalize($row) : null;
|
|
}
|
|
|
|
private function update(int $id, array $data): bool {
|
|
global $wpdb;
|
|
$formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($data));
|
|
return $wpdb->update($this->table(), $data, ['id' => $id], $formats, ['%d']) !== false;
|
|
}
|
|
|
|
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);
|
|
$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 !== ''))
|
|
: [];
|
|
$apply_form = json_decode((string) ($row['apply_form'] ?? ''), true);
|
|
$row['apply_form'] = is_array($apply_form) ? $apply_form : null;
|
|
return $row;
|
|
}
|
|
}
|