diff --git a/src/Payment/UserOrders.php b/src/Payment/UserOrders.php index 8b1823c2..f3bd9b0c 100644 --- a/src/Payment/UserOrders.php +++ b/src/Payment/UserOrders.php @@ -1,543 +1,552 @@ - */ - private array $post_title_cache = []; - - /** @var array */ - private array $post_image_cache = []; - - /** @var array */ - private array $type_labels_cache = []; - - public static function get_instance(): self { - if (null === self::$instance) { - self::$instance = new self(); - } - return self::$instance; - } - - /** - * @param array $args - * @return array - */ - public function get_order_center_view(int $user_id, array $args = []): array { - $query = OrderCenterQuery::from_array($args); - $empty = $this->empty_view($query); - if ($user_id <= 0 || !class_exists(OrderRepository::class)) { - return $empty; - } - - $repo_args = $query->repository_args(); - $repo = OrderRepository::get_instance(); - $orders = $repo->get_user_orders($user_id, $repo_args); - $summary = $repo->get_user_order_center_summary($user_id, $repo_args); - $total = (int) ($summary['total'] ?? 0); - $total_pages = max(1, (int) ceil($total / $query->per_page())); - $page = min($query->page(), $total_pages); - - if ($page !== $query->page() && $total > 0) { - $repo_args['offset'] = ($page - 1) * $query->per_page(); - $orders = $repo->get_user_orders($user_id, $repo_args); - } - - return [ - 'items' => $this->decorate_order_views($orders), - 'total' => $total, - 'total_pages' => $total_pages, - 'page' => $page, - 'query' => array_merge($query->url_values(), ['order_page' => $page]), - 'stats' => is_array($summary['stats'] ?? null) ? $summary['stats'] : ['total' => 0, 'pending' => 0, 'paid' => 0], - 'invalidation_tags' => ['orders'], - 'product_types' => $this->product_type_filter_options(), - ]; - } - - /** @return array|null */ - public function get_order_detail_view(int $user_id, string $order_number, bool $reconcile = false): ?array { - if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { - return null; - } - - $order = OrderRepository::get_instance()->get_user_order($user_id, $order_number); - if (!$order) { - return null; - } - - $reconciliation = [ - 'state' => 'not_needed', - 'required' => false, - ]; - if (class_exists(Gateway::class)) { - $gateway = Gateway::get_instance(); - $required = $gateway->needs_reconciliation($order); - if ($required && $reconcile) { - $order = $gateway->reconcile_order($order_number, $order) ?: $order; - $required = $gateway->needs_reconciliation($order); - $reconciliation['state'] = $required ? 'refresh_available' : 'checked'; - } elseif ($required) { - $reconciliation['state'] = 'refresh_available'; - } - $reconciliation['required'] = $required; - } - - return $this->decorate_order_view($order, true, $reconciliation); - } - - /** @return array|null */ - public function refresh_order(int $user_id, string $order_number): ?array { - return $this->get_order_detail_view($user_id, $order_number, true); - } - - /** @return array */ - public function get_user_order_stats(int $user_id): array { - if ($user_id <= 0 || !class_exists(OrderRepository::class)) { - return ['total' => 0, 'pending' => 0, 'paid' => 0]; - } - - $summary = OrderRepository::get_instance()->get_user_order_center_summary($user_id); - return is_array($summary['stats'] ?? null) - ? $summary['stats'] - : ['total' => 0, 'pending' => 0, 'paid' => 0]; - } - - /** @return array */ - public function product_type_filter_options(): array { - if ($this->type_labels_cache === []) { - $this->type_labels_cache = DomainModel::order_product_type_labels(); - } - return array_merge(['' => __('全部类型', 'aksrt')], $this->type_labels_cache); - } - - /** - * Prime post and post-meta caches for all resource rows before decoration. - * - * @param array> $orders - */ - private function prime_resource_caches(array $orders): void { - $post_ids = []; - foreach ($orders as $order) { - $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; - foreach ([(int) ($order['product_id'] ?? 0), (int) ($meta['post_id'] ?? 0)] as $post_id) { - if ($post_id > 0) { - $post_ids[$post_id] = $post_id; - } - } - } - if ($post_ids === []) { - return; - } - - _prime_post_caches(array_values($post_ids), false, true); - $thumbnail_ids = []; - foreach ($post_ids as $post_id) { - $thumbnail_id = (int) get_post_thumbnail_id($post_id); - if ($thumbnail_id > 0) { - $thumbnail_ids[$thumbnail_id] = $thumbnail_id; - } - } - if ($thumbnail_ids !== []) { - _prime_post_caches(array_values($thumbnail_ids), false, true); - } - } - - /** - * @param array> $orders - * @return array> - */ - private function decorate_order_views(array $orders): array { - $this->prime_resource_caches($orders); - return array_map(fn(array $order): array => $this->decorate_order_view($order, false), $orders); - } - - /** - * @param array $order - * @param array|null $reconciliation - * @return array - */ - public function decorate_order_view(array $order, bool $detail = true, ?array $reconciliation = null): array { - $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; - $product_type = DomainModel::resolve_order_product_type(sanitize_key((string) ($order['product_type'] ?? '')), $meta); - $product_id = (int) ($order['product_id'] ?? 0); - $status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING)); - $currency = sanitize_key((string) ($order['currency'] ?? 'CNY')); - $is_points = $currency === 'points' - || sanitize_key((string) ($order['payment_method'] ?? '')) === 'points' - || sanitize_key((string) ($meta['pay_modo'] ?? '')) === 'points'; - if ($this->type_labels_cache === []) { - $this->type_labels_cache = DomainModel::order_product_type_labels(); - } - $type_label = $this->type_labels_cache[$product_type] ?? __('订单', 'aksrt'); - $title = $this->resolve_title($meta, $product_type, $product_id, $type_label); - $image = $this->resolve_image($meta, $product_id); - $group = $this->status_group($status); - $payment_method = sanitize_key((string) ($order['payment_method'] ?? '')); - // 卡密核销不发生任何真实支付(现金在售卡时已确认),实付恒为 0, - // 视图层统一归零以覆盖历史行(历史数据不做迁移)。 - $amount = (string) ($order['amount'] ?? '0.00'); - if ($payment_method === 'card_pass') { - $amount = $is_points ? '0' : '0.00'; - } - $can_pay = $group === OrderCenterQuery::STATUS_PENDING; - $can_cancel = $group === OrderCenterQuery::STATUS_PENDING; - $order_number = (string) ($order['order_number'] ?? ''); - $after_sale_service = class_exists(AfterSaleService::class) ? AfterSaleService::get_instance() : null; - $after_sales = $detail && $after_sale_service ? $after_sale_service->list_for_order($order_number) : []; - $after_sale_state = $this->after_sale_state($group, $order_number, (string) ($order['paid_at'] ?? ''), $after_sale_service, $after_sales); - $updated_at = $this->resolve_updated_at($order); - - $view = [ - 'id' => (int) ($order['id'] ?? 0), - 'order_number' => $order_number, - 'amount' => $amount, - 'currency' => $currency, - 'currency_symbol' => $is_points - ? (class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt')) - : '¥', - 'is_points' => $is_points, - 'status' => $status, - 'status_group' => $group, - 'status_label' => $this->status_label($group), - 'product_type' => $product_type, - 'product_type_label' => $type_label, - 'product_id' => $product_id, - 'product_name' => $title, - 'image' => $image, - 'created_at' => (string) ($order['created_at'] ?? ''), - 'payment_method' => [ - 'value' => $payment_method, - 'label' => $this->payment_method_label($payment_method), - ], - 'allowed_actions' => [ - 'can_pay' => $can_pay, - 'can_cancel' => $can_cancel, - ], - 'after_sale' => $after_sale_state, - 'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status), - 'updated_at' => $updated_at, - 'status_version' => $this->resolve_status_version($order, $updated_at), - 'invalidation_tags' => array_values(array_filter([ - 'orders', - $order_number !== '' ? 'order:' . sanitize_key($order_number) : '', - ])), - ]; - if ($detail) { - // 前台订单详情只暴露统一流水号(order_number);账本流水号为后台口径, - // 不下发到前台,避免同一单据出现两个对外号码。 - $view['paid_at'] = (string) ($order['paid_at'] ?? ''); - $view['expires_at'] = (string) ($order['expires_at'] ?? ''); - $view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false]; - $view['after_sales'] = $after_sales; - } - return $view; - } - - /** - * 售后入口状态——前台「申请售后 / 售后详细」按钮与三个弹窗的唯一数据源。 - * - * 列表模式只拉最新一条工单(latest_for_order);详情模式直接复用已取到的 - * 全量工单(list_for_order 按 id DESC,首条即最新)。 - * - * @param array> $records - * @return array - */ - private function after_sale_state(string $group, string $order_number, string $paid_at, ?AfterSaleService $service, array $records = []): array { - $settings = class_exists('\AKSRT\Settings\PaymentRuntime') - ? \AKSRT\Settings\PaymentRuntime::get_after_sale_settings() - : []; - $settings = is_array($settings) ? $settings : []; - - $open_statuses = ['pending', 'processing', 'approved']; - $latest = null; - $open = false; - if ($service !== null) { - if ($records) { - foreach ($records as $record) { - if ($latest === null) { - $latest = $record; - } - if (in_array((string) ($record['status'] ?? ''), $open_statuses, true)) { - $open = true; - } - } - } elseif ($order_number !== '') { - $latest = $service->latest_for_order($order_number); - $open = $latest !== null && in_array((string) ($latest['status'] ?? ''), $open_statuses, true); - } - } - - // 售后只对已支付订单开放;开关关闭时整体不处理售后。 - $enabled = $group === OrderCenterQuery::STATUS_PAID && !empty($settings['enabled']); - $expired = $enabled && class_exists('\AKSRT\Settings\PaymentRuntime') - && \AKSRT\Settings\PaymentRuntime::after_sale_expired($paid_at); - $has_application = $latest !== null; - - return [ - 'enabled' => $enabled, - 'expired' => (bool) $expired, - 'has_application' => $has_application, - 'open' => $open, - 'can_apply' => $enabled && !$expired && !$open, - 'can_reapply' => $enabled && !$expired && !$open && $has_application, - 'latest' => $has_application ? [ - 'status' => (string) ($latest['status'] ?? ''), - 'reason' => (string) ($latest['reason'] ?? ''), - 'images' => array_values((array) ($latest['images'] ?? [])), - 'refund_amount' => (string) ($latest['refund_amount'] ?? '0'), - 'created_at' => (string) ($latest['created_at'] ?? ''), - 'reviewed_at' => (string) ($latest['reviewed_at'] ?? ''), - 'completed_at' => (string) ($latest['completed_at'] ?? ''), - 'admin_note' => (string) ($latest['admin_note'] ?? ''), - ] : null, - ]; - } - - /** @return array */ - private function empty_view(OrderCenterQuery $query): array { - return [ - 'items' => [], - 'total' => 0, - 'total_pages' => 1, - 'page' => 1, - 'query' => $query->url_values(), - 'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0], - 'invalidation_tags' => ['orders'], - 'product_types' => $this->product_type_filter_options(), - ]; - } - - /** @param array $order */ - private function resolve_updated_at(array $order): string { - $latest = ''; - $latest_timestamp = 0; - foreach (['created_at', 'paid_at', 'cancelled_at', 'refunded_at'] as $field) { - $value = trim((string) ($order[$field] ?? '')); - if ($value === '') { - continue; - } - $timestamp = strtotime($value); - if ($timestamp !== false && $timestamp >= $latest_timestamp) { - $latest_timestamp = $timestamp; - $latest = $value; - } - } - return $latest; - } - - /** @param array $order */ - private function resolve_status_version(array $order, string $updated_at): string { - return substr(hash('sha256', implode('|', [ - (int) ($order['id'] ?? 0), - (string) ($order['order_number'] ?? ''), - sanitize_key((string) ($order['status'] ?? '')), - $updated_at, - ])), 0, 16); - } - - private function status_group(string $status): string { - return isset(DomainModel::order_status_labels()[$status]) ? $status : ''; - } - - private function status_label(string $group): string { - return DomainModel::order_status_labels()[$group] ?? __('未知状态', 'aksrt'); - } - - /** @param array $meta */ - private function resolve_title(array $meta, string $type, int $product_id, string $fallback): string { - // 卡密核销订单:按 note.meta 生成可读名称(余额/积分卡记面额,会员卡记档位与时长)。 - if (!empty($meta['card_pass'])) { - $title = $this->resolve_card_pass_title($meta); - if ($title !== '') { - return $title; - } - } - foreach (['product_title', 'title', 'package_title'] as $key) { - if (!empty($meta[$key]) && is_string($meta[$key])) { - return (string) $meta[$key]; - } - } - if ($type === DomainModel::ORDER_PRODUCT_VIP) { - return __('VIP 会员', 'aksrt'); - } - if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) { - return __('余额充值', 'aksrt'); - } - if ($type === DomainModel::ORDER_PRODUCT_POINTS) { - return __('积分购买', 'aksrt'); - } - if ($product_id > 0) { - $cache_key = (string) $product_id; - if (!array_key_exists($cache_key, $this->post_title_cache)) { - $title = get_the_title($product_id); - $this->post_title_cache[$cache_key] = is_string($title) ? $title : ''; - } - if ($this->post_title_cache[$cache_key] !== '') { - return $this->post_title_cache[$cache_key]; - } - } - return $fallback; - } - - /** - * 卡密核销订单名称: - * - 余额卡:卡密兑换-余额充值{面额} - * - 积分卡:卡密兑换-积分充值{点数} - * - 会员卡:购入[{会员档位名}]{N}天(永久卡为「永久」) - * - * @param array $meta - */ - private function resolve_card_pass_title(array $meta): string { - $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; - $card_type = sanitize_key((string) ($meta['card_type'] ?? '')); - if ($card_type === ChargeCard::TYPE_BALANCE) { - $amount = (string) ($card_meta['amount'] ?? ''); - if (str_contains($amount, '.')) { - $amount = rtrim(rtrim($amount, '0'), '.'); - } - return __('卡密兑换-余额充值', 'aksrt') . $amount; - } - if ($card_type === ChargeCard::TYPE_CREDITS) { - return __('卡密兑换-积分充值', 'aksrt') . (string) max(0, (int) ($card_meta['points'] ?? 0)); - } - if ($card_type === ChargeCard::TYPE_VIP) { - $level = max(1, min(2, (int) ($card_meta['level'] ?? 1))); - $days = max(0, (int) ($card_meta['days'] ?? 0)); - $levels = class_exists('\AKSRT\Settings\Settings') - ? (\AKSRT\Settings\Settings::get_payment_vip_settings()['levels'] ?? []) - : []; - $level_name = is_array($levels) ? (string) ($levels[$level]['name'] ?? '') : ''; - if ($level_name === '') { - $level_name = 'VIP' . $level; - } - $suffix = $days > 0 ? sprintf(__('%d天', 'aksrt'), $days) : __('永久', 'aksrt'); - return sprintf(__('购入[%1$s]%2$s', 'aksrt'), $level_name, $suffix); - } - return ''; - } - - /** @param array $meta */ - private function resolve_image(array $meta, int $product_id): string { - if (!empty($meta['thumb']) && is_string($meta['thumb'])) { - return esc_url_raw($meta['thumb']); - } - if ($product_id > 0) { - $cache_key = (string) $product_id; - if (!array_key_exists($cache_key, $this->post_image_cache)) { - $image = has_post_thumbnail($product_id) - ? get_the_post_thumbnail_url($product_id, 'thumbnail') - : ''; - $this->post_image_cache[$cache_key] = is_string($image) ? $image : ''; - } - return $this->post_image_cache[$cache_key]; - } - return ''; - } - - private function payment_method_label(string $method): string { - $labels = [ - 'balance' => __('站内余额', 'aksrt'), - 'points' => __('积分支付', 'aksrt'), - 'alipay' => __('支付宝', 'aksrt'), - 'wechatpay' => __('微信支付', 'aksrt'), - 'paypal' => 'PayPal', - 'stripe' => 'Stripe', - ]; - return $labels[$method] ?? ($method !== '' ? $method : __('—', 'aksrt')); - } - - private function get_order_countdown(string $expires_at, string $status): int { - if ($status !== Order::STATUS_PENDING || $expires_at === '') { - return 0; - } - $expires = strtotime($expires_at); - return $expires ? max(0, $expires - current_time('timestamp')) : 0; - } - - /** @return array{success:bool,message?:string,data?:array} */ - public function cancel_order(int $user_id, string $order_number): array { - if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { - return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; - } - $repo = OrderRepository::get_instance(); - $order = $repo->get_user_order($user_id, $order_number); - if (!$order || $this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) { - return ['success' => false, 'message' => __('当前订单不可关闭。', 'aksrt')]; - } - if (!$repo->update_status($order_number, Order::STATUS_CLOSED, [ - 'note' => __('用户在前台关闭订单。', 'aksrt'), - ])) { - return ['success' => false, 'message' => __('关闭订单失败,请稍后重试。', 'aksrt')]; - } - return ['success' => true, 'data' => ['order' => $this->get_order_detail_view($user_id, $order_number)]]; - } - - /** @return array{success:bool,message?:string,data?:array} */ - public function pay_order(int $user_id, string $order_number, string $payment_method = ''): array { - if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { - return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; - } - $repo = OrderRepository::get_instance(); - $order = $repo->get_user_order($user_id, $order_number); - if (!$order) { - return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')]; - } - if ($this->status_group((string) ($order['status'] ?? '')) === OrderCenterQuery::STATUS_PAID) { - return ['success' => true, 'data' => [ - 'redirect' => false, - 'payment_url' => '', - 'order_number' => $order_number, - 'order' => $this->get_order_detail_view($user_id, $order_number), - 'replayed' => true, - 'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id), - 'message' => __('支付成功。', 'aksrt'), - ]]; - } - if ($this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) { - return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')]; - } - $stored_method = sanitize_key((string) ($order['payment_method'] ?? '')); - $payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance')); - if ($stored_method !== '' && $payment_method !== $stored_method) { - return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')]; - } - $manager = class_exists(Gateway::class) ? Gateway::get_instance() : null; - $gateway = $manager ? $manager->get_gateway($payment_method) : null; - if (!$gateway || (method_exists($gateway, 'is_configured') && !$gateway->is_configured())) { - return ['success' => false, 'message' => __('当前支付方式不可用。', 'aksrt')]; - } - $order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number); - $order['payment_method'] = $payment_method; - $result = PaymentOrchestrator::pay($gateway, $order, ['paid_note' => __('用户在前台完成支付。', 'aksrt')]); - if (empty($result['success'])) { - return [ - 'success' => false, - 'code' => (string) ($result['code'] ?? 'aksrt_pay_failed'), - 'status' => (int) ($result['status'] ?? 400), - 'message' => (string) ($result['message'] ?? __('支付失败。', 'aksrt')), - ]; - } - return ['success' => true, 'data' => [ - 'redirect' => !empty($result['redirect']), - 'payment_url' => (string) ($result['payment_url'] ?? ''), - 'order_number' => $order_number, - 'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null, - 'replayed' => !empty($result['replayed']), - 'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null, - 'message' => (string) ($result['message'] ?? ''), - ]]; - } -} + */ + private array $post_title_cache = []; + + /** @var array */ + private array $post_image_cache = []; + + /** @var array */ + private array $type_labels_cache = []; + + public static function get_instance(): self { + if (null === self::$instance) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * @param array $args + * @return array + */ + public function get_order_center_view(int $user_id, array $args = []): array { + $query = OrderCenterQuery::from_array($args); + $empty = $this->empty_view($query); + if ($user_id <= 0 || !class_exists(OrderRepository::class)) { + return $empty; + } + + $repo_args = $query->repository_args(); + $repo = OrderRepository::get_instance(); + $orders = $repo->get_user_orders($user_id, $repo_args); + $summary = $repo->get_user_order_center_summary($user_id, $repo_args); + $total = (int) ($summary['total'] ?? 0); + $total_pages = max(1, (int) ceil($total / $query->per_page())); + $page = min($query->page(), $total_pages); + + if ($page !== $query->page() && $total > 0) { + $repo_args['offset'] = ($page - 1) * $query->per_page(); + $orders = $repo->get_user_orders($user_id, $repo_args); + } + + return [ + 'items' => $this->decorate_order_views($orders), + 'total' => $total, + 'total_pages' => $total_pages, + 'page' => $page, + 'query' => array_merge($query->url_values(), ['order_page' => $page]), + 'stats' => is_array($summary['stats'] ?? null) ? $summary['stats'] : ['total' => 0, 'pending' => 0, 'paid' => 0], + 'invalidation_tags' => ['orders'], + 'product_types' => $this->product_type_filter_options(), + ]; + } + + /** @return array|null */ + public function get_order_detail_view(int $user_id, string $order_number, bool $reconcile = false): ?array { + if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { + return null; + } + + $order = OrderRepository::get_instance()->get_user_order($user_id, $order_number); + if (!$order) { + return null; + } + + $reconciliation = [ + 'state' => 'not_needed', + 'required' => false, + ]; + if (class_exists(Gateway::class)) { + $gateway = Gateway::get_instance(); + $required = $gateway->needs_reconciliation($order); + if ($required && $reconcile) { + $order = $gateway->reconcile_order($order_number, $order) ?: $order; + $required = $gateway->needs_reconciliation($order); + $reconciliation['state'] = $required ? 'refresh_available' : 'checked'; + } elseif ($required) { + $reconciliation['state'] = 'refresh_available'; + } + $reconciliation['required'] = $required; + } + + return $this->decorate_order_view($order, true, $reconciliation); + } + + /** @return array|null */ + public function refresh_order(int $user_id, string $order_number): ?array { + return $this->get_order_detail_view($user_id, $order_number, true); + } + + /** @return array */ + public function get_user_order_stats(int $user_id): array { + if ($user_id <= 0 || !class_exists(OrderRepository::class)) { + return ['total' => 0, 'pending' => 0, 'paid' => 0]; + } + + $summary = OrderRepository::get_instance()->get_user_order_center_summary($user_id); + return is_array($summary['stats'] ?? null) + ? $summary['stats'] + : ['total' => 0, 'pending' => 0, 'paid' => 0]; + } + + /** @return array */ + public function product_type_filter_options(): array { + if ($this->type_labels_cache === []) { + $this->type_labels_cache = DomainModel::order_product_type_labels(); + } + return array_merge(['' => __('全部类型', 'aksrt')], $this->type_labels_cache); + } + + /** + * Prime post and post-meta caches for all resource rows before decoration. + * + * @param array> $orders + */ + private function prime_resource_caches(array $orders): void { + $post_ids = []; + foreach ($orders as $order) { + $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; + foreach ([(int) ($order['product_id'] ?? 0), (int) ($meta['post_id'] ?? 0)] as $post_id) { + if ($post_id > 0) { + $post_ids[$post_id] = $post_id; + } + } + } + if ($post_ids === []) { + return; + } + + _prime_post_caches(array_values($post_ids), false, true); + $thumbnail_ids = []; + foreach ($post_ids as $post_id) { + $thumbnail_id = (int) get_post_thumbnail_id($post_id); + if ($thumbnail_id > 0) { + $thumbnail_ids[$thumbnail_id] = $thumbnail_id; + } + } + if ($thumbnail_ids !== []) { + _prime_post_caches(array_values($thumbnail_ids), false, true); + } + } + + /** + * @param array> $orders + * @return array> + */ + private function decorate_order_views(array $orders): array { + $this->prime_resource_caches($orders); + return array_map(fn(array $order): array => $this->decorate_order_view($order, false), $orders); + } + + /** + * @param array $order + * @param array|null $reconciliation + * @return array + */ + public function decorate_order_view(array $order, bool $detail = true, ?array $reconciliation = null): array { + $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; + $product_type = DomainModel::resolve_order_product_type(sanitize_key((string) ($order['product_type'] ?? '')), $meta); + $product_id = (int) ($order['product_id'] ?? 0); + $status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING)); + $currency = sanitize_key((string) ($order['currency'] ?? 'CNY')); + $is_points = $currency === 'points' + || sanitize_key((string) ($order['payment_method'] ?? '')) === 'points' + || sanitize_key((string) ($meta['pay_modo'] ?? '')) === 'points'; + if ($this->type_labels_cache === []) { + $this->type_labels_cache = DomainModel::order_product_type_labels(); + } + $type_label = $this->type_labels_cache[$product_type] ?? __('订单', 'aksrt'); + $title = $this->resolve_title($meta, $product_type, $product_id, $type_label); + $image = $this->resolve_image($meta, $product_id); + $group = $this->status_group($status); + $payment_method = sanitize_key((string) ($order['payment_method'] ?? '')); + // 卡密核销不发生任何真实支付(现金在售卡时已确认),实付恒为 0, + // 视图层统一归零以覆盖历史行(历史数据不做迁移)。 + $amount = (string) ($order['amount'] ?? '0.00'); + if ($payment_method === 'card_pass') { + $amount = $is_points ? '0' : '0.00'; + } + $can_pay = $group === OrderCenterQuery::STATUS_PENDING; + $can_cancel = $group === OrderCenterQuery::STATUS_PENDING; + $order_number = (string) ($order['order_number'] ?? ''); + $after_sale_service = class_exists(AfterSaleService::class) ? AfterSaleService::get_instance() : null; + $after_sales = $detail && $after_sale_service ? $after_sale_service->list_for_order($order_number) : []; + $after_sale_state = $this->after_sale_state($group, $order_number, (string) ($order['paid_at'] ?? ''), $after_sale_service, $after_sales); + $updated_at = $this->resolve_updated_at($order); + + $view = [ + 'id' => (int) ($order['id'] ?? 0), + 'order_number' => $order_number, + 'amount' => $amount, + 'currency' => $currency, + 'currency_symbol' => $is_points + ? (class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt')) + : '¥', + 'is_points' => $is_points, + 'status' => $status, + 'status_group' => $group, + 'status_label' => $this->status_label($group), + 'product_type' => $product_type, + 'product_type_label' => $type_label, + 'product_id' => $product_id, + 'product_name' => $title, + 'image' => $image, + 'created_at' => (string) ($order['created_at'] ?? ''), + 'payment_method' => [ + 'value' => $payment_method, + 'label' => $this->payment_method_label($payment_method), + ], + 'allowed_actions' => [ + 'can_pay' => $can_pay, + 'can_cancel' => $can_cancel, + ], + 'after_sale' => $after_sale_state, + 'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status), + 'updated_at' => $updated_at, + 'status_version' => $this->resolve_status_version($order, $updated_at), + 'invalidation_tags' => array_values(array_filter([ + 'orders', + $order_number !== '' ? 'order:' . sanitize_key($order_number) : '', + ])), + ]; + if ($detail) { + // 前台订单详情只暴露统一流水号(order_number);账本流水号为后台口径, + // 不下发到前台,避免同一单据出现两个对外号码。 + $view['paid_at'] = (string) ($order['paid_at'] ?? ''); + $view['expires_at'] = (string) ($order['expires_at'] ?? ''); + $view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false]; + $view['after_sales'] = $after_sales; + } + return $view; + } + + /** + * 售后入口状态——前台「申请售后 / 售后详细」按钮与三个弹窗的唯一数据源。 + * + * 列表模式只拉最新一条工单(latest_for_order);详情模式直接复用已取到的 + * 全量工单(list_for_order 按 id DESC,首条即最新)。 + * + * @param array> $records + * @return array + */ + private function after_sale_state(string $group, string $order_number, string $paid_at, ?AfterSaleService $service, array $records = []): array { + $settings = class_exists('\AKSRT\Settings\PaymentRuntime') + ? \AKSRT\Settings\PaymentRuntime::get_after_sale_settings() + : []; + $settings = is_array($settings) ? $settings : []; + + $open_statuses = ['pending', 'processing', 'approved']; + $latest = null; + $open = false; + if ($service !== null) { + if ($records) { + foreach ($records as $record) { + if ($latest === null) { + $latest = $record; + } + if (in_array((string) ($record['status'] ?? ''), $open_statuses, true)) { + $open = true; + } + } + } elseif ($order_number !== '') { + $latest = $service->latest_for_order($order_number); + $open = $latest !== null && in_array((string) ($latest['status'] ?? ''), $open_statuses, true); + } + } + + // 售后只对已支付订单开放;开关关闭时整体不处理售后。 + $enabled = $group === OrderCenterQuery::STATUS_PAID && !empty($settings['enabled']); + $expired = $enabled && class_exists('\AKSRT\Settings\PaymentRuntime') + && \AKSRT\Settings\PaymentRuntime::after_sale_expired($paid_at); + $has_application = $latest !== null; + + // 可再次申请售后的状态判定(按工单状态推理,不再以「不能再申请」为常态): + // - pending / processing / approved:工单仍在流转(待审核 / 处理中 / 已通过待退款),不允许重复申请; + // - rejected:审核未通过,允许补充资料后再次申请; + // - cancelled:用户自行取消,允许重新发起; + // - completed:退款已完成,工单终结,不允许再次申请。 + $reapplyable_statuses = [AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED]; + $latest_status = (string) ($latest['status'] ?? ''); + + return [ + 'enabled' => $enabled, + 'expired' => (bool) $expired, + 'has_application' => $has_application, + 'open' => $open, + 'can_apply' => $enabled && !$expired && !$open, + 'can_reapply' => $enabled && !$expired && $has_application && in_array($latest_status, $reapplyable_statuses, true), + 'latest' => $has_application ? [ + 'status' => (string) ($latest['status'] ?? ''), + 'reason' => (string) ($latest['reason'] ?? ''), + 'apply_form' => is_array($latest['apply_form'] ?? null) ? $latest['apply_form'] : null, + 'images' => array_values((array) ($latest['images'] ?? [])), + 'refund_amount' => (string) ($latest['refund_amount'] ?? '0'), + 'created_at' => (string) ($latest['created_at'] ?? ''), + 'reviewed_at' => (string) ($latest['reviewed_at'] ?? ''), + 'completed_at' => (string) ($latest['completed_at'] ?? ''), + 'admin_note' => (string) ($latest['admin_note'] ?? ''), + ] : null, + ]; + } + + /** @return array */ + private function empty_view(OrderCenterQuery $query): array { + return [ + 'items' => [], + 'total' => 0, + 'total_pages' => 1, + 'page' => 1, + 'query' => $query->url_values(), + 'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0], + 'invalidation_tags' => ['orders'], + 'product_types' => $this->product_type_filter_options(), + ]; + } + + /** @param array $order */ + private function resolve_updated_at(array $order): string { + $latest = ''; + $latest_timestamp = 0; + foreach (['created_at', 'paid_at', 'cancelled_at', 'refunded_at'] as $field) { + $value = trim((string) ($order[$field] ?? '')); + if ($value === '') { + continue; + } + $timestamp = strtotime($value); + if ($timestamp !== false && $timestamp >= $latest_timestamp) { + $latest_timestamp = $timestamp; + $latest = $value; + } + } + return $latest; + } + + /** @param array $order */ + private function resolve_status_version(array $order, string $updated_at): string { + return substr(hash('sha256', implode('|', [ + (int) ($order['id'] ?? 0), + (string) ($order['order_number'] ?? ''), + sanitize_key((string) ($order['status'] ?? '')), + $updated_at, + ])), 0, 16); + } + + private function status_group(string $status): string { + return isset(DomainModel::order_status_labels()[$status]) ? $status : ''; + } + + private function status_label(string $group): string { + return DomainModel::order_status_labels()[$group] ?? __('未知状态', 'aksrt'); + } + + /** @param array $meta */ + private function resolve_title(array $meta, string $type, int $product_id, string $fallback): string { + // 卡密核销订单:按 note.meta 生成可读名称(余额/积分卡记面额,会员卡记档位与时长)。 + if (!empty($meta['card_pass'])) { + $title = $this->resolve_card_pass_title($meta); + if ($title !== '') { + return $title; + } + } + foreach (['product_title', 'title', 'package_title'] as $key) { + if (!empty($meta[$key]) && is_string($meta[$key])) { + return (string) $meta[$key]; + } + } + if ($type === DomainModel::ORDER_PRODUCT_VIP) { + return __('VIP 会员', 'aksrt'); + } + if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) { + return __('余额充值', 'aksrt'); + } + if ($type === DomainModel::ORDER_PRODUCT_POINTS) { + return __('积分购买', 'aksrt'); + } + if ($product_id > 0) { + $cache_key = (string) $product_id; + if (!array_key_exists($cache_key, $this->post_title_cache)) { + $title = get_the_title($product_id); + $this->post_title_cache[$cache_key] = is_string($title) ? $title : ''; + } + if ($this->post_title_cache[$cache_key] !== '') { + return $this->post_title_cache[$cache_key]; + } + } + return $fallback; + } + + /** + * 卡密核销订单名称: + * - 余额卡:卡密兑换-余额充值{面额} + * - 积分卡:卡密兑换-积分充值{点数} + * - 会员卡:购入[{会员档位名}]{N}天(永久卡为「永久」) + * + * @param array $meta + */ + private function resolve_card_pass_title(array $meta): string { + $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; + $card_type = sanitize_key((string) ($meta['card_type'] ?? '')); + if ($card_type === ChargeCard::TYPE_BALANCE) { + $amount = (string) ($card_meta['amount'] ?? ''); + if (str_contains($amount, '.')) { + $amount = rtrim(rtrim($amount, '0'), '.'); + } + return __('卡密兑换-余额充值', 'aksrt') . $amount; + } + if ($card_type === ChargeCard::TYPE_CREDITS) { + return __('卡密兑换-积分充值', 'aksrt') . (string) max(0, (int) ($card_meta['points'] ?? 0)); + } + if ($card_type === ChargeCard::TYPE_VIP) { + $level = max(1, min(2, (int) ($card_meta['level'] ?? 1))); + $days = max(0, (int) ($card_meta['days'] ?? 0)); + $levels = class_exists('\AKSRT\Settings\Settings') + ? (\AKSRT\Settings\Settings::get_payment_vip_settings()['levels'] ?? []) + : []; + $level_name = is_array($levels) ? (string) ($levels[$level]['name'] ?? '') : ''; + if ($level_name === '') { + $level_name = 'VIP' . $level; + } + $suffix = $days > 0 ? sprintf(__('%d天', 'aksrt'), $days) : __('永久', 'aksrt'); + return sprintf(__('购入[%1$s]%2$s', 'aksrt'), $level_name, $suffix); + } + return ''; + } + + /** @param array $meta */ + private function resolve_image(array $meta, int $product_id): string { + if (!empty($meta['thumb']) && is_string($meta['thumb'])) { + return esc_url_raw($meta['thumb']); + } + if ($product_id > 0) { + $cache_key = (string) $product_id; + if (!array_key_exists($cache_key, $this->post_image_cache)) { + $image = has_post_thumbnail($product_id) + ? get_the_post_thumbnail_url($product_id, 'thumbnail') + : ''; + $this->post_image_cache[$cache_key] = is_string($image) ? $image : ''; + } + return $this->post_image_cache[$cache_key]; + } + return ''; + } + + private function payment_method_label(string $method): string { + $labels = [ + 'balance' => __('站内余额', 'aksrt'), + 'points' => __('积分支付', 'aksrt'), + 'alipay' => __('支付宝', 'aksrt'), + 'wechatpay' => __('微信支付', 'aksrt'), + 'paypal' => 'PayPal', + 'stripe' => 'Stripe', + ]; + return $labels[$method] ?? ($method !== '' ? $method : __('—', 'aksrt')); + } + + private function get_order_countdown(string $expires_at, string $status): int { + if ($status !== Order::STATUS_PENDING || $expires_at === '') { + return 0; + } + $expires = strtotime($expires_at); + return $expires ? max(0, $expires - current_time('timestamp')) : 0; + } + + /** @return array{success:bool,message?:string,data?:array} */ + public function cancel_order(int $user_id, string $order_number): array { + if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { + return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; + } + $repo = OrderRepository::get_instance(); + $order = $repo->get_user_order($user_id, $order_number); + if (!$order || $this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) { + return ['success' => false, 'message' => __('当前订单不可关闭。', 'aksrt')]; + } + if (!$repo->update_status($order_number, Order::STATUS_CLOSED, [ + 'note' => __('用户在前台关闭订单。', 'aksrt'), + ])) { + return ['success' => false, 'message' => __('关闭订单失败,请稍后重试。', 'aksrt')]; + } + return ['success' => true, 'data' => ['order' => $this->get_order_detail_view($user_id, $order_number)]]; + } + + /** @return array{success:bool,message?:string,data?:array} */ + public function pay_order(int $user_id, string $order_number, string $payment_method = ''): array { + if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { + return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; + } + $repo = OrderRepository::get_instance(); + $order = $repo->get_user_order($user_id, $order_number); + if (!$order) { + return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')]; + } + if ($this->status_group((string) ($order['status'] ?? '')) === OrderCenterQuery::STATUS_PAID) { + return ['success' => true, 'data' => [ + 'redirect' => false, + 'payment_url' => '', + 'order_number' => $order_number, + 'order' => $this->get_order_detail_view($user_id, $order_number), + 'replayed' => true, + 'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id), + 'message' => __('支付成功。', 'aksrt'), + ]]; + } + if ($this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) { + return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')]; + } + $stored_method = sanitize_key((string) ($order['payment_method'] ?? '')); + $payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance')); + if ($stored_method !== '' && $payment_method !== $stored_method) { + return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')]; + } + $manager = class_exists(Gateway::class) ? Gateway::get_instance() : null; + $gateway = $manager ? $manager->get_gateway($payment_method) : null; + if (!$gateway || (method_exists($gateway, 'is_configured') && !$gateway->is_configured())) { + return ['success' => false, 'message' => __('当前支付方式不可用。', 'aksrt')]; + } + $order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number); + $order['payment_method'] = $payment_method; + $result = PaymentOrchestrator::pay($gateway, $order, ['paid_note' => __('用户在前台完成支付。', 'aksrt')]); + if (empty($result['success'])) { + return [ + 'success' => false, + 'code' => (string) ($result['code'] ?? 'aksrt_pay_failed'), + 'status' => (int) ($result['status'] ?? 400), + 'message' => (string) ($result['message'] ?? __('支付失败。', 'aksrt')), + ]; + } + return ['success' => true, 'data' => [ + 'redirect' => !empty($result['redirect']), + 'payment_url' => (string) ($result['payment_url'] ?? ''), + 'order_number' => $order_number, + 'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null, + 'replayed' => !empty($result['replayed']), + 'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null, + 'message' => (string) ($result['message'] ?? ''), + ]]; + } +}