Files
wordpress-theme-aksrt/src/AKSRT/Payment/UserOrders.php
T
kate522andClaude Fable 5 723dd180e4 feat(payment): tighten gateways and implement Epay
Keep only balance, Alipay, WeChat, PayPal, Stripe, Epay, and card-pass
channels; remove placeholder aggregators. Add dual-channel Epay gateway
with MD5 notify/return, register cashier methods, and gate card-pass
redeem behind the payment channel switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 12:59:28 +08:00

570 lines
24 KiB
PHP

<?php
/**
* Front-end "My Orders" view-model (all product_types).
*
* Shop logistics/review stay shop-only; content/VIP/recharge get type-aware cards.
*
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class UserOrders {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Paginated user order center payload.
*
* @param array{page?:int,per_page?:int,status?:string,product_type?:string} $args
* @return array{items:array,total:int,total_pages:int,page:int,stats:array,product_types:array}
*/
public function get_order_center_view(int $user_id, array $args = []): array {
if ($user_id <= 0 || !class_exists(OrderRepository::class)) {
return [
'items' => [],
'total' => 0,
'total_pages' => 1,
'page' => 1,
'stats' => $this->empty_stats(),
'product_types' => $this->product_type_filter_options(),
];
}
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = max(1, min(50, (int) ($args['per_page'] ?? 20)));
$status = sanitize_key((string) ($args['status'] ?? ''));
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
$repo_args = [
'limit' => $per_page,
'offset' => ($page - 1) * $per_page,
];
if ($status !== '') {
$repo_args['status'] = $status;
}
if ($product_type !== '' && $product_type !== 'all') {
$repo_args['product_type'] = $product_type;
}
$repo = OrderRepository::get_instance();
$orders = $repo->get_user_orders($user_id, $repo_args);
$total = $repo->count_user_orders($user_id, $repo_args);
$items = [];
foreach ($orders as $order) {
$items[] = $this->decorate_order_view($order, true);
}
return [
'items' => $items,
'total' => $total,
'total_pages' => max(1, (int) ceil(max(0, $total) / $per_page)),
'page' => $page,
'stats' => $this->get_user_order_stats($user_id),
'product_types' => $this->product_type_filter_options(),
];
}
public function get_order_detail_view(int $user_id, string $order_number): ?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;
}
return $this->decorate_order_view($order, true);
}
/**
* @return array{total:int,pending:int,paid:int}
*/
public function get_user_order_stats(int $user_id): array {
if ($user_id <= 0 || !class_exists(OrderRepository::class)) {
return $this->empty_stats();
}
$repo = OrderRepository::get_instance();
return [
'total' => $repo->count_user_orders($user_id, []),
'pending' => $repo->count_user_orders($user_id, ['status' => Order::STATUS_PENDING]),
'paid' => $repo->count_user_orders($user_id, ['status' => Order::STATUS_PAID]),
];
}
/**
* @return array<string, string>
*/
public function product_type_filter_options(): array {
$labels = \AKSRT\Core\DomainModel::order_product_type_labels();
return array_merge(['' => __('全部类型', 'aksrt')], $labels);
}
/**
* @param array<string, mixed> $order Normalized order row.
* @return array<string, mixed>
*/
public function decorate_order_view(array $order, bool $with_detail = false): array {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$order_number = (string) ($order['order_number'] ?? '');
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
$status = (string) ($order['status'] ?? Order::STATUS_PENDING);
$product_id = (int) ($order['product_id'] ?? 0);
$is_shop = $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_SHOP
|| $product_type === 'shop';
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
$is_points = $currency === 'points'
|| sanitize_key((string) ($order['payment_method'] ?? '')) === 'points'
|| (!empty($meta['pay_modo']) && (string) $meta['pay_modo'] === 'points');
$type_labels = \AKSRT\Core\DomainModel::order_product_type_labels();
$type_label = $type_labels[$product_type] ?? ($product_type !== '' ? $product_type : __('订单', 'aksrt'));
$title = $this->resolve_order_title($order, $meta, $product_type, $product_id, $type_label);
$image = $this->resolve_order_image($order, $meta, $product_type, $product_id);
$permalink = $this->resolve_order_permalink($product_type, $product_id, $meta);
$order_items = [];
if ($is_shop && class_exists('\AKSRT\Shop\Shop')) {
$order_items = \AKSRT\Shop\Shop::get_instance()->get_order_items($order_number);
}
$has_structured_items = !empty($order_items);
$product = [];
if ($has_structured_items && class_exists('\AKSRT\Shop\Shop')) {
$product = \AKSRT\Shop\Shop::get_instance()->build_product_card_view((int) ($order_items[0]['product_id'] ?? 0));
} elseif ($is_shop && $product_id > 0 && class_exists('\AKSRT\Shop\Shop')) {
$product = \AKSRT\Shop\Shop::get_instance()->build_product_card_view($product_id);
}
if (!empty($product['title'])) {
$title = (string) $product['title'];
}
if (!empty($product['image'])) {
$image = (string) $product['image'];
}
if (!empty($product['permalink'])) {
$permalink = (string) $product['permalink'];
}
$shipping_records = [];
$after_sale_items = [];
if ($is_shop) {
if (class_exists(Shipping::class)) {
$shipping_records = Shipping::get_instance()->get_by_order($order_number);
}
if (class_exists(AfterSale::class)) {
$as = AfterSale::get_instance()->get_list([
'limit' => 20,
'offset' => 0,
'order_number' => $order_number,
]);
$after_sale_items = $as['items'] ?? [];
}
}
$subtotal = (float) ($meta['items'][0]['line_total'] ?? $order['amount'] ?? 0);
$shipping_fee = (float) ($meta['shipping_fee'] ?? 0);
$discount = (float) ($meta['discount'] ?? 0);
$expires_at = (string) ($order['expires_at'] ?? '');
$payment_value = sanitize_key((string) ($meta['payment_method']['value'] ?? $order['payment_method'] ?? 'balance'));
$can_pay = in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)
&& $payment_value !== 'manual_review';
$can_cancel = in_array($status, [Order::STATUS_PENDING, Order::STATUS_CREATED, Order::STATUS_EXPIRED], true);
$can_after_sale = $is_shop && in_array($status, [Order::STATUS_PAID, Order::STATUS_DELIVERED, Order::STATUS_COMPLETED], true);
$can_receipt = $is_shop && $status === Order::STATUS_DELIVERED;
$can_review = $is_shop && $status === Order::STATUS_PENDING_REVIEW;
$can_rebuy = $is_shop && in_array($status, [Order::STATUS_COMPLETED, Order::STATUS_PENDING_REVIEW], true);
$can_view = $permalink !== '' && in_array($status, [
Order::STATUS_PAID,
Order::STATUS_DELIVERED,
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
], true);
$view = [
'id' => (int) ($order['id'] ?? 0),
'order_number' => $order_number,
'amount' => (float) ($order['amount'] ?? 0),
'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_label' => Order::get_valid_statuses()[$status] ?? $status,
'product_type' => $product_type,
'product_type_label' => $type_label,
'product_id' => $product_id,
'product_name' => $title,
'product' => $product ?: [
'title' => $title,
'image' => $image,
'permalink' => $permalink,
],
'image' => $image,
'permalink' => $permalink,
'created_at' => (string) ($order['created_at'] ?? ''),
'paid_at' => (string) ($order['paid_at'] ?? ''),
'expires_at' => $expires_at,
'subtotal' => $subtotal,
'shipping_fee' => $shipping_fee,
'discount' => $discount,
'address' => $meta['address'] ?? [],
'payment_method' => $meta['payment_method'] ?? [
'value' => (string) ($order['payment_method'] ?? ''),
'label' => $this->payment_method_label((string) ($order['payment_method'] ?? '')),
],
'coupon' => $meta['coupon'] ?? [],
'items' => $has_structured_items ? $order_items : ($meta['items'] ?? []),
'shipping_method' => $meta['shipping_method'] ?? [],
'customer_note' => $meta['customer_note'] ?? ($order['note_text'] ?? ''),
'shipping_records' => $shipping_records,
'pay_url' => '#',
'actions' => [
'can_pay' => $can_pay,
'can_cancel' => $can_cancel,
'can_after_sale' => $can_after_sale,
'can_receipt' => $can_receipt,
'can_review' => $can_review,
'can_rebuy' => $can_rebuy,
'can_view' => $can_view,
],
'time_left' => $this->get_order_countdown($expires_at, $status),
'secondary_url' => $this->resolve_secondary_url($product_type),
'secondary_label' => $this->resolve_secondary_label($product_type),
];
if ($with_detail) {
$view['after_sale_records'] = $after_sale_items;
$view['timeline'] = $this->build_order_timeline($order, $shipping_records, $after_sale_items);
}
return $view;
}
/**
* Cancel pending order (any product_type).
*
* @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) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
$status = (string) ($order['status'] ?? '');
if (!in_array($status, [Order::STATUS_PENDING, Order::STATUS_CREATED, Order::STATUS_EXPIRED], true)) {
return ['success' => false, 'message' => __('当前订单不可取消。', 'aksrt')];
}
$repo->update_status($order_number, Order::STATUS_CANCELLED, [
'force' => true,
'note' => __('用户在前台取消订单。', 'aksrt'),
]);
return [
'success' => true,
'data' => [
'order' => $this->get_order_detail_view($user_id, $order_number),
],
];
}
/**
* Resume payment via configured gateway (any product_type).
*
* @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')];
}
// Prefer shop checkout when available (handles points + shop meta).
if (class_exists('\AKSRT\Shop\Shop')) {
return \AKSRT\Shop\Shop::get_instance()->pay_order($user_id, $order_number, $payment_method);
}
$repo = OrderRepository::get_instance();
$order = $repo->get_user_order($user_id, $order_number);
if (!$order) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
$status = (string) ($order['status'] ?? '');
if (!in_array($status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
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')];
}
$gateway_manager = class_exists(Gateway::class) ? Gateway::get_instance() : null;
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
if (!$gateway) {
return ['success' => false, 'message' => __('当前支付方式不可用。', 'aksrt')];
}
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
}
$lock = class_exists(PaymentAttemptLock::class)
? PaymentAttemptLock::acquire($order_number)
: false;
if ($lock === false) {
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
}
try {
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
$payment_result = $gateway->create_payment($order);
if (empty($payment_result['success'])) {
return ['success' => false, 'message' => $payment_result['message'] ?? __('支付失败。', 'aksrt')];
}
$code_url = $payment_result['payment_data']['code_url'] ?? '';
if (!empty($payment_result['payment_url']) || $code_url !== '') {
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) $code_url,
'order_number' => $order_number,
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
],
];
}
$paid = $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户在前台完成支付。', 'aksrt'),
'require_transition' => true,
]);
if (!$paid) {
$compensation = $gateway->refund(
$order_number,
(float) ($order['amount'] ?? 0),
'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24)
);
if (empty($compensation['success'])) {
error_log(sprintf(
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
$order_number,
$payment_method,
(string) ($compensation['message'] ?? 'unknown error')
));
return ['success' => false, 'message' => __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')];
}
return ['success' => false, 'message' => __('订单状态已变化,扣款已退回。', 'aksrt')];
}
return [
'success' => true,
'data' => [
'order' => $this->get_order_detail_view($user_id, $order_number),
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
],
];
} finally {
PaymentAttemptLock::release($order_number, $lock);
}
}
private function empty_stats(): array {
return ['total' => 0, 'pending' => 0, 'paid' => 0];
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $meta
*/
private function resolve_order_title(array $order, array $meta, string $product_type, int $product_id, string $type_label): string {
foreach (['product_title', 'title', 'package_title'] as $key) {
if (!empty($meta[$key]) && is_string($meta[$key])) {
return (string) $meta[$key];
}
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
$pkg = (string) ($meta['package_title'] ?? '');
if ($pkg !== '') {
return $pkg;
}
return __('VIP 会员', 'aksrt');
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
return (string) ($meta['title'] ?? __('余额充值', 'aksrt'));
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
return (string) ($meta['title'] ?? __('购买积分', 'aksrt'));
}
if ($product_id > 0) {
$post_title = get_the_title($product_id);
if (is_string($post_title) && $post_title !== '') {
return $post_title;
}
}
return $type_label;
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $meta
*/
private function resolve_order_image(array $order, array $meta, string $product_type, int $product_id): string {
if (!empty($meta['thumb']) && is_string($meta['thumb'])) {
return esc_url_raw((string) $meta['thumb']);
}
if ($product_id > 0 && has_post_thumbnail($product_id)) {
$url = get_the_post_thumbnail_url($product_id, 'thumbnail');
if (is_string($url) && $url !== '') {
return $url;
}
}
return '';
}
/**
* @param array<string, mixed> $meta
*/
private function resolve_order_permalink(string $product_type, int $product_id, array $meta): string {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
return function_exists('aksrt_get_user_center_tab_url')
? aksrt_get_user_center_tab_url('vip')
: '';
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|| $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
return function_exists('aksrt_get_user_center_tab_url')
? aksrt_get_user_center_tab_url('credits')
: '';
}
$post_id = $product_id > 0 ? $product_id : (int) ($meta['post_id'] ?? 0);
if ($post_id > 0) {
$link = get_permalink($post_id);
return is_string($link) ? $link : '';
}
return '';
}
private function resolve_secondary_url(string $product_type): string {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('vip') : '';
}
if (in_array($product_type, [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY,
], true)) {
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('credits') : '';
}
return '';
}
private function resolve_secondary_label(string $product_type): string {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
return __('我的会员', 'aksrt');
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
return __('我的余额', 'aksrt');
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
return class_exists('\AKSRT\Settings\Settings')
? sprintf(__('我的%s', 'aksrt'), \AKSRT\Settings\Settings::get_currency_name())
: __('我的积分', 'aksrt');
}
return '';
}
private function payment_method_label(string $method): string {
$method = sanitize_key($method);
$map = [
'balance' => __('站内余额', 'aksrt'),
'points' => __('积分支付', 'aksrt'),
'alipay' => __('支付宝', 'aksrt'),
'wechatpay' => __('微信支付', 'aksrt'),
'wechat' => __('微信支付', 'aksrt'),
'paypal' => 'PayPal',
'stripe' => 'Stripe',
'epay_alipay' => __('易支付·支付宝', 'aksrt'),
'epay_wxpay' => __('易支付·微信', 'aksrt'),
];
return $map[$method] ?? ($method !== '' ? $method : __('—', 'aksrt'));
}
/**
* @param array<string, mixed> $order
* @param array<int, array> $shipping_records
* @param array<int, array> $after_sale_records
* @return array<int, array{time:string,label:string}>
*/
private function build_order_timeline(array $order, array $shipping_records, array $after_sale_records): array {
$timeline = [];
$date_fields = [
'created_at' => __('订单创建', 'aksrt'),
'paid_at' => __('完成支付', 'aksrt'),
'delivered_at' => __('订单发货', 'aksrt'),
'completed_at' => __('交易完成', 'aksrt'),
'cancelled_at' => __('订单取消', 'aksrt'),
'refunded_at' => __('完成退款', 'aksrt'),
];
foreach ($date_fields as $key => $label) {
if (!empty($order[$key])) {
$timeline[] = [
'time' => (string) $order[$key],
'label' => $label,
];
}
}
foreach ($shipping_records as $record) {
$timeline[] = [
'time' => (string) ($record['created_at'] ?? ''),
'label' => sprintf(__('物流更新:%s', 'aksrt'), $record['tracking_no'] ?? ''),
];
}
foreach ($after_sale_records as $record) {
$timeline[] = [
'time' => (string) ($record['created_at'] ?? ''),
'label' => sprintf(__('售后申请:%s', 'aksrt'), $record['reason'] ?? ''),
];
}
usort($timeline, static function ($a, $b) {
return strcmp($a['time'] ?? '', $b['time'] ?? '');
});
return $timeline;
}
private function get_order_countdown(string $expires_at, string $status): int {
if ($expires_at === '' || !in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)) {
return 0;
}
$left = strtotime($expires_at) - time();
return max(0, (int) $left);
}
}