refactor(classes): split oversize modules + bootstrap hygiene
- Trait-split content-pay, vip-service, balance-service, order-repository - Procedural split of user module into user-parts/* - Split scripts enqueue_public into part-1b/1c; settings bulk trait - Bootstrap: explicit notifications, widgets glob-only, home-modules init - ADR: freeze domain placement for pay/revenue/notify boundaries
This commit is contained in:
1 parent
ce1c13b947
commit
9c309b4c7f
28 files changed
+7350
-6688
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,603 @@
|
||||
<?php
|
||||
/**
|
||||
* Extracted trait: AKSRT_Order_Repository_Admin
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait AKSRT_Order_Repository_Admin {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Build a stable admin-facing order context payload for workbench pages.
|
||||
*/
|
||||
public function get_admin_order_context(string $order_number): array {
|
||||
$order_number = sanitize_text_field($order_number);
|
||||
$contexts = $this->get_admin_order_context_map([$order_number]);
|
||||
|
||||
return $contexts[$order_number] ?? $this->get_empty_admin_order_context($order_number);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Build admin-facing order context payloads in bulk to avoid per-row order queries.
|
||||
*
|
||||
* @param array<int, string> $order_numbers
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public function get_admin_order_context_map(array $order_numbers): array {
|
||||
global $wpdb;
|
||||
|
||||
$order_numbers = array_values(array_unique(array_filter(array_map(
|
||||
static function ($order_number): string {
|
||||
return sanitize_text_field((string) $order_number);
|
||||
},
|
||||
$order_numbers
|
||||
))));
|
||||
|
||||
if (empty($order_numbers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$contexts = [];
|
||||
foreach ($order_numbers as $order_number) {
|
||||
$contexts[$order_number] = $this->get_empty_admin_order_context($order_number);
|
||||
}
|
||||
|
||||
$placeholders = implode(', ', array_fill(0, count($order_numbers), '%s'));
|
||||
$sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders})";
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A);
|
||||
|
||||
if (empty($rows)) {
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
$orders = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$order = $this->normalize_row($row);
|
||||
$orders[(string) ($order['order_number'] ?? '')] = $order;
|
||||
}
|
||||
|
||||
$resolved_order_numbers = array_keys($orders);
|
||||
$shipping_map = class_exists('AKSRT_Shipping')
|
||||
? AKSRT_Shipping::get_instance()->get_summary_map($resolved_order_numbers)
|
||||
: [];
|
||||
$after_sale_map = class_exists('AKSRT_After_Sale')
|
||||
? AKSRT_After_Sale::get_instance()->get_summary_map($resolved_order_numbers)
|
||||
: [];
|
||||
|
||||
foreach ($orders as $order_number => $order) {
|
||||
$contexts[$order_number] = $this->build_admin_order_context($order, [
|
||||
'shipping_summary' => $shipping_map[$order_number] ?? [],
|
||||
'after_sale_summary' => $after_sale_map[$order_number] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 更新订单状态(带状态机验证)
|
||||
*/
|
||||
/**
|
||||
* Merge operator admin note into order note meta without destroying JSON payload.
|
||||
*/
|
||||
public function update_admin_note(string $order_number, string $admin_note): bool {
|
||||
global $wpdb;
|
||||
|
||||
$order = $this->get($order_number);
|
||||
if (!$order) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
|
||||
$decoded = $raw !== '' ? json_decode($raw, true) : null;
|
||||
$admin_note = sanitize_textarea_field($admin_note);
|
||||
|
||||
if (is_array($decoded)) {
|
||||
$decoded['admin_note'] = $admin_note;
|
||||
$new_note = wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} elseif ($raw !== '') {
|
||||
$new_note = wp_json_encode([
|
||||
'customer_note' => $raw,
|
||||
'admin_note' => $admin_note,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} else {
|
||||
$new_note = wp_json_encode([
|
||||
'admin_note' => $admin_note,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
$result = $wpdb->update(
|
||||
$this->table(),
|
||||
['note' => $new_note],
|
||||
['order_number' => $order_number],
|
||||
['%s'],
|
||||
['%s']
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 管理后台订单列表 — 支持 search(order_number / product_type 模糊匹配)
|
||||
*/
|
||||
/**
|
||||
* Paid orders that still need shipping (no shipping row yet).
|
||||
*
|
||||
* @return array{items: array<int,array<string,mixed>>, total: int, page: int, max_pages: int}
|
||||
*/
|
||||
public function get_pending_shipment_orders(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
$search = sanitize_text_field((string) ($args['search'] ?? $args['s'] ?? ''));
|
||||
|
||||
$order_table = $this->table();
|
||||
// Must match AKSRT_Shipping::table() key.
|
||||
$shipping_table = class_exists('AKSRT_DB_Migrator')
|
||||
? AKSRT_DB_Migrator::table('order_shipping')
|
||||
: $wpdb->prefix . 'aksrt_order_shipping';
|
||||
|
||||
$where = ["o.status = %s"];
|
||||
$params = ['paid'];
|
||||
|
||||
if ($search !== '') {
|
||||
$where[] = '(o.order_number LIKE %s OR o.product_type LIKE %s)';
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
// NOT EXISTS avoids ONLY_FULL_GROUP_BY issues from LEFT JOIN + GROUP BY.
|
||||
$where[] = "NOT EXISTS (
|
||||
SELECT 1 FROM {$shipping_table} s
|
||||
WHERE s.order_number = o.order_number
|
||||
)";
|
||||
$where_sql = 'WHERE ' . implode(' AND ', $where);
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$order_table} o {$where_sql}";
|
||||
$total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $params));
|
||||
|
||||
$sql = "SELECT o.*
|
||||
FROM {$order_table} o
|
||||
{$where_sql}
|
||||
ORDER BY COALESCE(o.paid_at, o.created_at) DESC, o.id DESC
|
||||
LIMIT %d OFFSET %d";
|
||||
$query_params = $params;
|
||||
$query_params[] = $limit;
|
||||
$query_params[] = $offset;
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $query_params), ARRAY_A);
|
||||
$items = array_map([$this, 'normalize_row'], (array) $rows);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => (int) floor($offset / $limit) + 1,
|
||||
'max_pages' => (int) ceil(max(1, $total) / $limit),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function get_orders_admin(array $args = [], string $search = ''): array {
|
||||
global $wpdb;
|
||||
|
||||
[$where, $params] = $this->build_admin_order_filters($args, $search);
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
return array_map([$this, 'normalize_row'], (array) $rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 管理后台订单计数 — 与 get_orders_admin 共用过滤条件
|
||||
*/
|
||||
public function get_orders_count(array $args = [], string $search = ''): int {
|
||||
global $wpdb;
|
||||
|
||||
[$where, $params] = $this->build_admin_order_filters($args, $search);
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
|
||||
if (!empty($params)) {
|
||||
return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
|
||||
}
|
||||
return (int) $wpdb->get_var($sql);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Aggregate paid content (resource) sales by product_id (post ID).
|
||||
*
|
||||
* @param array{
|
||||
* product_type?: string,
|
||||
* product_ids?: int[],
|
||||
* status_in?: string[],
|
||||
* limit?: int,
|
||||
* offset?: int,
|
||||
* orderby?: string,
|
||||
* order?: string
|
||||
* } $args
|
||||
* @return array{items: list<array{product_id:int,product_type:string,sales_count:int,avg_amount:float,revenue:float}>, total: int}
|
||||
*/
|
||||
public function get_content_sales_summary(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE));
|
||||
if ($product_type === '') {
|
||||
$product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE;
|
||||
}
|
||||
|
||||
$status_in = $args['status_in'] ?? AKSRT_Domain_Model::paid_order_statuses();
|
||||
if (!is_array($status_in) || empty($status_in)) {
|
||||
$status_in = AKSRT_Domain_Model::paid_order_statuses();
|
||||
}
|
||||
$status_in = array_values(array_filter(array_map('sanitize_key', $status_in)));
|
||||
if (empty($status_in)) {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$where = ['product_type = %s', 'product_id > 0'];
|
||||
$params = [$product_type];
|
||||
|
||||
$status_placeholders = implode(', ', array_fill(0, count($status_in), '%s'));
|
||||
$where[] = "status IN ({$status_placeholders})";
|
||||
foreach ($status_in as $st) {
|
||||
$params[] = $st;
|
||||
}
|
||||
|
||||
$product_ids = $args['product_ids'] ?? [];
|
||||
if (is_array($product_ids) && !empty($product_ids)) {
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) {
|
||||
return $id > 0;
|
||||
})));
|
||||
if (!empty($ids)) {
|
||||
$id_ph = implode(', ', array_fill(0, count($ids), '%d'));
|
||||
$where[] = "product_id IN ({$id_ph})";
|
||||
foreach ($ids as $id) {
|
||||
$params[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$where_sql = 'WHERE ' . implode(' AND ', $where);
|
||||
$table = $this->table();
|
||||
|
||||
$total_sql = "SELECT COUNT(DISTINCT product_id) FROM {$table} {$where_sql}";
|
||||
$total = (int) $wpdb->get_var($wpdb->prepare($total_sql, $params));
|
||||
|
||||
$orderby_map = [
|
||||
'sales_count' => 'sales_count',
|
||||
'revenue' => 'revenue',
|
||||
'avg_amount' => 'avg_amount',
|
||||
'product_id' => 'product_id',
|
||||
];
|
||||
$orderby_key = sanitize_key((string) ($args['orderby'] ?? 'sales_count'));
|
||||
$orderby_col = $orderby_map[$orderby_key] ?? 'sales_count';
|
||||
$order_dir = strtoupper((string) ($args['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$list_sql = "SELECT product_id,
|
||||
product_type,
|
||||
COUNT(*) AS sales_count,
|
||||
AVG(amount) AS avg_amount,
|
||||
SUM(amount) AS revenue
|
||||
FROM {$table}
|
||||
{$where_sql}
|
||||
GROUP BY product_id, product_type
|
||||
ORDER BY {$orderby_col} {$order_dir}
|
||||
LIMIT %d OFFSET %d";
|
||||
$list_params = $params;
|
||||
$list_params[] = $limit;
|
||||
$list_params[] = $offset;
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
|
||||
$items = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$items[] = [
|
||||
'product_id' => (int) ($row['product_id'] ?? 0),
|
||||
'product_type' => (string) ($row['product_type'] ?? $product_type),
|
||||
'sales_count' => (int) ($row['sales_count'] ?? 0),
|
||||
'avg_amount' => round((float) ($row['avg_amount'] ?? 0), 2),
|
||||
'revenue' => round((float) ($row['revenue'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Paid order totals for a set of product IDs (resource content).
|
||||
*
|
||||
* @param int[] $product_ids
|
||||
* @return array<int, array{sales_count:int,avg_amount:float,revenue:float}>
|
||||
*/
|
||||
public function get_content_sales_map(array $product_ids, string $product_type = ''): array {
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) {
|
||||
return $id > 0;
|
||||
})));
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($product_type === '') {
|
||||
$product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE;
|
||||
}
|
||||
|
||||
$summary = $this->get_content_sales_summary([
|
||||
'product_type' => $product_type,
|
||||
'product_ids' => $ids,
|
||||
'limit' => max(count($ids), 1),
|
||||
'offset' => 0,
|
||||
'orderby' => 'product_id',
|
||||
'order' => 'ASC',
|
||||
]);
|
||||
|
||||
$map = [];
|
||||
foreach ($summary['items'] as $item) {
|
||||
$pid = (int) ($item['product_id'] ?? 0);
|
||||
if ($pid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$map[$pid] = [
|
||||
'sales_count' => (int) ($item['sales_count'] ?? 0),
|
||||
'avg_amount' => (float) ($item['avg_amount'] ?? 0),
|
||||
'revenue' => (float) ($item['revenue'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Shared WHERE builder for admin order list/count.
|
||||
*
|
||||
* @return array{0: string[], 1: array<int, mixed>}
|
||||
*/
|
||||
private function build_admin_order_filters(array $args, string $search = ''): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
|
||||
$product_id = (int) ($args['product_id'] ?? 0);
|
||||
if ($product_id > 0) {
|
||||
$where[] = 'product_id = %d';
|
||||
$params[] = $product_id;
|
||||
}
|
||||
|
||||
$user_id = (int) ($args['user_id'] ?? 0);
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
$payment_method = sanitize_key((string) ($args['payment_method'] ?? ''));
|
||||
if ($payment_method !== '') {
|
||||
$where[] = 'payment_method = %s';
|
||||
$params[] = $payment_method;
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
// Numeric search may mean user_id or product_id; also match order number / type.
|
||||
if (ctype_digit($search)) {
|
||||
$where[] = '(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d)';
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = (int) $search;
|
||||
$params[] = (int) $search;
|
||||
} else {
|
||||
$where[] = '(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s)';
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
}
|
||||
|
||||
return [$where, $params];
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function get_empty_admin_order_context(string $order_number): array {
|
||||
return [
|
||||
'exists' => false,
|
||||
'summary' => [
|
||||
'order_number' => $order_number,
|
||||
'status' => '',
|
||||
'status_label' => '',
|
||||
'amount' => 0.0,
|
||||
'currency_symbol' => '¥',
|
||||
'created_at' => '',
|
||||
'paid_at' => '',
|
||||
],
|
||||
'buyer' => [],
|
||||
'address' => [],
|
||||
'payment_method' => [],
|
||||
'shipping_method' => [],
|
||||
'shipping' => [
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'after_sale' => [
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'coupon' => [],
|
||||
'pricing' => [
|
||||
'subtotal' => 0.0,
|
||||
'shipping_fee' => 0.0,
|
||||
'discount' => 0.0,
|
||||
'amount' => 0.0,
|
||||
],
|
||||
'items' => [],
|
||||
'customer_note' => '',
|
||||
'raw_order' => null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<string, mixed> $related
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_admin_order_context(array $order, array $related = []): array {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$user = get_userdata((int) ($order['user_id'] ?? 0));
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
$address = is_array($meta['address'] ?? null) ? $meta['address'] : [];
|
||||
$payment_method = is_array($meta['payment_method'] ?? null) ? $meta['payment_method'] : [];
|
||||
$shipping_method = is_array($meta['shipping_method'] ?? null) ? $meta['shipping_method'] : [];
|
||||
$coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : [];
|
||||
$items = is_array($meta['items'] ?? null) ? $meta['items'] : [];
|
||||
$shipping_fee = (float) ($meta['shipping_fee'] ?? 0);
|
||||
$discount = (float) ($meta['discount'] ?? 0);
|
||||
$amount = (float) ($order['amount'] ?? 0);
|
||||
$subtotal = $this->calculate_order_subtotal($items, max(0, $amount + $discount - $shipping_fee));
|
||||
$shipping_summary = is_array($related['shipping_summary'] ?? null) ? $related['shipping_summary'] : [];
|
||||
$after_sale_summary = is_array($related['after_sale_summary'] ?? null) ? $related['after_sale_summary'] : [];
|
||||
|
||||
$address['full_text'] = $this->build_address_text($address);
|
||||
|
||||
return [
|
||||
'exists' => true,
|
||||
'summary' => [
|
||||
'order_number' => (string) ($order['order_number'] ?? ''),
|
||||
'status' => $status,
|
||||
'status_label' => class_exists('AKSRT_Order') ? (AKSRT_Order::get_valid_statuses()[$status] ?? $status) : $status,
|
||||
'amount' => $amount,
|
||||
'currency_symbol' => '¥',
|
||||
'created_at' => (string) ($order['created_at'] ?? ''),
|
||||
'paid_at' => (string) ($order['paid_at'] ?? ''),
|
||||
'product_type' => (string) ($order['product_type'] ?? ''),
|
||||
'product_id' => (int) ($order['product_id'] ?? 0),
|
||||
],
|
||||
'buyer' => [
|
||||
'id' => (int) ($order['user_id'] ?? 0),
|
||||
'display_name' => $user ? (string) $user->display_name : '',
|
||||
'user_login' => $user ? (string) $user->user_login : '',
|
||||
'user_email' => $user ? (string) $user->user_email : '',
|
||||
],
|
||||
'address' => $address,
|
||||
'payment_method' => [
|
||||
'value' => (string) ($payment_method['value'] ?? $order['payment_method'] ?? ''),
|
||||
'label' => (string) ($payment_method['label'] ?? $order['payment_method'] ?? ''),
|
||||
],
|
||||
'shipping_method' => [
|
||||
'value' => (string) ($shipping_method['value'] ?? ''),
|
||||
'label' => (string) ($shipping_method['label'] ?? ''),
|
||||
'fee' => $shipping_fee,
|
||||
],
|
||||
'shipping' => [
|
||||
'count' => (int) ($shipping_summary['count'] ?? 0),
|
||||
'latest' => is_array($shipping_summary['latest'] ?? null) ? $shipping_summary['latest'] : [],
|
||||
],
|
||||
'after_sale' => [
|
||||
'count' => (int) ($after_sale_summary['count'] ?? 0),
|
||||
'latest' => is_array($after_sale_summary['latest'] ?? null) ? $after_sale_summary['latest'] : [],
|
||||
],
|
||||
'coupon' => $coupon,
|
||||
'pricing' => [
|
||||
'subtotal' => $subtotal,
|
||||
'shipping_fee' => $shipping_fee,
|
||||
'discount' => $discount,
|
||||
'amount' => $amount,
|
||||
],
|
||||
'items' => $items,
|
||||
'customer_note' => (string) ($meta['customer_note'] ?? $order['note_text'] ?? ''),
|
||||
'raw_order' => $order,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
*/
|
||||
private function calculate_order_subtotal(array $items, float $fallback): float {
|
||||
if (empty($items)) {
|
||||
return round($fallback, 2);
|
||||
}
|
||||
|
||||
$subtotal = 0.0;
|
||||
foreach ($items as $item) {
|
||||
if (isset($item['line_total'])) {
|
||||
$subtotal += (float) $item['line_total'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$subtotal += (float) ($item['price'] ?? 0) * max(1, (int) ($item['quantity'] ?? 1));
|
||||
}
|
||||
|
||||
if ($subtotal <= 0) {
|
||||
return round($fallback, 2);
|
||||
}
|
||||
|
||||
return round($subtotal, 2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function build_address_text(array $address): string {
|
||||
$segments = [];
|
||||
|
||||
foreach (['name', 'phone', 'province', 'city', 'district', 'address'] as $key) {
|
||||
$value = trim((string) ($address[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
$segments[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($address['full_text']) && is_string($address['full_text'])) {
|
||||
return (string) $address['full_text'];
|
||||
}
|
||||
|
||||
return implode(' / ', $segments);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
<?php
|
||||
/**
|
||||
* Extracted trait: AKSRT_Order_Repository_Core
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait AKSRT_Order_Repository_Core {
|
||||
|
||||
|
||||
|
||||
private function table(): string {
|
||||
return AKSRT_DB_Migrator::table('order');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* CRUD
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 创建订单(写入表)
|
||||
*/
|
||||
public function create(array $data): string {
|
||||
global $wpdb;
|
||||
|
||||
$order_number = $data['order_number'] ?? $this->generate_order_number();
|
||||
|
||||
$wpdb->insert($this->table(), [
|
||||
'order_number' => $order_number,
|
||||
'user_id' => (int) ($data['user_id'] ?? 0),
|
||||
'product_type' => sanitize_key((string) ($data['product_type'] ?? '')),
|
||||
'product_id' => (int) ($data['product_id'] ?? 0),
|
||||
'amount' => (float) ($data['amount'] ?? 0),
|
||||
'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')),
|
||||
'status' => AKSRT_Domain_Model::normalize_order_status((string) ($data['status'] ?? AKSRT_Domain_Model::ORDER_STATUS_PENDING)),
|
||||
'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')),
|
||||
'payment_transaction_id' => sanitize_text_field((string) ($data['payment_transaction_id'] ?? '')),
|
||||
'created_at' => $data['created_at'] ?? current_time('mysql'),
|
||||
'paid_at' => $data['paid_at'] ?? null,
|
||||
'expires_at' => $data['expires_at'] ?? null,
|
||||
'note' => sanitize_textarea_field((string) ($data['note'] ?? '')),
|
||||
], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
|
||||
|
||||
if ($wpdb->insert_id) {
|
||||
return $order_number;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取单个订单
|
||||
*/
|
||||
public function get(string $order_number): ?array {
|
||||
global $wpdb;
|
||||
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE order_number = %s",
|
||||
$order_number
|
||||
), ARRAY_A);
|
||||
|
||||
if ($row) {
|
||||
return $this->normalize_row($row);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取用户订单列表
|
||||
*/
|
||||
public function get_user_orders(int $user_id, array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
return array_map([$this, 'normalize_row'], (array) $rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取单个用户订单。
|
||||
*/
|
||||
public function get_user_order(int $user_id, string $order_number): ?array {
|
||||
$order = $this->get($order_number);
|
||||
if (!$order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($user_id > 0 && (int) ($order['user_id'] ?? 0) !== $user_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取用户订单总数。
|
||||
*/
|
||||
public function count_user_orders(int $user_id, array $args = []): int {
|
||||
global $wpdb;
|
||||
|
||||
if ($user_id <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$where = ['user_id = %d'];
|
||||
$params = [$user_id];
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
|
||||
$where_sql = 'WHERE ' . implode(' AND ', $where);
|
||||
$sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
|
||||
return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
|
||||
global $wpdb;
|
||||
|
||||
$valid = AKSRT_Domain_Model::order_status_labels();
|
||||
if (!isset($valid[$new_status])) return false;
|
||||
|
||||
$order = $this->get($order_number);
|
||||
if (!$order) return false;
|
||||
|
||||
$current_status = $order['status'] ?? 'created';
|
||||
|
||||
if (empty($extra['force']) && !AKSRT_Domain_Model::can_transition_order_status($current_status, $new_status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$update_data = ['status' => $new_status];
|
||||
$format = ['%s'];
|
||||
|
||||
$time_fields = [
|
||||
'paid' => 'paid_at',
|
||||
'delivered' => 'delivered_at',
|
||||
'completed' => 'completed_at',
|
||||
'cancelled' => 'cancelled_at',
|
||||
'refunded' => 'refunded_at',
|
||||
];
|
||||
|
||||
foreach ($time_fields as $status_key => $field) {
|
||||
if ($new_status === $status_key) {
|
||||
$update_data[$field] = current_time('mysql');
|
||||
$format[] = '%s';
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($extra['payment_method'])) {
|
||||
$update_data['payment_method'] = sanitize_key($extra['payment_method']);
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
if (!empty($extra['payment_transaction_id'])) {
|
||||
$update_data['payment_transaction_id'] = sanitize_text_field($extra['payment_transaction_id']);
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
if (!empty($extra['note'])) {
|
||||
// Preserve JSON pay meta (coupon/topic_pay/content_pay/…); stash human text as status_note.
|
||||
$status_note = sanitize_textarea_field((string) $extra['note']);
|
||||
$raw_note = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
|
||||
$decoded_note = $raw_note !== '' ? json_decode($raw_note, true) : null;
|
||||
if (is_array($decoded_note)) {
|
||||
$decoded_note['status_note'] = $status_note;
|
||||
$merged = wp_json_encode($decoded_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$update_data['note'] = is_string($merged) ? $merged : $status_note;
|
||||
} else {
|
||||
$update_data['note'] = $status_note;
|
||||
}
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
$result = $wpdb->update($this->table(), $update_data, ['order_number' => $order_number], $format, ['%s']);
|
||||
|
||||
if ($result !== false) {
|
||||
// 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。
|
||||
// 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。
|
||||
if ($new_status === AKSRT_Domain_Model::ORDER_STATUS_PAID && $current_status !== AKSRT_Domain_Model::ORDER_STATUS_PAID) {
|
||||
$paid_order = array_merge($order, [
|
||||
'status' => $new_status,
|
||||
'payment_method' => $update_data['payment_method'] ?? ($order['payment_method'] ?? ''),
|
||||
'payment_transaction_id' => $update_data['payment_transaction_id'] ?? ($order['payment_transaction_id'] ?? ''),
|
||||
]);
|
||||
if (isset($update_data['note'])) {
|
||||
// Keep pre-update JSON payload for listeners (coupon burn etc.);
|
||||
// only attach status_note if callers mutated note.
|
||||
$pre_meta = [];
|
||||
$pre_raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
|
||||
if ($pre_raw !== '') {
|
||||
$pre_decoded = json_decode($pre_raw, true);
|
||||
if (is_array($pre_decoded)) {
|
||||
$pre_meta = $pre_decoded;
|
||||
}
|
||||
}
|
||||
if ($pre_meta !== []) {
|
||||
$paid_order['meta_payload'] = $pre_meta;
|
||||
// Prefer merged DB note (JSON + status_note) for downstream reads.
|
||||
$paid_order['note'] = $update_data['note'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单到账。
|
||||
*
|
||||
* @param string $order_number 订单号
|
||||
* @param array $paid_order 订单数据(status=paid)
|
||||
*/
|
||||
do_action('aksrt_order_paid', $order_number, $paid_order);
|
||||
}
|
||||
}
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Delete closed (cancelled/expired) orders older than $days days.
|
||||
* Mirrors zibll ZibPay::clear_order(14).
|
||||
*
|
||||
* @return int Number of rows deleted.
|
||||
*/
|
||||
public function clear_closed_orders(int $days = 14): int {
|
||||
global $wpdb;
|
||||
|
||||
$days = max(1, min(3650, $days));
|
||||
$table = $this->table();
|
||||
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
|
||||
// Prefer cancelled_at when set; fall back to created_at for older rows.
|
||||
$sql = "DELETE FROM {$table}
|
||||
WHERE status IN ('cancelled', 'expired')
|
||||
AND COALESCE(NULLIF(cancelled_at, ''), created_at) < %s";
|
||||
$deleted = $wpdb->query($wpdb->prepare($sql, $cutoff));
|
||||
return $deleted === false ? 0 : (int) $deleted;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Hard-delete a single order by order_number (admin hygiene).
|
||||
*/
|
||||
public function delete_order(string $order_number): bool {
|
||||
global $wpdb;
|
||||
$order_number = sanitize_text_field($order_number);
|
||||
if ($order_number === '') {
|
||||
return false;
|
||||
}
|
||||
$ok = $wpdb->delete($this->table(), ['order_number' => $order_number], ['%s']);
|
||||
return $ok !== false && $ok > 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取订单统计
|
||||
*/
|
||||
public function get_stats(int $user_id = 0): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = '';
|
||||
$params = [];
|
||||
if ($user_id > 0) {
|
||||
$where = 'WHERE user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid,
|
||||
SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) as delivered,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled,
|
||||
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded,
|
||||
SUM(CASE WHEN status IN ('paid','delivered','completed') THEN amount ELSE 0 END) as revenue
|
||||
FROM {$this->table()} {$where}";
|
||||
|
||||
if (!empty($params)) {
|
||||
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
} else {
|
||||
$row = $wpdb->get_row($sql, ARRAY_A);
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'pending' => (int) ($row['pending'] ?? 0),
|
||||
'paid' => (int) ($row['paid'] ?? 0),
|
||||
'delivered' => (int) ($row['delivered'] ?? 0),
|
||||
'completed' => (int) ($row['completed'] ?? 0),
|
||||
'cancelled' => (int) ($row['cancelled'] ?? 0),
|
||||
'refunded' => (int) ($row['refunded'] ?? 0),
|
||||
'revenue' => (float) ($row['revenue'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function has_paid_access(int $user_id, string $product_type, int $product_id): bool {
|
||||
if ($user_id <= 0 || $product_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function get_valid_vip_order(int $user_id): ?array {
|
||||
return $this->find_latest_success_order($user_id, AKSRT_Domain_Model::ORDER_PRODUCT_VIP, 0, true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Migration bridge: option → table
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 一次性迁移:把 option 中的订单搬到表中。
|
||||
* 幂等 — 已存在的 order_number 不会重复插入。
|
||||
*/
|
||||
public function migrate_from_option(): int {
|
||||
global $wpdb;
|
||||
|
||||
$orders = get_option('aksrt_orders', []);
|
||||
if (!is_array($orders) || empty($orders)) return 0;
|
||||
|
||||
$migrated = 0;
|
||||
foreach ($orders as $order_number => $data) {
|
||||
$existing = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$this->table()} WHERE order_number = %s",
|
||||
$order_number
|
||||
));
|
||||
if ($existing) continue;
|
||||
|
||||
$wpdb->insert($this->table(), [
|
||||
'order_number' => $order_number,
|
||||
'user_id' => (int) ($data['user_id'] ?? 0),
|
||||
'product_type' => sanitize_key((string) ($data['product_type'] ?? '')),
|
||||
'product_id' => (int) ($data['product_id'] ?? 0),
|
||||
'amount' => (float) ($data['amount'] ?? 0),
|
||||
'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')),
|
||||
'status' => AKSRT_Domain_Model::normalize_order_status((string) ($data['status'] ?? AKSRT_Domain_Model::ORDER_STATUS_PENDING)),
|
||||
'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')),
|
||||
'created_at' => $data['created_at'] ?? current_time('mysql'),
|
||||
'paid_at' => $data['paid_at'] ?? null,
|
||||
'delivered_at' => $data['delivered_at'] ?? null,
|
||||
'completed_at' => $data['completed_at'] ?? null,
|
||||
'cancelled_at' => $data['cancelled_at'] ?? null,
|
||||
'refunded_at' => $data['refunded_at'] ?? null,
|
||||
'expires_at' => $data['expires_at'] ?? null,
|
||||
'note' => '',
|
||||
], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
|
||||
|
||||
if ($wpdb->insert_id) $migrated++;
|
||||
}
|
||||
|
||||
update_option(AKSRT_Domain_Model::ORDER_MIGRATED_OPTION, time());
|
||||
|
||||
return $migrated;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Internal helpers
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
private function normalize_row(array $row): array {
|
||||
$row['id'] = (int) $row['id'];
|
||||
$row['user_id'] = (int) $row['user_id'];
|
||||
$row['product_id'] = (int) $row['product_id'];
|
||||
$row['amount'] = (float) $row['amount'];
|
||||
$row['status'] = AKSRT_Domain_Model::normalize_order_status((string) ($row['status'] ?? ''));
|
||||
|
||||
$row['meta_payload'] = [];
|
||||
if (!empty($row['note']) && is_string($row['note'])) {
|
||||
$decoded = json_decode($row['note'], true);
|
||||
if (is_array($decoded)) {
|
||||
$row['meta_payload'] = $decoded;
|
||||
if (isset($decoded['customer_note']) && is_string($decoded['customer_note'])) {
|
||||
$row['note_text'] = $decoded['customer_note'];
|
||||
}
|
||||
} else {
|
||||
$row['note_text'] = $row['note'];
|
||||
}
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function find_latest_success_order(int $user_id, string $product_type, int $product_id = 0, bool $ignore_product_id = false): ?array {
|
||||
global $wpdb;
|
||||
|
||||
if ($user_id <= 0 || $product_type === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$status_placeholders = implode(', ', array_fill(0, count(AKSRT_Domain_Model::paid_order_statuses()), '%s'));
|
||||
$params = array_merge(
|
||||
[$user_id, sanitize_key($product_type)],
|
||||
$ignore_product_id ? [] : [$product_id],
|
||||
AKSRT_Domain_Model::paid_order_statuses()
|
||||
);
|
||||
|
||||
$product_sql = $ignore_product_id ? '' : ' AND product_id = %d';
|
||||
$sql = "SELECT * FROM {$this->table()}
|
||||
WHERE user_id = %d
|
||||
AND product_type = %s{$product_sql}
|
||||
AND status IN ({$status_placeholders})
|
||||
ORDER BY COALESCE(paid_at, created_at) DESC, id DESC
|
||||
LIMIT 1";
|
||||
|
||||
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
return is_array($row) ? $this->normalize_row($row) : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function generate_order_number(): string {
|
||||
return 'AKS' . date('YmdHis') . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user