refactor(classes): organize inc/classes into domain folders

Move ~94 root class shells and root *-traits into domain directories
(core, auth, user, credits, content, payment/gateways, forum, shop,
sms, ai, messages, extensions, widgets, settings, admin). Update
bootstrap require paths, co-locate traits with owners, fix forum/user
relative requires, drop duplicate shop controller requires, and wire
home-modules load. Path-only; class names and init order unchanged.
This commit is contained in:
kate522 committed 2026-07-17 11:11:57 +08:00
1 parent 2a8fe68048
commit ce1c13b947
115 files changed
+171 -224

No files matched your search

@@ -0,0 +1,811 @@
<?php
/**
* AKSRT After Sale — 售后服务
*
* 管理退款、换货等售后工单。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_After_Sale {
private static $instance = null;
const TYPE_REFUND = 'refund';
const TYPE_REFUND_RETURN = 'refund_return';
const TYPE_EXCHANGE = 'exchange';
const TYPE_REPAIR = 'repair';
const TYPE_WARRANTY = 'warranty';
const TYPE_INSURED_PRICE = 'insured_price';
const TYPE_REPLACEMENT = 'replacement';
const STATUS_PENDING = 'pending';
const STATUS_PROCESSING = 'processing';
const STATUS_APPROVED = 'approved';
const STATUS_REJECTED = 'rejected';
const STATUS_COMPLETED = 'completed';
const STATUS_CANCELLED = 'cancelled';
const PROGRESS_WAITING_USER_SHIP = 1;
const PROGRESS_WAITING_MERCHANT = 2;
const PROGRESS_WAITING_USER_RECEIVE = 3;
const PROGRESS_DONE = 4;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function table(): string {
return AKSRT_DB_Migrator::table('after_sale');
}
/**
* 创建售后工单
*/
public function create(array $data): int {
global $wpdb;
$order_number = sanitize_text_field((string) ($data['order_number'] ?? ''));
$user_id = (int) ($data['user_id'] ?? 0);
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_REFUND));
$reason = sanitize_textarea_field((string) ($data['reason'] ?? ''));
$amount = (float) ($data['amount'] ?? 0);
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
if ($order_number === '' || $user_id <= 0 || $reason === '') {
return 0;
}
$valid_types = [self::TYPE_REFUND, self::TYPE_REFUND_RETURN, self::TYPE_EXCHANGE, self::TYPE_REPAIR, self::TYPE_WARRANTY, self::TYPE_INSURED_PRICE, self::TYPE_REPLACEMENT];
if (!in_array($type, $valid_types, true)) {
$type = self::TYPE_REFUND;
}
$result = $wpdb->insert($this->table(), [
'order_number' => $order_number,
'user_id' => $user_id,
'type' => $type,
'reason' => $reason,
'status' => self::STATUS_PENDING,
'amount' => $amount,
'note' => $note,
'created_at' => current_time('mysql'),
]);
return $result ? (int) $wpdb->insert_id : 0;
}
/**
* 获取售后工单列表
*/
public function get_list(array $args = []): array {
global $wpdb;
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$include_order_context = !empty($args['include_order_context']);
$status = sanitize_key((string) ($args['status'] ?? ''));
$stage = sanitize_key((string) ($args['stage'] ?? ''));
$order_number = sanitize_text_field((string) ($args['order_number'] ?? ''));
$type = sanitize_key((string) ($args['type'] ?? ''));
$date_from = sanitize_text_field((string) ($args['date_from'] ?? ''));
$date_to = sanitize_text_field((string) ($args['date_to'] ?? ''));
$where = [];
$params = [];
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
if ($stage !== '' && $status === '') {
$stage_statuses = $this->map_stage_to_statuses($stage);
if (!empty($stage_statuses)) {
$where[] = 'status IN (' . implode(', ', array_fill(0, count($stage_statuses), '%s')) . ')';
$params = array_merge($params, $stage_statuses);
}
}
if ($order_number !== '') {
$where[] = 'order_number = %s';
$params[] = $order_number;
}
if ($type !== '') {
$where[] = 'type = %s';
$params[] = $type;
}
if ($date_from !== '') {
$where[] = 'created_at >= %s';
$params[] = $date_from . ' 00:00:00';
}
if ($date_to !== '') {
$where[] = 'created_at <= %s';
$params[] = $date_to . ' 23:59:59';
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
$total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql);
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
$items = array_map([$this, 'decorate_record'], (array) $items);
if ($include_order_context && !empty($items) && class_exists('AKSRT_Order_Repository')) {
$contexts = AKSRT_Order_Repository::get_instance()->get_admin_order_context_map(array_column($items, 'order_number'));
foreach ($items as &$item) {
$item['order_context'] = $contexts[(string) ($item['order_number'] ?? '')] ?? [];
}
unset($item);
}
return [
'items' => $items ?: [],
'total' => $total,
'page' => floor($offset / $limit) + 1,
'max_pages' => ceil($total / $limit),
];
}
/**
* 获取单个售后工单
*/
public function get(int $id): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d",
$id
), ARRAY_A);
return $row ? $this->decorate_record($row) : null;
}
/**
* 获取单个售后单并附带订单上下文。
*/
public function get_with_order_context(int $id): ?array {
$record = $this->get($id);
if (!$record) {
return null;
}
$record['order_context'] = class_exists('AKSRT_Order_Repository')
? AKSRT_Order_Repository::get_instance()->get_admin_order_context((string) ($record['order_number'] ?? ''))
: [];
return $record;
}
public static function get_type_options(): array {
return [
['value' => self::TYPE_REFUND, 'label' => __('退款', 'aksrt')],
['value' => self::TYPE_EXCHANGE, 'label' => __('换货', 'aksrt')],
['value' => self::TYPE_REPAIR, 'label' => __('维修', 'aksrt')],
];
}
public static function get_status_options(): array {
return [
['value' => self::STATUS_PENDING, 'label' => __('待处理', 'aksrt')],
['value' => self::STATUS_PROCESSING, 'label' => __('处理中', 'aksrt')],
['value' => self::STATUS_APPROVED, 'label' => __('已通过', 'aksrt')],
['value' => self::STATUS_REJECTED, 'label' => __('已拒绝', 'aksrt')],
['value' => self::STATUS_COMPLETED, 'label' => __('已完成', 'aksrt')],
['value' => self::STATUS_CANCELLED, 'label' => __('已取消', 'aksrt')],
];
}
public static function get_stage_options(): array {
return [
['value' => 'review', 'label' => __('审核处理中', 'aksrt')],
['value' => 'approved', 'label' => __('已同意,待完成', 'aksrt')],
['value' => 'completed', 'label' => __('售后完成', 'aksrt')],
['value' => 'rejected', 'label' => __('售后拒绝', 'aksrt')],
['value' => 'cancelled', 'label' => __('已取消', 'aksrt')],
];
}
/**
* R68: Get after-sale reason options from shop settings (§6.8).
*
* @return array{refund: array, return: array}
*/
public static function get_reason_options(): array {
if (!class_exists('AKSRT_Settings')) {
return ['refund' => [], 'return' => []];
}
$shop = AKSRT_Settings::get_shop_settings();
$refund_text = (string) ($shop['timing']['after_sale_reason_refund'] ?? '');
$return_text = (string) ($shop['timing']['after_sale_reason_return'] ?? '');
return [
'refund' => self::parse_reason_lines($refund_text),
'return' => self::parse_reason_lines($return_text),
];
}
private static function parse_reason_lines(string $text): array {
if ($text === '') {
return [];
}
$lines = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $text)));
$result = [];
foreach (array_values($lines) as $i => $line) {
$result[] = [
'id' => $i + 1,
'label' => $line,
];
}
return $result;
}
/**
* @param array<int, string> $order_numbers
* @return array<string, array<string, mixed>>
*/
public function get_summary_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 [];
}
$map = [];
foreach ($order_numbers as $order_number) {
$map[$order_number] = [
'count' => 0,
'latest' => [],
];
}
$placeholders = implode(', ', array_fill(0, count($order_numbers), '%s'));
$sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders}) ORDER BY created_at DESC, id DESC";
$rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A);
foreach ((array) $rows as $row) {
$record = $this->decorate_record($row);
$order_number = (string) ($record['order_number'] ?? '');
if (!isset($map[$order_number])) {
continue;
}
$map[$order_number]['count']++;
if (empty($map[$order_number]['latest'])) {
$map[$order_number]['latest'] = $this->build_summary_item($record);
}
}
return $map;
}
/**
* 更新售后工单状态
*/
public function update_status(int $id, string $new_status, int $operator_id = 0, string $operator_note = ''): bool {
global $wpdb;
$record = $this->get($id);
if (!$record) {
return false;
}
$valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_COMPLETED, self::STATUS_CANCELLED];
if (!in_array($new_status, $valid, true)) return false;
if (!$this->can_transition_status((string) ($record['status'] ?? ''), $new_status)) {
return false;
}
$update = [
'status' => $new_status,
'updated_at' => current_time('mysql'),
];
if ($operator_id > 0) {
$update['operator_id'] = $operator_id;
}
if ($operator_note !== '') {
$update['operator_note'] = sanitize_textarea_field($operator_note);
}
if (in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
$update['resolved_at'] = current_time('mysql');
}
$updated = (bool) $wpdb->update($this->table(), $update, ['id' => $id]);
if ($updated) {
$note = $operator_note !== '' ? $operator_note : sprintf(__('售后状态更新为:%s', 'aksrt'), $this->get_status_label($new_status));
$this->sync_order_status($record, $new_status, $note);
}
return $updated;
}
/**
* 获取统计
*/
public function get_stats(): array {
global $wpdb;
$table = $this->table();
$counts = $wpdb->get_results(
"SELECT status, COUNT(*) as cnt FROM {$table} GROUP BY status",
ARRAY_A
);
$counts = array_column($counts, 'cnt', 'status');
$stats = [
'total' => 0,
'pending' => 0,
'processing' => 0,
'approved' => 0,
'rejected' => 0,
'completed' => 0,
'cancelled' => 0,
];
foreach ($counts as $status => $cnt) {
if (isset($stats[$status])) {
$stats[$status] = (int) $cnt;
}
$stats['total'] += (int) $cnt;
}
return $stats;
}
private function decorate_record(array $record): array {
$record['id'] = (int) ($record['id'] ?? 0);
$record['user_id'] = (int) ($record['user_id'] ?? 0);
$record['operator_id'] = isset($record['operator_id']) ? (int) $record['operator_id'] : 0;
$record['amount'] = (float) ($record['amount'] ?? 0);
$record['status_label'] = $this->get_status_label((string) ($record['status'] ?? ''));
$record['type_label'] = $this->get_type_label((string) ($record['type'] ?? ''));
$record['operator'] = $this->get_user_summary((int) ($record['operator_id'] ?? 0));
$record['applicant'] = $this->get_user_summary((int) ($record['user_id'] ?? 0));
$record['stage'] = $this->build_stage_payload($record);
return $record;
}
private function build_stage_payload(array $record): array {
$status = (string) ($record['status'] ?? '');
if (in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) {
return [
'key' => 'review',
'label' => __('审核处理中', 'aksrt'),
];
}
if ($status === self::STATUS_APPROVED) {
return [
'key' => 'approved',
'label' => __('已同意,待完成', 'aksrt'),
];
}
if ($status === self::STATUS_COMPLETED) {
return [
'key' => 'completed',
'label' => __('售后完成', 'aksrt'),
];
}
if ($status === self::STATUS_REJECTED) {
return [
'key' => 'rejected',
'label' => __('售后拒绝', 'aksrt'),
];
}
return [
'key' => 'cancelled',
'label' => __('已取消', 'aksrt'),
];
}
private function get_status_label(string $status): string {
foreach (self::get_status_options() as $item) {
if (($item['value'] ?? '') === $status) {
return (string) ($item['label'] ?? $status);
}
}
return $status;
}
private function get_type_label(string $type): string {
foreach (self::get_type_options() as $item) {
if (($item['value'] ?? '') === $type) {
return (string) ($item['label'] ?? $type);
}
}
return $type;
}
private function get_user_summary(int $user_id): array {
if ($user_id <= 0) {
return [];
}
$user = get_userdata($user_id);
if (!$user) {
return [];
}
return [
'id' => $user_id,
'display_name' => (string) $user->display_name,
'user_login' => (string) $user->user_login,
'user_email' => (string) $user->user_email,
];
}
/**
* @return array<int, string>
*/
private function map_stage_to_statuses(string $stage): array {
$map = [
'review' => [self::STATUS_PENDING, self::STATUS_PROCESSING],
'approved' => [self::STATUS_APPROVED],
'completed' => [self::STATUS_COMPLETED],
'rejected' => [self::STATUS_REJECTED],
'cancelled' => [self::STATUS_CANCELLED],
];
return $map[$stage] ?? [];
}
private function can_transition_status(string $current_status, string $new_status): bool {
$map = [
self::STATUS_PENDING => [self::STATUS_PROCESSING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED],
self::STATUS_PROCESSING => [self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED],
self::STATUS_APPROVED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED],
self::STATUS_REJECTED => [],
self::STATUS_COMPLETED => [],
self::STATUS_CANCELLED => [],
];
return in_array($new_status, $map[$current_status] ?? [], true);
}
private function build_summary_item(array $record): array {
return [
'id' => (int) ($record['id'] ?? 0),
'type' => (string) ($record['type'] ?? ''),
'type_label' => (string) ($record['type_label'] ?? ''),
'status' => (string) ($record['status'] ?? ''),
'status_label' => (string) ($record['status_label'] ?? ''),
'stage' => is_array($record['stage'] ?? null) ? $record['stage'] : [],
'amount' => (float) ($record['amount'] ?? 0),
'reason' => (string) ($record['reason'] ?? ''),
'created_at' => (string) ($record['created_at'] ?? ''),
'updated_at' => (string) ($record['updated_at'] ?? ''),
'resolved_at' => (string) ($record['resolved_at'] ?? ''),
];
}
/**
* R69: Execute actual refund — credit back user balance / points.
*/
private function execute_refund(?array $order, float $refund_amount): void {
if (!$order || $refund_amount <= 0) {
return;
}
$user_id = (int) ($order['user_id'] ?? 0);
if ($user_id <= 0) {
return;
}
$payment_method = (string) ($order['payment_method'] ?? 'balance');
$order_number = (string) ($order['order_number'] ?? '');
// Balance refund: prefer cash wallet, fall back to points.
if ($payment_method === 'balance') {
if (class_exists('AKSRT_Balance') && AKSRT_Balance::enabled()) {
AKSRT_Balance::add(
$user_id,
(float) $refund_amount,
'order_refund',
['type' => 'order', 'order' => $order_number],
sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number)
);
} elseif (class_exists('AKSRT_Credits')) {
AKSRT_Credits::add(
$user_id,
(int) round($refund_amount),
'order_refund',
['order_number' => $order_number, 'type' => 'refund'],
sprintf(__('售后退款金额 ¥%s', 'aksrt'), number_format($refund_amount, 2))
);
}
}
// Deduct sales_volume for refunded items
$meta = json_decode((string) ($order['note'] ?? ''), true);
$items = is_array($meta) ? ($meta['items'] ?? []) : [];
if (empty($items) && !empty($order['product_id'])) {
$items = [['product_id' => (int) $order['product_id'], 'quantity' => 1]];
}
foreach ($items as $item) {
$pid = (int) ($item['product_id'] ?? 0);
$qty = max(1, (int) ($item['quantity'] ?? 1));
if ($pid <= 0) {
continue;
}
$sales = (int) get_post_meta($pid, '_aksrt_sales_volume', true);
update_post_meta($pid, '_aksrt_sales_volume', max(0, $sales - $qty));
}
/**
* Fires after refund execution.
*
* @param string $order_number
* @param int $user_id
* @param float $refund_amount
* @param string $payment_method
*/
do_action('aksrt_shop_refund_executed', $order_number, $user_id, $refund_amount, $payment_method);
}
private function sync_order_status(array $record, string $new_status, string $note = ''): void {
if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Order')) {
return;
}
$type = (string) ($record['type'] ?? '');
$is_refund_type = in_array($type, [self::TYPE_REFUND, self::TYPE_REFUND_RETURN, self::TYPE_INSURED_PRICE], true);
if (!$is_refund_type) {
return;
}
$order_number = sanitize_text_field((string) ($record['order_number'] ?? ''));
if ($order_number === '') {
return;
}
$repository = AKSRT_Order_Repository::get_instance();
if (in_array($new_status, [self::STATUS_PROCESSING, self::STATUS_APPROVED], true)) {
$repository->update_status($order_number, AKSRT_Order::STATUS_REFUNDING, [
'note' => $note,
]);
return;
}
if ($new_status === self::STATUS_COMPLETED) {
$order = $repository->get($order_number);
if (($order['status'] ?? '') !== AKSRT_Order::STATUS_REFUNDING) {
$repository->update_status($order_number, AKSRT_Order::STATUS_REFUNDING, [
'note' => $note,
]);
}
$this->execute_refund($order, (float) ($record['amount'] ?? 0));
$repository->update_status($order_number, AKSRT_Order::STATUS_REFUNDED, [
'note' => $note,
]);
}
}
/* ============================================================
* Merchant Actions (§6.8)
* ============================================================ */
public function merchant_approve(int $ticket_id, int $operator_id, string $note = ''): bool {
global $wpdb;
$record = $this->get_by_id($ticket_id);
if (!$record || $record['status'] !== self::STATUS_PENDING) {
return false;
}
$type = (string) $record['type'];
$needs_return = in_array($type, [self::TYPE_REFUND_RETURN, self::TYPE_EXCHANGE, self::TYPE_REPLACEMENT], true);
$new_status = $needs_return ? self::STATUS_PROCESSING : self::STATUS_APPROVED;
$progress = $needs_return ? self::PROGRESS_WAITING_USER_SHIP : self::PROGRESS_WAITING_MERCHANT;
$wpdb->update($this->table(), [
'status' => $new_status,
'operator_id' => $operator_id,
'operator_note' => sanitize_textarea_field($note),
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
update_post_meta($ticket_id, '_aksrt_as_progress', $progress);
$this->sync_order_status($record, $new_status, $note ?: __('商家已同意售后', 'aksrt'));
return true;
}
public function merchant_reject(int $ticket_id, int $operator_id, string $note = ''): bool {
global $wpdb;
$record = $this->get_by_id($ticket_id);
if (!$record || $record['status'] !== self::STATUS_PENDING) {
return false;
}
$wpdb->update($this->table(), [
'status' => self::STATUS_REJECTED,
'operator_id' => $operator_id,
'operator_note' => sanitize_textarea_field($note),
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
update_post_meta($ticket_id, '_aksrt_as_progress', 0);
return true;
}
public function user_submit_return_shipping(int $ticket_id, int $user_id, string $carrier, string $tracking_no): bool {
global $wpdb;
$record = $this->get_by_id($ticket_id);
if (!$record || (int) ($record['user_id'] ?? 0) !== $user_id) {
return false;
}
if ($record['status'] !== self::STATUS_PROCESSING) {
return false;
}
$return_data = wp_json_encode([
'carrier' => sanitize_text_field($carrier),
'tracking_no' => sanitize_text_field($tracking_no),
'submitted_at' => current_time('mysql'),
], JSON_UNESCAPED_UNICODE);
$wpdb->update($this->table(), [
'note' => $return_data,
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
update_post_meta($ticket_id, '_aksrt_as_progress', self::PROGRESS_WAITING_MERCHANT);
return true;
}
public function merchant_confirm_return(int $ticket_id, int $operator_id, string $note = ''): bool {
global $wpdb;
$record = $this->get_by_id($ticket_id);
if (!$record || $record['status'] !== self::STATUS_PROCESSING) {
return false;
}
$type = (string) $record['type'];
if (in_array($type, [self::TYPE_EXCHANGE, self::TYPE_REPLACEMENT], true)) {
update_post_meta($ticket_id, '_aksrt_as_progress', self::PROGRESS_WAITING_USER_RECEIVE);
$wpdb->update($this->table(), [
'operator_note' => sanitize_textarea_field($note),
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
return true;
}
$wpdb->update($this->table(), [
'status' => self::STATUS_COMPLETED,
'operator_id' => $operator_id,
'operator_note' => sanitize_textarea_field($note),
'updated_at' => current_time('mysql'),
'resolved_at' => current_time('mysql'),
], ['id' => $ticket_id]);
update_post_meta($ticket_id, '_aksrt_as_progress', self::PROGRESS_DONE);
$this->sync_order_status($record, self::STATUS_COMPLETED, $note ?: __('售后已完成', 'aksrt'));
return true;
}
/* ============================================================
* Shipping status window + type constraints (§6.8)
* ============================================================ */
public function can_apply_after_sale(array $order, string $type): bool {
$status = (string) ($order['status'] ?? '');
if (!in_array($status, [
AKSRT_Order::STATUS_PAID,
AKSRT_Order::STATUS_DELIVERED,
AKSRT_Order::STATUS_COMPLETED,
AKSRT_Order::STATUS_PENDING_REVIEW,
], true)) {
return false;
}
$shipping_status = (int) ($order['shipping_status'] ?? 0);
if ($type === self::TYPE_REFUND) {
return true;
}
if (in_array($type, [self::TYPE_REFUND_RETURN, self::TYPE_EXCHANGE, self::TYPE_REPLACEMENT], true)) {
return $shipping_status >= AKSRT_Domain_Model::SHIPPING_STATUS_SHIPPED;
}
if ($type === self::TYPE_WARRANTY) {
return $shipping_status >= AKSRT_Domain_Model::SHIPPING_STATUS_COMPLETED;
}
return true;
}
public function get_type_timeouts(): array {
$shop = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_shop_settings() : [];
$timing = $shop['timing'] ?? [];
return [
self::TYPE_REFUND => (int) ($timing['refund_max_day'] ?? 7),
self::TYPE_REFUND_RETURN => (int) ($timing['refund_return_max_day'] ?? 7),
self::TYPE_EXCHANGE => (int) ($timing['exchange_max_day'] ?? 7),
self::TYPE_REPLACEMENT => (int) ($timing['replacement_max_day'] ?? 7),
self::TYPE_WARRANTY => (int) ($timing['warranty_max_day'] ?? 30),
self::TYPE_INSURED_PRICE => (int) ($timing['insured_price_max_day'] ?? 15),
];
}
/* ============================================================
* Auto-cancel Cron (§6.8)
* ============================================================ */
public function run_auto_cancel_cron(): void {
global $wpdb;
$table = $this->table();
$timeouts = $this->get_type_timeouts();
foreach ($timeouts as $type => $max_days) {
if ($max_days <= 0) continue;
$cutoff = date('Y-m-d H:i:s', strtotime("-{$max_days} days"));
$stale = $wpdb->get_results($wpdb->prepare(
"SELECT id, order_number, type, status FROM {$table} WHERE type = %s AND status IN (%s, %s) AND created_at < %s LIMIT 200",
$type, self::STATUS_PENDING, self::STATUS_PROCESSING, $cutoff
), ARRAY_A);
foreach ($stale as $record) {
$wpdb->update($table, [
'status' => self::STATUS_CANCELLED,
'updated_at' => current_time('mysql'),
'operator_note' => sprintf(__('系统自动取消(超过 %d 天未处理)', 'aksrt'), $max_days),
], ['id' => (int) $record['id']]);
}
}
}
private function get_by_id(int $id): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d",
$id
), ARRAY_A);
return $row ?: null;
}
public static function get_valid_types(): array {
return [
self::TYPE_REFUND => __('仅退款', 'aksrt'),
self::TYPE_REFUND_RETURN => __('退货退款', 'aksrt'),
self::TYPE_EXCHANGE => __('换货', 'aksrt'),
self::TYPE_REPLACEMENT => __('补发', 'aksrt'),
self::TYPE_WARRANTY => __('保修', 'aksrt'),
self::TYPE_INSURED_PRICE => __('保价', 'aksrt'),
];
}
public static function get_progress_labels(): array {
return [
self::PROGRESS_WAITING_USER_SHIP => __('等待用户发货', 'aksrt'),
self::PROGRESS_WAITING_MERCHANT => __('等待商家处理', 'aksrt'),
self::PROGRESS_WAITING_USER_RECEIVE => __('等待用户收货', 'aksrt'),
self::PROGRESS_DONE => __('已完成', 'aksrt'),
];
}
}
@@ -0,0 +1,727 @@
<?php
/**
* AKSRT Charge Card — 卡密(余额 / VIP / 积分)
*
* zibll-style card_pass: typed inventory, single-use CAS redeem,
* public REST for user-center, admin create/batch.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Charge_Card {
private static $instance = null;
public const TYPE_BALANCE = 'balance';
public const TYPE_VIP = 'vip';
public const TYPE_CREDITS = 'credits';
public const STATUS_ACTIVE = 'active';
public const STATUS_USED = 'used';
public const STATUS_EXPIRED = 'expired';
public const STATUS_DISABLED = 'disabled';
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('rest_api_init', [$this, 'register_public_routes']);
}
private function table(): string {
return AKSRT_DB_Migrator::table('charge_card');
}
public function register_public_routes(): void {
register_rest_route('aksrt/v1', '/pay/card-pass/options', [
'methods' => 'GET',
'callback' => [$this, 'rest_options'],
'permission_callback' => [$this, 'check_rest_nonce'],
]);
register_rest_route('aksrt/v1', '/pay/card-pass/redeem', [
'methods' => 'POST',
'callback' => [$this, 'rest_redeem'],
'permission_callback' => [$this, 'check_logged_in_rest'],
]);
}
/**
* @param WP_REST_Request $request
* @return true|WP_Error
*/
public function check_rest_nonce(WP_REST_Request $request) {
$nonce = $request->get_header('x-wp-nonce');
if (!$nonce) {
return new WP_Error('aksrt_rest_no_nonce', __('缺少安全令牌', 'aksrt'), ['status' => 403]);
}
if (!wp_verify_nonce($nonce, 'wp_rest')) {
return new WP_Error('aksrt_rest_bad_nonce', __('安全令牌已过期,请刷新页面', 'aksrt'), ['status' => 403]);
}
return true;
}
/**
* @param WP_REST_Request $request
* @return true|WP_Error
*/
public function check_logged_in_rest(WP_REST_Request $request) {
$nonce = $this->check_rest_nonce($request);
if (is_wp_error($nonce)) {
return $nonce;
}
if (!is_user_logged_in()) {
return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
return true;
}
public function rest_options(WP_REST_Request $request) {
return new WP_REST_Response([
'success' => true,
'data' => $this->get_public_options(),
], 200);
}
public function rest_redeem(WP_REST_Request $request) {
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$password = sanitize_text_field((string) ($params['password'] ?? $params['code'] ?? ''));
$card = sanitize_text_field((string) ($params['card'] ?? ''));
$type = sanitize_key((string) ($params['type'] ?? ''));
if ($type !== '' && !in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
$type = '';
}
$result = $this->redeem_for_user(get_current_user_id(), $password, $card, $type);
if (is_wp_error($result)) {
return $result;
}
return new WP_REST_Response([
'success' => true,
'data' => $result,
], 200);
}
/**
* @return array<string,mixed>
*/
public function get_public_options(): array {
$balance = ['enabled' => false, 'desc' => '', 'only_password' => true];
$vip = ['enabled' => false, 'desc' => '', 'only_password' => true];
if (class_exists('AKSRT_Settings')) {
if (method_exists('AKSRT_Settings', 'get_payment_recharge_settings')) {
$recharge = AKSRT_Settings::get_payment_recharge_settings();
$pc = is_array($recharge['pass_charge'] ?? null) ? $recharge['pass_charge'] : [];
$balance = [
'enabled' => !empty($pc['enabled']),
'desc' => (string) ($pc['desc'] ?? ''),
'only_password' => !isset($pc['only_password']) || !empty($pc['only_password']),
];
}
if (method_exists('AKSRT_Settings', 'get_payment_vip_settings')) {
$vip_s = AKSRT_Settings::get_payment_vip_settings();
$benefit = is_array($vip_s['benefit'] ?? null) ? $vip_s['benefit'] : [];
$vip = [
'enabled' => !empty($benefit['pass_charge']),
'desc' => (string) ($benefit['pass_charge_desc'] ?? ''),
'only_password' => !isset($benefit['pass_charge_only_password']) || !empty($benefit['pass_charge_only_password']),
];
}
}
// Points card-pass: zibll points_pass_exchange_* (stored in aksrt_settings).
$credits = ['enabled' => false, 'desc' => '', 'only_password' => true];
$all = class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_all')
? AKSRT_Settings::get_all()
: (array) get_option('aksrt_settings', []);
if (!empty($all['points_pass_exchange_s'])) {
// Require credits system on when available.
$credits_on = !class_exists('AKSRT_Settings') || !method_exists('AKSRT_Settings', 'credits_enabled') || AKSRT_Settings::credits_enabled();
$credits = [
'enabled' => $credits_on,
'desc' => sanitize_textarea_field((string) ($all['points_pass_exchange_desc'] ?? '')),
'only_password' => !isset($all['points_pass_exchange_only_password']) || !empty($all['points_pass_exchange_only_password']),
];
}
return [
'balance' => $balance,
'vip' => $vip,
'credits' => $credits,
'labels' => [
'title' => __('卡密兑换', 'aksrt'),
'password' => __('卡密', 'aksrt'),
'card' => __('卡号', 'aksrt'),
'submit_balance' => __('兑换余额', 'aksrt'),
'submit_vip' => __('兑换会员', 'aksrt'),
'submit_credits' => __('兑换积分', 'aksrt'),
'placeholder_pw' => __('请输入卡密', 'aksrt'),
'placeholder_card' => __('请输入卡号', 'aksrt'),
],
];
}
/**
* Create one card.
*
* Accepted $data keys:
* - password|code (required unless auto)
* - card (optional)
* - type balance|vip|credits
* - amount (balance)
* - level, days (vip; days 0 = permanent)
* - points (credits)
* - meta (array override)
* - expires_at, note
*/
public function create(array $data): int {
global $wpdb;
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_BALANCE));
if (!in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
$type = self::TYPE_BALANCE;
}
$password = sanitize_text_field((string) ($data['password'] ?? $data['code'] ?? ''));
if ($password === '') {
return 0;
}
$card = sanitize_text_field((string) ($data['card'] ?? ''));
$meta = $this->normalize_meta($type, $data);
if ($meta === null) {
return 0;
}
// Uniqueness: password alone (only_password mode common).
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE password = %s LIMIT 1",
$password
));
if ($exists) {
return 0;
}
$expires_at = sanitize_text_field((string) ($data['expires_at'] ?? ''));
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
$amount_col = (float) ($meta['amount'] ?? $meta['points'] ?? 0);
$result = $wpdb->insert($this->table(), [
'card' => $card,
'password' => $password,
'type' => $type,
'amount' => $amount_col,
'balance' => $amount_col,
'status' => self::STATUS_ACTIVE,
'meta' => wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'order_number' => '',
'used_by' => null,
'used_at' => null,
'expires_at' => $expires_at !== '' ? $expires_at : null,
'created_at' => current_time('mysql'),
'note' => $note,
]);
return $result ? (int) $wpdb->insert_id : 0;
}
/**
* @param array<string,mixed> $meta_fields
* @return array<int,string> passwords/codes generated
*/
public function batch_create(string $type, array $meta_fields, int $count, int $length = 16, string $prefix = '', string $expires_at = '', string $note = ''): array {
$type = sanitize_key($type);
if (!in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
$type = self::TYPE_BALANCE;
}
$count = max(1, min(500, $count));
$length = max(8, min(32, $length));
$prefix = sanitize_text_field($prefix);
$codes = [];
for ($i = 0; $i < $count; $i++) {
$password = $prefix . strtoupper(wp_generate_password($length, false));
$id = $this->create(array_merge($meta_fields, [
'type' => $type,
'password' => $password,
'expires_at' => $expires_at,
'note' => $note,
]));
if ($id > 0) {
$codes[] = $password;
}
}
return $codes;
}
/**
* @return array{success:bool,message:string,type?:string,amount?:float,level?:int,days?:int,order_number?:string,balance?:int}|WP_Error
*/
public function redeem_for_user(int $user_id, string $password, string $card = '', string $expected_type = '') {
$user_id = max(0, $user_id);
$password = sanitize_text_field($password);
$card = sanitize_text_field($card);
if ($user_id <= 0) {
return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
if ($password === '') {
return new WP_Error('aksrt_card_empty', __('请输入卡密', 'aksrt'), ['status' => 400]);
}
// Simple per-user throttle.
$throttle_key = 'aksrt_card_pass_rl_' . $user_id;
$hits = (int) get_transient($throttle_key);
if ($hits >= 20) {
return new WP_Error('aksrt_card_rate', __('操作过于频繁,请稍后再试', 'aksrt'), ['status' => 429]);
}
set_transient($throttle_key, $hits + 1, MINUTE_IN_SECONDS);
$row = $this->find_active($password, $card, $expected_type);
if (!$row) {
return new WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]);
}
$type = sanitize_key((string) ($row['type'] ?? self::TYPE_BALANCE));
if ($expected_type !== '' && $type !== $expected_type) {
return new WP_Error('aksrt_card_type', __('当前卡密不能用于该兑换类型', 'aksrt'), ['status' => 400]);
}
$gate = $this->assert_type_enabled($type);
if (is_wp_error($gate)) {
return $gate;
}
// only_password: if feature requires card number and empty, fail.
$opts = $this->get_public_options();
$only_password = true;
if ($type === self::TYPE_BALANCE) {
$only_password = !empty($opts['balance']['only_password']);
} elseif ($type === self::TYPE_VIP) {
$only_password = !empty($opts['vip']['only_password']);
}
if (!$only_password && $card === '' && (string) ($row['card'] ?? '') !== '') {
return new WP_Error('aksrt_card_need_no', __('请输入卡号', 'aksrt'), ['status' => 400]);
}
if (!$only_password && $card !== '' && (string) ($row['card'] ?? '') !== '' && (string) $row['card'] !== $card) {
return new WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]);
}
if (!empty($row['expires_at']) && strtotime((string) $row['expires_at']) < time()) {
global $wpdb;
$wpdb->update($this->table(), ['status' => self::STATUS_EXPIRED], ['id' => (int) $row['id']], ['%s'], ['%d']);
return new WP_Error('aksrt_card_expired', __('卡密已过期', 'aksrt'), ['status' => 400]);
}
$meta = $this->decode_meta($row);
$card_id = (int) $row['id'];
// Create audit order first (pending), then CAS mark used, then fulfill + mark paid.
$order_number = $this->create_redeem_order($user_id, $type, $meta, $card_id, $password);
if ($order_number === '') {
return new WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
if (!$this->mark_used_cas($card_id, $user_id, $order_number)) {
// Race lost.
if (class_exists('AKSRT_Order')) {
AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_CANCELLED, [
'note' => __('卡密已被使用,订单取消', 'aksrt'),
]);
}
return new WP_Error('aksrt_card_used', __('该卡密已使用', 'aksrt'), ['status' => 400]);
}
$fulfill = $this->fulfill($user_id, $type, $meta, $order_number, $card_id);
if (is_wp_error($fulfill)) {
return $fulfill;
}
if (class_exists('AKSRT_Order')) {
AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [
'payment_method' => 'card_pass',
'payment_transaction_id' => 'card_pass_' . $card_id,
'note' => (string) ($fulfill['message'] ?? __('卡密兑换成功', 'aksrt')),
]);
}
$out = array_merge([
'success' => true,
'type' => $type,
'order_number' => $order_number,
'reload' => true,
], $fulfill);
if (class_exists('AKSRT_Credits')) {
$out['balance'] = AKSRT_Credits::balance($user_id);
}
return $out;
}
/**
* @return array<string,mixed>|null
*/
public function find_active(string $password, string $card = '', string $type = ''): ?array {
global $wpdb;
$password = sanitize_text_field($password);
if ($password === '') {
return null;
}
$sql = "SELECT * FROM {$this->table()} WHERE password = %s";
$params = [$password];
if ($type !== '') {
$sql .= ' AND type = %s';
$params[] = sanitize_key($type);
}
$sql .= ' LIMIT 1';
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
if (!$row) {
return null;
}
if ((string) ($row['status'] ?? '') !== self::STATUS_ACTIVE) {
return null;
}
return $row;
}
public function mark_used_cas(int $card_id, int $user_id, string $order_number): bool {
global $wpdb;
if ($card_id <= 0) {
return false;
}
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET status = %s, used_by = %d, used_at = %s, order_number = %s, balance = 0
WHERE id = %d AND status = %s",
self::STATUS_USED,
$user_id,
current_time('mysql'),
$order_number,
$card_id,
self::STATUS_ACTIVE
));
return $updated === 1;
}
/**
* @param array<string,mixed> $meta
* @return array{message:string,amount?:float,level?:int,days?:int}|WP_Error
*/
private function fulfill(int $user_id, string $type, array $meta, string $order_number, int $card_id) {
if ($type === self::TYPE_BALANCE) {
$amount = round((float) ($meta['amount'] ?? 0), 2);
if ($amount <= 0) {
return new WP_Error('aksrt_card_amount', __('当前卡密可充值金额为0', 'aksrt'), ['status' => 400]);
}
// Cash wallet first (Zibll pay_balance); fall back to points if wallet class missing.
if (class_exists('AKSRT_Balance')) {
$new = AKSRT_Balance::add($user_id, $amount, 'card_pass_balance', [
'type' => 'charge_card',
'id' => $card_id,
'order' => $order_number,
], sprintf(__('卡密充值 %s 元', 'aksrt'), (string) $amount));
if (is_wp_error($new)) {
return new WP_Error('aksrt_balance_fail', $new->get_error_message() ?: __('余额入账失败', 'aksrt'), ['status' => 500]);
}
return [
'message' => sprintf(__('卡密充值成功,充值金额:%s 元', 'aksrt'), (string) $amount),
'amount' => (float) $amount,
];
}
if (!class_exists('AKSRT_Credits')) {
return new WP_Error('aksrt_card_amount', __('余额系统不可用', 'aksrt'), ['status' => 500]);
}
$pts = (int) round($amount);
$new = AKSRT_Credits::add($user_id, $pts, 'card_pass_balance', [
'ref_type' => 'charge_card',
'ref_id' => $card_id,
'order' => $order_number,
], sprintf(__('卡密充值 %d', 'aksrt'), $pts));
if ($new === null || $new === false || is_wp_error($new)) {
return new WP_Error('aksrt_credit_fail', __('余额入账失败', 'aksrt'), ['status' => 500]);
}
return [
'message' => sprintf(__('卡密充值成功,充值金额:%s', 'aksrt'), (string) $pts),
'amount' => (float) $pts,
];
}
if ($type === self::TYPE_VIP) {
$level = max(1, min(2, (int) ($meta['level'] ?? 1)));
$days = max(0, (int) ($meta['days'] ?? 0));
if (!class_exists('AKSRT_Vip_Service')) {
return new WP_Error('aksrt_vip_unavailable', __('会员系统不可用', 'aksrt'), ['status' => 500]);
}
AKSRT_Vip_Service::get_instance()->activate_vip($user_id, $level, $days);
$msg = $days > 0
? sprintf(
/* translators: 1: level 2: days */
__('卡密兑换成功:VIP%s %d 天', 'aksrt'),
(string) $level,
$days
)
: sprintf(
/* translators: %s: level */
__('卡密兑换成功:VIP%s 永久', 'aksrt'),
(string) $level
);
return [
'message' => $msg,
'level' => $level,
'days' => $days,
];
}
if ($type === self::TYPE_CREDITS) {
$points = (int) ($meta['points'] ?? 0);
if ($points <= 0 || !class_exists('AKSRT_Credits')) {
return new WP_Error('aksrt_card_points', __('当前卡密可兑换积分为0', 'aksrt'), ['status' => 400]);
}
AKSRT_Credits::add($user_id, $points, 'card_pass_credits', [
'ref_type' => 'charge_card',
'ref_id' => $card_id,
'order' => $order_number,
], sprintf(__('卡密兑换积分 %d', 'aksrt'), $points));
return [
'message' => sprintf(__('卡密兑换成功,积分:%d', 'aksrt'), $points),
'amount' => (float) $points,
];
}
return new WP_Error('aksrt_card_type', __('不支持的卡密类型', 'aksrt'), ['status' => 400]);
}
/**
* @param array<string,mixed> $meta
*/
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password): string {
if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Domain_Model')) {
return 'CARD' . $card_id . '_' . time();
}
$product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RECHARGE;
$product_id = $card_id;
$amount = 0.0;
$currency = 'CNY';
if ($type === self::TYPE_VIP) {
$product_type = AKSRT_Domain_Model::ORDER_PRODUCT_VIP;
$amount = 0.0;
} elseif ($type === self::TYPE_BALANCE) {
$amount = round((float) ($meta['amount'] ?? 0), 2);
$currency = 'CNY';
} elseif ($type === self::TYPE_CREDITS) {
$amount = (float) ($meta['points'] ?? 0);
$currency = 'points';
}
$note = wp_json_encode([
'card_pass' => 1,
'card_id' => $card_id,
'card_type' => $type,
'meta' => $meta,
'password' => substr(md5($password), 0, 8),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return AKSRT_Order_Repository::get_instance()->create([
'user_id' => $user_id,
'product_type' => $product_type,
'product_id' => $product_id,
'amount' => $amount,
'currency' => $currency,
'status' => class_exists('AKSRT_Order') ? AKSRT_Order::STATUS_PENDING : 'pending',
'payment_method' => 'card_pass',
'note' => is_string($note) ? $note : '',
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
]);
}
/**
* @return true|WP_Error
*/
private function assert_type_enabled(string $type) {
$opts = $this->get_public_options();
if ($type === self::TYPE_BALANCE && empty($opts['balance']['enabled'])) {
return new WP_Error('aksrt_card_disabled', __('余额卡密兑换未开启', 'aksrt'), ['status' => 403]);
}
if ($type === self::TYPE_VIP && empty($opts['vip']['enabled'])) {
return new WP_Error('aksrt_card_disabled', __('会员卡密兑换未开启', 'aksrt'), ['status' => 403]);
}
if ($type === self::TYPE_CREDITS && empty($opts['credits']['enabled'])) {
return new WP_Error('aksrt_card_disabled', __('积分卡密兑换未开启', 'aksrt'), ['status' => 403]);
}
return true;
}
/**
* @param array<string,mixed> $data
* @return array<string,mixed>|null
*/
private function normalize_meta(string $type, array $data): ?array {
if (is_array($data['meta'] ?? null)) {
$raw = $data['meta'];
} else {
$raw = $data;
}
if ($type === self::TYPE_BALANCE) {
$amount = round((float) ($raw['amount'] ?? $data['amount'] ?? 0), 2);
if ($amount <= 0) {
return null;
}
return ['amount' => $amount];
}
if ($type === self::TYPE_VIP) {
$level = max(1, min(2, (int) ($raw['level'] ?? $data['level'] ?? 1)));
$days = max(0, (int) ($raw['days'] ?? $data['days'] ?? $data['vip_days'] ?? 0));
return ['level' => $level, 'days' => $days];
}
if ($type === self::TYPE_CREDITS) {
$points = max(0, (int) ($raw['points'] ?? $data['points'] ?? $data['credits'] ?? 0));
if ($points <= 0) {
return null;
}
return ['points' => $points];
}
return null;
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function decode_meta(array $row): array {
$meta = [];
if (!empty($row['meta'])) {
if (is_array($row['meta'])) {
$meta = $row['meta'];
} else {
$decoded = json_decode((string) $row['meta'], true);
if (is_array($decoded)) {
$meta = $decoded;
}
}
}
// Legacy amount-only rows.
if ($meta === [] && isset($row['amount'])) {
$meta = ['amount' => (float) $row['amount']];
}
return $meta;
}
/**
* Admin list helper (supports new + legacy columns).
*
* @return array{items:array,total:int,page:int,max_pages:int}
*/
public function get_list(array $args = []): array {
global $wpdb;
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$status = sanitize_key((string) ($args['status'] ?? ''));
$type = sanitize_key((string) ($args['type'] ?? ''));
$search = sanitize_text_field((string) ($args['search'] ?? $args['s'] ?? ''));
$where = [];
$params = [];
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
if ($type !== '') {
if ($type === 'points') {
$type = self::TYPE_CREDITS;
}
$where[] = 'type = %s';
$params[] = $type;
}
if ($search !== '') {
$like = '%' . $wpdb->esc_like($search) . '%';
$where[] = '(password LIKE %s OR card LIKE %s OR note LIKE %s OR CAST(id AS CHAR) = %s)';
$params[] = $like;
$params[] = $like;
$params[] = $like;
$params[] = $search;
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
$total = $params
? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params))
: (int) $wpdb->get_var($count_sql);
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return [
'items' => $items ?: [],
'total' => $total,
'page' => (int) floor($offset / $limit) + 1,
'max_pages' => (int) ceil(max(1, $total) / $limit),
];
}
public function disable(int $id): bool {
global $wpdb;
return (bool) $wpdb->update($this->table(), [
'status' => self::STATUS_DISABLED,
], ['id' => $id], ['%s'], ['%d']);
}
/**
* @return array<string,mixed>
*/
public function get_stats(): array {
global $wpdb;
$table = $this->table();
$row = $wpdb->get_row(
"SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN status = 'used' THEN 1 ELSE 0 END) as used,
SUM(amount) as total_amount,
SUM(CASE WHEN status = 'used' THEN amount ELSE 0 END) as used_amount
FROM {$table}",
ARRAY_A
);
return [
'total' => (int) ($row['total'] ?? 0),
'active' => (int) ($row['active'] ?? 0),
'used' => (int) ($row['used'] ?? 0),
'total_amount' => (float) ($row['total_amount'] ?? 0),
'used_amount' => (float) ($row['used_amount'] ?? 0),
];
}
/**
* Legacy alias used by older admin code.
*
* @return array{success:bool,message:string,amount?:float}
*/
public function redeem(string $code, int $user_id): array {
$result = $this->redeem_for_user($user_id, $code, '', self::TYPE_BALANCE);
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message()];
}
return [
'success' => true,
'message' => (string) ($result['message'] ?? __('兑换成功', 'aksrt')),
'amount' => (float) ($result['amount'] ?? 0),
];
}
}
+406
View File
@@ -0,0 +1,406 @@
<?php
/**
* AKSRT Coupon — 优惠券系统
*
* 使用专用表 {prefix}aksrt_coupon 存储优惠券。
* 支持:创建、列表、验证、核销。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Coupon {
const TYPE_PERCENT = 'percent';
const TYPE_FIXED = 'fixed';
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
private function table(): string {
return AKSRT_DB_Migrator::table('coupon');
}
/**
* 创建优惠券
*/
public function create(array $data): int {
global $wpdb;
$code = sanitize_text_field((string) ($data['code'] ?? ''));
if ($code === '') return 0;
// Check uniqueness
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE code = %s",
$code
));
if ($exists) return 0;
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_FIXED));
if (!in_array($type, [self::TYPE_PERCENT, self::TYPE_FIXED], true)) {
$type = self::TYPE_FIXED;
}
$wpdb->insert($this->table(), [
'code' => $code,
'type' => $type,
'value' => (float) ($data['value'] ?? 0),
'min_amount' => (float) ($data['min_amount'] ?? 0),
'usage_limit' => (int) ($data['usage_limit'] ?? 0),
'used_count' => 0,
'expires_at' => sanitize_text_field((string) ($data['expires_at'] ?? '')),
'per_user_limit' => (int) ($data['per_user_limit'] ?? 0),
'product_scope' => wp_json_encode($data['product_scope'] ?? [], JSON_UNESCAPED_UNICODE),
'max_discount' => (float) ($data['max_discount'] ?? 0),
'status' => 'active',
'created_at' => current_time('mysql'),
], ['%s', '%s', '%f', '%f', '%d', '%d', '%s', '%d', '%s', '%f', '%s', '%s']);
return (int) $wpdb->insert_id;
}
/**
* 获取优惠券列表
*/
public function get_list(array $args = []): array {
global $wpdb;
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
$search = sanitize_text_field((string) ($args['search'] ?? ''));
if ($search !== '') {
$where[] = 'code LIKE %s';
$params[] = '%' . $wpdb->esc_like($search) . '%';
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
if (!empty($params)) {
$total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $params));
} else {
$total = (int) $wpdb->get_var($count_sql);
}
$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 [
'items' => array_map([$this, 'normalize_row'], (array) $rows),
'total' => $total,
'page' => max(1, (int) ceil($offset / $limit) + 1),
'total_pages' => max(1, (int) ceil($total / $limit)),
];
}
/**
* 验证优惠券是否可用
*
* @return array{valid: bool, coupon?: array, message?: string}
*/
public function validate(string $code, float $order_amount = 0, array $args = []): array {
global $wpdb;
$code = sanitize_text_field($code);
if ($code === '') {
return ['valid' => false, 'message' => '请输入优惠码'];
}
$coupon = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE code = %s",
$code
), ARRAY_A);
if (!$coupon) {
return ['valid' => false, 'message' => '优惠码不存在'];
}
$coupon = $this->normalize_row($coupon);
if ($coupon['status'] !== 'active') {
return ['valid' => false, 'message' => '优惠码已失效'];
}
if (!empty($coupon['expires_at']) && strtotime($coupon['expires_at']) < time()) {
return ['valid' => false, 'message' => '优惠码已过期'];
}
if ($coupon['usage_limit'] > 0 && $coupon['used_count'] >= $coupon['usage_limit']) {
return ['valid' => false, 'message' => '优惠码已用完'];
}
if ($coupon['min_amount'] > 0 && $order_amount < $coupon['min_amount']) {
return ['valid' => false, 'message' => '订单金额不满足最低消费要求'];
}
// Per-user limit check
$user_id = (int) ($args['user_id'] ?? 0);
if ($coupon['per_user_limit'] > 0 && $user_id > 0) {
$user_used = $this->get_user_usage_count($coupon['id'], $user_id);
if ($user_used >= $coupon['per_user_limit']) {
return ['valid' => false, 'message' => '您已使用该优惠码达到上限'];
}
}
// Product / content scope check.
// Supports shop product cats and content post cats.
if (!empty($coupon['product_scope']) && !empty($args['product_ids'])) {
$scope = is_array($coupon['product_scope']) ? $coupon['product_scope'] : json_decode($coupon['product_scope'], true);
if (is_array($scope) && !empty($scope)) {
$scope_type = sanitize_key((string) ($scope['type'] ?? ''));
$scope_ids = array_map('intval', (array) ($scope['ids'] ?? []));
$product_ids = array_map('intval', (array) $args['product_ids']);
$taxonomies = array_values(array_filter(array_map('sanitize_key', (array) ($scope['taxonomies'] ?? []))));
if ($taxonomies === []) {
// content context prefers category; shop admin may pass aksrt_product_cat.
$context = sanitize_key((string) ($args['context'] ?? ''));
$taxonomies = ($context === 'content')
? ['category']
: ['aksrt_product_cat', 'category'];
}
if ($scope_type === 'include' && empty(array_intersect($product_ids, $scope_ids))) {
return ['valid' => false, 'message' => __('该优惠码不适用于当前商品', 'aksrt')];
}
if ($scope_type === 'exclude' && !empty(array_intersect($product_ids, $scope_ids))) {
return ['valid' => false, 'message' => __('该优惠码不适用于当前商品', 'aksrt')];
}
if (in_array($scope_type, ['category', 'content_category', 'post_category'], true)) {
$matched = false;
foreach ($product_ids as $pid) {
foreach ($taxonomies as $tax) {
$cats = wp_get_post_terms($pid, $tax, ['fields' => 'ids']);
if (!is_wp_error($cats) && !empty(array_intersect(array_map('intval', (array) $cats), $scope_ids))) {
$matched = true;
break 2;
}
}
}
if (!$matched) {
return ['valid' => false, 'message' => __('该优惠码不适用于当前分类', 'aksrt')];
}
}
}
}
// Calculate discount
$discount = 0;
if ($coupon['type'] === self::TYPE_PERCENT) {
$discount = $order_amount * ($coupon['value'] / 100);
} else {
$discount = min($coupon['value'], $order_amount);
}
// Max discount cap
if ($coupon['max_discount'] > 0 && $discount > $coupon['max_discount']) {
$discount = $coupon['max_discount'];
}
return [
'valid' => true,
'coupon' => $coupon,
'discount' => round($discount, 2),
];
}
/**
* 核销优惠券
*/
public function use_coupon(string $code): bool {
global $wpdb;
$code = sanitize_text_field($code);
$result = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()} SET used_count = used_count + 1 WHERE code = %s",
$code
));
return $result !== false;
}
/**
* 禁用优惠券
*/
public function disable(int $id): bool {
global $wpdb;
return $wpdb->update($this->table(), ['status' => 'disabled'], ['id' => $id], ['%s'], ['%d']) !== false;
}
public function enable(int $id): bool {
global $wpdb;
return $wpdb->update($this->table(), ['status' => 'active'], ['id' => $id], ['%s'], ['%d']) !== false;
}
/**
* Get one coupon by id.
*/
public function get(int $id): ?array {
global $wpdb;
if ($id <= 0) {
return null;
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d",
$id
), ARRAY_A);
if (!$row) {
return null;
}
return $this->normalize_row($row);
}
/**
* Update mutable coupon fields (not code / used_count / created_at).
*/
public function update(int $id, array $data): bool {
global $wpdb;
if ($id <= 0 || !$this->get($id)) {
return false;
}
$fields = [];
$format = [];
if (array_key_exists('type', $data)) {
$type = sanitize_key((string) $data['type']);
if (!in_array($type, [self::TYPE_PERCENT, self::TYPE_FIXED], true)) {
$type = self::TYPE_FIXED;
}
$fields['type'] = $type;
$format[] = '%s';
}
if (array_key_exists('value', $data)) {
$fields['value'] = (float) $data['value'];
$format[] = '%f';
}
if (array_key_exists('min_amount', $data)) {
$fields['min_amount'] = (float) $data['min_amount'];
$format[] = '%f';
}
if (array_key_exists('max_discount', $data)) {
$fields['max_discount'] = (float) $data['max_discount'];
$format[] = '%f';
}
if (array_key_exists('usage_limit', $data)) {
$fields['usage_limit'] = (int) $data['usage_limit'];
$format[] = '%d';
}
if (array_key_exists('per_user_limit', $data)) {
$fields['per_user_limit'] = (int) $data['per_user_limit'];
$format[] = '%d';
}
if (array_key_exists('expires_at', $data)) {
$fields['expires_at'] = sanitize_text_field((string) $data['expires_at']);
$format[] = '%s';
}
if (array_key_exists('product_scope', $data)) {
$scope = is_array($data['product_scope']) ? $data['product_scope'] : [];
$fields['product_scope'] = wp_json_encode($scope, JSON_UNESCAPED_UNICODE);
$format[] = '%s';
}
if (array_key_exists('status', $data)) {
$status = sanitize_key((string) $data['status']);
if (in_array($status, ['active', 'disabled'], true)) {
$fields['status'] = $status;
$format[] = '%s';
}
}
if (!$fields) {
return false;
}
return $wpdb->update($this->table(), $fields, ['id' => $id], $format, ['%d']) !== false;
}
/**
* Batch-create coupons with auto-generated codes.
*
* @return array{created:int,codes:array<int,string>}
*/
public function batch_create(array $data, int $count = 1, int $length = 10, string $prefix = ''): array {
$count = max(1, min(100, $count));
$length = max(6, min(24, $length));
$prefix = sanitize_text_field($prefix);
$codes = [];
$created = 0;
for ($i = 0; $i < $count; $i++) {
$code = $prefix !== ''
? $prefix . strtoupper(wp_generate_password($length, false, false))
: strtoupper(wp_generate_password($length, false, false));
$payload = $data;
$payload['code'] = $code;
$id = $this->create($payload);
if ($id > 0) {
$created++;
$codes[] = $code;
}
}
return [
'created' => $created,
'codes' => $codes,
];
}
/**
* Count how many times a user has used a specific coupon.
*/
public function get_user_usage_count(int $coupon_id, int $user_id): int {
global $wpdb;
$table = AKSRT_DB_Migrator::table('coupon_usage');
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE coupon_id = %d AND user_id = %d",
$coupon_id, $user_id
));
}
/**
* Log a coupon usage record.
*/
public function log_usage(int $coupon_id, int $user_id, string $order_number, float $discount): void {
global $wpdb;
$table = AKSRT_DB_Migrator::table('coupon_usage');
$wpdb->insert($table, [
'coupon_id' => $coupon_id,
'user_id' => $user_id,
'order_number' => $order_number,
'discount' => $discount,
'created_at' => current_time('mysql'),
], ['%d', '%d', '%s', '%f', '%s']);
}
private function normalize_row(array $row): array {
$row['id'] = (int) $row['id'];
$row['value'] = (float) $row['value'];
$row['min_amount'] = (float) $row['min_amount'];
$row['usage_limit'] = (int) $row['usage_limit'];
$row['used_count'] = (int) $row['used_count'];
$row['per_user_limit'] = (int) ($row['per_user_limit'] ?? 0);
$row['max_discount'] = (float) ($row['max_discount'] ?? 0);
if (!empty($row['product_scope']) && is_string($row['product_scope'])) {
$row['product_scope'] = json_decode($row['product_scope'], true) ?: [];
}
return $row;
}
}
@@ -0,0 +1,243 @@
<?php
/**
* AKSRT Creator Revenue Settlement.
*
* Wires order-paid into the pre-existing revenue ledger
* (`aksrt_revenue` table + AKSRT_Revenue service).
*
* Scope:
* - Shop product orders (line items via AKSRT_Shop::get_order_items)
* - Non-shop cash: content_pay / topic_pay / plate_follow_pay
* - Share ratio from AKSRT_Settings::get_payment_revenue_settings()
* - Buyer == author skipped; amount≤0 skipped; points cash skipped
* - Idempotent per order_number via type=order guard row
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
if (!class_exists('AKSRT_Creator_Revenue')) {
class AKSRT_Creator_Revenue {
private static ?AKSRT_Creator_Revenue $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('aksrt_order_paid', [$this, 'handle_order_paid'], 20, 2);
}
/**
* Whether creator revenue settlement is enabled.
*/
private function is_enabled(): bool {
if (!class_exists('AKSRT_Revenue') || !class_exists('AKSRT_Settings')) {
return false;
}
$settings = AKSRT_Settings::get_payment_revenue_settings();
return !empty($settings['enabled']);
}
/**
* Resolve share ratio (percentage 0-100) for a given product author.
*/
private function resolve_share_ratio(int $author_id): float {
$settings = AKSRT_Settings::get_payment_revenue_settings();
$ratio = (float) ($settings['share_ratio'] ?? 0);
// VIP-tier author overrides, if configured (>0 wins).
if ($author_id > 0 && function_exists('aksrt_get_user_vip_level')) {
$level = (int) aksrt_get_user_vip_level($author_id);
if ($level === 1 && (float) ($settings['ratio_vip1'] ?? 0) > 0) {
$ratio = (float) $settings['ratio_vip1'];
} elseif ($level >= 2 && (float) ($settings['ratio_vip2'] ?? 0) > 0) {
$ratio = (float) $settings['ratio_vip2'];
}
}
return max(0.0, min(100.0, $ratio));
}
/**
* Guard: has this order already been settled into revenue?
*/
private function already_settled(string $order_number): bool {
if ($order_number === '' || !class_exists('AKSRT_DB_Migrator')) {
return false;
}
global $wpdb;
$table = AKSRT_DB_Migrator::table('revenue');
if ($table === '') {
return false;
}
$count = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE order_number = %s AND type = %s",
$order_number,
'order'
));
return $count > 0;
}
/**
* aksrt_order_paid listener: credit creators their share.
*
* @param string $order_number Paid order number.
* @param array $order Order row (status=paid).
*/
public function handle_order_paid(string $order_number, array $order): void {
if (!$this->is_enabled() || $order_number === '') {
return;
}
if ($this->already_settled($order_number)) {
return;
}
$buyer_id = (int) ($order['user_id'] ?? 0);
$items = [];
if (class_exists('AKSRT_Shop')) {
$shop = AKSRT_Shop::get_instance();
if (method_exists($shop, 'get_order_items')) {
$items = (array) $shop->get_order_items($order_number);
}
}
if (!empty($items)) {
$this->settle_shop_items($order_number, $buyer_id, $items);
return;
}
$this->settle_non_shop_cash_order($order_number, $order, $buyer_id);
}
/**
* @param list<array<string,mixed>> $items
*/
private function settle_shop_items(string $order_number, int $buyer_id, array $items): void {
$revenue = AKSRT_Revenue::get_instance();
foreach ($items as $item) {
$product_id = (int) ($item['product_id'] ?? 0);
$pay_price = (float) ($item['pay_price'] ?? $item['total_price'] ?? 0);
if ($product_id <= 0 || $pay_price <= 0) {
continue;
}
$author_id = (int) get_post_field('post_author', $product_id);
if ($author_id <= 0 || $author_id === $buyer_id) {
continue;
}
$ratio = $this->resolve_share_ratio($author_id);
if ($ratio <= 0) {
continue;
}
$amount = round($pay_price * $ratio / 100, 2);
if ($amount <= 0) {
continue;
}
$revenue->add($author_id, $amount, $order_number, 'order');
}
}
/**
* Content / topic / plate_follow cash orders (no shop line items).
*/
private function settle_non_shop_cash_order(string $order_number, array $order, int $buyer_id): void {
$meta = $this->decode_note($order);
if (($meta['pay_modo'] ?? '') === 'points') {
return;
}
$payment_method = sanitize_key((string) ($order['payment_method'] ?? ''));
$currency = sanitize_key((string) ($order['currency'] ?? ''));
if ($payment_method === 'points' || $currency === 'points') {
return;
}
$base_amount = round((float) ($order['amount'] ?? $meta['payable'] ?? 0), 2);
if ($base_amount <= 0) {
return;
}
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
$product_id = (int) ($order['product_id'] ?? 0);
if ($product_id <= 0) {
return;
}
$post = get_post($product_id);
if (!($post instanceof WP_Post)) {
return;
}
$is_plate = !empty($meta['plate_follow_pay'])
|| $product_type === AKSRT_Domain_Model::ORDER_PRODUCT_PLATE_FOLLOW;
$is_topic = !empty($meta['topic_pay'])
|| ($product_type === AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE
&& $post->post_type === 'aksrt_forum_topic');
$is_content = !empty($meta['content_pay'])
&& $post->post_type === 'post';
// Resource without content_pay/topic_pay and not a known cash kind: skip.
if (!$is_plate && !$is_topic && !$is_content) {
return;
}
// Guard: plate must be plate CPT; topic must be topic CPT.
if ($is_plate && $post->post_type !== 'aksrt_forum_plate') {
return;
}
if ($is_topic && $post->post_type !== 'aksrt_forum_topic') {
return;
}
$author_id = (int) $post->post_author;
if ($author_id <= 0 || $author_id === $buyer_id) {
return;
}
$ratio = $this->resolve_share_ratio($author_id);
if ($ratio <= 0) {
return;
}
$amount = round($base_amount * $ratio / 100, 2);
if ($amount <= 0) {
return;
}
// Re-check after shop-less path; shop path already returned.
if ($this->already_settled($order_number)) {
return;
}
AKSRT_Revenue::get_instance()->add($author_id, $amount, $order_number, 'order');
}
/**
* @param array<string,mixed> $order
* @return array<string,mixed>
*/
private function decode_note(array $order): array {
if (isset($order['meta_payload']) && is_array($order['meta_payload'])) {
return $order['meta_payload'];
}
$note = (string) ($order['note'] ?? '');
if ($note === '') {
return [];
}
$decoded = json_decode($note, true);
return is_array($decoded) ? $decoded : [];
}
}
}
@@ -0,0 +1,171 @@
<?php
/**
* AKSRT Discount Engine
*
* 优惠活动计算引擎 — reduction 立减 / discount 打折 / gift 买赠。
* scope: item / product / author / order。
* 绑定在 shop_discount taxonomy 上,商品可挂多个活动。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Discount {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Get all active discount campaigns for a product.
*
* @param int $product_id
* @return array<int, array> Sorted by priority desc.
*/
public function get_active_discounts(int $product_id): array {
$terms = wp_get_object_terms($product_id, 'shop_discount', ['fields' => 'all']);
if (is_wp_error($terms) || empty($terms)) {
return [];
}
$now = current_time('timestamp');
$active = [];
foreach ($terms as $term) {
$meta = AKSRT_Shop_Term_Meta::get_discount_meta($term->term_id);
// Time limit check
if (!empty($meta['start']) && strtotime($meta['start']) > $now) {
continue;
}
if (!empty($meta['end']) && strtotime($meta['end']) < $now) {
continue;
}
$meta['term_id'] = $term->term_id;
$meta['name'] = $term->name;
$active[] = $meta;
}
// Sort by priority desc
usort($active, function ($a, $b) {
return ((int) $b['priority']) - ((int) $a['priority']);
});
return $active;
}
/**
* Calculate discount for a single line item.
*
* @param float $unit_price Unit price
* @param int $quantity Quantity
* @param int $product_id Product ID
* @param int $user_id User ID (for user_limit checks)
* @return array{
* original: float,
* discount_amount: float,
* final_price: float,
* gifts: array,
* applied: array<int, array>
* }
*/
public function calculate(float $unit_price, int $quantity, int $product_id, int $user_id = 0): array {
$original = $unit_price * $quantity;
$discount_amount = 0.0;
$gifts = [];
$applied = [];
$discounts = $this->get_active_discounts($product_id);
foreach ($discounts as $d) {
// User limit check
if (!empty($d['user_limit']) && $user_id > 0) {
if (!$this->check_user_limit($d['user_limit'], $user_id)) {
continue;
}
}
// Price limit check
if ((float) $d['price_limit'] > 0 && $original < (float) $d['price_limit']) {
continue;
}
$type = $d['type'] ?? 'reduction';
$value = (float) ($d['value'] ?? 0);
switch ($type) {
case 'reduction':
// 立减
$discount_amount += min($value * $quantity, $original - $discount_amount);
break;
case 'discount':
// 打折 (value = 折扣系数 e.g. 8.5 = 85折)
if ($value > 0 && $value < 10) {
$rate = $value / 10.0;
$save = ($original - $discount_amount) * (1 - $rate);
$discount_amount += $save;
}
break;
case 'gift':
// 买赠 — collect gift config, no price reduction
if (!empty($d['gift_config'])) {
$gift_data = json_decode($d['gift_config'], true);
if (is_array($gift_data)) {
$gifts[] = $gift_data;
}
}
break;
}
$applied[] = [
'term_id' => $d['term_id'],
'name' => $d['name'] ?? '',
'type' => $type,
'value' => $value,
];
}
$discount_amount = min($discount_amount, $original);
return [
'original' => $original,
'discount_amount' => round($discount_amount, 2),
'final_price' => round($original - $discount_amount, 2),
'gifts' => $gifts,
'applied' => $applied,
];
}
/**
* Check if a user meets the user_limit requirement.
*
* @param string $limit 'vip' / 'vip_2' / 'auth'
* @param int $user_id
* @return bool
*/
private function check_user_limit(string $limit, int $user_id): bool {
if (empty($limit)) {
return true;
}
switch ($limit) {
case 'vip':
return function_exists('aksrt_is_vip') ? aksrt_is_vip($user_id) : false;
case 'vip_2':
return function_exists('aksrt_is_vip2') ? aksrt_is_vip2($user_id) : false;
case 'auth':
return function_exists('aksrt_is_verified') ? aksrt_is_verified($user_id) : false;
default:
return true;
}
}
}
File diff suppressed because it is too large. Load diff
+147
View File
@@ -0,0 +1,147 @@
<?php
/**
* AKSRT Order facade.
*
* Keeps the public contract stable while delegating storage to
* `AKSRT_Order_Repository`.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Order {
public const STATUS_CREATED = AKSRT_Domain_Model::ORDER_STATUS_CREATED;
public const STATUS_PENDING = AKSRT_Domain_Model::ORDER_STATUS_PENDING;
public const STATUS_PAID = AKSRT_Domain_Model::ORDER_STATUS_PAID;
public const STATUS_DELIVERED = AKSRT_Domain_Model::ORDER_STATUS_DELIVERED;
public const STATUS_COMPLETED = AKSRT_Domain_Model::ORDER_STATUS_COMPLETED;
public const STATUS_CANCELLED = AKSRT_Domain_Model::ORDER_STATUS_CANCELLED;
public const STATUS_EXPIRED = AKSRT_Domain_Model::ORDER_STATUS_EXPIRED;
public const STATUS_REFUNDING = AKSRT_Domain_Model::ORDER_STATUS_REFUNDING;
public const STATUS_REFUNDED = AKSRT_Domain_Model::ORDER_STATUS_REFUNDED;
public const STATUS_PENDING_REVIEW = AKSRT_Domain_Model::ORDER_STATUS_PENDING_REVIEW;
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public static function get_valid_statuses(): array {
return AKSRT_Domain_Model::order_status_labels();
}
public static function can_transition(string $from, string $to): bool {
return AKSRT_Domain_Model::can_transition_order_status($from, $to);
}
public function init(): void {
add_action('wp_ajax_aksrt_order_list', [$this, 'ajax_list']);
add_action('wp_ajax_aksrt_order_update_status', [$this, 'ajax_update_status']);
add_action('wp_ajax_aksrt_order_stats', [$this, 'ajax_stats']);
}
public function create(int $user_id, string $product_type, int $product_id, float $amount, string $currency = 'CNY'): string {
// Guest content orders may use user_id=0 (Zibll pay_no_logged_in).
if ($user_id < 0 || $product_id < 0 || $amount < 0) {
return '';
}
return $this->repo()->create([
'user_id' => $user_id,
'product_type' => sanitize_key($product_type),
'product_id' => $product_id,
'amount' => $amount,
'currency' => sanitize_key($currency),
'status' => self::STATUS_PENDING,
]);
}
public function get(string $order_number): ?array {
return $this->repo()->get($order_number);
}
public function get_user_orders(int $user_id, array $args = []): array {
return $this->repo()->get_user_orders($user_id, $args);
}
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
return $this->repo()->update_status($order_number, $new_status, $extra);
}
public function get_stats(int $user_id = 0): array {
return $this->repo()->get_stats($user_id);
}
public function has_paid_access(int $user_id, string $product_type, int $product_id): bool {
return $this->repo()->has_paid_access($user_id, $product_type, $product_id);
}
public function get_valid_vip_order(int $user_id): ?array {
return $this->repo()->get_valid_vip_order($user_id);
}
public function ajax_list(): void {
check_ajax_referer('aksrt_public_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
}
$status = sanitize_key((string) ($_GET['status'] ?? ''));
$search = sanitize_text_field((string) ($_GET['search'] ?? ''));
$limit = max(1, min(100, (int) ($_GET['limit'] ?? 20)));
$offset = max(0, (int) ($_GET['offset'] ?? 0));
$orders = $this->repo()->get_orders_admin([
'status' => $status,
'limit' => $limit,
'offset' => $offset,
], $search);
wp_send_json_success([
'orders' => $orders,
'stats' => $this->get_stats(),
'total' => $this->repo()->get_orders_count(['status' => $status], $search),
]);
}
public function ajax_update_status(): void {
check_ajax_referer('aksrt_public_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
}
$order_number = sanitize_text_field((string) ($_POST['order_number'] ?? ''));
$new_status = sanitize_key((string) ($_POST['status'] ?? ''));
$note = sanitize_textarea_field((string) ($_POST['note'] ?? ''));
if ($order_number === '' || $new_status === '') {
wp_send_json_error(['message' => __('参数无效', 'aksrt')]);
}
if ($this->update_status($order_number, $new_status, ['note' => $note, 'force' => true])) {
wp_send_json_success(['message' => __('订单状态已更新', 'aksrt')]);
}
wp_send_json_error(['message' => __('更新失败', 'aksrt')]);
}
public function ajax_stats(): void {
check_ajax_referer('aksrt_public_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
}
wp_send_json_success($this->get_stats());
}
private function repo(): AKSRT_Order_Repository {
return AKSRT_Order_Repository::get_instance();
}
}
@@ -0,0 +1,490 @@
<?php
/**
* AKSRT Payment Gateway — 支付网关适配器层
*
* 定义支付网关统一接口和基础实现。
* 具体网关(支付宝、微信支付等)通过子类实现。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
/**
* 支付网关统一接口
*/
interface AKSRT_Payment_Gateway_Interface {
/**
* 创建支付单
*
* @param array $order 订单数据
* @return array{success: bool, payment_url?: string, payment_data?: array, message?: string}
*/
public function create_payment(array $order): array;
/**
* 验证回调
*
* @param WP_REST_Request $request 回调请求
* @return array{success: bool, order_number?: string, amount?: float, transaction_id?: string, message?: string}
*/
public function verify_callback(WP_REST_Request $request): array;
/**
* 查询支付状态
*
* @param string $order_number 订单号
* @return array{success: bool, status?: string, transaction_id?: string, message?: string}
*/
public function query_status(string $order_number): array;
/**
* 退款
*
* @param string $order_number 订单号
* @param float $amount 退款金额
* @return array{success: bool, refund_id?: string, message?: string}
*/
public function refund(string $order_number, float $amount): array;
/**
* 获取网关显示名称
*/
public function get_name(): string;
/**
* 获取网关标识
*/
public function get_id(): string;
/**
* 网关是否已配置
*/
public function is_configured(): bool;
}
/**
* 支付网关管理器
*/
class AKSRT_Payment_Gateway {
private static $instance = null;
private $gateways = [];
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
/**
* 注册网关
*/
public function register(string $id, AKSRT_Payment_Gateway_Interface $gateway): void {
$this->gateways[$id] = $gateway;
}
/**
* 获取网关
*/
public function get_gateway(string $id): ?AKSRT_Payment_Gateway_Interface {
return $this->gateways[$id] ?? null;
}
/**
* 获取所有已注册网关
*
* @return AKSRT_Payment_Gateway_Interface[]
*/
public function get_all(): array {
return $this->gateways;
}
/**
* 获取可用网关列表(已配置的)
*/
public function get_available(): array {
$available = [];
foreach ($this->gateways as $id => $gateway) {
if ($gateway->is_configured()) {
$available[$id] = $gateway;
}
}
return $available;
}
/**
* 获取网关状态摘要(用于后台展示)
*/
public function get_status_summary(): array {
$summary = [];
foreach ($this->gateways as $id => $gateway) {
$summary[] = [
'id' => $id,
'name' => $gateway->get_name(),
'configured' => $gateway->is_configured(),
'available' => $gateway->is_configured(),
];
}
return $summary;
}
/**
* 绑定支付回调路由。必须在 functions.php 中初始化。
*
* 此前网关 create_payment() 把 notify_url/return_url 指向
* /wp-json/aksrt/v1/payment/{gateway}/{notify|return|cancel},但这些路由
* 从未注册,导致所有跳转型网关(支付宝/微信/PayPal/Stripe)无法完成支付。
*/
public function init(): void {
add_action('rest_api_init', [$this, 'register_routes']);
}
public function register_routes(): void {
$args = [
'methods' => ['GET', 'POST'],
'permission_callback' => '__return_true', // 真实性由各网关签名校验保证
'args' => [
'gateway' => ['sanitize_callback' => 'sanitize_key'],
],
];
register_rest_route('aksrt/v1', '/payment/(?P<gateway>[a-z0-9_]+)/notify', array_merge($args, [
'callback' => [$this, 'handle_notify'],
]));
register_rest_route('aksrt/v1', '/payment/(?P<gateway>[a-z0-9_]+)/return', array_merge($args, [
'callback' => [$this, 'handle_return'],
]));
register_rest_route('aksrt/v1', '/payment/(?P<gateway>[a-z0-9_]+)/cancel', array_merge($args, [
'callback' => [$this, 'handle_cancel'],
]));
}
/**
* 异步通知(服务器到服务器)。验签 → 到账 → 输出网关期望的 ACK。
*/
public function handle_notify(WP_REST_Request $request) {
$gateway_id = sanitize_key((string) $request->get_param('gateway'));
$gateway = $this->get_gateway($gateway_id);
if (!$gateway) {
return new WP_REST_Response('gateway not found', 404);
}
$verify = $gateway->verify_callback($request);
$verified = !empty($verify['success']);
$paid = $verified && ($verify['status'] ?? '') === 'paid';
if ($paid) {
$this->complete_order($gateway_id, $verify);
}
// 网关各自要求的 ACK 格式
if ($gateway_id === 'wechatpay' && method_exists($gateway, 'output_callback_response')) {
$gateway->output_callback_response(['success' => $verified]); // exit
}
if ($gateway_id === 'alipay') {
// 支付宝异步通知要求返回纯文本 success,否则会重复通知
header('Content-Type: text/plain; charset=utf-8');
echo $verified ? 'success' : 'fail';
exit;
}
// Stripe / PayPal webhook:200 表示已接收;验签失败返回 400 触发重试
return new WP_REST_Response(['received' => $verified], $verified ? 200 : 400);
}
/**
* 用户浏览器同步返回。尽力确认到账(兜底异步通知延迟),再跳回订单中心。
*/
public function handle_return(WP_REST_Request $request) {
$gateway_id = sanitize_key((string) $request->get_param('gateway'));
$order_number = sanitize_text_field((string) ($request->get_param('order')
?: $request->get_param('out_trade_no')
?: ''));
$gateway = $this->get_gateway($gateway_id);
$status = 'pending';
if ($gateway) {
if ($gateway_id === 'alipay') {
// 支付宝同步返回带签名参数,可直接验签作为到账兜底
$verify = $gateway->verify_callback($request);
if (!empty($verify['success'])) {
$order_number = $order_number ?: sanitize_text_field((string) ($verify['order_number'] ?? ''));
if (($verify['status'] ?? '') === 'paid' && $this->complete_order($gateway_id, $verify)) {
$status = 'success';
}
}
} elseif ($gateway_id === 'paypal' && method_exists($gateway, 'capture_order')) {
// PayPal v2:用户批准后在 return 时捕获(token = PayPal 订单号)
$paypal_order_id = sanitize_text_field((string) ($request->get_param('token') ?: ''));
if ($paypal_order_id !== '') {
$cap = $gateway->capture_order($paypal_order_id);
if (!empty($cap['success']) && ($cap['status'] ?? '') === 'paid') {
$order_number = $order_number ?: sanitize_text_field((string) ($cap['order_number'] ?? ''));
$cap['order_number'] = $order_number;
if ($this->complete_order($gateway_id, $cap)) {
$status = 'success';
}
}
}
} elseif ($order_number !== '') {
// 其余网关:主动查询订单状态兜底
$q = $gateway->query_status($order_number);
if (!empty($q['success']) && ($q['status'] ?? '') === 'paid') {
$done = $this->complete_order($gateway_id, [
'order_number' => $order_number,
'amount' => 0, // 由网关 API 已确认到账,金额校验交给 notify 路径
'transaction_id' => $q['transaction_id'] ?? '',
'status' => 'paid',
]);
if ($done) {
$status = 'success';
}
}
}
}
$this->redirect_to_orders($order_number, $status);
return null;
}
/**
* 用户取消支付,跳回订单中心。
*/
public function handle_cancel(WP_REST_Request $request) {
$order_number = sanitize_text_field((string) ($request->get_param('order')
?: $request->get_param('out_trade_no')
?: ''));
$this->redirect_to_orders($order_number, 'cancelled');
return null;
}
/**
* 幂等、带金额校验的订单完成。仅由验签通过的回调调用。
*
* @param string $gateway_id 网关 ID
* @param array $verify 网关 verify_callback / query 结果
* @return bool
*/
public function complete_order(string $gateway_id, array $verify): bool {
$order_number = sanitize_text_field((string) ($verify['order_number'] ?? ''));
if ($order_number === '' || !class_exists('AKSRT_Order_Repository')) {
return false;
}
$repo = AKSRT_Order_Repository::get_instance();
$order = $repo->get($order_number);
if (!$order) {
return false;
}
$current = (string) ($order['status'] ?? '');
// 幂等:已支付/已交付/已完成视为成功,不重复处理(回调可能多次到达)
if (in_array($current, ['paid', 'delivered', 'completed'], true)) {
return true;
}
// 只允许从未支付态完成
if (!in_array($current, ['created', 'pending'], true)) {
return false;
}
// 金额校验,防止前端/回调金额被篡改
$expected = (float) ($order['amount'] ?? 0);
$paid_amount = (float) ($verify['amount'] ?? 0);
if ($expected > 0 && $paid_amount > 0 && abs($expected - $paid_amount) > 0.01) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf('[AKSRT Payment] amount mismatch order=%s expected=%.2f paid=%.2f gateway=%s', $order_number, $expected, $paid_amount, $gateway_id));
}
return false;
}
return $repo->update_status($order_number, AKSRT_Order::STATUS_PAID, [
'payment_method' => $gateway_id,
'payment_transaction_id' => sanitize_text_field((string) ($verify['transaction_id'] ?? '')),
'note' => __('支付网关回调确认到账。', 'aksrt'),
]);
}
private function redirect_to_orders(string $order_number, string $status): void {
$url = add_query_arg(array_filter([
'payment' => sanitize_key($status),
'order' => $order_number,
]), $this->orders_page_url());
nocache_headers();
wp_safe_redirect($url);
exit;
}
private function orders_page_url(): string {
$pages = get_posts([
'post_type' => 'page',
'post_status' => 'publish',
'posts_per_page' => 1,
'meta_key' => '_wp_page_template',
'meta_value' => 'templates/pages/page-orders.php',
'fields' => 'ids',
'no_found_rows' => true,
]);
if (!empty($pages)) {
return (string) get_permalink((int) $pages[0]);
}
return home_url('/orders/');
}
}
/**
* 站内余额支付网关(内置实现)
*
* 优先扣现金钱包(AKSRT_Balance / pay_balance);未启用时回退到积分。
*/
class AKSRT_Balance_Gateway implements AKSRT_Payment_Gateway_Interface {
public function get_id(): string {
return 'balance';
}
public function get_name(): string {
return __('站内余额', 'aksrt');
}
public function is_configured(): bool {
$wallet_on = class_exists('AKSRT_Balance') && AKSRT_Balance::enabled();
$credits_on = class_exists('AKSRT_Credits');
if (!$wallet_on && !$credits_on) {
return false;
}
if (class_exists('AKSRT_Settings_Payment_Runtime')) {
return AKSRT_Settings_Payment_Runtime::is_gateway_enabled('balance');
}
return true;
}
public function create_payment(array $order): array {
$user_id = (int) ($order['user_id'] ?? 0);
$amount = round((float) ($order['amount'] ?? 0), 2);
$order_number = (string) ($order['order_number'] ?? '');
// Prefer cash wallet when pay_balance_s is on.
if (class_exists('AKSRT_Balance') && AKSRT_Balance::enabled()) {
if ($user_id <= 0) {
return ['success' => false, 'message' => __('请先登录', 'aksrt')];
}
$balance = AKSRT_Balance::balance($user_id);
if ($balance < $amount) {
return ['success' => false, 'message' => __('余额不足', 'aksrt')];
}
$result = AKSRT_Balance::deduct(
$user_id,
$amount,
'order_payment',
['type' => 'order', 'id' => 0, 'order' => $order_number],
sprintf(__('订单 %s 支付', 'aksrt'), $order_number)
);
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message() ?: __('扣款失败', 'aksrt')];
}
return [
'success' => true,
'transaction_id' => 'BAL_' . ($order_number !== '' ? $order_number : (string) time()),
'message' => __('余额支付成功', 'aksrt'),
];
}
// Fallback: legacy points-as-balance (1 元 = 100 积分).
if (!class_exists('AKSRT_Credits')) {
return ['success' => false, 'message' => __('积分系统未启用', 'aksrt')];
}
$credits_cost = (int) ceil($amount * 100);
$balance = AKSRT_Credits::balance($user_id);
if ($balance < $credits_cost) {
return ['success' => false, 'message' => __('余额不足', 'aksrt')];
}
$result = AKSRT_Credits::deduct(
$user_id,
$credits_cost,
'order_payment',
['order' => $order_number],
sprintf(__('订单 %s 支付', 'aksrt'), $order_number)
);
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message() ?: __('扣款失败', 'aksrt')];
}
return [
'success' => true,
'transaction_id' => 'BAL_' . ($order_number !== '' ? $order_number : (string) time()),
'message' => __('余额支付成功', 'aksrt'),
];
}
public function verify_callback(WP_REST_Request $request): array {
// Balance payment is synchronous — no callback needed
return ['success' => false, 'message' => __('余额支付无需回调验证', 'aksrt')];
}
public function query_status(string $order_number): array {
$order_repo = AKSRT_Order_Repository::get_instance();
$order = $order_repo->get($order_number);
if (!$order) {
return ['success' => false, 'message' => __('订单不存在', 'aksrt')];
}
return [
'success' => true,
'status' => $order['status'],
'transaction_id' => $order['payment_transaction_id'] ?? '',
];
}
public function refund(string $order_number, float $amount): array {
$order_repo = AKSRT_Order_Repository::get_instance();
$order = $order_repo->get($order_number);
if (!$order) {
return ['success' => false, 'message' => __('订单不存在', 'aksrt')];
}
$user_id = (int) $order['user_id'];
$amount = round((float) $amount, 2);
if (class_exists('AKSRT_Balance') && AKSRT_Balance::enabled()) {
$result = AKSRT_Balance::add(
$user_id,
$amount,
'order_refund',
['type' => 'order', 'order' => $order_number],
sprintf(__('订单 %s 退款', 'aksrt'), $order_number)
);
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
}
return ['success' => true, 'refund_id' => 'BAL_REF_' . $order_number, 'message' => __('退款成功', 'aksrt')];
}
if (!class_exists('AKSRT_Credits')) {
return ['success' => false, 'message' => __('积分系统未启用', 'aksrt')];
}
$credits_refund = (int) ceil($amount * 100);
$result = AKSRT_Credits::add(
$user_id,
$credits_refund,
'order_refund',
['order' => $order_number],
sprintf(__('订单 %s 退款', 'aksrt'), $order_number)
);
if (is_wp_error($result)) {
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
}
return ['success' => true, 'refund_id' => 'BAL_REF_' . $order_number, 'message' => __('退款成功', 'aksrt')];
}
}
+187
View File
@@ -0,0 +1,187 @@
<?php
/**
* AKSRT Rebate — 返佣系统
*
* 管理订单返佣、推广返佣记录。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Rebate {
private static $instance = null;
const TYPE_ORDER = 'order';
const TYPE_invite = 'invite';
const STATUS_PENDING = 'pending';
const STATUS_SETTLED = 'settled';
const STATUS_CANCELLED = 'cancelled';
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function table(): string {
return AKSRT_DB_Migrator::table('rebate_log');
}
/**
* 创建返佣记录
*/
public function create(array $data): int {
global $wpdb;
$user_id = (int) ($data['user_id'] ?? 0);
$order_number = sanitize_text_field((string) ($data['order_number'] ?? ''));
$source_user_id = (int) ($data['source_user_id'] ?? 0);
$amount = (float) ($data['amount'] ?? 0);
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_ORDER));
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
if ($user_id <= 0 || $amount <= 0) {
return 0;
}
$valid_types = [self::TYPE_ORDER, self::TYPE_invite];
if (!in_array($type, $valid_types, true)) {
$type = self::TYPE_ORDER;
}
$result = $wpdb->insert($this->table(), [
'user_id' => $user_id,
'order_number' => $order_number,
'source_user_id' => $source_user_id > 0 ? $source_user_id : null,
'amount' => $amount,
'type' => $type,
'status' => self::STATUS_PENDING,
'created_at' => current_time('mysql'),
'note' => $note,
]);
$id = $result ? (int) $wpdb->insert_id : 0;
if ($id > 0) {
do_action('aksrt_pay_rebate_added', $user_id, $amount, [
'rebate_id' => $id,
'order_number' => $order_number,
'type' => $type,
]);
}
return $id;
}
/**
* 结算返佣
*/
public function settle(int $id): bool {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d AND status = %s",
$id, self::STATUS_PENDING
), ARRAY_A);
if (!$row) return false;
$user_id = (int) $row['user_id'];
$amount = (float) $row['amount'];
$result = $wpdb->update($this->table(), [
'status' => self::STATUS_SETTLED,
'settled_at' => current_time('mysql'),
], ['id' => $id]);
if ($result && class_exists('AKSRT_Credits')) {
AKSRT_Credits::add($user_id, (int) $amount, 'rebate', [
'ref_type' => 'rebate',
'ref_id' => $id,
]);
}
return (bool) $result;
}
/**
* 取消返佣
*/
public function cancel(int $id): bool {
global $wpdb;
return (bool) $wpdb->update($this->table(), [
'status' => self::STATUS_CANCELLED,
], ['id' => $id, 'status' => self::STATUS_PENDING]);
}
/**
* 获取返佣列表
*/
public function get_list(array $args = []): array {
global $wpdb;
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$status = sanitize_key((string) ($args['status'] ?? ''));
$user_id = (int) ($args['user_id'] ?? 0);
$where = [];
$params = [];
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
if ($user_id > 0) {
$where[] = 'user_id = %d';
$params[] = $user_id;
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
$total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql);
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return [
'items' => $items ?: [],
'total' => $total,
'page' => floor($offset / $limit) + 1,
'max_pages' => ceil($total / $limit),
];
}
/**
* 获取统计
*/
public function get_stats(): array {
global $wpdb;
$table = $this->table();
$row = $wpdb->get_row(
"SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'settled' THEN 1 ELSE 0 END) as settled,
SUM(amount) as total_amount,
SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END) as settled_amount
FROM {$table}",
ARRAY_A
);
return [
'total' => (int) ($row['total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'settled' => (int) ($row['settled'] ?? 0),
'total_amount' => (float) ($row['total_amount'] ?? 0),
'settled_amount' => (float) ($row['settled_amount'] ?? 0),
];
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
/**
* AKSRT Revenue — 收益台账
*
* 管理用户收益记录、结算和统计。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Revenue {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
private function table(): string {
return AKSRT_DB_Migrator::table('revenue');
}
/**
* 记录收益
*/
public function add(int $user_id, float $amount, string $order_number = '', string $type = 'income'): int {
global $wpdb;
if ($user_id <= 0 || $amount <= 0) return 0;
$wpdb->insert($this->table(), [
'user_id' => $user_id,
'order_number' => sanitize_text_field($order_number),
'amount' => $amount,
'type' => sanitize_key($type),
'status' => 'pending',
'created_at' => current_time('mysql'),
], ['%d', '%s', '%f', '%s', '%s', '%s']);
$id = (int) $wpdb->insert_id;
if ($id > 0) {
do_action('aksrt_creator_income_added', $user_id, $amount, [
'order_number' => $order_number,
'type' => $type,
'revenue_id' => $id,
]);
}
return $id;
}
/**
* 结算收益
*/
public function settle(int $revenue_id): bool {
global $wpdb;
return $wpdb->update(
$this->table(),
['status' => 'settled', 'settled_at' => current_time('mysql')],
['id' => $revenue_id, 'status' => 'pending'],
['%s', '%s'],
['%d', '%s']
) !== false;
}
/**
* 获取用户收益列表
*/
public function get_user_revenues(int $user_id, array $args = []): array {
global $wpdb;
$where = ['user_id = %d'];
$params = [$user_id];
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
}
/**
* Admin list (all users) with optional status / user filter.
*
* @param array{status?:string,user_id?:int,limit?:int,offset?:int} $args
* @return array{items:array<int,array>,total:int,page:int,max_pages:int}
*/
public function get_list(array $args = []): array {
global $wpdb;
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$status = sanitize_key((string) ($args['status'] ?? ''));
$user_id = (int) ($args['user_id'] ?? 0);
$where = [];
$params = [];
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
if ($user_id > 0) {
$where[] = 'user_id = %d';
$params[] = $user_id;
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
$total = $params
? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params))
: (int) $wpdb->get_var($count_sql);
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return [
'items' => $items ?: [],
'total' => $total,
'page' => (int) floor($offset / $limit) + 1,
'max_pages' => (int) ceil(max(1, $total) / $limit),
];
}
/**
* 获取收益统计
*/
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
COALESCE(SUM(amount), 0) as total,
COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) as pending,
COALESCE(SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END), 0) as settled,
COUNT(*) as count
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' => (float) ($row['total'] ?? 0),
'pending' => (float) ($row['pending'] ?? 0),
'settled' => (float) ($row['settled'] ?? 0),
'count' => (int) ($row['count'] ?? 0),
];
}
}
@@ -0,0 +1,373 @@
<?php
/**
* AKSRT Shipping — 物流管理
*
* 管理订单发货、物流单号、发货状态。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Shipping {
use AKSRT_Shipping_Part_1;
use AKSRT_Shipping_Part_2;
private static $instance = null;
const STATUS_SHIPPED = 'shipped';
const STATUS_IN_TRANSIT = 'in_transit';
const STATUS_DELIVERED = 'delivered';
const STATUS_RETURNED = 'returned';
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function table(): string {
return AKSRT_DB_Migrator::table('order_shipping');
}
/**
* 创建发货记录
*/
public function create(array $data): int {
global $wpdb;
$order_number = sanitize_text_field((string) ($data['order_number'] ?? ''));
$carrier = sanitize_text_field((string) ($data['carrier'] ?? ''));
$tracking_no = sanitize_text_field((string) ($data['tracking_no'] ?? ''));
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
$operator_id = (int) ($data['operator_id'] ?? 0);
if ($order_number === '' || $carrier === '' || $tracking_no === '') {
return 0;
}
$result = $wpdb->insert($this->table(), [
'order_number' => $order_number,
'carrier' => $carrier,
'tracking_no' => $tracking_no,
'status' => self::STATUS_SHIPPED,
'note' => $note,
'operator_id' => $operator_id ?: null,
'created_at' => current_time('mysql'),
]);
$insert_id = $result ? (int) $wpdb->insert_id : 0;
if ($insert_id > 0) {
$this->sync_order_status($order_number, self::STATUS_SHIPPED, sprintf(
__('发货记录已创建:%1$s %2$s', 'aksrt'),
$this->get_carrier_label($carrier),
$tracking_no
));
// R68: Email notification on shipping creation (§6.6)
$this->send_shipping_notification($order_number, $carrier, $tracking_no, $note);
}
return $insert_id;
}
/**
* 获取发货记录列表
*/
public function get_list(array $args = []): array {
global $wpdb;
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$include_order_context = !empty($args['include_order_context']);
$status = sanitize_key((string) ($args['status'] ?? ''));
$order_number = sanitize_text_field((string) ($args['order_number'] ?? ''));
$date_from = sanitize_text_field((string) ($args['date_from'] ?? ''));
$date_to = sanitize_text_field((string) ($args['date_to'] ?? ''));
$tracking_no = sanitize_text_field((string) ($args['tracking_no'] ?? ''));
$where = [];
$params = [];
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
if ($order_number !== '') {
$where[] = 'order_number = %s';
$params[] = $order_number;
}
if ($tracking_no !== '') {
$where[] = 'tracking_no LIKE %s';
$params[] = '%' . $wpdb->esc_like($tracking_no) . '%';
}
if ($date_from !== '') {
$where[] = 'created_at >= %s';
$params[] = $date_from . ' 00:00:00';
}
if ($date_to !== '') {
$where[] = 'created_at <= %s';
$params[] = $date_to . ' 23:59:59';
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
$total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql);
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
$items = array_map([$this, 'decorate_record'], (array) $items);
if ($include_order_context && !empty($items) && class_exists('AKSRT_Order_Repository')) {
$contexts = AKSRT_Order_Repository::get_instance()->get_admin_order_context_map(array_column($items, 'order_number'));
foreach ($items as &$item) {
$item['order_context'] = $contexts[(string) ($item['order_number'] ?? '')] ?? [];
}
unset($item);
}
return [
'items' => $items ?: [],
'total' => $total,
'page' => floor($offset / $limit) + 1,
'max_pages' => ceil($total / $limit),
];
}
/**
* 获取单个发货记录
*/
public function get(int $id): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d",
$id
), ARRAY_A);
return $row ? $this->decorate_record($row) : null;
}
/**
* 获取订单的发货记录
*/
public function get_by_order(string $order_number): array {
global $wpdb;
$items = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY created_at DESC",
$order_number
), ARRAY_A);
return array_map([$this, 'decorate_record'], (array) $items);
}
/**
* 获取单个发货记录并附带订单上下文。
*/
public function get_with_order_context(int $id): ?array {
$record = $this->get($id);
if (!$record) {
return null;
}
$record['order_context'] = class_exists('AKSRT_Order_Repository')
? AKSRT_Order_Repository::get_instance()->get_admin_order_context((string) ($record['order_number'] ?? ''))
: [];
return $record;
}
/**
* @param array<int, string> $order_numbers
* @return array<string, array<string, mixed>>
*/
public function get_summary_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 [];
}
$map = [];
foreach ($order_numbers as $order_number) {
$map[$order_number] = [
'count' => 0,
'latest' => [],
];
}
$placeholders = implode(', ', array_fill(0, count($order_numbers), '%s'));
$sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders}) ORDER BY created_at DESC, id DESC";
$rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A);
foreach ((array) $rows as $row) {
$record = $this->decorate_record($row);
$order_number = (string) ($record['order_number'] ?? '');
if (!isset($map[$order_number])) {
continue;
}
$map[$order_number]['count']++;
if (empty($map[$order_number]['latest'])) {
$map[$order_number]['latest'] = $this->build_summary_item($record);
}
}
return $map;
}
public function batch_update_status(array $ids, string $new_status, string $note = '', int $operator_id = 0): array {
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
$result = [
'updated_ids' => [],
'failed' => [],
];
foreach ($ids as $id) {
$record = $this->get($id);
if (!$record) {
$result['failed'][] = [
'id' => $id,
'reason' => __('发货记录不存在', 'aksrt'),
];
continue;
}
if (!$this->can_transition_status((string) ($record['status'] ?? ''), $new_status)) {
$result['failed'][] = [
'id' => $id,
'reason' => __('当前状态不允许批量更新到目标状态', 'aksrt'),
];
continue;
}
if ($this->update_status($id, $new_status, $note, $operator_id)) {
$result['updated_ids'][] = $id;
} else {
$result['failed'][] = [
'id' => $id,
'reason' => __('状态更新失败', 'aksrt'),
];
}
}
return $result;
}
/**
* 更新发货状态
*/
public function update_status(int $id, string $new_status, string $note = '', int $operator_id = 0): bool {
global $wpdb;
$record = $this->get($id);
if (!$record) {
return false;
}
$valid = [self::STATUS_SHIPPED, self::STATUS_IN_TRANSIT, self::STATUS_DELIVERED, self::STATUS_RETURNED];
if (!in_array($new_status, $valid, true)) return false;
if (!$this->can_transition_status((string) ($record['status'] ?? ''), $new_status)) {
return false;
}
$update = [
'status' => $new_status,
'updated_at' => current_time('mysql'),
];
if ($note !== '') {
$update['note'] = sanitize_textarea_field($note);
}
if ($operator_id > 0) {
$update['operator_id'] = $operator_id;
}
if ($new_status === self::STATUS_DELIVERED) {
$update['delivered_at'] = current_time('mysql');
}
$updated = (bool) $wpdb->update($this->table(), $update, ['id' => $id]);
if ($updated) {
$this->sync_order_status(
(string) ($record['order_number'] ?? ''),
$new_status,
$note !== '' ? $note : sprintf(__('物流状态更新为:%s', 'aksrt'), $this->get_status_label($new_status))
);
}
return $updated;
}
/**
* 获取统计
*/
public function get_stats(): array {
global $wpdb;
$table = $this->table();
$counts = $wpdb->get_results(
"SELECT status, COUNT(*) as cnt FROM {$table} GROUP BY status",
ARRAY_A
);
$counts = array_column($counts, 'cnt', 'status');
$stats = [
'total' => 0,
'shipped' => 0,
'in_transit' => 0,
'delivered' => 0,
'returned' => 0,
];
foreach ($counts as $status => $cnt) {
if (isset($stats[$status])) {
$stats[$status] = (int) $cnt;
}
$stats['total'] += (int) $cnt;
}
return $stats;
}
/**
* 获取常用快递公司列表
*/
public static function get_carrier_options(): array {
return [
['value' => 'sf', 'label' => '顺丰速运'],
['value' => 'yd', 'label' => '韵达快递'],
['value' => 'zt', 'label' => '中通快递'],
['value' => 'yt', 'label' => '圆通速递'],
['value' => 'sto', 'label' => '申通快递'],
['value' => 'jd', 'label' => '京东物流'],
['value' => 'ems', 'label' => 'EMS'],
['value' => 'db', 'label' => '德邦快递'],
['value' => 'yunda', 'label' => '韵达快递'],
['value' => 'other', 'label' => '其他'],
];
}
public static function get_status_options(): array {
return [
['value' => self::STATUS_SHIPPED, 'label' => __('已发货', 'aksrt')],
['value' => self::STATUS_IN_TRANSIT, 'label' => __('运输中', 'aksrt')],
['value' => self::STATUS_DELIVERED, 'label' => __('已签收', 'aksrt')],
['value' => self::STATUS_RETURNED, 'label' => __('已退回', 'aksrt')],
];
}
/**
* Send email notification to order customer when shipping record is created.
*/
}
@@ -0,0 +1,271 @@
<?php
/**
* AKSRT WeChat Notify — 微信通知骨架
*
* 提供微信模板消息发送和配置管理。
* 当前为骨架实现,后续 Phase 补充真实微信 API 集成。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_WeChat_Notify {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
public function init(): void {
add_action('aksrt_notification_sent', [$this, 'maybe_send_wechat'], 10, 3);
add_action('wp_ajax_aksrt_wechat_status', [$this, 'ajax_status']);
}
/**
* 获取微信通知配置
*/
public function get_config(): array {
if (class_exists('AKSRT_Settings')) {
return AKSRT_Settings::get_wechat_settings();
}
return ['enabled' => false];
}
/**
* 发送微信模板消息
*
* @param int $user_id 用户 ID
* @param string $template 模板标识
* @param array $data 模板数据
* @return bool
*/
public function send(int $user_id, string $template, array $data = []): bool {
$config = $this->get_config();
if (empty($config['enabled'])) {
return false;
}
// 获取用户的微信 openid
$openid = get_user_meta($user_id, 'aksrt_wechat_openid', true);
if (empty($openid)) {
return false;
}
// 获取 access token
$access_token = $this->get_access_token($config);
if (empty($access_token)) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT WeChat] Failed to get access token');
}
return false;
}
// 构建模板消息
$template_id = $config['template_id'] ?? '';
if (empty($template_id)) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT WeChat] No template ID configured');
}
return false;
}
$message = $this->build_template_message($template, $data, $openid, $template_id);
// 调用微信模板消息 API
$response = wp_remote_post('https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=' . $access_token, [
'headers' => ['Content-Type' => 'application/json'],
'body' => wp_json_encode($message),
'timeout' => 10,
]);
if (is_wp_error($response)) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT WeChat] API error: ' . $response->get_error_message());
}
return false;
}
$body = json_decode(wp_remote_retrieve_body($response), true);
$errcode = (int) ($body['errcode'] ?? 0);
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf('[AKSRT WeChat] User: %d, Template: %s, Result: %s', $user_id, $template, wp_json_encode($body)));
}
return $errcode === 0;
}
/**
* 获取微信 access token(带缓存)
*/
private function get_access_token(array $config): string {
$cache_key = 'aksrt_wechat_token';
$cached = get_transient($cache_key);
if (!empty($cached)) return (string) $cached;
$app_id = $config['app_id'] ?? '';
$app_secret = $config['app_secret'] ?? '';
if (empty($app_id) || empty($app_secret)) return '';
$response = wp_remote_get(sprintf(
'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s',
urlencode($app_id),
urlencode($app_secret)
));
if (is_wp_error($response)) return '';
$body = json_decode(wp_remote_retrieve_body($response), true);
$token = $body['access_token'] ?? '';
$expires = max(600, (int) ($body['expires_in'] ?? 7200) - 300);
if (!empty($token)) {
set_transient($cache_key, $token, $expires);
}
return (string) $token;
}
/**
* 构建模板消息体
*/
private function build_template_message(string $template, array $data, string $openid, string $template_id): array {
$url = $data['url'] ?? home_url('/');
$values = $data['values'] ?? [];
$template_data = [];
foreach ($values as $key => $value) {
$template_data[$key] = [
'value' => (string) $value,
'color' => '#333333',
];
}
return [
'touser' => $openid,
'template_id' => $template_id,
'url' => esc_url($url),
'data' => $template_data,
];
}
/**
* 绑定用户微信 openid
*/
public function bind_user(int $user_id, string $openid): bool {
if ($user_id <= 0 || empty($openid)) return false;
return (bool) update_user_meta($user_id, 'aksrt_wechat_openid', sanitize_text_field($openid));
}
/**
* 解绑用户微信
*/
public function unbind_user(int $user_id): bool {
return (bool) delete_user_meta($user_id, 'aksrt_wechat_openid');
}
/**
* 监听站内通知,联动微信通知
*/
public function maybe_send_wechat(int $user_id, string $type, array $data): void {
$config = $this->get_config();
if (empty($config['enabled'])) return;
// 仅特定类型通知发送微信
$allowed_types = ['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'forum_reply'];
if (!in_array($type, $allowed_types, true)) return;
$this->send($user_id, $type, $data);
}
/**
* AJAX: Get WeChat notification status (admin only).
*/
public function ajax_status(): void {
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
}
wp_send_json_success($this->get_status());
}
/**
* Get WeChat service status (used by REST and AJAX).
*/
public function get_status(): array {
$config = $this->get_config();
$has_token = false;
if (!empty($config['app_id']) && !empty($config['app_secret'])) {
$cached = get_transient('aksrt_wechat_token');
$has_token = !empty($cached);
}
// Count bound users
global $wpdb;
$bound_count = 0;
if ($wpdb) {
$bound_count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key = 'aksrt_wechat_openid' AND meta_value != ''"
);
}
return [
'enabled' => !empty($config['enabled']),
'appid' => $config['app_id'] ?? '',
'configured' => !empty($config['app_id']) && !empty($config['app_secret']),
'has_token' => $has_token,
'has_template' => !empty($config['template_id']),
'bound_users' => $bound_count,
'app_secret' => !empty($config['app_secret']) ? '***' : '',
'allowed_types' => ['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'forum_reply'],
];
}
/**
* Get notification subscriptions for a user.
*/
public function get_user_subscriptions(int $user_id): array {
$subs = get_user_meta($user_id, 'aksrt_wechat_subscriptions', true);
if (!is_array($subs)) {
$subs = [
'comment_reply' => true,
'system_notice' => true,
'order_paid' => true,
'credits_change' => false,
'forum_reply' => false,
];
}
return $subs;
}
/**
* Update notification subscriptions for a user.
*/
public function update_user_subscriptions(int $user_id, array $subscriptions): bool {
$allowed = ['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'forum_reply'];
$clean = [];
foreach ($allowed as $key) {
$clean[$key] = !empty($subscriptions[$key]);
}
return (bool) update_user_meta($user_id, 'aksrt_wechat_subscriptions', $clean);
}
/**
* Check if a user should receive a specific notification type via WeChat.
*/
public function should_notify(int $user_id, string $type): bool {
$config = $this->get_config();
if (empty($config['enabled'])) return false;
$openid = get_user_meta($user_id, 'aksrt_wechat_openid', true);
if (empty($openid)) return false;
$subs = $this->get_user_subscriptions($user_id);
return !empty($subs[$type]);
}
}
@@ -0,0 +1,413 @@
<?php
/**
* AKSRT Withdrawal — 提现台账 + 状态副作用(冻结退回)
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Withdrawal {
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_REJECTED = 'rejected';
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function table(): string {
return AKSRT_DB_Migrator::table('withdrawal');
}
/**
* 创建提现申请
*/
public function create(int $user_id, float $amount, string $method = '', string $account_info = ''): int {
global $wpdb;
if ($user_id <= 0 || $amount <= 0) {
return 0;
}
$wpdb->insert($this->table(), [
'user_id' => $user_id,
'amount' => $amount,
'method' => sanitize_key($method),
'account_info' => sanitize_textarea_field($account_info),
'status' => self::STATUS_PENDING,
'created_at' => current_time('mysql'),
], ['%d', '%f', '%s', '%s', '%s', '%s']);
return (int) $wpdb->insert_id;
}
/**
* Get single row.
*
* @return array<string,mixed>|null
*/
public function get(int $withdrawal_id): ?array {
global $wpdb;
if ($withdrawal_id <= 0) {
return null;
}
$row = $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d LIMIT 1", $withdrawal_id),
ARRAY_A
);
return is_array($row) ? $row : null;
}
/**
* 更新提现状态(无余额副作用)。优先使用 process_status_change()。
*/
public function update_status(int $withdrawal_id, string $new_status, string $note = ''): bool {
global $wpdb;
$update = ['status' => sanitize_key($new_status)];
$format = ['%s'];
if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) {
$update['processed_at'] = current_time('mysql');
$format[] = '%s';
}
if ($note !== '') {
$update['note'] = sanitize_textarea_field($note);
$format[] = '%s';
}
return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false;
}
/**
* Status transition with balance side-effects.
* Reject of balance-source holds refunds gross_amount once (idempotent).
*
* @return true|WP_Error
*/
public function process_status_change(int $withdrawal_id, string $new_status, string $note = '') {
$new_status = sanitize_key($new_status);
$allowed = [
self::STATUS_PENDING,
self::STATUS_APPROVED,
self::STATUS_PROCESSING,
self::STATUS_COMPLETED,
self::STATUS_REJECTED,
];
if ($withdrawal_id <= 0 || !in_array($new_status, $allowed, true)) {
return new WP_Error('aksrt_withdraw_status', __('提现状态无效', 'aksrt'));
}
$row = $this->get($withdrawal_id);
if (!$row) {
return new WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'));
}
$old_status = sanitize_key((string) ($row['status'] ?? ''));
if ($old_status === $new_status && $note === '') {
return true;
}
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
$user_id = (int) ($row['user_id'] ?? 0);
// Refund frozen balance when moving into rejected (once).
if (
$new_status === self::STATUS_REJECTED
&& $old_status !== self::STATUS_REJECTED
&& empty($meta['balance_refunded'])
&& $this->is_balance_source($meta)
&& class_exists('AKSRT_Balance')
&& $user_id > 0
) {
$gross = $this->resolve_gross_amount($row, $meta);
if ($gross > 0) {
$refund = AKSRT_Balance::add(
$user_id,
$gross,
'withdraw_refund',
['type' => 'withdraw', 'id' => $withdrawal_id],
sprintf(__('提现拒绝退回 #%d', 'aksrt'), $withdrawal_id)
);
if (is_wp_error($refund)) {
return $refund;
}
$meta['balance_refunded'] = true;
$meta['refunded_at'] = current_time('mysql');
}
}
$account_info = $this->encode_account_meta($meta, (string) ($row['account_info'] ?? ''));
$merged_note = $this->merge_note((string) ($row['note'] ?? ''), $note);
global $wpdb;
$update = [
'status' => $new_status,
'account_info' => $account_info,
];
$format = ['%s', '%s'];
if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) {
$update['processed_at'] = current_time('mysql');
$format[] = '%s';
}
if ($merged_note !== '') {
$update['note'] = $merged_note;
$format[] = '%s';
}
$ok = $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false;
if (!$ok) {
return new WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'));
}
// Notify user on terminal status (Zibll withdraw_reply parity).
if (class_exists('AKSRT_Message') && $user_id > 0 && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
$gross = $this->resolve_gross_amount($row, $meta);
$amount_txt = class_exists('AKSRT_Balance') ? AKSRT_Balance::format($gross) : number_format($gross, 2);
if ($new_status === self::STATUS_REJECTED) {
$title = __('提现已拒绝', 'aksrt');
$body = sprintf(__('您申请提现 %s 元已被拒绝,冻结金额已退回钱包。', 'aksrt'), $amount_txt);
if ($note !== '') {
$body .= ' ' . sprintf(__('备注:%s', 'aksrt'), $note);
}
} else {
$title = __('提现已完成', 'aksrt');
$body = sprintf(__('您申请提现 %s 元已处理完成。', 'aksrt'), $amount_txt);
}
AKSRT_Message::get_instance()->send($user_id, 'withdraw', $body, [
'title' => $title,
'target_url' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : '',
]);
}
return true;
}
/**
* Whether user has open (pending/processing/approved) withdrawals.
*/
public function has_open_withdrawal(int $user_id): bool {
return $this->count_open($user_id) > 0;
}
/**
* Count open withdrawals.
*/
public function count_open(int $user_id): int {
global $wpdb;
if ($user_id <= 0) {
return 0;
}
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()}
WHERE user_id = %d AND status IN ('pending','approved','processing')",
$user_id
));
}
/**
* Sum of frozen gross amounts still open (balance source).
*/
public function sum_pending_hold(int $user_id): float {
global $wpdb;
if ($user_id <= 0) {
return 0.0;
}
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT amount, account_info FROM {$this->table()}
WHERE user_id = %d AND status IN ('pending','approved','processing')",
$user_id
), ARRAY_A);
$total = 0.0;
foreach ((array) $rows as $row) {
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
if (!$this->is_balance_source($meta) || !empty($meta['balance_refunded'])) {
continue;
}
$total += $this->resolve_gross_amount($row, $meta);
}
return round($total, 2);
}
/**
* 获取用户提现列表
*
* @param array{status?:string,limit?:int,offset?:int,open_only?:bool} $args
* @return list<array<string,mixed>>
*/
public function get_user_withdrawals(int $user_id, array $args = []): array {
global $wpdb;
$where = ['user_id = %d'];
$params = [$user_id];
if (!empty($args['open_only'])) {
$where[] = "status IN ('pending','approved','processing')";
} else {
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . ' ORDER BY created_at DESC LIMIT %d OFFSET %d';
$params[] = $limit;
$params[] = $offset;
$rows = (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return array_map([$this, 'normalize_row'], $rows);
}
/**
* 获取所有提现列表(管理后台)
*
* @return list<array<string,mixed>>
*/
public function get_all(array $args = []): array {
global $wpdb;
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
$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;
if (!empty($params)) {
$rows = (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
} else {
$rows = (array) $wpdb->get_results($sql, ARRAY_A);
}
return array_map([$this, 'normalize_row'], $rows);
}
public function get_count(array $args = []): int {
global $wpdb;
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
return (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$this->table()} WHERE status = %s",
$status
));
}
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}");
}
public function get_stats(): array {
global $wpdb;
$row = $wpdb->get_row(
"SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END), 0) as total_paid
FROM {$this->table()}",
ARRAY_A
);
return [
'total' => (int) ($row['total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'completed' => (int) ($row['completed'] ?? 0),
'total_paid' => (float) ($row['total_paid'] ?? 0),
];
}
/**
* @param array<string,mixed> $row
* @return array<string,mixed>
*/
private function normalize_row(array $row): array {
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
$gross = $this->resolve_gross_amount($row, $meta);
$row['meta'] = $meta;
$row['gross_amount'] = $gross;
$row['fee_rate'] = (int) ($meta['fee_rate'] ?? 0);
$row['display_account'] = (string) ($meta['account'] ?? $row['account_info'] ?? '');
$row['source'] = (string) ($meta['source'] ?? 'balance');
return $row;
}
/**
* @return array<string,mixed>
*/
private function parse_account_meta(string $raw): array {
$raw = trim($raw);
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : ['account' => $raw];
}
/**
* @param array<string,mixed> $meta
*/
private function encode_account_meta(array $meta, string $fallback_raw): string {
if ($meta === []) {
return $fallback_raw;
}
$json = wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return is_string($json) ? $json : $fallback_raw;
}
/**
* @param array<string,mixed> $meta
*/
private function is_balance_source(array $meta): bool {
$source = sanitize_key((string) ($meta['source'] ?? 'balance'));
// Empty / balance = cash wallet freeze path.
return $source === '' || $source === 'balance';
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed> $meta
*/
private function resolve_gross_amount(array $row, array $meta): float {
if (isset($meta['gross_amount'])) {
return max(0, round((float) $meta['gross_amount'], 2));
}
// Legacy rows: amount was net or gross without meta.
return max(0, round((float) ($row['amount'] ?? 0), 2));
}
private function merge_note(string $old, string $new): string {
$new = sanitize_textarea_field($new);
if ($new === '') {
return sanitize_textarea_field($old);
}
$old = sanitize_textarea_field($old);
if ($old === '') {
return $new;
}
return $old . "\n" . $new;
}
}
@@ -0,0 +1,196 @@
<?php
/**
* AKSRT Alipay Gateway — 支付宝支付网关
*
* 使用 yurunsoft/pay-sdk 实现电脑网站支付、手机网站支付。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Alipay_Gateway implements AKSRT_Payment_Gateway_Interface {
public function get_id(): string {
return 'alipay';
}
public function get_name(): string {
return __('支付宝', 'aksrt');
}
public function is_configured(): bool {
if (class_exists('AKSRT_Settings_Payment_Runtime')
&& !AKSRT_Settings_Payment_Runtime::is_gateway_enabled('alipay')) {
return false;
}
$config = $this->get_config();
return !empty($config['app_id']) && !empty($config['private_key']) && !empty($config['alipay_public_key']);
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
$config = $this->get_config();
$order_number = $order['order_number'] ?? '';
$amount = (float) ($order['amount'] ?? 0);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$payment_type = $config['payment_type'] ?? 'page'; // page / wap
try {
$sdk = $this->create_sdk($config);
if ($payment_type === 'wap') {
// 手机网站支付
$request = new \Yurun\PaySDK\AlipayApp\Wap\Params\Pay\Request;
$request->notify_url = home_url('/wp-json/aksrt/v1/payment/alipay/notify');
$request->return_url = home_url('/wp-json/aksrt/v1/payment/alipay/return');
$request->businessParams->out_trade_no = $order_number;
$request->businessParams->total_amount = number_format($amount, 2, '.', '');
$request->businessParams->subject = $subject;
} else {
// 电脑网站支付
$request = new \Yurun\PaySDK\AlipayApp\Page\Params\Pay\Request;
$request->notify_url = home_url('/wp-json/aksrt/v1/payment/alipay/notify');
$request->return_url = home_url('/wp-json/aksrt/v1/payment/alipay/return');
$request->businessParams->out_trade_no = $order_number;
$request->businessParams->total_amount = number_format($amount, 2, '.', '');
$request->businessParams->subject = $subject;
}
// 构建支付跳转 URL(prepareExecute 已将查询参数追加到 $url)
$sdk->prepareExecute($request, $url, $data);
$payment_url = $url;
return [
'success' => true,
'payment_url' => $payment_url,
'payment_data' => ['order_number' => $order_number],
'message' => __('支付宝支付链接已生成', 'aksrt'),
];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function verify_callback(WP_REST_Request $request): array {
$config = $this->get_config();
try {
$sdk = $this->create_sdk($config);
$params = $request->get_params();
if ($sdk->verifyCallback($params)) {
$trade_status = $params['trade_status'] ?? '';
return [
'success' => true,
'order_number' => $params['out_trade_no'] ?? '',
'amount' => (float) ($params['total_amount'] ?? 0),
'transaction_id' => $params['trade_no'] ?? '',
'status' => in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED']) ? 'paid' : 'pending',
];
}
return ['success' => false, 'message' => __('支付宝回调验证失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function query_status(string $order_number): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
$config = $this->get_config();
try {
$sdk = $this->create_sdk($config);
$request = new \Yurun\PaySDK\AlipayApp\Params\Query\Request;
$request->businessParams->out_trade_no = $order_number;
$result = $sdk->execute($request);
if ($sdk->checkResult()) {
$trade_status = $result['trade_status'] ?? '';
$status = 'pending';
if (in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED'])) {
$status = 'paid';
} elseif ($trade_status === 'TRADE_CLOSED') {
$status = 'cancelled';
}
return [
'success' => true,
'status' => $status,
'transaction_id' => $result['trade_no'] ?? '',
];
}
return ['success' => false, 'message' => $sdk->getError() ?: __('查询失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function refund(string $order_number, float $amount): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
$config = $this->get_config();
try {
$sdk = $this->create_sdk($config);
$request = new \Yurun\PaySDK\AlipayApp\Params\Refund\Request;
$request->businessParams->out_trade_no = $order_number;
$request->businessParams->refund_amount = number_format($amount, 2, '.', '');
$request->businessParams->refund_reason = __('用户申请退款', 'aksrt');
$request->businessParams->out_request_no = $order_number . '_refund_' . time();
$result = $sdk->execute($request);
if ($sdk->checkResult()) {
return [
'success' => true,
'refund_id' => $result['trade_no'] ?? '',
'message' => __('退款成功', 'aksrt'),
];
}
return ['success' => false, 'message' => $sdk->getError() ?: __('退款失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
private function get_config(): array {
if (!class_exists('AKSRT_Settings')) {
return [];
}
$all = AKSRT_Settings::get_all();
return [
'app_id' => (string) ($all['alipay_appid'] ?? ''),
'private_key' => (string) ($all['alipay_privatekey'] ?? ''),
'alipay_public_key' => (string) ($all['alipay_publickey'] ?? ''),
'return_url' => (string) ($all['alipay_return_url'] ?? ''),
'notify_url' => (string) ($all['alipay_notify_url'] ?? ''),
'payment_type' => (string) ($all['alipay_payment_type'] ?? 'page'),
'sign_type' => (string) ($all['alipay_sign_type'] ?? 'RSA2'),
];
}
private function create_sdk(array $config): \Yurun\PaySDK\AlipayApp\SDK {
$params = new \Yurun\PaySDK\AlipayApp\Params\PublicParams;
$params->appID = $config['app_id'] ?? '';
$params->appPrivateKey = $config['private_key'] ?? '';
$params->appPublicKey = $config['alipay_public_key'] ?? '';
$params->sign_type = $config['sign_type'] ?? 'RSA2';
return new \Yurun\PaySDK\AlipayApp\SDK($params);
}
}
@@ -0,0 +1,251 @@
<?php
/**
* AKSRT PayPal Gateway — PayPal 支付网关
*
* 使用 WordPress HTTP API 直接调用 PayPal REST API,无需第三方 SDK。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_PayPal_Gateway implements AKSRT_Payment_Gateway_Interface {
public function get_id(): string {
return 'paypal';
}
public function get_name(): string {
return __('PayPal', 'aksrt');
}
public function is_configured(): bool {
if (class_exists('AKSRT_Settings_Payment_Runtime')
&& !AKSRT_Settings_Payment_Runtime::is_gateway_enabled('paypal')) {
return false;
}
$config = $this->get_config();
return !empty($config['client_id']) && !empty($config['client_secret']);
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('PayPal 未配置', 'aksrt')];
}
$config = $this->get_config();
$order_number = $order['order_number'] ?? '';
$amount = (float) ($order['amount'] ?? 0);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$currency = $config['currency'] ?? 'USD';
try {
$response = $this->api_request('POST', '/v2/checkout/orders', [
'intent' => 'CAPTURE',
'purchase_units' => [[
'reference_id' => $order_number,
'description' => $subject,
'amount' => [
'currency_code' => $currency,
'value' => number_format($amount, 2, '.', ''),
],
]],
'application_context' => [
'return_url' => home_url('/wp-json/aksrt/v1/payment/paypal/return?order=' . $order_number),
'cancel_url' => home_url('/wp-json/aksrt/v1/payment/paypal/cancel?order=' . $order_number),
'brand_name' => get_bloginfo('name'),
'user_action' => 'PAY_NOW',
],
]);
if ($response['status'] === 201) {
$data = $response['body'];
$approveUrl = '';
foreach ($data['links'] ?? [] as $link) {
if ($link['rel'] === 'approve') {
$approveUrl = $link['href'];
break;
}
}
return [
'success' => true,
'payment_url' => $approveUrl,
'payment_data' => [
'order_number' => $order_number,
'paypal_id' => $data['id'] ?? '',
],
'message' => __('PayPal 订单已创建', 'aksrt'),
];
}
return ['success' => false, 'message' => $response['body']['message'] ?? __('PayPal 创建失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function verify_callback(WP_REST_Request $request): array {
try {
$body = json_decode($request->get_body(), true);
$eventType = $body['event_type'] ?? '';
if ($eventType !== 'CHECKOUT.ORDER.APPROVED') {
return ['success' => false, 'message' => __('非支付完成事件', 'aksrt')];
}
$resource = $body['resource'] ?? [];
$orderNumber = '';
$amount = 0;
foreach ($resource['purchase_units'] ?? [] as $unit) {
$orderNumber = $unit['reference_id'] ?? '';
$amount = (float) ($unit['amount']['value'] ?? 0);
break;
}
return [
'success' => true,
'order_number' => $orderNumber,
'amount' => $amount,
'transaction_id' => $resource['id'] ?? '',
'status' => 'paid',
];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function query_status(string $order_number): array {
return [
'success' => true,
'status' => 'pending',
'message' => __('PayPal 状态通过 Webhook 更新', 'aksrt'),
];
}
public function refund(string $order_number, float $amount): array {
return [
'success' => false,
'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'),
];
}
private function get_config(): array {
if (!class_exists('AKSRT_Settings')) {
return [];
}
$all = AKSRT_Settings::get_all();
return [
'client_id' => (string) ($all['paypal_client_id'] ?? ''),
'client_secret' => (string) ($all['paypal_client_secret'] ?? ''),
'currency' => (string) ($all['paypal_currency'] ?? 'USD'),
'mode' => !empty($all['paypal_sandbox']) ? 'sandbox' : 'live',
];
}
/**
* 捕获已批准的 PayPal 订单(v2 CAPTURE 必需步骤)。
* 用户在 return_url 返回后调用,确认真正扣款。
*
* @param string $paypal_order_id PayPal 订单 ID(create 时返回的 id)
* @return array{success:bool, order_number?:string, amount?:float, transaction_id?:string, status?:string, message?:string}
*/
public function capture_order(string $paypal_order_id): array {
if ($paypal_order_id === '') {
return ['success' => false, 'message' => __('缺少 PayPal 订单号', 'aksrt')];
}
try {
$response = $this->api_request('POST', '/v2/checkout/orders/' . rawurlencode($paypal_order_id) . '/capture');
$body = $response['body'];
if (in_array($response['status'], [200, 201], true) && ($body['status'] ?? '') === 'COMPLETED') {
$unit = $body['purchase_units'][0] ?? [];
$capture = $unit['payments']['captures'][0] ?? [];
return [
'success' => true,
'order_number' => $unit['reference_id'] ?? '',
'amount' => (float) ($capture['amount']['value'] ?? 0),
'transaction_id' => $capture['id'] ?? ($body['id'] ?? ''),
'status' => 'paid',
];
}
return ['success' => false, 'message' => $body['message'] ?? __('PayPal 捕获失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* 获取 OAuth2 access token(v2 REST 必需)。
*/
private function get_access_token(): string {
$config = $this->get_config();
$isSandbox = ($config['mode'] ?? 'live') === 'sandbox';
$baseUrl = $isSandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
$response = wp_remote_post($baseUrl . '/v1/oauth2/token', [
'headers' => [
'Authorization' => 'Basic ' . base64_encode(($config['client_id'] ?? '') . ':' . ($config['client_secret'] ?? '')),
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => 'grant_type=client_credentials',
'timeout' => 30,
]);
if (is_wp_error($response)) {
throw new \Exception($response->get_error_message());
}
$body = json_decode(wp_remote_retrieve_body($response), true);
$token = $body['access_token'] ?? '';
if ($token === '') {
throw new \Exception($body['error_description'] ?? __('PayPal 鉴权失败', 'aksrt'));
}
return (string) $token;
}
/**
* 调用 PayPal REST API(带 OAuth2 Bearer token)
*/
private function api_request(string $method, string $endpoint, array $data = []): array {
$config = $this->get_config();
$isSandbox = ($config['mode'] ?? 'live') === 'sandbox';
$baseUrl = $isSandbox
? 'https://api-m.sandbox.paypal.com'
: 'https://api-m.paypal.com';
$url = $baseUrl . $endpoint;
$headers = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $this->get_access_token(),
];
$args = [
'method' => $method,
'headers' => $headers,
'timeout' => 30,
];
if (!empty($data)) {
$args['body'] = wp_json_encode($data);
}
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
throw new \Exception($response->get_error_message());
}
$status = wp_remote_retrieve_response_code($response);
$body = json_decode(wp_remote_retrieve_body($response), true);
return [
'status' => $status,
'body' => $body ?: [],
];
}
}
@@ -0,0 +1,289 @@
<?php
/**
* AKSRT Stripe Gateway — Stripe 支付网关
*
* 使用 stripe/stripe-php 实现 Checkout Session 支付、Webhook 回调、退款。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Stripe_Gateway implements AKSRT_Payment_Gateway_Interface {
public function get_id(): string {
return 'stripe';
}
public function get_name(): string {
return __('Stripe', 'aksrt');
}
public function is_configured(): bool {
if (class_exists('AKSRT_Settings_Payment_Runtime')
&& !AKSRT_Settings_Payment_Runtime::is_gateway_enabled('stripe')) {
return false;
}
$config = $this->get_config();
return !empty($config['secret_key']) && !empty($config['publishable_key']);
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
}
$config = $this->get_config();
$order_number = $order['order_number'] ?? '';
$amount = (float) ($order['amount'] ?? 0);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$currency = strtolower($config['currency'] ?? 'usd');
try {
$stripe = $this->create_client($config);
// 创建 Checkout Session
$session_params = [
'payment_method_types' => $this->get_payment_methods($config),
'line_items' => [[
'price_data' => [
'currency' => $currency,
'product_data' => [
'name' => $subject,
],
'unit_amount' => $this->to_smallest_unit($amount, $currency),
],
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => home_url('/wp-json/aksrt/v1/payment/stripe/return?session_id={CHECKOUT_SESSION_ID}&order=' . $order_number),
'cancel_url' => home_url('/wp-json/aksrt/v1/payment/stripe/cancel?order=' . $order_number),
'metadata' => [
'order_number' => $order_number,
],
];
// 启用自动支付方式(Apple Pay, Google Pay 等)
if (!empty($config['auto_payment_methods'])) {
$session_params['automatic_payment_methods'] = [
'enabled' => true,
];
}
$session = \Stripe\Checkout\Session::create($session_params, [
'api_key' => $config['secret_key'],
]);
return [
'success' => true,
'payment_url' => $session->url,
'payment_data' => [
'order_number' => $order_number,
'session_id' => $session->id,
],
'message' => __('Stripe 支付页面已创建', 'aksrt'),
];
} catch (\Stripe\Exception\ApiErrorException $e) {
return ['success' => false, 'message' => $e->getMessage()];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function verify_callback(WP_REST_Request $request): array {
$config = $this->get_config();
try {
$stripe = $this->create_client($config);
$payload = $request->get_body();
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
// 验证 Webhook 签名
$event = \Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
$config['webhook_secret'] ?? ''
);
if ($event->type !== 'checkout.session.completed') {
return ['success' => false, 'message' => __('非支付完成事件', 'aksrt')];
}
$session = $event->data->object;
$orderNumber = $session->metadata->order_number ?? '';
$amount = ($session->amount_total ?? 0) / 100;
return [
'success' => true,
'order_number' => $orderNumber,
'amount' => $amount,
'transaction_id' => $session->payment_intent ?? $session->id,
'status' => $session->payment_status === 'paid' ? 'paid' : 'pending',
];
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return ['success' => false, 'message' => __('Webhook 签名验证失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function query_status(string $order_number): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
}
$config = $this->get_config();
try {
$stripe = $this->create_client($config);
// 注意:Stripe API 的 Session::all() 不支持按 metadata 过滤。
// 此处通过创建时间倒序获取最近的 session,再匹配 metadata。
// 生产环境建议在 create_payment 时保存 session_id 并用 Session::retrieve() 查询。
$sessions = \Stripe\Checkout\Session::all([
'limit' => 100,
], ['api_key' => $config['secret_key']]);
// 手动按 metadata 匹配
$found = null;
foreach ($sessions->data as $session) {
if (($session->metadata->order_number ?? '') === $order_number) {
$found = $session;
break;
}
}
if ($found) {
$status = 'pending';
if ($found->payment_status === 'paid') {
$status = 'paid';
} elseif ($found->status === 'expired') {
$status = 'cancelled';
}
return [
'success' => true,
'status' => $status,
'transaction_id' => $found->payment_intent ?? '',
];
}
return ['success' => true, 'status' => 'pending', 'message' => __('未找到支付记录', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function refund(string $order_number, float $amount): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
}
$config = $this->get_config();
try {
$stripe = $this->create_client($config);
// 先查询 payment_intent(手动按 metadata 匹配)
$sessions = \Stripe\Checkout\Session::all([
'limit' => 100,
], ['api_key' => $config['secret_key']]);
$found = null;
foreach ($sessions->data as $session) {
if (($session->metadata->order_number ?? '') === $order_number) {
$found = $session;
break;
}
}
if (!$found) {
return ['success' => false, 'message' => __('未找到支付记录', 'aksrt')];
}
$paymentIntentId = $found->payment_intent;
if (empty($paymentIntentId)) {
return ['success' => false, 'message' => __('未找到支付意图', 'aksrt')];
}
$currency = strtolower($config['currency'] ?? 'usd');
$refund = \Stripe\Refund::create([
'payment_intent' => $paymentIntentId,
'amount' => $this->to_smallest_unit($amount, $currency),
'reason' => 'requested_by_customer',
], ['api_key' => $config['secret_key']]);
return [
'success' => true,
'refund_id' => $refund->id,
'message' => __('退款成功', 'aksrt'),
];
} catch (\Stripe\Exception\ApiErrorException $e) {
return ['success' => false, 'message' => $e->getMessage()];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* 获取 Stripe Publishable Key(前端使用)。
*/
public function get_publishable_key(): string {
$config = $this->get_config();
return $config['publishable_key'] ?? '';
}
private function get_config(): array {
if (!class_exists('AKSRT_Settings')) {
return [];
}
$all = AKSRT_Settings::get_all();
return [
'secret_key' => (string) ($all['stripe_secret_key'] ?? ''),
'publishable_key' => (string) ($all['stripe_publishable_key'] ?? ''),
'webhook_secret' => (string) ($all['stripe_webhook_secret'] ?? ''),
'currency' => (string) ($all['stripe_currency'] ?? 'usd'),
'mode' => !empty($all['stripe_test_mode']) ? 'test' : 'live',
];
}
private function create_client(array $config): void {
\Stripe\Stripe::setApiKey($config['secret_key']);
// 设置 API 版本
\Stripe\Stripe::setApiVersion('2023-10-16');
// 启用遥测(可选)
if (!empty($config['enable_telemetry'])) {
\Stripe\Stripe::setEnableTelemetry(true);
}
}
/**
* 获取支持的支付方式。
*/
private function get_payment_methods(array $config): array {
$methods = $config['payment_methods'] ?? ['card'];
// 验证支付方式
$valid = ['card', 'alipay', 'wechat_pay', 'sepa_debit', 'ideal', 'bancontact', 'giropay', 'sofort'];
return array_values(array_intersect($methods, $valid)) ?: ['card'];
}
/**
* 将金额转换为最小单位(分/cent)。
*/
private function to_smallest_unit(float $amount, string $currency): int {
// 零小数货币(日元、韩元等)
$zeroDecimal = ['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'];
if (in_array(strtolower($currency), $zeroDecimal, true)) {
return (int) $amount;
}
return (int) round($amount * 100);
}
}
@@ -0,0 +1,244 @@
<?php
/**
* AKSRT WeChat Pay Gateway — 微信支付网关
*
* 使用 yurunsoft/pay-sdk 实现 Native 支付(扫码)、JSAPI 支付、H5 支付。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_WechatPay_Gateway implements AKSRT_Payment_Gateway_Interface {
public function get_id(): string {
return 'wechatpay';
}
public function get_name(): string {
return __('微信支付', 'aksrt');
}
public function is_configured(): bool {
if (class_exists('AKSRT_Settings_Payment_Runtime')
&& !AKSRT_Settings_Payment_Runtime::is_gateway_enabled('wechatpay')) {
return false;
}
$config = $this->get_config();
return !empty($config['mch_id']) && !empty($config['key']);
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
$config = $this->get_config();
$order_number = $order['order_number'] ?? '';
$amount = (float) ($order['amount'] ?? 0);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$payment_type = $config['payment_type'] ?? 'native'; // native / jsapi / h5
try {
$sdk = $this->create_sdk($config);
$total_fee = (int) ($amount * 100); // 转为分
switch ($payment_type) {
case 'jsapi':
$request = new \Yurun\PaySDK\Weixin\JSAPI\Params\Pay\Request;
$request->openid = $order['openid'] ?? '';
break;
case 'h5':
$request = new \Yurun\PaySDK\Weixin\H5\Params\Pay\Request;
break;
case 'native':
default:
$request = new \Yurun\PaySDK\Weixin\Native\Params\Pay\Request;
break;
}
$request->body = $subject;
$request->out_trade_no = $order_number;
$request->total_fee = $total_fee;
$request->fee_type = 'CNY';
$request->notify_url = home_url('/wp-json/aksrt/v1/payment/wechatpay/notify');
$request->spbill_create_ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$result = $sdk->execute($request);
if ($sdk->checkResult()) {
$result_data = [
'success' => true,
'payment_data' => array_merge($result, ['order_number' => $order_number]),
'message' => __('微信支付订单已创建', 'aksrt'),
];
if ($payment_type === 'native' && !empty($result['code_url'])) {
$result_data['payment_url'] = $result['code_url'];
} elseif ($payment_type === 'h5' && !empty($result['h5_url'])) {
$result_data['payment_url'] = $result['h5_url'];
}
return $result_data;
}
return ['success' => false, 'message' => $sdk->getError() ?: __('微信支付创建失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function verify_callback(WP_REST_Request $request): array {
$config = $this->get_config();
try {
$sdk = $this->create_sdk($config);
// 读取原始请求体
$xml = file_get_contents('php://input');
$data = \Yurun\PaySDK\Lib\XML::fromString($xml);
if ($sdk->verifyCallback($data)) {
return [
'success' => true,
'order_number' => $data['out_trade_no'] ?? '',
'amount' => (($data['total_fee'] ?? 0) / 100),
'transaction_id' => $data['transaction_id'] ?? '',
'status' => ($data['result_code'] ?? '') === 'SUCCESS' ? 'paid' : 'pending',
];
}
return ['success' => false, 'message' => __('微信支付回调签名验证失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* 输出微信支付回调响应
*/
public function output_callback_response(array $result): void {
if ($result['success'] ?? false) {
echo '<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>';
} else {
echo '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[' . ($result['message'] ?? 'FAIL') . ']]></return_msg></xml>';
}
exit;
}
public function query_status(string $order_number): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
$config = $this->get_config();
try {
$sdk = $this->create_sdk($config);
$request = new \Yurun\PaySDK\Weixin\OrderQuery\Request;
$request->out_trade_no = $order_number;
$result = $sdk->execute($request);
if ($sdk->checkResult()) {
$trade_state = $result['trade_state'] ?? '';
$status = 'pending';
if ($trade_state === 'SUCCESS') {
$status = 'paid';
} elseif (in_array($trade_state, ['CLOSED', 'REVOKED'])) {
$status = 'cancelled';
} elseif ($trade_state === 'REFUND') {
$status = 'refunded';
}
return [
'success' => true,
'status' => $status,
'transaction_id' => $result['transaction_id'] ?? '',
];
}
return ['success' => false, 'message' => $sdk->getError() ?: __('查询失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function refund(string $order_number, float $amount): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
$config = $this->get_config();
try {
$sdk = $this->create_sdk($config);
// 先查询订单获取 transaction_id
$query = new \Yurun\PaySDK\Weixin\OrderQuery\Request;
$query->out_trade_no = $order_number;
$query_result = $sdk->execute($query);
if (!$sdk->checkResult() || empty($query_result['transaction_id'])) {
return ['success' => false, 'message' => __('未找到支付记录', 'aksrt')];
}
$refund_fee = (int) ($amount * 100);
$request = new \Yurun\PaySDK\Weixin\Refund\Request;
$request->out_trade_no = $order_number;
$request->out_refund_no = 'REF_' . $order_number . '_' . time();
$request->total_fee = (int) ($query_result['total_fee'] ?? $refund_fee);
$request->refund_fee = $refund_fee;
$request->refund_desc = __('用户申请退款', 'aksrt');
$result = $sdk->execute($request);
if ($sdk->checkResult()) {
return [
'success' => true,
'refund_id' => $result['refund_id'] ?? '',
'message' => __('退款成功', 'aksrt'),
];
}
return ['success' => false, 'message' => $sdk->getError() ?: __('退款失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
private function get_config(): array {
if (!class_exists('AKSRT_Settings')) {
return [];
}
$all = AKSRT_Settings::get_all();
return [
'mch_id' => (string) ($all['wechat_merchantid'] ?? ''),
'key' => (string) ($all['wechat_jsapi'] ?? ''),
'appid' => (string) ($all['wechat_appid'] ?? ''),
'appsecret' => (string) ($all['wechat_appsecret'] ?? ''),
'cert_path' => (string) ($all['wechat_cert_path'] ?? ''),
'key_path' => (string) ($all['wechat_key_path'] ?? ''),
'payment_type' => (string) ($all['wechat_payment_type'] ?? 'native'),
];
}
private function create_sdk(array $config): \Yurun\PaySDK\Weixin\SDK {
$params = new \Yurun\PaySDK\Weixin\Params\PublicParams;
$params->appID = $config['appid'] ?? '';
$params->mch_id = $config['mch_id'] ?? '';
$params->key = $config['key'] ?? '';
$params->sign_type = $config['sign_type'] ?? 'HMAC-SHA256';
if (!empty($config['cert_path'])) {
$params->certPath = $config['cert_path'];
}
if (!empty($config['key_path'])) {
$params->keyPath = $config['key_path'];
}
return new \Yurun\PaySDK\Weixin\SDK($params);
}
}
@@ -0,0 +1,365 @@
<?php
defined('ABSPATH') || exit;
trait AKSRT_Shipping_Part_1 {
private function send_shipping_notification(string $order_number, string $carrier, string $tracking_no, string $note = ''): void {
if (!class_exists('AKSRT_Order_Repository')) {
return;
}
$order = AKSRT_Order_Repository::get_instance()->get($order_number);
if (!$order) {
return;
}
$user_id = (int) ($order['user_id'] ?? 0);
if ($user_id <= 0) {
return;
}
$user = get_userdata($user_id);
if (!$user || empty($user->user_email)) {
return;
}
$carrier_label = $this->get_carrier_label($carrier);
$site_name = get_bloginfo('name');
$subject = sprintf(__('[%s] 您的订单已发货', 'aksrt'), $site_name);
$body = sprintf(
__('您好 %1$s,\n\n您的订单 %2$s 已发货。\n\n物流公司:%3$s\n运单号:%4$s\n%5$s\n\n如有疑问请联系客服。\n\n%s', 'aksrt'),
$user->display_name,
$order_number,
$carrier_label,
$tracking_no,
$note !== '' ? sprintf(__('备注:%s', 'aksrt'), $note) : '',
$site_name
);
wp_mail($user->user_email, $subject, $body);
}
private function decorate_record(array $record): array {
$record['id'] = (int) ($record['id'] ?? 0);
$record['operator_id'] = isset($record['operator_id']) ? (int) $record['operator_id'] : 0;
$record['status_label'] = $this->get_status_label((string) ($record['status'] ?? ''));
$record['carrier_label'] = $this->get_carrier_label((string) ($record['carrier'] ?? ''));
$record['operator'] = $this->get_user_summary((int) ($record['operator_id'] ?? 0));
return $record;
}
private function get_carrier_label(string $carrier): string {
foreach (self::get_carrier_options() as $item) {
if (($item['value'] ?? '') === $carrier) {
return (string) ($item['label'] ?? $carrier);
}
}
return $carrier;
}
private function get_status_label(string $status): string {
foreach (self::get_status_options() as $item) {
if (($item['value'] ?? '') === $status) {
return (string) ($item['label'] ?? $status);
}
}
return $status;
}
private function get_user_summary(int $user_id): array {
if ($user_id <= 0) {
return [];
}
$user = get_userdata($user_id);
if (!$user) {
return [];
}
return [
'id' => $user_id,
'display_name' => (string) $user->display_name,
'user_login' => (string) $user->user_login,
];
}
private function build_summary_item(array $record): array {
return [
'id' => (int) ($record['id'] ?? 0),
'status' => (string) ($record['status'] ?? ''),
'status_label' => (string) ($record['status_label'] ?? ''),
'carrier' => (string) ($record['carrier'] ?? ''),
'carrier_label' => (string) ($record['carrier_label'] ?? ''),
'tracking_no' => (string) ($record['tracking_no'] ?? ''),
'created_at' => (string) ($record['created_at'] ?? ''),
'updated_at' => (string) ($record['updated_at'] ?? ''),
'delivered_at' => (string) ($record['delivered_at'] ?? ''),
];
}
private function can_transition_status(string $current_status, string $new_status): bool {
$map = [
self::STATUS_SHIPPED => [self::STATUS_IN_TRANSIT, self::STATUS_DELIVERED, self::STATUS_RETURNED],
self::STATUS_IN_TRANSIT => [self::STATUS_DELIVERED, self::STATUS_RETURNED],
self::STATUS_DELIVERED => [],
self::STATUS_RETURNED => [],
];
return in_array($new_status, $map[$current_status] ?? [], true);
}
private function sync_order_status(string $order_number, string $shipping_status, string $note = ''): void {
if ($order_number === '' || !class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Order')) {
return;
}
if (!in_array($shipping_status, [self::STATUS_SHIPPED, self::STATUS_IN_TRANSIT, self::STATUS_DELIVERED], true)) {
return;
}
AKSRT_Order_Repository::get_instance()->update_status($order_number, AKSRT_Order::STATUS_DELIVERED, [
'note' => $note,
]);
}
/* ============================================================
* Express Query API (§6.6 快递查询)
*
* Supports kuaidi100, aliyun, kdniao.
* Traces cached in order_shipping.express_data JSON.
* Refresh interval from shop settings.
* ============================================================ */
public function query_express(int $shipping_id, bool $force = false): array {
global $wpdb;
$record = $this->get($shipping_id);
if (!$record) {
return ['success' => false, 'message' => __('发货记录不存在', 'aksrt')];
}
$tracking_no = (string) ($record['tracking_no'] ?? '');
$carrier = (string) ($record['carrier'] ?? '');
if ($tracking_no === '' || $carrier === '') {
return ['success' => false, 'message' => __('缺少运单号或物流公司', 'aksrt')];
}
$cached = $this->get_express_cache($shipping_id);
if (!$force && !empty($cached) && !$this->should_refresh_cache($cached)) {
return ['success' => true, 'data' => $cached];
}
if (!empty($cached['signed'])) {
return ['success' => true, 'data' => $cached];
}
$shop_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_shop_settings() : [];
$api_sdk = sanitize_key((string) ($shop_settings['express']['api_sdk'] ?? 'kuaidi100'));
$result = $this->call_express_api($api_sdk, $carrier, $tracking_no, $shop_settings);
if ($result['success']) {
$express_data = [
'query_time' => current_time('mysql'),
'traces' => $result['traces'] ?? [],
'state' => $result['state'] ?? 'unknown',
'signed' => !empty($result['signed']),
'api' => $api_sdk,
];
$wpdb->update(
$this->table(),
['note' => wp_json_encode($express_data, JSON_UNESCAPED_UNICODE)],
['id' => $shipping_id],
['%s'],
['%d']
);
if (!empty($result['signed']) && ($record['status'] ?? '') !== self::STATUS_DELIVERED) {
$this->update_status($shipping_id, self::STATUS_DELIVERED, __('快递已签收(API检测)', 'aksrt'));
}
return ['success' => true, 'data' => $express_data];
}
return $result;
}
private function get_express_cache(int $shipping_id): array {
global $wpdb;
$note = $wpdb->get_var($wpdb->prepare(
"SELECT note FROM {$this->table()} WHERE id = %d",
$shipping_id
));
if (!$note) return [];
$data = json_decode($note, true);
return is_array($data) && isset($data['traces']) ? $data : [];
}
private function should_refresh_cache(array $cached): bool {
if (empty($cached['query_time'])) return true;
$shop = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_shop_settings() : [];
$interval = max(30, min(240, (int) ($shop['express']['query_interval'] ?? 60)));
$last = strtotime($cached['query_time']);
return (time() - $last) > ($interval * 60);
}
private function call_express_api(string $sdk, string $carrier, string $tracking_no, array $settings): array {
$carrier_code = $this->map_carrier_code($sdk, $carrier);
if ($sdk === 'kuaidi100') {
return $this->query_kuaidi100($carrier_code, $tracking_no, $settings);
}
if ($sdk === 'kdniao') {
return $this->query_kdniao($carrier_code, $tracking_no, $settings);
}
if ($sdk === 'aliyun') {
return $this->query_aliyun($carrier_code, $tracking_no, $settings);
}
return ['success' => false, 'message' => __('未配置快递查询接口', 'aksrt')];
}
private function query_kuaidi100(string $com, string $num, array $settings): array {
$key = sanitize_text_field((string) ($settings['express']['kuaidi100_key'] ?? ''));
$customer = sanitize_text_field((string) ($settings['express']['kuaidi100_customer'] ?? ''));
if ($key === '' || $customer === '') {
return ['success' => false, 'message' => __('快递100 API 未配置', 'aksrt')];
}
$param = wp_json_encode(['com' => $com, 'num' => $num]);
$sign = strtoupper(md5($param . $key . $customer));
$response = wp_remote_post('https://poll.kuaidi100.com/poll/query.do', [
'body' => ['customer' => $customer, 'sign' => $sign, 'param' => $param],
'timeout' => 10,
]);
if (is_wp_error($response)) {
return ['success' => false, 'message' => $response->get_error_message()];
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($body) || (int) ($body['status'] ?? 0) !== 200) {
return ['success' => false, 'message' => (string) ($body['message'] ?? __('查询失败', 'aksrt'))];
}
$state = (string) ($body['state'] ?? '0');
$signed = $state === '3';
$traces = [];
foreach ((array) ($body['data'] ?? []) as $item) {
$traces[] = [
'time' => (string) ($item['ftime'] ?? $item['time'] ?? ''),
'context' => (string) ($item['context'] ?? ''),
];
}
return ['success' => true, 'traces' => $traces, 'state' => $state, 'signed' => $signed];
}
private function query_kdniao(string $code, string $num, array $settings): array {
$app_id = sanitize_text_field((string) ($settings['express']['kdniao_app_id'] ?? ''));
$app_key = sanitize_text_field((string) ($settings['express']['kdniao_app_key'] ?? ''));
if ($app_id === '' || $app_key === '') {
return ['success' => false, 'message' => __('快递鸟 API 未配置', 'aksrt')];
}
$request_data = wp_json_encode(['ShipperCode' => $code, 'LogisticCode' => $num]);
$data_sign = urlencode(base64_encode(md5($request_data . $app_key)));
$response = wp_remote_post('https://api.kdniao.com/Ebusiness/EbusinessOrderHandle.aspx', [
'body' => [
'RequestData' => urlencode($request_data),
'EBusinessID' => $app_id,
'RequestType' => '1002',
'DataSign' => $data_sign,
'DataType' => '2',
],
'timeout' => 10,
]);
if (is_wp_error($response)) {
return ['success' => false, 'message' => $response->get_error_message()];
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($body) || !($body['Success'] ?? false)) {
return ['success' => false, 'message' => (string) ($body['Reason'] ?? __('查询失败', 'aksrt'))];
}
$state = (string) ($body['State'] ?? '0');
$signed = $state === '3';
$traces = [];
foreach ((array) ($body['Traces'] ?? []) as $item) {
$traces[] = [
'time' => (string) ($item['AcceptTime'] ?? ''),
'context' => (string) ($item['AcceptStation'] ?? ''),
];
}
return ['success' => true, 'traces' => $traces, 'state' => $state, 'signed' => $signed];
}
private function query_aliyun(string $code, string $num, array $settings): array {
$app_code = sanitize_text_field((string) ($settings['express']['aliyun_app_code'] ?? ''));
if ($app_code === '') {
return ['success' => false, 'message' => __('阿里云快递 API 未配置', 'aksrt')];
}
$url = add_query_arg(['no' => $num, 'type' => $code], 'https://wuliu.market.alicloudapi.com/kdi');
$response = wp_remote_get($url, [
'headers' => ['Authorization' => 'APPCODE ' . $app_code],
'timeout' => 10,
]);
if (is_wp_error($response)) {
return ['success' => false, 'message' => $response->get_error_message()];
}
$body = json_decode(wp_remote_retrieve_body($response), true);
if (!is_array($body) || (int) ($body['status'] ?? -1) !== 0) {
return ['success' => false, 'message' => (string) ($body['msg'] ?? __('查询失败', 'aksrt'))];
}
$result_data = $body['result'] ?? [];
$state = (string) ($result_data['deliverystatus'] ?? '0');
$signed = $state === '3';
$traces = [];
foreach ((array) ($result_data['list'] ?? []) as $item) {
$traces[] = [
'time' => (string) ($item['time'] ?? ''),
'context' => (string) ($item['status'] ?? ''),
];
}
return ['success' => true, 'traces' => $traces, 'state' => $state, 'signed' => $signed];
}
private function map_carrier_code(string $sdk, string $carrier): string {
$kuaidi100_map = [
'sf' => 'shunfeng', 'yd' => 'yunda', 'zt' => 'zhongtong', 'yt' => 'yuantong',
'sto' => 'shentong', 'jd' => 'jd', 'ems' => 'ems', 'db' => 'debangkuaidi',
'yunda' => 'yunda',
];
$kdniao_map = [
'sf' => 'SF', 'yd' => 'YD', 'zt' => 'ZTO', 'yt' => 'YTO',
'sto' => 'STO', 'jd' => 'JD', 'ems' => 'EMS', 'db' => 'DBL',
'yunda' => 'YD',
];
if ($sdk === 'kuaidi100') {
return $kuaidi100_map[$carrier] ?? $carrier;
}
if ($sdk === 'kdniao') {
return $kdniao_map[$carrier] ?? strtoupper($carrier);
}
return $carrier;
}
}
@@ -0,0 +1,146 @@
<?php
defined('ABSPATH') || exit;
trait AKSRT_Shipping_Part_2 {
public function build_express_timeline(int $shipping_id): array {
$record = $this->get($shipping_id);
if (!$record) return [];
$cached = $this->get_express_cache($shipping_id);
$timeline = [];
$timeline[] = [
'time' => (string) ($record['created_at'] ?? ''),
'label' => sprintf(__('商家已发货:%s %s', 'aksrt'), $record['carrier_label'] ?? '', $record['tracking_no'] ?? ''),
'type' => 'shipped',
];
if (!empty($cached['traces'])) {
foreach ($cached['traces'] as $trace) {
$timeline[] = [
'time' => (string) ($trace['time'] ?? ''),
'label' => (string) ($trace['context'] ?? ''),
'type' => 'trace',
];
}
}
if (!empty($cached['signed'])) {
$timeline[] = [
'time' => (string) ($record['delivered_at'] ?? ($cached['query_time'] ?? '')),
'label' => __('快递已签收', 'aksrt'),
'type' => 'signed',
];
}
usort($timeline, static fn($a, $b) => strcmp($a['time'] ?? '', $b['time'] ?? ''));
return $timeline;
}
/* ============================================================
* Shipping Fee Resolution (§6.6 运费模板)
*
* 3 types: free / fixed / amount (满额包邮)
* Inheritance: product → category → theme defaults
* ============================================================ */
public function resolve_shipping_fee(int $product_id, float $order_amount = 0, array $address = []): array {
$config = $this->get_product_shipping_config($product_id);
$fee_type = (string) ($config['fee_type'] ?? 'fixed');
$fee_amount = (float) ($config['fee_amount'] ?? 0);
$free_threshold = (float) ($config['free_threshold'] ?? 0);
if ($fee_type === 'free') {
return ['fee' => 0.0, 'type' => 'free', 'label' => __('包邮', 'aksrt')];
}
if ($fee_type === 'amount' && $free_threshold > 0 && $order_amount >= $free_threshold) {
return ['fee' => 0.0, 'type' => 'free_threshold', 'label' => sprintf(__('满%.0f包邮', 'aksrt'), $free_threshold)];
}
$region_fee = $this->resolve_region_fee($config, $address);
if ($region_fee !== null) {
return ['fee' => $region_fee, 'type' => 'region', 'label' => __('地区运费', 'aksrt')];
}
return ['fee' => $fee_amount, 'type' => 'fixed', 'label' => sprintf(__('运费 ¥%.2f', 'aksrt'), $fee_amount)];
}
private function get_product_shipping_config(int $product_id): array {
$meta = get_post_meta($product_id, '_aksrt_shipping_config', true);
if (is_array($meta) && !empty($meta['fee_type'])) {
return $meta;
}
if (class_exists('AKSRT_Shop_Term_Meta')) {
$cat_config = AKSRT_Shop_Term_Meta::resolve_inheritance($product_id, 'shipping_fee');
if (is_array($cat_config) && !empty($cat_config['fee_type'])) {
return $cat_config;
}
}
if (class_exists('AKSRT_Settings')) {
$shop = AKSRT_Settings::get_shop_settings();
return [
'fee_type' => sanitize_key((string) ($shop['defaults']['shipping_fee_type'] ?? 'fixed')),
'fee_amount' => (float) ($shop['defaults']['shipping_fee_amount'] ?? 12),
'free_threshold' => (float) ($shop['defaults']['shipping_free_threshold'] ?? 0),
'region_fees' => (array) ($shop['defaults']['region_fees'] ?? []),
];
}
return ['fee_type' => 'fixed', 'fee_amount' => 12, 'free_threshold' => 0, 'region_fees' => []];
}
private function resolve_region_fee(array $config, array $address): ?float {
$region_fees = (array) ($config['region_fees'] ?? []);
if (empty($region_fees) || empty($address['province'])) {
return null;
}
$province = (string) $address['province'];
foreach ($region_fees as $region) {
$provinces = (array) ($region['provinces'] ?? []);
if (in_array($province, $provinces, true)) {
return (float) ($region['fee'] ?? 0);
}
}
return null;
}
public static function get_china_regions(): array {
$file = get_template_directory() . '/assets/data/china-regions.json';
if (!file_exists($file)) {
return self::get_builtin_provinces();
}
$data = json_decode((string) file_get_contents($file), true);
return is_array($data) ? $data : self::get_builtin_provinces();
}
private static function get_builtin_provinces(): array {
return [
['code' => '11', 'name' => '北京市'], ['code' => '12', 'name' => '天津市'],
['code' => '13', 'name' => '河北省'], ['code' => '14', 'name' => '山西省'],
['code' => '15', 'name' => '内蒙古自治区'], ['code' => '21', 'name' => '辽宁省'],
['code' => '22', 'name' => '吉林省'], ['code' => '23', 'name' => '黑龙江省'],
['code' => '31', 'name' => '上海市'], ['code' => '32', 'name' => '江苏省'],
['code' => '33', 'name' => '浙江省'], ['code' => '34', 'name' => '安徽省'],
['code' => '35', 'name' => '福建省'], ['code' => '36', 'name' => '江西省'],
['code' => '37', 'name' => '山东省'], ['code' => '41', 'name' => '河南省'],
['code' => '42', 'name' => '湖北省'], ['code' => '43', 'name' => '湖南省'],
['code' => '44', 'name' => '广东省'], ['code' => '45', 'name' => '广西壮族自治区'],
['code' => '46', 'name' => '海南省'], ['code' => '50', 'name' => '重庆市'],
['code' => '51', 'name' => '四川省'], ['code' => '52', 'name' => '贵州省'],
['code' => '53', 'name' => '云南省'], ['code' => '54', 'name' => '西藏自治区'],
['code' => '61', 'name' => '陕西省'], ['code' => '62', 'name' => '甘肃省'],
['code' => '63', 'name' => '青海省'], ['code' => '64', 'name' => '宁夏回族自治区'],
['code' => '65', 'name' => '新疆维吾尔自治区'],
['code' => '71', 'name' => '台湾省'],
['code' => '81', 'name' => '香港特别行政区'],
['code' => '82', 'name' => '澳门特别行政区'],
];
}
}