From 00a087b1a4bd2f651399218c17c290f7f6375065 Mon Sep 17 00:00:00 2001 From: kate522 Date: Tue, 6 Oct 2026 18:07:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(payment):=20=E5=94=AE=E5=90=8E=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E8=A1=A8=E5=8D=95=E7=BB=93=E6=9E=84=E5=8C=96=E5=85=A5?= =?UTF-8?q?=E5=BA=93=EF=BC=88=E9=A2=84=E7=BD=AE=E5=8E=9F=E5=9B=A0/?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E8=B5=84=E6=96=99/=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E7=8B=AC=E7=AB=8B=E5=AD=98=E5=82=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Payment/AfterSaleService.php | 1300 ++++++++++++++++-------------- 1 file changed, 680 insertions(+), 620 deletions(-) diff --git a/src/Payment/AfterSaleService.php b/src/Payment/AfterSaleService.php index aa25d068..7ad2509c 100644 --- a/src/Payment/AfterSaleService.php +++ b/src/Payment/AfterSaleService.php @@ -1,620 +1,680 @@ -[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'], - '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) { - $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]); - } - - 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) { - $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]); - } - - 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]); - } - 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, '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; - $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_APPROVED, 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','approved')", $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, self::STATUS_APPROVED], true); - } - - public function review(int $id, string $decision, string $note = '') { - if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]); - $decision = sanitize_key($decision); - if (!in_array($decision, ['approve', 'reject', 'complete'], true)) return new \WP_Error('aksrt_after_sale_decision', __('审核动作无效', 'aksrt'), ['status' => 400]); - global $wpdb; - $initial = $this->get($id); - if (!$initial) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]); - $order_number = (string) ($initial['order_number'] ?? ''); - $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->get($id); - if (!$row || !hash_equals($order_number, (string) ($row['order_number'] ?? '')) || !in_array((string) $row['status'], [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]); - $note = sanitize_textarea_field($note); - if ((string) $row['status'] === self::STATUS_PROCESSING) { - if ($decision === 'complete') { - $gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true); - if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') { - return new \WP_Error('aksrt_after_sale_manual_only', __('该申请不是待人工确认的退款', 'aksrt'), ['status' => 409]); - } - $order = OrderRepository::get_instance()->get((string) $row['order_number']); - if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]); - $finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? '')); - return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); - } - if ($decision === 'reject') { - $gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true); - if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') { - return new \WP_Error('aksrt_after_sale_processing', __('退款处理中,不能拒绝;请同步退款结果', 'aksrt'), ['status' => 409]); - } - $order = OrderRepository::get_instance()->get((string) $row['order_number']); - if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]); - $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); - $revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? [])); - if (is_wp_error($revoke)) return $revoke; - if ($revoke !== []) { - $compensated = $this->compensate_revoke($row, $order, $revoke, $note); - if (is_wp_error($compensated)) return $compensated; - } - if (!$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('管理员确认未执行渠道退款,已恢复冻结资产', 'aksrt')])) { - return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]); - } - return $this->get((int) $row['id']); - } - return $this->sync_gateway_refund($row, $note); - } - if ($decision === 'complete') return new \WP_Error('aksrt_after_sale_manual_only', __('只有待人工确认的退款可以直接完成', 'aksrt'), ['status' => 409]); - if ($decision === 'reject') { - if (!$this->update($id, ['status' => self::STATUS_REJECTED, 'admin_note' => $note, 'reviewed_at' => current_time('mysql')])) { - return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]); - } - return $this->get($id); - } - return $this->approve($row, $note); - } finally { - $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); - } - } - - private function approve(array $row, string $note) { - $order_number = (string) $row['order_number']; - $order = OrderRepository::get_instance()->get($order_number); - if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('订单已不是可退款状态', 'aksrt'), ['status' => 409]); - $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); - $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; - $method = sanitize_key((string) ($order['payment_method'] ?? '')); - if ($type === DomainModel::ORDER_PRODUCT_VIP && !$this->vip_reversal_is_safe($order, $meta)) return new \WP_Error('aksrt_after_sale_vip_snapshot', __('会员权益已变化或缺少快照,不能安全撤销', 'aksrt'), ['status' => 409]); - $revoke = $this->assets_to_revoke($order, $type, $meta); - if (is_wp_error($revoke)) return $revoke; - if ($method === 'card_pass') { - if (!$this->asset_entries_available($revoke)) return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能撤销卡密权益', 'aksrt'), ['status' => 409]); - return $this->commit_status_refund($row, $order, $note, $revoke); - } - if ($method === 'free') { - return $this->commit_status_refund($row, $order, $note, $revoke); - } - $refund_local = in_array($method, ['balance', 'points'], true); - if ($refund_local) { - $refund_asset = $method === 'points' ? AssetType::POINTS : AssetType::BALANCE; - $refund_minor = $refund_asset === AssetType::POINTS ? max(0, (int) ($order['amount'] ?? 0)) : $this->cash_minor((string) ($row['refund_amount'] ?? '0')); - if ($refund_minor <= 0) return new \WP_Error('aksrt_after_sale_amount', __('退款金额无效', 'aksrt'), ['status' => 400]); - $entries = $revoke; - // A points-purchase reversal already includes the original balance payment - // in assets_to_revoke(); do not return that payment twice. - if (!($type === DomainModel::ORDER_PRODUCT_POINTS && $method === 'balance')) { - $entries[] = ['user_id' => (int) $order['user_id'], 'asset' => $refund_asset->value, 'delta_minor' => $refund_minor, 'entry_type' => 'credit', 'remark' => sprintf(__('订单 %s 售后退款', 'aksrt'), $order_number)]; - } - return $this->commit_local_refund($row, $order, $entries, $note); - } - $gateway = Gateway::get_instance()->get_gateway($method); - if (!$gateway || !method_exists($gateway, 'refund')) return new \WP_Error('aksrt_after_sale_gateway', __('支付渠道不可用,无法退款', 'aksrt'), ['status' => 409]); - $request_id = (string) ($row['gateway_request_id'] ?? '') ?: 'AS_' . (int) $row['id']; - if (!$this->asset_entries_available($revoke)) { - return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能审核通过退款', 'aksrt'), ['status' => 409]); - } - if (!$this->update((int) $row['id'], ['status' => self::STATUS_PROCESSING, 'admin_note' => $note, 'gateway_request_id' => $request_id, 'reviewed_at' => current_time('mysql')])) { - return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败,未发起退款', 'aksrt'), ['status' => 500]); - } - if ($revoke !== []) { - $held = $this->post_asset_entries($row, $order, $revoke, 'after-sale-revoke', 'after_sale_revoke', $note); - if (is_wp_error($held)) { - $this->update((int) $row['id'], ['status' => self::STATUS_PENDING, 'gateway_request_id' => null, 'reviewed_at' => null]); - return $held; - } - } - try { - $result = $gateway->refund($order_number, (string) $row['refund_amount'], $request_id); - } catch (\Throwable $error) { - $result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()]; - } - if (!is_array($result)) { - $result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')]; - } - $this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? ''))]); - $status = sanitize_key((string) ($result['status'] ?? 'unknown')); - if ($status === 'failed') { - if ($revoke !== []) { - $compensated = $this->compensate_revoke($row, $order, $revoke, $note); - if (is_wp_error($compensated)) { - $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]); - return $this->get((int) $row['id']); - } - } - $this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => sanitize_textarea_field((string) ($result['message'] ?? $note))]); - return $this->get((int) $row['id']); - } - // Missing, unknown, and pending provider outcomes are ambiguous. Keep the - // application processing and the asset clawback in place until query_refund - // explicitly confirms success or failure. - if (empty($result['success']) || $status !== 'succeeded') { - $this->update((int) $row['id'], ['admin_note' => sanitize_textarea_field((string) ($result['message'] ?? __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')))]); - return $this->get((int) $row['id']); - } - $finished = $this->finish_refund($row, $order, $note); - return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); - } - - private function commit_local_refund(array $row, array $order, array $entries, string $note) { - $number = (string) $order['order_number']; - $commission = CommissionLedger::get_instance()->prepare_full_refund($number); - if (is_wp_error($commission)) return $commission; - $guard = static fn() => CommissionLedger::get_instance()->guard_full_refund($commission); - $mutation = function () use ($row, $order, $note, $commission) { - $commission_guard = CommissionLedger::get_instance()->guard_full_refund($commission); - if (is_wp_error($commission_guard) || $commission_guard !== true) return is_wp_error($commission_guard) ? $commission_guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); - $finished = $this->finish_refund($row, $order, $note, false); - if ($finished === true) { - LedgerService::get_instance()->after_commit(static function (): void { - OrderRepository::get_instance()->dispatch_managed_status_events(); - }); - } - return $finished; - }; - $result = LedgerService::get_instance()->post(new LedgerCommand( - \AKSRT\Ledger\OperationKey::deterministic('after-sale-refund', [(int) $row['id']]), 'order_refund', $entries, - 'order', $number, get_current_user_id(), ['order_number' => $number] - ), $guard, $mutation); - if (is_wp_error($result)) return $result; - CommissionLedger::get_instance()->clawback_after_refund($commission); - return $this->get((int) $row['id']); - } - - private function post_asset_entries(array $row, array $order, array $entries, string $prefix, string $reason, string $note) { - return LedgerService::get_instance()->post(new LedgerCommand( - \AKSRT\Ledger\OperationKey::deterministic($prefix, [(int) $row['id']]), $reason, $entries, - 'order', (string) $order['order_number'], get_current_user_id(), ['order_number' => (string) $order['order_number'], 'after_sale_id' => (int) $row['id']] - )); - } - - private function commit_status_refund(array $row, array $order, string $note, array $entries = []) { - global $wpdb; - $number = (string) $order['order_number']; - $commission = CommissionLedger::get_instance()->prepare_full_refund($number); - if (is_wp_error($commission)) return $commission; - if ($entries !== []) { - $mutation = function () use ($row, $order, $note, $commission) { - $guard = CommissionLedger::get_instance()->guard_full_refund($commission); - if (is_wp_error($guard) || $guard !== true) return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); - $finished = $this->finish_refund($row, $order, $note, false); - if ($finished === true) { - LedgerService::get_instance()->after_commit(static function (): void { OrderRepository::get_instance()->dispatch_managed_status_events(); }); - } - return $finished; - }; - $result = LedgerService::get_instance()->post(new LedgerCommand( - \AKSRT\Ledger\OperationKey::deterministic('after-sale-card-refund', [(int) $row['id']]), 'order_refund', $entries, - 'order', $number, get_current_user_id(), ['order_number' => $number] - ), static fn() => CommissionLedger::get_instance()->guard_full_refund($commission), $mutation); - if (is_wp_error($result)) return $result; - CommissionLedger::get_instance()->clawback_after_refund($commission); - return $this->get((int) $row['id']); - } - if ($wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]); - $guard = CommissionLedger::get_instance()->guard_full_refund($commission); - if (is_wp_error($guard) || $guard !== true) { $wpdb->query('ROLLBACK'); return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); } - $finished = $this->finish_order_and_application($row, $order, $note); - if (is_wp_error($finished) || !$finished) { $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } - $applied = CommissionLedger::get_instance()->apply_full_refund($commission); - if (is_wp_error($applied) || $wpdb->query('COMMIT') === false) { $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } - OrderRepository::get_instance()->dispatch_managed_status_events(); - CommissionLedger::get_instance()->clawback_after_refund($commission); - return $this->get((int) $row['id']); - } - - private function sync_gateway_refund(array $row, string $note) { - $order = OrderRepository::get_instance()->get((string) $row['order_number']); - $method = sanitize_key((string) ($order['payment_method'] ?? '')); - $gateway = Gateway::get_instance()->get_gateway($method); - $request_id = (string) ($row['gateway_request_id'] ?? ''); - if (!$order || !$gateway || !method_exists($gateway, 'query_refund') || $request_id === '') return new \WP_Error('aksrt_after_sale_sync_unavailable', __('该支付渠道暂不支持自动查询退款状态', 'aksrt'), ['status' => 409]); - try { - $result = $gateway->query_refund($request_id, (string) $row['order_number']); - } catch (\Throwable $error) { - $result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()]; - } - if (!is_array($result)) { - $result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')]; - } - $this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? $row['gateway_refund_id'] ?? ''))]); - $status = sanitize_key((string) ($result['status'] ?? 'unknown')); - if ($status === 'succeeded' && !empty($result['success'])) { - $finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? '')); - return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); - } - if ($status === 'failed' && !empty($result['success'])) { - $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); - $revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? [])); - if (is_wp_error($revoke)) { - $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但无法确认资产补偿内容;申请保持处理中,请人工核对。', 'aksrt') . ' ' . $revoke->get_error_message()]); - return $this->get((int) $row['id']); - } - if ($revoke !== []) { - $compensated = $this->compensate_revoke($row, $order, $revoke, $note); - if (is_wp_error($compensated)) { - $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]); - return $this->get((int) $row['id']); - } - } - $this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('支付渠道退款失败,已恢复撤销的资产', 'aksrt')]); - } elseif ($status !== 'succeeded') { - $this->update((int) $row['id'], ['admin_note' => $note !== '' ? $note : __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')]); - } - return $this->get((int) $row['id']); - } - - private function compensate_revoke(array $row, array $order, array $entries, string $note): bool|\WP_Error { - $compensate = array_map(static function (array $entry): array { $entry['delta_minor'] = -(int) $entry['delta_minor']; $entry['entry_type'] = 'credit'; $entry['remark'] = __('退款渠道失败,恢复已扣回资产', 'aksrt'); return $entry; }, $entries); - $result = $this->post_asset_entries($row, $order, $compensate, 'after-sale-revoke-compensate', 'after_sale_revoke_compensate', $note); - return is_wp_error($result) ? $result : true; - } - - private function assets_to_revoke(array $order, string $type, array $meta): array|\WP_Error { - $user_id = (int) ($order['user_id'] ?? 0); - if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) { - $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; - try { $minor = AssetAmount::balance((string) ($meta['credited_amount'] ?? $meta['credit_amount'] ?? $card_meta['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_asset', __('充值到账金额缺失,不能退款', 'aksrt'), ['status' => 409]); } - return $this->debit_entry($user_id, AssetType::BALANCE, $minor, __('撤销充值余额', 'aksrt')); - } - if ($type === DomainModel::ORDER_PRODUCT_POINTS) { - $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; - $points = max(0, (int) ($meta['points'] ?? $card_meta['points'] ?? 0)); - if ($points <= 0) return new \WP_Error('aksrt_after_sale_asset', __('购买积分数量缺失,不能退款', 'aksrt'), ['status' => 409]); - $entries = $this->debit_entry($user_id, AssetType::POINTS, $points, __('撤销订单发放积分', 'aksrt')); - if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'balance') { - try { $paid = AssetAmount::balance((string) ($order['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_amount', __('积分订单支付金额无效', 'aksrt'), ['status' => 409]); } - $entries[] = ['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => $paid, 'entry_type' => 'credit', 'remark' => __('退回购买积分使用的余额', 'aksrt')]; - } - return $entries; - } - return []; - } - - private function debit_entry(int $user_id, AssetType $asset, int $minor, string $remark): array|\WP_Error { - if ($user_id <= 0 || $minor <= 0) return new \WP_Error('aksrt_after_sale_asset', __('待撤销资产金额无效', 'aksrt'), ['status' => 409]); - return [['user_id' => $user_id, 'asset' => $asset->value, 'delta_minor' => -$minor, 'entry_type' => 'debit', 'remark' => $remark]]; - } - - private function finish_refund(array $row, array $order, string $note, bool $manage_transaction = true) { - global $wpdb; - $number = (string) $order['order_number']; - $commission = CommissionLedger::get_instance()->prepare_full_refund($number); - if (is_wp_error($commission)) return $commission; - if ($manage_transaction && $wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]); - $guarded = CommissionLedger::get_instance()->guard_full_refund($commission); - if (is_wp_error($guarded) || $guarded !== true) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($guarded) ? $guarded : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); } - $finished = $this->finish_order_and_application($row, $order, $note); - if (is_wp_error($finished) || !$finished) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款成功但本地订单更新失败,请重试同步', 'aksrt'), ['status' => 500]); } - $applied = CommissionLedger::get_instance()->apply_full_refund($commission); - if (is_wp_error($applied) || ($manage_transaction && $wpdb->query('COMMIT') === false)) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } - if ($manage_transaction) { - OrderRepository::get_instance()->dispatch_managed_status_events(); - CommissionLedger::get_instance()->clawback_after_refund($commission); - } - return true; - } - - private function finish_order_and_application(array $row, array $order, string $note): bool|\WP_Error { - $number = (string) $order['order_number']; - if (!$this->revoke_vip_if_needed($order)) return new \WP_Error('aksrt_after_sale_vip', __('会员权益撤销失败', 'aksrt'), ['status' => 409]); - if (!OrderRepository::get_instance()->update_status($number, Order::STATUS_REFUNDED, ['allow_refund' => true, 'after_sale_id' => (int) $row['id'], 'transaction_managed' => true, 'note' => $note !== '' ? $note : __('售后退款完成', 'aksrt')])) return new \WP_Error('aksrt_after_sale_order', __('订单退款状态更新失败', 'aksrt'), ['status' => 409]); - return $this->update((int) $row['id'], ['status' => self::STATUS_COMPLETED, 'admin_note' => $note, 'completed_at' => current_time('mysql'), 'reviewed_at' => current_time('mysql')]); - } - - private function vip_reversal_is_safe(array $order, array $meta): bool { - $before = is_array($meta['vip_before'] ?? null) ? $meta['vip_before'] : null; - $after = is_array($meta['vip_after'] ?? null) ? $meta['vip_after'] : null; - if (!$before || !$after || !class_exists('\\AKSRT\\Content\\VipService')) return false; - $current = \AKSRT\Content\VipService::get_instance()->get_membership_snapshot((int) $order['user_id']); - foreach (['level', 'expires', 'base_level', 'base_expires', 'expired_level'] as $key) { - if ((int) ($current[$key] ?? 0) !== (int) ($after[$key] ?? 0)) return false; - } - return true; - } - - private function revoke_vip_if_needed(array $order): bool { - $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); - if ($type !== DomainModel::ORDER_PRODUCT_VIP) return true; - $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; - if (!$this->vip_reversal_is_safe($order, $meta)) return false; - $before = $meta['vip_before']; - $service = \AKSRT\Content\VipService::get_instance(); - if ((int) ($before['level'] ?? 0) <= 0) { - $result = $service->mutate_membership((int) $order['user_id'], 'disable', [], ['reason' => 'order_refund', 'source' => 'after_sale']); - } elseif ((int) ($before['base_level'] ?? 0) === 0 && (int) ($before['base_expires'] ?? 0) === 0 && (int) ($before['expired_level'] ?? 0) === 0) { - $result = $service->mutate_membership((int) $order['user_id'], 'set', ['level' => (int) $before['level'], 'expires' => (int) $before['expires']], ['reason' => 'order_refund', 'source' => 'after_sale']); - } else { - return false; - } - return !is_wp_error($result); - } - - private function refund_amount(array $order): ?string { - if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0)); - try { return AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; } - } - - private function cash_minor(string $value): int { try { return AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; } } - - /** @param array> $entries */ - private function asset_entries_available(array $entries): bool { - $required = []; - foreach ($entries as $entry) { - $delta = (int) ($entry['delta_minor'] ?? 0); - if ($delta < 0) { - $key = (int) ($entry['user_id'] ?? 0) . ':' . (string) ($entry['asset'] ?? ''); - $required[$key] = ($required[$key] ?? 0) + abs($delta); - } - } - foreach ($required as $key => $amount) { - [$user_id, $asset] = explode(':', $key, 2); - $balance = (string) $asset === AssetType::POINTS->value - ? \AKSRT\Credits\MetaAssetService::get_instance()->balance_points((int) $user_id) - : \AKSRT\Credits\MetaAssetService::get_instance()->balance_minor((int) $user_id); - if ($balance < (int) $amount) return false; - } - return true; - } - - /** @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','approved') ORDER BY id DESC LIMIT 1", $number), ARRAY_A); - return is_array($row) ? $this->normalize($row) : null; - } - - private 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 !== '')) - : []; - return $row; - } -} +[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) { + $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) { + $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]); + } + + /** @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_APPROVED, 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','approved')", $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, self::STATUS_APPROVED], true); + } + + public function review(int $id, string $decision, string $note = '') { + if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]); + $decision = sanitize_key($decision); + if (!in_array($decision, ['approve', 'reject', 'complete'], true)) return new \WP_Error('aksrt_after_sale_decision', __('审核动作无效', 'aksrt'), ['status' => 400]); + global $wpdb; + $initial = $this->get($id); + if (!$initial) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]); + $order_number = (string) ($initial['order_number'] ?? ''); + $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->get($id); + if (!$row || !hash_equals($order_number, (string) ($row['order_number'] ?? '')) || !in_array((string) $row['status'], [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]); + $note = sanitize_textarea_field($note); + if ((string) $row['status'] === self::STATUS_PROCESSING) { + if ($decision === 'complete') { + $gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true); + if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') { + return new \WP_Error('aksrt_after_sale_manual_only', __('该申请不是待人工确认的退款', 'aksrt'), ['status' => 409]); + } + $order = OrderRepository::get_instance()->get((string) $row['order_number']); + if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]); + $finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? '')); + return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); + } + if ($decision === 'reject') { + $gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true); + if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') { + return new \WP_Error('aksrt_after_sale_processing', __('退款处理中,不能拒绝;请同步退款结果', 'aksrt'), ['status' => 409]); + } + $order = OrderRepository::get_instance()->get((string) $row['order_number']); + if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]); + $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); + $revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? [])); + if (is_wp_error($revoke)) return $revoke; + if ($revoke !== []) { + $compensated = $this->compensate_revoke($row, $order, $revoke, $note); + if (is_wp_error($compensated)) return $compensated; + } + if (!$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('管理员确认未执行渠道退款,已恢复冻结资产', 'aksrt')])) { + return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]); + } + return $this->get((int) $row['id']); + } + return $this->sync_gateway_refund($row, $note); + } + if ($decision === 'complete') return new \WP_Error('aksrt_after_sale_manual_only', __('只有待人工确认的退款可以直接完成', 'aksrt'), ['status' => 409]); + if ($decision === 'reject') { + if (!$this->update($id, ['status' => self::STATUS_REJECTED, 'admin_note' => $note, 'reviewed_at' => current_time('mysql')])) { + return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]); + } + return $this->get($id); + } + return $this->approve($row, $note); + } finally { + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); + } + } + + private function approve(array $row, string $note) { + $order_number = (string) $row['order_number']; + $order = OrderRepository::get_instance()->get($order_number); + if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('订单已不是可退款状态', 'aksrt'), ['status' => 409]); + $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); + $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; + $method = sanitize_key((string) ($order['payment_method'] ?? '')); + if ($type === DomainModel::ORDER_PRODUCT_VIP && !$this->vip_reversal_is_safe($order, $meta)) return new \WP_Error('aksrt_after_sale_vip_snapshot', __('会员权益已变化或缺少快照,不能安全撤销', 'aksrt'), ['status' => 409]); + $revoke = $this->assets_to_revoke($order, $type, $meta); + if (is_wp_error($revoke)) return $revoke; + if ($method === 'card_pass') { + if (!$this->asset_entries_available($revoke)) return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能撤销卡密权益', 'aksrt'), ['status' => 409]); + return $this->commit_status_refund($row, $order, $note, $revoke); + } + if ($method === 'free') { + return $this->commit_status_refund($row, $order, $note, $revoke); + } + $refund_local = in_array($method, ['balance', 'points'], true); + if ($refund_local) { + $refund_asset = $method === 'points' ? AssetType::POINTS : AssetType::BALANCE; + $refund_minor = $refund_asset === AssetType::POINTS ? max(0, (int) ($order['amount'] ?? 0)) : $this->cash_minor((string) ($row['refund_amount'] ?? '0')); + if ($refund_minor <= 0) return new \WP_Error('aksrt_after_sale_amount', __('退款金额无效', 'aksrt'), ['status' => 400]); + $entries = $revoke; + // A points-purchase reversal already includes the original balance payment + // in assets_to_revoke(); do not return that payment twice. + if (!($type === DomainModel::ORDER_PRODUCT_POINTS && $method === 'balance')) { + $entries[] = ['user_id' => (int) $order['user_id'], 'asset' => $refund_asset->value, 'delta_minor' => $refund_minor, 'entry_type' => 'credit', 'remark' => sprintf(__('订单 %s 售后退款', 'aksrt'), $order_number)]; + } + return $this->commit_local_refund($row, $order, $entries, $note); + } + $gateway = Gateway::get_instance()->get_gateway($method); + if (!$gateway || !method_exists($gateway, 'refund')) return new \WP_Error('aksrt_after_sale_gateway', __('支付渠道不可用,无法退款', 'aksrt'), ['status' => 409]); + $request_id = (string) ($row['gateway_request_id'] ?? '') ?: 'AS_' . (int) $row['id']; + if (!$this->asset_entries_available($revoke)) { + return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能审核通过退款', 'aksrt'), ['status' => 409]); + } + if (!$this->update((int) $row['id'], ['status' => self::STATUS_PROCESSING, 'admin_note' => $note, 'gateway_request_id' => $request_id, 'reviewed_at' => current_time('mysql')])) { + return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败,未发起退款', 'aksrt'), ['status' => 500]); + } + if ($revoke !== []) { + $held = $this->post_asset_entries($row, $order, $revoke, 'after-sale-revoke', 'after_sale_revoke', $note); + if (is_wp_error($held)) { + $this->update((int) $row['id'], ['status' => self::STATUS_PENDING, 'gateway_request_id' => null, 'reviewed_at' => null]); + return $held; + } + } + try { + $result = $gateway->refund($order_number, (string) $row['refund_amount'], $request_id); + } catch (\Throwable $error) { + $result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()]; + } + if (!is_array($result)) { + $result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')]; + } + $this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? ''))]); + $status = sanitize_key((string) ($result['status'] ?? 'unknown')); + if ($status === 'failed') { + if ($revoke !== []) { + $compensated = $this->compensate_revoke($row, $order, $revoke, $note); + if (is_wp_error($compensated)) { + $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]); + return $this->get((int) $row['id']); + } + } + $this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => sanitize_textarea_field((string) ($result['message'] ?? $note))]); + return $this->get((int) $row['id']); + } + // Missing, unknown, and pending provider outcomes are ambiguous. Keep the + // application processing and the asset clawback in place until query_refund + // explicitly confirms success or failure. + if (empty($result['success']) || $status !== 'succeeded') { + $this->update((int) $row['id'], ['admin_note' => sanitize_textarea_field((string) ($result['message'] ?? __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')))]); + return $this->get((int) $row['id']); + } + $finished = $this->finish_refund($row, $order, $note); + return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); + } + + private function commit_local_refund(array $row, array $order, array $entries, string $note) { + $number = (string) $order['order_number']; + $commission = CommissionLedger::get_instance()->prepare_full_refund($number); + if (is_wp_error($commission)) return $commission; + $guard = static fn() => CommissionLedger::get_instance()->guard_full_refund($commission); + $mutation = function () use ($row, $order, $note, $commission) { + $commission_guard = CommissionLedger::get_instance()->guard_full_refund($commission); + if (is_wp_error($commission_guard) || $commission_guard !== true) return is_wp_error($commission_guard) ? $commission_guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); + $finished = $this->finish_refund($row, $order, $note, false); + if ($finished === true) { + LedgerService::get_instance()->after_commit(static function (): void { + OrderRepository::get_instance()->dispatch_managed_status_events(); + }); + } + return $finished; + }; + $result = LedgerService::get_instance()->post(new LedgerCommand( + \AKSRT\Ledger\OperationKey::deterministic('after-sale-refund', [(int) $row['id']]), 'order_refund', $entries, + 'order', $number, get_current_user_id(), ['order_number' => $number] + ), $guard, $mutation); + if (is_wp_error($result)) return $result; + CommissionLedger::get_instance()->clawback_after_refund($commission); + return $this->get((int) $row['id']); + } + + private function post_asset_entries(array $row, array $order, array $entries, string $prefix, string $reason, string $note) { + return LedgerService::get_instance()->post(new LedgerCommand( + \AKSRT\Ledger\OperationKey::deterministic($prefix, [(int) $row['id']]), $reason, $entries, + 'order', (string) $order['order_number'], get_current_user_id(), ['order_number' => (string) $order['order_number'], 'after_sale_id' => (int) $row['id']] + )); + } + + private function commit_status_refund(array $row, array $order, string $note, array $entries = []) { + global $wpdb; + $number = (string) $order['order_number']; + $commission = CommissionLedger::get_instance()->prepare_full_refund($number); + if (is_wp_error($commission)) return $commission; + if ($entries !== []) { + $mutation = function () use ($row, $order, $note, $commission) { + $guard = CommissionLedger::get_instance()->guard_full_refund($commission); + if (is_wp_error($guard) || $guard !== true) return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); + $finished = $this->finish_refund($row, $order, $note, false); + if ($finished === true) { + LedgerService::get_instance()->after_commit(static function (): void { OrderRepository::get_instance()->dispatch_managed_status_events(); }); + } + return $finished; + }; + $result = LedgerService::get_instance()->post(new LedgerCommand( + \AKSRT\Ledger\OperationKey::deterministic('after-sale-card-refund', [(int) $row['id']]), 'order_refund', $entries, + 'order', $number, get_current_user_id(), ['order_number' => $number] + ), static fn() => CommissionLedger::get_instance()->guard_full_refund($commission), $mutation); + if (is_wp_error($result)) return $result; + CommissionLedger::get_instance()->clawback_after_refund($commission); + return $this->get((int) $row['id']); + } + if ($wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]); + $guard = CommissionLedger::get_instance()->guard_full_refund($commission); + if (is_wp_error($guard) || $guard !== true) { $wpdb->query('ROLLBACK'); return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); } + $finished = $this->finish_order_and_application($row, $order, $note); + if (is_wp_error($finished) || !$finished) { $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } + $applied = CommissionLedger::get_instance()->apply_full_refund($commission); + if (is_wp_error($applied) || $wpdb->query('COMMIT') === false) { $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } + OrderRepository::get_instance()->dispatch_managed_status_events(); + CommissionLedger::get_instance()->clawback_after_refund($commission); + return $this->get((int) $row['id']); + } + + private function sync_gateway_refund(array $row, string $note) { + $order = OrderRepository::get_instance()->get((string) $row['order_number']); + $method = sanitize_key((string) ($order['payment_method'] ?? '')); + $gateway = Gateway::get_instance()->get_gateway($method); + $request_id = (string) ($row['gateway_request_id'] ?? ''); + if (!$order || !$gateway || !method_exists($gateway, 'query_refund') || $request_id === '') return new \WP_Error('aksrt_after_sale_sync_unavailable', __('该支付渠道暂不支持自动查询退款状态', 'aksrt'), ['status' => 409]); + try { + $result = $gateway->query_refund($request_id, (string) $row['order_number']); + } catch (\Throwable $error) { + $result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()]; + } + if (!is_array($result)) { + $result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')]; + } + $this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? $row['gateway_refund_id'] ?? ''))]); + $status = sanitize_key((string) ($result['status'] ?? 'unknown')); + if ($status === 'succeeded' && !empty($result['success'])) { + $finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? '')); + return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); + } + if ($status === 'failed' && !empty($result['success'])) { + $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); + $revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? [])); + if (is_wp_error($revoke)) { + $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但无法确认资产补偿内容;申请保持处理中,请人工核对。', 'aksrt') . ' ' . $revoke->get_error_message()]); + return $this->get((int) $row['id']); + } + if ($revoke !== []) { + $compensated = $this->compensate_revoke($row, $order, $revoke, $note); + if (is_wp_error($compensated)) { + $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]); + return $this->get((int) $row['id']); + } + } + $this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('支付渠道退款失败,已恢复撤销的资产', 'aksrt')]); + } elseif ($status !== 'succeeded') { + $this->update((int) $row['id'], ['admin_note' => $note !== '' ? $note : __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')]); + } + return $this->get((int) $row['id']); + } + + private function compensate_revoke(array $row, array $order, array $entries, string $note): bool|\WP_Error { + $compensate = array_map(static function (array $entry): array { $entry['delta_minor'] = -(int) $entry['delta_minor']; $entry['entry_type'] = 'credit'; $entry['remark'] = __('退款渠道失败,恢复已扣回资产', 'aksrt'); return $entry; }, $entries); + $result = $this->post_asset_entries($row, $order, $compensate, 'after-sale-revoke-compensate', 'after_sale_revoke_compensate', $note); + return is_wp_error($result) ? $result : true; + } + + private function assets_to_revoke(array $order, string $type, array $meta): array|\WP_Error { + $user_id = (int) ($order['user_id'] ?? 0); + if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) { + $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; + try { $minor = AssetAmount::balance((string) ($meta['credited_amount'] ?? $meta['credit_amount'] ?? $card_meta['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_asset', __('充值到账金额缺失,不能退款', 'aksrt'), ['status' => 409]); } + return $this->debit_entry($user_id, AssetType::BALANCE, $minor, __('撤销充值余额', 'aksrt')); + } + if ($type === DomainModel::ORDER_PRODUCT_POINTS) { + $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; + $points = max(0, (int) ($meta['points'] ?? $card_meta['points'] ?? 0)); + if ($points <= 0) return new \WP_Error('aksrt_after_sale_asset', __('购买积分数量缺失,不能退款', 'aksrt'), ['status' => 409]); + $entries = $this->debit_entry($user_id, AssetType::POINTS, $points, __('撤销订单发放积分', 'aksrt')); + if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'balance') { + try { $paid = AssetAmount::balance((string) ($order['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_amount', __('积分订单支付金额无效', 'aksrt'), ['status' => 409]); } + $entries[] = ['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => $paid, 'entry_type' => 'credit', 'remark' => __('退回购买积分使用的余额', 'aksrt')]; + } + return $entries; + } + return []; + } + + private function debit_entry(int $user_id, AssetType $asset, int $minor, string $remark): array|\WP_Error { + if ($user_id <= 0 || $minor <= 0) return new \WP_Error('aksrt_after_sale_asset', __('待撤销资产金额无效', 'aksrt'), ['status' => 409]); + return [['user_id' => $user_id, 'asset' => $asset->value, 'delta_minor' => -$minor, 'entry_type' => 'debit', 'remark' => $remark]]; + } + + private function finish_refund(array $row, array $order, string $note, bool $manage_transaction = true) { + global $wpdb; + $number = (string) $order['order_number']; + $commission = CommissionLedger::get_instance()->prepare_full_refund($number); + if (is_wp_error($commission)) return $commission; + if ($manage_transaction && $wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]); + $guarded = CommissionLedger::get_instance()->guard_full_refund($commission); + if (is_wp_error($guarded) || $guarded !== true) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($guarded) ? $guarded : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); } + $finished = $this->finish_order_and_application($row, $order, $note); + if (is_wp_error($finished) || !$finished) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款成功但本地订单更新失败,请重试同步', 'aksrt'), ['status' => 500]); } + $applied = CommissionLedger::get_instance()->apply_full_refund($commission); + if (is_wp_error($applied) || ($manage_transaction && $wpdb->query('COMMIT') === false)) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } + if ($manage_transaction) { + OrderRepository::get_instance()->dispatch_managed_status_events(); + CommissionLedger::get_instance()->clawback_after_refund($commission); + } + return true; + } + + private function finish_order_and_application(array $row, array $order, string $note): bool|\WP_Error { + $number = (string) $order['order_number']; + if (!$this->revoke_vip_if_needed($order)) return new \WP_Error('aksrt_after_sale_vip', __('会员权益撤销失败', 'aksrt'), ['status' => 409]); + if (!OrderRepository::get_instance()->update_status($number, Order::STATUS_REFUNDED, ['allow_refund' => true, 'after_sale_id' => (int) $row['id'], 'transaction_managed' => true, 'note' => $note !== '' ? $note : __('售后退款完成', 'aksrt')])) return new \WP_Error('aksrt_after_sale_order', __('订单退款状态更新失败', 'aksrt'), ['status' => 409]); + return $this->update((int) $row['id'], ['status' => self::STATUS_COMPLETED, 'admin_note' => $note, 'completed_at' => current_time('mysql'), 'reviewed_at' => current_time('mysql')]); + } + + private function vip_reversal_is_safe(array $order, array $meta): bool { + $before = is_array($meta['vip_before'] ?? null) ? $meta['vip_before'] : null; + $after = is_array($meta['vip_after'] ?? null) ? $meta['vip_after'] : null; + if (!$before || !$after || !class_exists('\\AKSRT\\Content\\VipService')) return false; + $current = \AKSRT\Content\VipService::get_instance()->get_membership_snapshot((int) $order['user_id']); + foreach (['level', 'expires', 'base_level', 'base_expires', 'expired_level'] as $key) { + if ((int) ($current[$key] ?? 0) !== (int) ($after[$key] ?? 0)) return false; + } + return true; + } + + private function revoke_vip_if_needed(array $order): bool { + $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); + if ($type !== DomainModel::ORDER_PRODUCT_VIP) return true; + $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; + if (!$this->vip_reversal_is_safe($order, $meta)) return false; + $before = $meta['vip_before']; + $service = \AKSRT\Content\VipService::get_instance(); + if ((int) ($before['level'] ?? 0) <= 0) { + $result = $service->mutate_membership((int) $order['user_id'], 'disable', [], ['reason' => 'order_refund', 'source' => 'after_sale']); + } elseif ((int) ($before['base_level'] ?? 0) === 0 && (int) ($before['base_expires'] ?? 0) === 0 && (int) ($before['expired_level'] ?? 0) === 0) { + $result = $service->mutate_membership((int) $order['user_id'], 'set', ['level' => (int) $before['level'], 'expires' => (int) $before['expires']], ['reason' => 'order_refund', 'source' => 'after_sale']); + } else { + return false; + } + return !is_wp_error($result); + } + + private function refund_amount(array $order): ?string { + if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0)); + try { return AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; } + } + + private function cash_minor(string $value): int { try { return AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; } } + + /** @param array> $entries */ + private function asset_entries_available(array $entries): bool { + $required = []; + foreach ($entries as $entry) { + $delta = (int) ($entry['delta_minor'] ?? 0); + if ($delta < 0) { + $key = (int) ($entry['user_id'] ?? 0) . ':' . (string) ($entry['asset'] ?? ''); + $required[$key] = ($required[$key] ?? 0) + abs($delta); + } + } + foreach ($required as $key => $amount) { + [$user_id, $asset] = explode(':', $key, 2); + $balance = (string) $asset === AssetType::POINTS->value + ? \AKSRT\Credits\MetaAssetService::get_instance()->balance_points((int) $user_id) + : \AKSRT\Credits\MetaAssetService::get_instance()->balance_minor((int) $user_id); + if ($balance < (int) $amount) return false; + } + return true; + } + + /** @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','approved') ORDER BY id DESC LIMIT 1", $number), ARRAY_A); + return is_array($row) ? $this->normalize($row) : null; + } + + private 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; + } +}