[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 $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 $form * @return array */ 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> */ 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> */ 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> $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 $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; } }