*/ 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']; $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:工单仍在流转(待审核 / 处理中),不允许重复申请; // - rejected:审核未通过,允许补充资料后再次申请; // - cancelled:用户自行取消,允许重新发起; // - completed:退款已完成,工单终结,不允许再次申请。 $reapplyable_statuses = [AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED]; $latest_status = (string) ($latest['status'] ?? ''); // 有工单时只有 rejected / cancelled 才放行再次申请:completed 的工单已终结, // 订单有没有另标「已退款」都不影响——AfterSaleService::create() 按同一口径拦截。 $reapplyable = !$has_application || in_array($latest_status, $reapplyable_statuses, true); return [ 'enabled' => $enabled, 'expired' => (bool) $expired, 'has_application' => $has_application, 'open' => $open, 'can_apply' => $enabled && !$expired && !$open && $reapplyable, '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'] ?? ''), // 处理记录:每次状态提交一条(状态 + 备注 + 来源 + 时间),前台时间线的唯一数据源。 'handling_log' => array_values((array) ($latest['handling_log'] ?? [])), ] : 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'] ?? ''), ]]; } }