refactor: flatten src/AKSRT → src (drop theme-identifier dir)

Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
This commit is contained in:
kate522 committed 2026-08-02 22:06:06 +08:00
1 parent 56f1a8175f
commit 8c5b1555c9
285 files changed
+26 -3600

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
/**
* 站内余额支付网关(内置实现)。
*
* 仅扣现金钱包(\AKSRT\Credits\Balance / pay_balance_s),不再回退到积分。
*/
class BalanceGateway implements GatewayInterface {
public function get_id(): string {
return 'balance';
}
public function get_name(): string {
return __('站内余额', 'aksrt');
}
public function is_configured(): bool {
// Cash wallet only — do not treat points as 余额.
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
return false;
}
if (class_exists('\AKSRT\Settings\PaymentRuntime')) {
return \AKSRT\Settings\PaymentRuntime::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'] ?? '');
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
return ['success' => false, 'message' => __('余额功能未启用', 'aksrt')];
}
if ($user_id <= 0) {
return ['success' => false, 'message' => __('请先登录', 'aksrt')];
}
$balance = \AKSRT\Credits\Balance::balance($user_id);
if ($balance < $amount) {
return ['success' => false, 'message' => __('余额不足', 'aksrt')];
}
$result = \AKSRT\Credits\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'),
];
}
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 = OrderRepository::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, string $request_id = ''): array {
$order_repo = OrderRepository::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\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
return ['success' => false, 'message' => __('余额功能未启用', 'aksrt')];
}
$request_id = sanitize_text_field($request_id);
$refund_key = $request_id !== '' ? $request_id : 'order:' . $order_number;
$lock_key = 'aksrt_balance_refund_' . hash('sha256', $refund_key);
$existing = get_option($lock_key, []);
if (is_array($existing) && ($existing['status'] ?? '') === 'succeeded') {
return [
'success' => true,
'refund_id' => 'BAL_REF_' . $order_number,
'message' => __('退款已处理(幂等)', 'aksrt'),
];
}
if (is_array($existing) && ($existing['status'] ?? '') === 'pending') {
return ['success' => false, 'status' => 'pending', 'message' => __('退款正在处理中,请稍后查询', 'aksrt')];
}
$marker = ['status' => 'pending', 'token' => wp_generate_uuid4(), 'created_at' => time()];
if (!add_option($lock_key, $marker, '', false)) {
return ['success' => false, 'status' => 'pending', 'message' => __('退款正在处理中,请稍后查询', 'aksrt')];
}
try {
$result = \AKSRT\Credits\Balance::add(
$user_id,
$amount,
'order_refund',
['type' => 'order', 'order' => $order_number, 'request_id' => $request_id],
sprintf(__('订单 %s 退款', 'aksrt'), $order_number)
);
if (is_wp_error($result)) {
delete_option($lock_key);
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
}
update_option($lock_key, [
'status' => 'succeeded',
'refund_id' => 'BAL_REF_' . $order_number,
'completed_at' => time(),
], false);
return ['success' => true, 'refund_id' => 'BAL_REF_' . $order_number, 'message' => __('退款成功', 'aksrt')];
} catch (\Throwable $e) {
delete_option($lock_key);
return ['success' => false, 'message' => $e->getMessage()];
}
}
}
+739
View File
@@ -0,0 +1,739 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class ChargeCard {
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\Core\DBMigrator::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 {
$labels = [
'title' => __('卡密兑换', 'aksrt'),
'password' => __('卡密', 'aksrt'),
'card' => __('卡号', 'aksrt'),
'submit_balance' => __('兑换余额', 'aksrt'),
'submit_vip' => __('兑换会员', 'aksrt'),
'submit_credits' => __('兑换积分', 'aksrt'),
'placeholder_pw' => __('请输入卡密', 'aksrt'),
'placeholder_card' => __('请输入卡号', 'aksrt'),
];
// Master kill-switch on 收款接口 → 卡密充值.
$channel_on = !class_exists('\AKSRT\Settings\PaymentRuntime')
|| \AKSRT\Settings\PaymentRuntime::is_gateway_enabled('card_pass');
if (!$channel_on) {
$off = ['enabled' => false, 'desc' => '', 'only_password' => true];
return [
'balance' => $off,
'vip' => $off,
'credits' => $off,
'labels' => $labels,
];
}
$balance = ['enabled' => false, 'desc' => '', 'only_password' => true];
$vip = ['enabled' => false, 'desc' => '', 'only_password' => true];
if (class_exists('\AKSRT\Settings\Settings')) {
if (method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
$recharge = \AKSRT\Settings\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\Settings', 'get_payment_vip_settings')) {
$vip_s = \AKSRT\Settings\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\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_all')
? \AKSRT\Settings\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\Settings') || !method_exists('\AKSRT\Settings\Settings', 'credits_enabled') || \AKSRT\Settings\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' => $labels,
];
}
/**
* 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\\Payment\\Order')) {
Order::get_instance()->update_status($order_number, 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\\Payment\\Order')) {
Order::get_instance()->update_status($order_number, 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\Credits')) {
$out['balance'] = \AKSRT\Credits\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\Credits\Balance')) {
$new = \AKSRT\Credits\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\Credits')) {
return new \WP_Error('aksrt_card_amount', __('余额系统不可用', 'aksrt'), ['status' => 500]);
}
$pts = (int) round($amount);
$new = \AKSRT\Credits\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\Content\VipService')) {
return new \WP_Error('aksrt_vip_unavailable', __('会员系统不可用', 'aksrt'), ['status' => 500]);
}
\AKSRT\Content\VipService::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\Credits')) {
return new \WP_Error('aksrt_card_points', __('当前卡密可兑换积分为0', 'aksrt'), ['status' => 400]);
}
\AKSRT\Credits\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\\Payment\\OrderRepository') || !class_exists('\AKSRT\Core\DomainModel')) {
return 'CARD' . $card_id . '_' . time();
}
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE;
$product_id = $card_id;
$amount = 0.0;
$currency = 'CNY';
if ($type === self::TYPE_VIP) {
$product_type = \AKSRT\Core\DomainModel::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 OrderRepository::get_instance()->create([
'user_id' => $user_id,
'product_type' => $product_type,
'product_id' => $product_id,
'amount' => $amount,
'currency' => $currency,
'status' => class_exists('AKSRT\\Payment\\Order') ? 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),
];
}
}
+399
View File
@@ -0,0 +1,399 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class 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\Core\DBMigrator::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 product 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 === []) {
$taxonomies = ['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\Core\DBMigrator::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\Core\DBMigrator::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;
}
}
+172
View File
@@ -0,0 +1,172 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class CreatorRevenue {
private static ?CreatorRevenue $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\\Payment\\Revenue') || !class_exists('\AKSRT\Settings\Settings')) {
return false;
}
$settings = \AKSRT\Settings\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\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\Core\DBMigrator')) {
return false;
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::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);
$this->settle_non_shop_cash_order($order_number, $order, $buyer_id);
}
/**
* Content / topic 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_content = !empty($meta['content_pay'])
&& $post->post_type === 'post';
// Resource without content_pay and not a known cash kind: skip.
if (!$is_content) {
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;
}
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 : [];
}
}
+406
View File
@@ -0,0 +1,406 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
/**
* 支付网关管理器(注册/回调路由/到账)。
*/
class 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, GatewayInterface $gateway): void {
$this->gateways[$id] = $gateway;
}
/**
* 获取网关
*/
public function get_gateway(string $id): ?GatewayInterface {
return $this->gateways[$id] ?? null;
}
/**
* 获取所有已注册网关
*
* @return GatewayInterface[]
*/
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';
$processed = $verified;
if ($paid) {
// Only ACK paid notifications after the order was actually synchronized.
$processed = $this->complete_order($gateway_id, $verify);
}
// 网关各自要求的 ACK 格式
if ($gateway_id === 'wechatpay' && method_exists($gateway, 'output_callback_response')) {
$gateway->output_callback_response(['success' => $processed]); // exit
}
if ($gateway_id === 'alipay' || str_starts_with($gateway_id, 'epay')) {
// 支付宝 / 易支付异步通知要求返回纯文本 success,否则会重复通知
if (method_exists($gateway, 'output_callback_response')) {
$gateway->output_callback_response(['success' => $processed]); // exit
}
header('Content-Type: text/plain; charset=utf-8');
echo $processed ? 'success' : 'fail';
exit;
}
// Stripe / PayPal webhook:200 表示已接收;处理失败返回 400 触发重试
return new \WP_REST_Response(['received' => $processed], $processed ? 200 : 400);
}
/**
* 用户浏览器同步返回。尽力确认到账(兜底异步通知延迟),再跳回订单中心。
*/
public function handle_return(\WP_REST_Request $request) {
$gateway_id = sanitize_key((string) $request->get_param('gateway'));
// Providers echo back whatever number we submitted, which for a re-pay is an
// attempt-suffixed out_trade_no. Map it to the local order for query + redirect.
$provider_ref = sanitize_text_field((string) ($request->get_param('order')
?: $request->get_param('out_trade_no')
?: ''));
$order_number = $this->local_order_number($provider_ref);
$gateway = $this->get_gateway($gateway_id);
$status = 'pending';
if ($gateway) {
if ($gateway_id === 'alipay' || str_starts_with($gateway_id, 'epay')) {
// 支付宝 / 易支付同步返回带签名参数,可直接验签作为到账兜底
$verify = $gateway->verify_callback($request);
if (!empty($verify['success'])) {
if ($order_number === '') {
$order_number = $this->local_order_number(
sanitize_text_field((string) ($verify['order_number'] ?? ''))
);
}
if (($verify['status'] ?? '') === 'paid' && $this->complete_order($gateway_id, $verify)) {
$status = 'success';
} elseif ($order_number !== '') {
// Synchronous returns generally omit trade_status; query after signature verification.
$query = $gateway->query_status($provider_ref !== '' ? $provider_ref : $order_number);
if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') {
if (empty($query['order_number'])) {
$query['order_number'] = $order_number;
}
if ($this->complete_order($gateway_id, $query)) {
$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') {
$captured_ref = sanitize_text_field((string) ($cap['order_number'] ?? ''));
$captured_order = $this->local_order_number($captured_ref);
if ($captured_order === ''
|| ($order_number !== '' && !hash_equals($order_number, $captured_order))) {
$this->redirect_to_orders($order_number, 'pending');
return null;
}
$order_number = $captured_order;
if ($this->complete_order($gateway_id, $cap)) {
$status = 'success';
}
}
}
} elseif ($order_number !== '') {
// 其余网关:用返回时的 provider ref 查询该次尝试;local number 仅用于跳转。
$q = $gateway->query_status($provider_ref !== '' ? $provider_ref : $order_number);
if (!empty($q['success']) && ($q['status'] ?? '') === 'paid') {
if (empty($q['order_number'])) {
$q['order_number'] = $order_number;
}
if ($this->complete_order($gateway_id, $q)) {
$status = 'success';
}
}
}
}
$this->redirect_to_orders($order_number, $status);
return null;
}
/**
* 用户取消支付,跳回订单中心。
*/
public function handle_cancel(\WP_REST_Request $request) {
$provider_ref = sanitize_text_field((string) ($request->get_param('order')
?: $request->get_param('out_trade_no')
?: ''));
$this->redirect_to_orders($this->local_order_number($provider_ref), 'cancelled');
return null;
}
/**
* Map a provider out_trade_no back to the local order number (identity when unknown).
*/
private function local_order_number(string $provider_ref): string {
if ($provider_ref === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
return $provider_ref;
}
$order = OrderRepository::get_instance()->resolve_by_out_trade_no($provider_ref);
if (!$order) {
return $provider_ref;
}
return sanitize_text_field((string) ($order['order_number'] ?? $provider_ref));
}
/**
* Reconcile a pending external order against the configured provider.
*
* @param array<string,mixed>|null $order
*/
public function reconcile_order(string $order_number, ?array $order = null): ?array {
if ($order_number === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
return $order;
}
$repo = OrderRepository::get_instance();
$order = is_array($order) ? $order : $repo->get($order_number);
if (!$order || !in_array((string) ($order['status'] ?? ''), [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
return $order;
}
$gateway_id = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($gateway_id === '' || in_array($gateway_id, ['balance', 'points', 'manual_review'], true)) {
return $order;
}
$gateway = $this->get_gateway($gateway_id);
if (!$gateway || !$gateway->is_configured()) {
return $order;
}
$throttle_key = 'aksrt_reconcile_' . hash('sha256', $order_number);
if (get_transient($throttle_key)) {
return $order;
}
set_transient($throttle_key, 1, 5);
$lock = PaymentAttemptLock::acquire('reconcile_' . $order_number);
if ($lock === false) {
return $repo->get($order_number) ?: $order;
}
try {
$provider_ref = OrderRepository::provider_out_trade_no($order);
$query = $gateway->query_status($provider_ref);
if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') {
if (empty($query['order_number'])) {
$query['order_number'] = $provider_ref;
}
$this->complete_order($gateway_id, $query);
}
} catch (\Throwable $e) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf('[AKSRT Payment] reconcile failed order=%s gateway=%s: %s', $order_number, $gateway_id, $e->getMessage()));
}
} finally {
PaymentAttemptLock::release('reconcile_' . $order_number, $lock);
}
return $repo->get($order_number) ?: $order;
}
/**
* 幂等、带金额校验的订单完成。仅由验签通过的回调调用。
*
* @param string $gateway_id 网关 ID
* @param array $verify 网关 verify_callback / query 结果
* @return bool
*/
public function complete_order(string $gateway_id, array $verify): bool {
$provider_out_trade_no = sanitize_text_field((string) ($verify['order_number'] ?? ''));
if ($provider_out_trade_no === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
return false;
}
$repo = OrderRepository::get_instance();
// Callbacks may carry attempt-suffixed out_trade_no (…P2); map back to local order.
$order = $repo->resolve_by_out_trade_no($provider_out_trade_no);
if (!$order) {
return false;
}
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
if ($order_number === '') {
return false;
}
$stored_gateway = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($stored_gateway === '' || !hash_equals($stored_gateway, sanitize_key($gateway_id))) {
return false;
}
// Provider-confirmed external payments must include an exact amount and transaction ID.
$expected_currency = strtoupper((string) ($order['currency'] ?? ''));
$paid_currency = strtoupper(sanitize_key((string) ($verify['currency'] ?? '')));
$expected = (float) ($order['amount'] ?? 0);
$paid_amount = (float) ($verify['amount'] ?? 0);
$transaction_id = sanitize_text_field((string) ($verify['transaction_id'] ?? ''));
if ($expected <= 0
|| $paid_amount <= 0
|| $transaction_id === ''
|| $expected_currency === ''
|| $paid_currency === ''
|| !hash_equals($expected_currency, $paid_currency)
|| abs($expected - $paid_amount) > 0.01) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf(
'[AKSRT Payment] invalid completion order=%s out_trade_no=%s expected=%.2f %s paid=%.2f %s transaction=%s gateway=%s',
$order_number,
$provider_out_trade_no,
$expected,
$expected_currency,
$paid_amount,
$paid_currency,
$transaction_id !== '' ? 'present' : 'missing',
$gateway_id
));
}
return false;
}
$current = (string) ($order['status'] ?? '');
// 幂等:仅同一交易号的重复回调可视为成功。不同交易号意味着
// 用户可能重复付款,不能静默 ACK 为已正确同步。
if (in_array($current, ['paid', 'delivered', 'completed'], true)) {
$stored_transaction = sanitize_text_field((string) ($order['payment_transaction_id'] ?? ''));
return $stored_transaction !== '' && hash_equals($stored_transaction, $transaction_id);
}
// 只允许从未支付态完成
if (!in_array($current, ['created', 'pending'], true)) {
return false;
}
return $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $gateway_id,
'payment_transaction_id' => $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/');
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
/**
* 支付网关统一接口
*/
interface GatewayInterface {
/**
* 创建支付单
*
* @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 退款金额
* @param string $request_id 幂等退款请求号
* @return array{success: bool, status?: string, refund_id?: string, request_id?: string, message?: string}
*/
public function refund(string $order_number, float $amount, string $request_id = ''): array;
/**
* 获取网关显示名称
*/
public function get_name(): string;
/**
* 获取网关标识
*/
public function get_id(): string;
/**
* 网关是否已配置
*/
public function is_configured(): bool;
}
+311
View File
@@ -0,0 +1,311 @@
<?php
/**
* Alipay gateway via yansongda/pay (cert mode).
*
* @package AKSRT
*/
namespace AKSRT\Payment\Gateways;
defined('ABSPATH') || exit;
use Yansongda\Pay\Pay;
class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
public function get_id(): string {
return 'alipay';
}
public function get_name(): string {
return __('支付宝', 'aksrt');
}
public function is_configured(): bool {
if (class_exists('\AKSRT\Settings\PaymentRuntime')
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled('alipay')) {
return false;
}
$c = $this->get_config();
return $c['app_id'] !== ''
&& $c['private_key'] !== ''
&& $c['app_public_cert'] !== ''
&& $c['alipay_public_cert'] !== ''
&& $c['alipay_root_cert'] !== '';
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
$order_number = (string) ($order['order_number'] ?? '');
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
if ($out_trade_no === '') {
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
$payment_type = $this->get_config()['payment_type'];
if ($order_number === '' || $out_trade_no === '' || $amount <= 0) {
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
}
try {
YansongdaPayFactory::boot_alipay();
$payload = [
'_method' => 'GET',
'out_trade_no' => $out_trade_no,
'total_amount' => number_format($amount, 2, '.', ''),
'subject' => mb_substr($subject, 0, 256),
];
$result = $payment_type === 'wap'
? Pay::alipay()->h5($payload)
: Pay::alipay()->web($payload);
$payment_url = YansongdaPayFactory::extract_payment_url($result);
if ($payment_url === '') {
return ['success' => false, 'message' => __('支付宝支付链接生成失败', 'aksrt')];
}
return [
'success' => true,
'payment_url' => $payment_url,
'payment_data' => [
'order_number' => $order_number,
'out_trade_no' => $out_trade_no,
],
'message' => __('支付宝支付链接已生成', 'aksrt'),
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function verify_callback(\WP_REST_Request $request): array {
try {
YansongdaPayFactory::boot_alipay();
// Use the unsanitized form/query payload; route params must not enter the signature base.
$callback_input = strtoupper($request->get_method()) === 'POST'
? $request->get_body_params()
: $request->get_query_params();
if (!is_array($callback_input)) {
$callback_input = [];
}
unset($callback_input['gateway']);
$data_obj = Pay::alipay()->callback($callback_input);
$data = YansongdaPayFactory::to_array($data_obj);
$config = $this->get_config();
$trade_status = (string) ($data['trade_status'] ?? '');
$paid = in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true);
$order_number = (string) ($data['out_trade_no'] ?? '');
$trade_no = (string) ($data['trade_no'] ?? '');
if (($data['app_id'] ?? '') !== $config['app_id']
|| $order_number === ''
|| ($paid && ($trade_no === '' || (float) ($data['total_amount'] ?? 0) <= 0))) {
return ['success' => false, 'message' => __('支付宝回调身份或订单数据无效', 'aksrt')];
}
return [
'success' => true,
'order_number' => $order_number,
'amount' => (float) ($data['total_amount'] ?? 0),
'currency' => 'CNY',
'transaction_id' => $trade_no,
'status' => $paid ? 'paid' : 'pending',
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* Alipay async notify expects plain text success/fail (Gateway also handles this).
*/
public function output_callback_response(array $result): void {
try {
if (!empty($result['success'])) {
YansongdaPayFactory::boot_alipay();
YansongdaPayFactory::emit_response(Pay::alipay()->success(), 'success');
}
} catch (\Throwable $e) {
// fall through
}
header('Content-Type: text/plain; charset=utf-8');
echo 'fail';
exit;
}
public function query_status(string $order_number): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
$out_trade_no = $this->resolve_out_trade_no($order_number);
try {
YansongdaPayFactory::boot_alipay();
$result = Pay::alipay()->query([
'out_trade_no' => $out_trade_no,
]);
$data = YansongdaPayFactory::to_array($result);
$trade_status = (string) ($data['trade_status'] ?? '');
$status = 'pending';
if (in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
$status = 'paid';
} elseif ($trade_status === 'TRADE_CLOSED') {
$status = 'cancelled';
}
return [
'success' => true,
// Keep provider out_trade_no for Gateway::complete_order resolve.
'order_number' => (string) ($data['out_trade_no'] ?? $out_trade_no),
'amount' => (float) ($data['total_amount'] ?? 0),
'currency' => 'CNY',
'transaction_id' => (string) ($data['trade_no'] ?? ''),
'status' => $status,
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* Best-effort close of a previous provider trade before re-pay.
*/
public function close_trade(string $out_trade_no): void {
$out_trade_no = sanitize_text_field($out_trade_no);
if ($out_trade_no === '' || !$this->is_configured()) {
return;
}
try {
YansongdaPayFactory::boot_alipay();
// alipay.trade.close — supported by yansongda as close().
Pay::alipay()->close([
'out_trade_no' => $out_trade_no,
]);
} catch (\Throwable $e) {
// ignore
}
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
if ($amount <= 0) {
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
}
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
$out_trade_no = $this->resolve_out_trade_no($order_number);
try {
YansongdaPayFactory::boot_alipay();
$refund_request_id = (string) preg_replace(
'/[^A-Za-z0-9_-]/',
'',
$request_id !== '' ? $request_id : $order_number . '_refund'
);
$result = Pay::alipay()->refund([
'out_trade_no' => $out_trade_no,
'refund_amount' => number_format($amount, 2, '.', ''),
'refund_reason' => __('用户申请退款', 'aksrt'),
'out_request_no' => $refund_request_id,
]);
$data = YansongdaPayFactory::to_array($result);
$fund_change = strtoupper((string) ($data['fund_change'] ?? ''));
$status = ($fund_change === 'Y' || (float) ($data['refund_fee'] ?? 0) > 0)
? 'succeeded'
: 'unknown';
return [
'success' => true,
'status' => $status,
'refund_id' => (string) ($data['trade_no'] ?? $data['out_trade_no'] ?? ''),
'request_id' => $refund_request_id,
'message' => $status === 'succeeded' ? __('退款成功', 'aksrt') : __('退款结果待确认', 'aksrt'),
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function query_refund(string $request_id, string $order_number = ''): array {
if (!$this->is_configured() || $request_id === '') {
return ['success' => false, 'status' => 'failed', 'message' => __('支付宝退款查询参数无效', 'aksrt')];
}
try {
YansongdaPayFactory::boot_alipay();
$params = ['out_request_no' => $request_id, '_action' => 'refund'];
if ($order_number !== '') {
$params['out_trade_no'] = $this->resolve_out_trade_no($order_number);
}
$result = Pay::alipay()->query($params);
$data = YansongdaPayFactory::to_array($result);
$refund_status = strtoupper((string) ($data['refund_status'] ?? ''));
$refund_amount = (float) ($data['refund_amount'] ?? 0);
$status = ($refund_status === 'REFUND_SUCCESS' || $refund_amount > 0)
? 'succeeded'
: ($refund_status === 'REFUND_FAIL' ? 'failed' : 'pending');
return [
'success' => true,
'status' => $status,
'refund_id' => (string) ($data['trade_no'] ?? $data['out_request_no'] ?? ''),
];
} catch (\Throwable $e) {
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
}
}
/**
* @return array{app_id:string,private_key:string,app_public_cert:string,alipay_public_cert:string,alipay_root_cert:string,payment_type:string}
*/
private function get_config(): array {
$all = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_all()
: [];
$type = sanitize_key((string) ($all['alipay_payment_type'] ?? 'page'));
if (!in_array($type, ['page', 'wap'], true)) {
$type = 'page';
}
return [
'app_id' => (string) ($all['alipay_appid'] ?? ''),
'private_key' => (string) ($all['alipay_privatekey'] ?? ''),
'app_public_cert' => (string) ($all['alipay_app_public_cert_path'] ?? ''),
'alipay_public_cert' => (string) ($all['alipay_public_cert_path'] ?? ''),
'alipay_root_cert' => (string) ($all['alipay_root_cert_path'] ?? ''),
'payment_type' => $type,
];
}
/**
* Map local order_number → provider out_trade_no (supports resume attempts).
*/
private function resolve_out_trade_no(string $order_number): string {
if (class_exists('\AKSRT\Payment\OrderRepository')) {
$repo = \AKSRT\Payment\OrderRepository::get_instance();
// A provider attempt id must be queried verbatim, including superseded Pn.
if ($repo->resolve_by_out_trade_no($order_number) && !$repo->get($order_number)) {
return $order_number;
}
$order = $repo->get($order_number);
if ($order) {
return \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
}
}
return $order_number;
}
}
+344
View File
@@ -0,0 +1,344 @@
<?php
/**
* Compatible Epay (彩虹易支付) gateway — alipay / wxpay channels.
*
* @package AKSRT
*/
namespace AKSRT\Payment\Gateways;
defined('ABSPATH') || exit;
class EpayGateway implements \AKSRT\Payment\GatewayInterface {
/** @var string alipay|wxpay */
private string $channel;
public function __construct(string $channel = 'alipay') {
$channel = sanitize_key($channel);
$this->channel = in_array($channel, ['alipay', 'wxpay'], true) ? $channel : 'alipay';
}
public function get_id(): string {
return $this->channel === 'wxpay' ? 'epay_wxpay' : 'epay_alipay';
}
public function get_name(): string {
return $this->channel === 'wxpay'
? __('易支付·微信', 'aksrt')
: __('易支付·支付宝', 'aksrt');
}
public function is_configured(): bool {
if (class_exists('\AKSRT\Settings\PaymentRuntime')
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled($this->get_id())) {
return false;
}
$c = $this->get_config();
return $c['apiurl'] !== '' && $c['pid'] !== '' && $c['key'] !== '';
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('易支付未配置', 'aksrt')];
}
$order_number = (string) ($order['order_number'] ?? '');
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
if ($out_trade_no === '') {
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
if ($order_number === '' || $out_trade_no === '' || $amount <= 0) {
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
}
$config = $this->get_config();
$params = [
'pid' => $config['pid'],
'type' => $this->channel,
'out_trade_no' => $out_trade_no,
'notify_url' => rest_url('aksrt/v1/payment/' . $this->get_id() . '/notify'),
'return_url' => rest_url('aksrt/v1/payment/' . $this->get_id() . '/return'),
'name' => mb_substr(wp_strip_all_tags($subject), 0, 127),
'money' => number_format($amount, 2, '.', ''),
];
$params['sign'] = $this->sign($params, $config['key']);
$params['sign_type'] = 'MD5';
$use_mobile = $this->prefer_mobile($config['device']);
if ($use_mobile) {
// mapi.php returns JSON with payurl / qrcode / urlscheme.
$api = $this->endpoint($config['apiurl'], 'mapi.php');
$response = wp_remote_post($api, [
'timeout' => 30,
'body' => $params,
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8'],
]);
if (is_wp_error($response)) {
return ['success' => false, 'message' => $response->get_error_message()];
}
$body = json_decode((string) wp_remote_retrieve_body($response), true);
if (!is_array($body)) {
// Fall back to browser submit if mapi is unavailable.
return $this->browser_submit_result($config['apiurl'], $params, $order_number, $out_trade_no);
}
$code = (int) ($body['code'] ?? 0);
if ($code !== 1) {
$msg = (string) ($body['msg'] ?? $body['message'] ?? __('易支付下单失败', 'aksrt'));
return ['success' => false, 'message' => $msg];
}
$pay_url = (string) ($body['payurl'] ?? $body['urlscheme'] ?? '');
$qrcode = (string) ($body['qrcode'] ?? '');
if ($pay_url === '' && $qrcode === '') {
return ['success' => false, 'message' => __('易支付未返回支付链接', 'aksrt')];
}
$result = [
'success' => true,
'payment_data' => [
'order_number' => $order_number,
'out_trade_no' => $out_trade_no,
'code_url' => $qrcode,
'channel' => $this->channel,
],
'message' => __('易支付订单已创建', 'aksrt'),
];
if ($pay_url !== '') {
$result['payment_url'] = $pay_url;
} elseif ($qrcode !== '') {
$result['payment_url'] = $qrcode;
}
return $result;
}
return $this->browser_submit_result($config['apiurl'], $params, $order_number, $out_trade_no);
}
public function verify_callback(\WP_REST_Request $request): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('易支付未配置', 'aksrt')];
}
$params = strtoupper($request->get_method()) === 'POST'
? $request->get_body_params()
: $request->get_query_params();
if (!is_array($params) || $params === []) {
// Some panels POST raw query string.
$params = $request->get_params();
}
if (!is_array($params)) {
$params = [];
}
unset($params['gateway'], $params['rest_route']);
$config = $this->get_config();
$sign = (string) ($params['sign'] ?? '');
if ($sign === '' || !$this->verify_sign($params, $config['key'], $sign)) {
return ['success' => false, 'message' => __('易支付签名校验失败', 'aksrt')];
}
$pid = (string) ($params['pid'] ?? '');
if ($pid !== '' && !hash_equals($config['pid'], $pid)) {
return ['success' => false, 'message' => __('易支付商户号不匹配', 'aksrt')];
}
$order_number = sanitize_text_field((string) ($params['out_trade_no'] ?? ''));
$transaction_id = sanitize_text_field((string) ($params['trade_no'] ?? ''));
$amount = (float) ($params['money'] ?? 0);
$trade_status = (string) ($params['trade_status'] ?? '');
$type = sanitize_key((string) ($params['type'] ?? ''));
// When type is present, require channel match.
if ($type !== '' && $type !== $this->channel) {
return ['success' => false, 'message' => __('易支付通道类型不匹配', 'aksrt')];
}
$paid = ($trade_status === '' || $trade_status === 'TRADE_SUCCESS');
if ($order_number === '' || ($paid && ($transaction_id === '' || $amount <= 0))) {
return ['success' => false, 'message' => __('易支付回调数据无效', 'aksrt')];
}
return [
'success' => true,
'order_number' => $order_number,
'amount' => $amount,
'currency' => 'CNY',
'transaction_id' => $transaction_id,
'status' => $paid ? 'paid' : 'pending',
];
}
/**
* Epay expects plain-text success/fail (handled also by Gateway for epay_* ids).
*/
public function output_callback_response(array $result): void {
header('Content-Type: text/plain; charset=utf-8');
echo !empty($result['success']) ? 'success' : 'fail';
exit;
}
public function query_status(string $order_number): array {
if (!$this->is_configured() || $order_number === '') {
return ['success' => false, 'message' => __('易支付未配置', 'aksrt')];
}
$out_trade_no = $this->resolve_out_trade_no($order_number);
$config = $this->get_config();
// Common compatible query: api.php?act=order&pid&key&out_trade_no
$url = add_query_arg([
'act' => 'order',
'pid' => $config['pid'],
'key' => $config['key'],
'out_trade_no' => $out_trade_no,
], $this->endpoint($config['apiurl'], 'api.php'));
$response = wp_remote_get($url, ['timeout' => 20]);
if (is_wp_error($response)) {
return ['success' => false, 'message' => $response->get_error_message()];
}
$body = json_decode((string) wp_remote_retrieve_body($response), true);
if (!is_array($body)) {
return ['success' => false, 'message' => __('易支付查询响应无效', 'aksrt')];
}
// code=1 success in most forks; status 1 = paid.
$code = (int) ($body['code'] ?? 0);
$status = (int) ($body['status'] ?? $body['trade_status'] ?? 0);
$paid = $code === 1 && ((string) $status === '1' || (string) ($body['trade_status'] ?? '') === 'TRADE_SUCCESS');
$amount = (float) ($body['money'] ?? $body['amount'] ?? 0);
$tx = (string) ($body['trade_no'] ?? $body['api_trade_no'] ?? '');
return [
'success' => true,
'order_number' => (string) ($body['out_trade_no'] ?? $out_trade_no),
'amount' => $amount,
'currency' => 'CNY',
'transaction_id' => $tx,
'status' => $paid ? 'paid' : 'pending',
];
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
return [
'success' => false,
'status' => 'failed',
'message' => __('易支付退款请在商户后台人工处理', 'aksrt'),
];
}
/**
* @return array{apiurl:string,pid:string,key:string,device:string}
*/
private function get_config(): array {
$all = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_all()
: [];
$device = sanitize_key((string) ($all['epay_device'] ?? 'auto'));
if (!in_array($device, ['auto', 'pc', 'mobile'], true)) {
$device = 'auto';
}
$apiurl = (string) ($all['epay_apiurl'] ?? '');
$apiurl = rtrim(trim($apiurl), '/') . '/';
if ($apiurl === '/') {
$apiurl = '';
}
return [
'apiurl' => $apiurl,
'pid' => (string) ($all['epay_pid'] ?? ''),
'key' => (string) ($all['epay_key'] ?? ''),
'device' => $device,
];
}
/**
* @param array<string,mixed> $params
*/
private function sign(array $params, string $key): string {
$filtered = [];
foreach ($params as $k => $v) {
if ($k === 'sign' || $k === 'sign_type') {
continue;
}
if ($v === '' || $v === null) {
continue;
}
$filtered[(string) $k] = is_scalar($v) ? (string) $v : '';
}
ksort($filtered);
$parts = [];
foreach ($filtered as $k => $v) {
$parts[] = $k . '=' . $v;
}
return md5(implode('&', $parts) . $key);
}
/**
* @param array<string,mixed> $params
*/
private function verify_sign(array $params, string $key, string $sign): bool {
$expected = $this->sign($params, $key);
return hash_equals(strtolower($expected), strtolower($sign));
}
private function endpoint(string $apiurl, string $file): string {
return rtrim($apiurl, '/') . '/' . ltrim($file, '/');
}
private function prefer_mobile(string $device): bool {
if ($device === 'mobile') {
return true;
}
if ($device === 'pc') {
return false;
}
// auto
if (!empty($_SERVER['HTTP_USER_AGENT'])) {
$ua = (string) $_SERVER['HTTP_USER_AGENT'];
return (bool) preg_match('/Mobile|Android|iPhone|iPad|iPod|MicroMessenger/i', $ua);
}
return false;
}
/**
* @param array<string,string> $params
* @return array{success:bool,payment_url?:string,payment_data?:array,message?:string}
*/
/**
* @param array<string,string> $params
* @return array{success:bool,payment_url?:string,payment_data?:array,message?:string}
*/
private function browser_submit_result(string $apiurl, array $params, string $order_number, string $out_trade_no = ''): array {
$url = add_query_arg($params, $this->endpoint($apiurl, 'submit.php'));
return [
'success' => true,
'payment_url' => $url,
'payment_data' => [
'order_number' => $order_number,
'out_trade_no' => $out_trade_no !== '' ? $out_trade_no : $order_number,
'channel' => $this->channel,
],
'message' => __('易支付支付链接已生成', 'aksrt'),
];
}
/**
* Map local order_number → provider out_trade_no (supports resume attempts).
*/
private function resolve_out_trade_no(string $order_number): string {
if (class_exists('\AKSRT\Payment\OrderRepository')) {
$repo = \AKSRT\Payment\OrderRepository::get_instance();
if ($repo->resolve_by_out_trade_no($order_number) && !$repo->get($order_number)) {
return $order_number;
}
$order = $repo->get($order_number);
if ($order) {
return \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
}
}
return $order_number;
}
}
+275
View File
@@ -0,0 +1,275 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment\Gateways;
defined('ABSPATH') || exit;
class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
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\PaymentRuntime')
&& !\AKSRT\Settings\PaymentRuntime::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 = (string) ($order['order_number'] ?? '');
// Re-pay attempts carry a fresh provider number; the callback resolves it back.
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
if ($out_trade_no === '') {
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$order_currency = strtoupper((string) ($order['currency'] ?? ''));
$currency = strtoupper((string) ($config['currency'] ?? 'USD'));
if ($order_currency === '' || !hash_equals($order_currency, $currency)) {
return ['success' => false, 'message' => __('PayPal 币种与订单币种不一致', 'aksrt')];
}
try {
$response = $this->api_request('POST', '/v2/checkout/orders', [
'intent' => 'CAPTURE',
'purchase_units' => [[
'reference_id' => $out_trade_no,
'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=' . rawurlencode($out_trade_no)),
'cancel_url' => home_url('/wp-json/aksrt/v1/payment/paypal/cancel?order=' . rawurlencode($out_trade_no)),
'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;
}
}
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
$persisted = \AKSRT\Payment\OrderRepository::get_instance()->merge_meta($order_number, [
'paypal_order_id' => (string) ($data['id'] ?? ''),
]);
if (!$persisted) {
return ['success' => false, 'message' => __('PayPal 支付单保存失败,请重试', 'aksrt')];
}
}
return [
'success' => true,
'payment_url' => $approveUrl,
'payment_data' => [
'order_number' => $order_number,
'out_trade_no' => $out_trade_no,
'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 {
// PayPal orders are finalized only by the authenticated capture call in handle_return().
// Never treat an unsigned CHECKOUT.ORDER.APPROVED webhook as payment completion.
return [
'success' => false,
'status' => 'pending',
'message' => __('PayPal 支付需在返回流程中完成捕获确认', 'aksrt'),
];
}
public function query_status(string $order_number): array {
if (!$this->is_configured() || !class_exists('\AKSRT\Payment\OrderRepository')) {
return ['success' => false, 'status' => 'pending', 'message' => __('PayPal 未配置', 'aksrt')];
}
$order = \AKSRT\Payment\OrderRepository::get_instance()->resolve_by_out_trade_no($order_number);
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$paypal_order_id = sanitize_text_field((string) ($meta['paypal_order_id'] ?? ''));
if ($paypal_order_id === '') {
return ['success' => true, 'status' => 'pending', 'message' => __('PayPal 支付尚未建立', 'aksrt')];
}
try {
$response = $this->api_request('GET', '/v2/checkout/orders/' . rawurlencode($paypal_order_id));
$body = $response['body'];
if ($response['status'] !== 200 || !is_array($body)) {
return ['success' => false, 'status' => 'pending', 'message' => __('PayPal 状态查询失败', 'aksrt')];
}
if (($body['status'] ?? '') === 'APPROVED') {
return $this->capture_order($paypal_order_id);
}
if (($body['status'] ?? '') === 'COMPLETED') {
return $this->completed_order_evidence($body);
}
return ['success' => true, 'status' => 'pending', 'message' => __('PayPal 支付处理中', 'aksrt')];
} catch (\Throwable $e) {
return ['success' => false, 'status' => 'pending', 'message' => $e->getMessage()];
}
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
return [
'success' => false,
'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'),
];
}
private function get_config(): array {
if (!class_exists('\AKSRT\Settings\Settings')) {
return [];
}
$all = \AKSRT\Settings\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') {
return $this->completed_order_evidence($body);
}
return ['success' => false, 'message' => $body['message'] ?? __('PayPal 捕获失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/** @param array<string,mixed> $body */
private function completed_order_evidence(array $body): array {
$unit = $body['purchase_units'][0] ?? [];
$capture = $unit['payments']['captures'][0] ?? [];
return [
'success' => true,
'order_number' => (string) ($unit['reference_id'] ?? ''),
'amount' => (float) ($capture['amount']['value'] ?? 0),
'currency' => strtoupper((string) ($capture['amount']['currency_code'] ?? '')),
'transaction_id' => (string) ($capture['id'] ?? ($body['id'] ?? '')),
'status' => 'paid',
];
}
/**
* 获取 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 ?: [],
];
}
}
+415
View File
@@ -0,0 +1,415 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment\Gateways;
defined('ABSPATH') || exit;
class StripeGateway implements \AKSRT\Payment\GatewayInterface {
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\PaymentRuntime')
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled('stripe')) {
return false;
}
$config = $this->get_config();
return !empty($config['secret_key'])
&& !empty($config['publishable_key'])
&& !empty($config['webhook_secret']);
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
}
$config = $this->get_config();
$order_number = (string) ($order['order_number'] ?? '');
// Re-pay attempts carry a fresh provider number; the callback resolves it back.
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
if ($out_trade_no === '') {
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$order_currency = strtoupper((string) ($order['currency'] ?? ''));
$currency = strtolower($config['currency'] ?? 'usd');
if ($order_currency === '' || !hash_equals($order_currency, strtoupper($currency))) {
return ['success' => false, 'message' => __('Stripe 币种与订单币种不一致', 'aksrt')];
}
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',
'client_reference_id' => $out_trade_no,
'success_url' => home_url('/wp-json/aksrt/v1/payment/stripe/return?session_id={CHECKOUT_SESSION_ID}&order=' . rawurlencode($out_trade_no)),
'cancel_url' => home_url('/wp-json/aksrt/v1/payment/stripe/cancel?order=' . rawurlencode($out_trade_no)),
'metadata' => [
'order_number' => $out_trade_no,
],
];
// 启用自动支付方式(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'],
]);
// Persist the session id before returning the redirect. Without this,
// status/refund lookup may miss older sessions after traffic exceeds 100.
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
$persisted = \AKSRT\Payment\OrderRepository::get_instance()->merge_meta($order_number, [
'stripe_session_id' => (string) $session->id,
'stripe_sessions' => $this->append_session_mapping($order, $out_trade_no, (string) $session->id),
]);
if (!$persisted) {
try {
\Stripe\Checkout\Session::expire((string) $session->id, [
'api_key' => $config['secret_key'],
]);
} catch (\Throwable $e) {
// Best-effort cleanup; do not return an untraceable session.
}
return ['success' => false, 'message' => __('Stripe 支付会话保存失败,请重试', 'aksrt')];
}
}
return [
'success' => true,
'payment_url' => $session->url,
'payment_data' => [
'order_number' => $order_number,
'out_trade_no' => $out_trade_no,
'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 ?? '';
$currency = strtolower((string) ($session->currency ?? $config['currency'] ?? 'usd'));
$amount = $this->from_smallest_unit((int) ($session->amount_total ?? 0), $currency);
return [
'success' => true,
'order_number' => $orderNumber,
'amount' => $amount,
'currency' => strtoupper($currency),
'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 {
$this->create_client($config);
$found = $this->find_session($order_number, $config, true);
if ($found) {
$status = 'pending';
if ($found->payment_status === 'paid') {
$status = 'paid';
} elseif ($found->status === 'expired') {
$status = 'cancelled';
}
$transaction_id = is_string($found->payment_intent)
? $found->payment_intent
: (string) ($found->payment_intent->id ?? '');
return [
'success' => true,
'order_number' => (string) ($found->metadata->order_number ?? $order_number),
'amount' => $this->from_smallest_unit(
(int) ($found->amount_total ?? 0),
strtolower((string) ($found->currency ?? $config['currency'] ?? 'usd'))
),
'currency' => strtoupper((string) ($found->currency ?? $config['currency'] ?? 'usd')),
'status' => $status,
'transaction_id' => $transaction_id,
];
}
return ['success' => true, 'status' => 'pending', 'message' => __('未找到支付记录', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
}
$config = $this->get_config();
try {
$this->create_client($config);
$found = $this->find_session($order_number, $config, true);
if (!$found) {
return ['success' => false, 'message' => __('未找到支付记录', 'aksrt')];
}
$paymentIntentId = is_string($found->payment_intent)
? $found->payment_intent
: (string) ($found->payment_intent->id ?? '');
if ($paymentIntentId === '') {
return ['success' => false, 'message' => __('未找到支付意图', 'aksrt')];
}
$currency = strtolower((string) ($found->currency ?? $config['currency'] ?? 'usd'));
$options = ['api_key' => $config['secret_key']];
if ($request_id !== '') {
$options['idempotency_key'] = $request_id;
}
$refund = \Stripe\Refund::create([
'payment_intent' => $paymentIntentId,
'amount' => $this->to_smallest_unit($amount, $currency),
'reason' => 'requested_by_customer',
'metadata' => [
'aksrt_request_id' => $request_id,
'order_number' => $order_number,
],
], $options);
return [
'success' => true,
'status' => ($refund->status ?? '') === 'succeeded' ? 'succeeded' : 'pending',
'refund_id' => $refund->id,
'request_id' => $request_id,
'message' => __('退款请求已提交', 'aksrt'),
];
} catch (\Stripe\Exception\ApiErrorException $e) {
return ['success' => false, 'message' => $e->getMessage()];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function query_refund(string $request_id, string $order_number = ''): array {
if (!$this->is_configured() || $request_id === '') {
return ['success' => false, 'status' => 'failed', 'message' => __('Stripe 退款查询参数无效', 'aksrt')];
}
try {
$config = $this->get_config();
$this->create_client($config);
$refunds = \Stripe\Refund::all(['limit' => 100], ['api_key' => $config['secret_key']]);
foreach ($refunds->data as $refund) {
if (($refund->metadata->aksrt_request_id ?? '') !== $request_id) {
continue;
}
$status = ($refund->status ?? '') === 'succeeded'
? 'succeeded'
: (($refund->status ?? '') === 'failed' ? 'failed' : 'pending');
return [
'success' => true,
'status' => $status,
'refund_id' => (string) ($refund->id ?? ''),
];
}
return ['success' => true, 'status' => 'unknown', 'message' => __('未找到 Stripe 退款记录', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
}
}
/**
* 获取 Stripe Publishable Key(前端使用)。
*/
public function get_publishable_key(): string {
$config = $this->get_config();
return $config['publishable_key'] ?? '';
}
/**
* @param array<string,mixed> $order
* @return array<string,string>
*/
private function append_session_mapping(array $order, string $out_trade_no, string $session_id): array {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$map = is_array($meta['stripe_sessions'] ?? null) ? $meta['stripe_sessions'] : [];
$map[$out_trade_no] = $session_id;
if (count($map) > 20) {
$map = array_slice($map, -20, null, true);
}
return $map;
}
/**
* Locate the Checkout Session for an order. Prefers the session id persisted
* at create time; falls back to scanning recent sessions for legacy orders.
*
* @return \Stripe\Checkout\Session|null
*/
private function find_session(string $order_number, array $config, bool $allow_legacy_scan = false) {
$session_id = '';
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$order = $repo->resolve_by_out_trade_no($order_number) ?: $repo->get($order_number);
if ($order) {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$sessions = is_array($meta['stripe_sessions'] ?? null) ? $meta['stripe_sessions'] : [];
$session_id = sanitize_text_field((string) ($sessions[$order_number] ?? ''));
if ($session_id === '' && $repo->get($order_number)) {
$current_ref = \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
$session_id = sanitize_text_field((string) ($sessions[$current_ref] ?? $meta['stripe_session_id'] ?? ''));
}
}
}
if ($session_id !== '') {
try {
return \Stripe\Checkout\Session::retrieve($session_id, ['api_key' => $config['secret_key']]);
} catch (\Exception $e) {
// Fall through to the scan below.
}
}
if (!$allow_legacy_scan) {
return null;
}
// Legacy orders created before session-id persistence can only be found
// through Stripe's recent-session window.
$sessions = \Stripe\Checkout\Session::all(['limit' => 100], ['api_key' => $config['secret_key']]);
foreach ($sessions->data as $session) {
if (($session->metadata->order_number ?? '') === $order_number) {
return $session;
}
}
return null;
}
private function get_config(): array {
if (!class_exists('\AKSRT\Settings\Settings')) {
return [];
}
$all = \AKSRT\Settings\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'];
}
private function from_smallest_unit(int $amount, string $currency): float {
$zero_decimal = ['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'];
if (in_array(strtolower($currency), $zero_decimal, true)) {
return (float) $amount;
}
return $amount / 100;
}
/**
* 将金额转换为最小单位(分/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);
}
}
+406
View File
@@ -0,0 +1,406 @@
<?php
/**
* WeChat Pay gateway via yansongda/pay (API v3).
*
* @package AKSRT
*/
namespace AKSRT\Payment\Gateways;
defined('ABSPATH') || exit;
use Yansongda\Pay\Pay;
class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
public function get_id(): string {
return 'wechatpay';
}
public function get_name(): string {
return __('微信支付', 'aksrt');
}
public function is_configured(): bool {
if (class_exists('\AKSRT\Settings\PaymentRuntime')
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled('wechatpay')) {
return false;
}
$c = $this->get_config();
return $c['mch_id'] !== ''
&& $c['appid'] !== ''
&& $c['api_v3_key'] !== ''
&& $c['private_key'] !== ''
&& $c['cert_path'] !== '';
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
$order_number = (string) ($order['order_number'] ?? '');
// Prefer attempt-specific out_trade_no so re-pay does not reuse a frozen provider order.
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
if ($out_trade_no === '') {
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
: $order_number;
}
$amount = (float) ($order['amount'] ?? 0);
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
$payment_type = $this->get_config()['payment_type'];
$total_fen = (int) round($amount * 100);
if ($order_number === '' || $out_trade_no === '' || $total_fen <= 0) {
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
}
try {
YansongdaPayFactory::boot_wechat();
$payload = [
'out_trade_no' => $out_trade_no,
'description' => mb_substr($subject, 0, 127),
'amount' => [
'total' => $total_fen,
'currency' => 'CNY',
],
];
switch ($payment_type) {
case 'h5':
$payload['scene_info'] = [
'payer_client_ip' => (string) ($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'),
'h5_info' => ['type' => 'Wap'],
];
$result = Pay::wechat()->h5($payload);
break;
case 'native':
default:
$result = Pay::wechat()->scan($payload);
break;
}
$data = YansongdaPayFactory::to_array($result);
$payment_data = array_merge($data, [
'order_number' => $order_number,
'out_trade_no' => $out_trade_no,
]);
$result_data = [
'success' => true,
'payment_data' => $payment_data,
'message' => __('微信支付订单已创建', 'aksrt'),
];
if ($payment_type === 'native' && !empty($data['code_url'])) {
$result_data['payment_url'] = (string) $data['code_url'];
} elseif ($payment_type === 'h5' && !empty($data['h5_url'])) {
$result_data['payment_url'] = (string) $data['h5_url'];
}
return $result_data;
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function verify_callback(\WP_REST_Request $request): array {
try {
YansongdaPayFactory::boot_wechat();
// Pass raw body + headers so callback works under WP REST (php://input may be drained).
$raw = $request->get_body();
$headers = [
'Content-Type' => (string) ($request->get_header('content-type') ?: 'application/json'),
'Wechatpay-Serial' => (string) $request->get_header('wechatpay-serial'),
'Wechatpay-Timestamp' => (string) $request->get_header('wechatpay-timestamp'),
'Wechatpay-Nonce' => (string) $request->get_header('wechatpay-nonce'),
'Wechatpay-Signature' => (string) $request->get_header('wechatpay-signature'),
];
$callback = Pay::wechat()->callback([
'body' => $raw !== '' ? $raw : file_get_contents('php://input'),
'headers' => $headers,
]);
$data = YansongdaPayFactory::to_array($callback);
// Decrypted resource is nested under resource (array) after CallbackPlugin.
$resource = [];
if (isset($data['resource']) && is_array($data['resource'])) {
$resource = $data['resource'];
// ciphertext decrypt may leave plaintext fields at resource root or under ciphertext key.
if (isset($resource['ciphertext']) && is_array($resource['ciphertext'])) {
$resource = $resource['ciphertext'];
}
}
$order_number = (string) ($resource['out_trade_no'] ?? $data['out_trade_no'] ?? '');
$transaction_id = (string) ($resource['transaction_id'] ?? $data['transaction_id'] ?? '');
$trade_state = (string) ($resource['trade_state'] ?? $data['trade_state'] ?? '');
$event_type = (string) ($data['event_type'] ?? '');
$total_fen = (int) YansongdaPayFactory::dig($resource, 'amount.total', 0);
if ($total_fen <= 0) {
$total_fen = (int) YansongdaPayFactory::dig($data, 'amount.total', 0);
}
$paid = ($trade_state === 'SUCCESS') || ($event_type === 'TRANSACTION.SUCCESS');
// When event is pay success, resource has trade_state SUCCESS.
if ($event_type !== '' && $event_type !== 'TRANSACTION.SUCCESS' && $trade_state !== 'SUCCESS') {
$paid = false;
}
$config = $this->get_config();
$mch_id = (string) ($resource['mchid'] ?? $data['mchid'] ?? '');
$app_id = (string) ($resource['appid'] ?? $data['appid'] ?? '');
$currency = (string) YansongdaPayFactory::dig($resource, 'amount.currency', '');
if ($order_number === ''
|| ($paid && (
$mch_id === ''
|| $app_id === ''
|| $currency === ''
|| !hash_equals($config['mch_id'], $mch_id)
|| !hash_equals($config['appid'], $app_id)
|| $currency !== 'CNY'
|| $transaction_id === ''
|| $total_fen <= 0
))
|| (!$paid && (
($mch_id !== '' && !hash_equals($config['mch_id'], $mch_id))
|| ($app_id !== '' && !hash_equals($config['appid'], $app_id))
|| ($currency !== '' && $currency !== 'CNY')
))) {
return ['success' => false, 'message' => __('微信支付回调身份或订单数据无效', 'aksrt')];
}
return [
'success' => true,
'order_number' => $order_number,
'amount' => $total_fen > 0 ? ($total_fen / 100) : 0.0,
'currency' => strtoupper((string) YansongdaPayFactory::dig($data, 'amount.currency', 'CNY')),
'transaction_id' => $transaction_id,
'status' => $paid ? 'paid' : 'pending',
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* Output WeChat v3 notify ACK (JSON).
*/
public function output_callback_response(array $result): void {
try {
YansongdaPayFactory::boot_wechat();
if (!empty($result['success'])) {
YansongdaPayFactory::emit_response(Pay::wechat()->success(), '{"code":"SUCCESS","message":"成功"}');
}
} catch (\Throwable $e) {
// fall through to fail body
}
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
}
echo wp_json_encode([
'code' => 'FAIL',
'message' => (string) ($result['message'] ?? 'FAIL'),
]);
exit;
}
public function query_status(string $order_number): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
$out_trade_no = $this->resolve_out_trade_no($order_number);
try {
YansongdaPayFactory::boot_wechat();
$action = $this->query_action();
$result = Pay::wechat()->query([
'out_trade_no' => $out_trade_no,
'_action' => $action,
]);
$data = YansongdaPayFactory::to_array($result);
$trade_state = (string) ($data['trade_state'] ?? '');
$status = 'pending';
if ($trade_state === 'SUCCESS') {
$status = 'paid';
} elseif (in_array($trade_state, ['CLOSED', 'REVOKED'], true)) {
$status = 'cancelled';
} elseif ($trade_state === 'REFUND') {
$status = 'refunded';
}
return [
'success' => true,
// Keep provider out_trade_no so Gateway::complete_order can resolve the local order.
'order_number' => (string) ($data['out_trade_no'] ?? $out_trade_no),
'amount' => ((int) YansongdaPayFactory::dig($data, 'amount.total', 0)) / 100,
'currency' => strtoupper((string) YansongdaPayFactory::dig($data, 'amount.currency', 'CNY')),
'transaction_id' => (string) ($data['transaction_id'] ?? ''),
'status' => $status,
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
/**
* Best-effort close of a previous provider trade before re-pay.
*/
public function close_trade(string $out_trade_no): void {
$out_trade_no = sanitize_text_field($out_trade_no);
if ($out_trade_no === '' || !$this->is_configured()) {
return;
}
try {
YansongdaPayFactory::boot_wechat();
Pay::wechat()->close([
'out_trade_no' => $out_trade_no,
'_action' => $this->query_action(),
]);
} catch (\Throwable $e) {
// ignore — new attempt still uses a fresh out_trade_no
}
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
$out_trade_no = $this->resolve_out_trade_no($order_number);
try {
YansongdaPayFactory::boot_wechat();
$action = $this->query_action();
$query = Pay::wechat()->query([
'out_trade_no' => $out_trade_no,
'_action' => $action,
]);
$q = YansongdaPayFactory::to_array($query);
$total_fen = (int) YansongdaPayFactory::dig($q, 'amount.total', 0);
$refund_fen = (int) round($amount * 100);
if ($total_fen <= 0) {
return ['success' => false, 'status' => 'failed', 'message' => __('无法确认原订单金额', 'aksrt')];
}
if ($refund_fen <= 0 || $refund_fen > $total_fen) {
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
}
$refund_request_id = (string) preg_replace(
'/[^A-Za-z0-9_-]/',
'',
$request_id !== '' ? $request_id : 'REF_' . $order_number
);
$result = Pay::wechat()->refund([
'_action' => 'refund_' . $action,
'out_trade_no' => $out_trade_no,
'out_refund_no' => $refund_request_id,
'reason' => __('用户申请退款', 'aksrt'),
'amount' => [
'refund' => $refund_fen,
'total' => $total_fen,
'currency' => 'CNY',
],
]);
$data = YansongdaPayFactory::to_array($result);
$status = strtoupper((string) ($data['status'] ?? ''));
$normalized = $status === 'SUCCESS'
? 'succeeded'
: (in_array($status, ['CLOSED', 'ABNORMAL'], true) ? 'failed' : 'pending');
return [
'success' => true,
'status' => $normalized,
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
'request_id' => $refund_request_id,
'message' => $normalized === 'succeeded' ? __('退款成功', 'aksrt') : __('退款请求已受理', 'aksrt'),
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function query_refund(string $request_id, string $order_number = ''): array {
if (!$this->is_configured() || $request_id === '') {
return ['success' => false, 'status' => 'failed', 'message' => __('微信退款查询参数无效', 'aksrt')];
}
try {
YansongdaPayFactory::boot_wechat();
$result = Pay::wechat()->query([
'out_refund_no' => $request_id,
'_action' => 'refund_' . $this->query_action(),
]);
$data = YansongdaPayFactory::to_array($result);
$status = strtoupper((string) ($data['status'] ?? ''));
$normalized = $status === 'SUCCESS'
? 'succeeded'
: (in_array($status, ['CLOSED', 'ABNORMAL'], true) ? 'failed' : 'pending');
return [
'success' => true,
'status' => $normalized,
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
];
} catch (\Throwable $e) {
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
}
}
/**
* @return array{mch_id:string,appid:string,api_v3_key:string,private_key:string,cert_path:string,payment_type:string}
*/
private function get_config(): array {
$all = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_all()
: [];
$type = sanitize_key((string) ($all['wechat_payment_type'] ?? 'native'));
if (!in_array($type, ['native', 'h5'], true)) {
$type = 'native';
}
return [
'mch_id' => (string) ($all['wechat_merchantid'] ?? ''),
'appid' => (string) ($all['wechat_appid'] ?? ''),
'api_v3_key' => (string) ($all['wechat_api_v3_key'] ?? ''),
'private_key' => (string) ($all['wechat_private_key_path'] ?? ''),
'cert_path' => (string) ($all['wechat_cert_path'] ?? ''),
'payment_type' => $type,
];
}
private function query_action(): string {
$map = [
'native' => 'native',
'h5' => 'h5',
];
$type = $this->get_config()['payment_type'];
return $map[$type] ?? 'native';
}
/**
* Map local order_number → provider out_trade_no (supports resume attempts).
*/
private function resolve_out_trade_no(string $order_number): string {
if (class_exists('\AKSRT\Payment\OrderRepository')) {
$repo = \AKSRT\Payment\OrderRepository::get_instance();
if ($repo->resolve_by_out_trade_no($order_number) && !$repo->get($order_number)) {
return $order_number;
}
$order = $repo->get($order_number);
if ($order) {
return \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
}
}
return $order_number;
}
}
@@ -0,0 +1,271 @@
<?php
/**
* Shared yansongda/pay config bootstrap for WeChat / Alipay gateways.
*
* @package AKSRT
*/
namespace AKSRT\Payment\Gateways;
defined('ABSPATH') || exit;
use Yansongda\Pay\Pay;
/**
* Builds Pay::config() payloads from aksrt_settings and boots the SDK.
*/
class YansongdaPayFactory {
/**
* Boot Pay with a partial channel config (wechat and/or alipay).
*
* @param array<string,mixed> $channels Keys: wechat?, alipay?
*/
public static function boot(array $channels): void {
$config = [
'logger' => self::logger_config(),
'http' => [
'timeout' => 10.0,
'connect_timeout' => 5.0,
],
];
if (!empty($channels['wechat']) && is_array($channels['wechat'])) {
$config['wechat'] = [
'default' => $channels['wechat'],
];
}
if (!empty($channels['alipay']) && is_array($channels['alipay'])) {
$config['alipay'] = [
'default' => $channels['alipay'],
];
}
// Re-config is safe; Pay caches tenant config per process.
Pay::config($config);
}
/**
* WeChat Pay API v3 merchant config.
*
* @return array<string,mixed>
*/
public static function wechat_from_settings(): array {
$all = self::settings();
$notify = rest_url('aksrt/v1/payment/wechatpay/notify');
return [
'mch_id' => (string) ($all['wechat_merchantid'] ?? ''),
'mch_secret_key' => (string) ($all['wechat_api_v3_key'] ?? ''),
'mch_secret_cert' => (string) ($all['wechat_private_key_path'] ?? ''),
'mch_public_cert_path' => (string) ($all['wechat_cert_path'] ?? ''),
'mp_app_id' => (string) ($all['wechat_appid'] ?? ''),
'notify_url' => $notify,
'mode' => Pay::MODE_NORMAL,
];
}
/**
* Alipay cert-mode config (yansongda/pay 3.x requires cert paths for SN + verify).
*
* app_secret_cert may be PEM string or file path.
* Cert path fields accept absolute file paths (or PEM content via get_public_cert).
*
* @return array<string,mixed>
*/
public static function alipay_from_settings(): array {
$all = self::settings();
$notify = rest_url('aksrt/v1/payment/alipay/notify');
$return = rest_url('aksrt/v1/payment/alipay/return');
return [
'app_id' => (string) ($all['alipay_appid'] ?? ''),
'app_secret_cert' => (string) ($all['alipay_privatekey'] ?? ''),
'app_public_cert_path' => (string) ($all['alipay_app_public_cert_path'] ?? ''),
'alipay_public_cert_path' => (string) ($all['alipay_public_cert_path'] ?? ''),
'alipay_root_cert_path' => (string) ($all['alipay_root_cert_path'] ?? ''),
'return_url' => $return,
'notify_url' => $notify,
'mode' => Pay::MODE_NORMAL,
];
}
public static function boot_wechat(): void {
self::boot(['wechat' => self::wechat_from_settings()]);
}
public static function boot_alipay(): void {
self::boot(['alipay' => self::alipay_from_settings()]);
}
/**
* Convert Collection / ArrayAccess / array to plain array.
*
* @param mixed $data
* @return array<string,mixed>
*/
public static function to_array($data): array {
if (is_array($data)) {
return $data;
}
if ($data instanceof \Yansongda\Supports\Collection) {
return $data->all();
}
if ($data instanceof \ArrayAccess) {
$out = [];
foreach ($data as $k => $v) {
$out[$k] = is_array($v) || $v instanceof \ArrayAccess || $v instanceof \Yansongda\Supports\Collection
? self::to_array($v)
: $v;
}
return $out;
}
if (is_object($data) && method_exists($data, 'toArray')) {
$arr = $data->toArray();
return is_array($arr) ? $arr : [];
}
return [];
}
/**
* Nested value from array using dot path.
*
* @param array<string,mixed> $data
* @return mixed
*/
public static function dig(array $data, string $path, $default = null) {
$cur = $data;
foreach (explode('.', $path) as $seg) {
if (!is_array($cur) || !array_key_exists($seg, $cur)) {
return $default;
}
$cur = $cur[$seg];
}
return $cur;
}
/**
* Extract a redirect / payment URL from alipay web/wap response.
*
* @param mixed $result
*/
public static function extract_payment_url($result): string {
if (is_string($result) && $result !== '') {
return $result;
}
if ($result instanceof \Psr\Http\Message\ResponseInterface) {
$loc = $result->getHeaderLine('Location');
if ($loc !== '') {
return $loc;
}
$body = (string) $result->getBody();
if (preg_match('#^https?://[^\s"\'<>]+$#', trim($body), $m)) {
return $m[0];
}
return '';
}
if (is_object($result)) {
if (method_exists($result, 'getTargetUrl')) {
$url = (string) $result->getTargetUrl();
if ($url !== '') {
return $url;
}
}
if (method_exists($result, '__toString')) {
$s = (string) $result;
if (strpos($s, 'http') === 0) {
return $s;
}
}
}
$arr = self::to_array($result);
foreach (['payment_url', 'h5_url', 'code_url', 'mweb_url'] as $k) {
if (!empty($arr[$k]) && is_string($arr[$k])) {
return $arr[$k];
}
}
return '';
}
/**
* HTML body from alipay Response (form auto-submit) when no Location header.
*
* @param mixed $result
*/
public static function extract_html_body($result): string {
if ($result instanceof \Psr\Http\Message\ResponseInterface) {
if ($result->getHeaderLine('Location') !== '') {
return '';
}
return (string) $result->getBody();
}
return '';
}
/**
* Emit SDK success()/fail() ResponseInterface (or string) and exit.
*
* @param mixed $response
*/
public static function emit_response($response, string $fallback_body = 'success'): void {
if ($response instanceof \Psr\Http\Message\ResponseInterface) {
$status = $response->getStatusCode();
if ($status > 0 && !headers_sent()) {
http_response_code($status);
}
foreach ($response->getHeaders() as $name => $values) {
if (strtolower((string) $name) === 'transfer-encoding') {
continue;
}
foreach ($values as $value) {
header($name . ': ' . $value, false);
}
}
$body = (string) $response->getBody();
echo $body !== '' ? $body : $fallback_body;
exit;
}
if (is_string($response) && $response !== '') {
echo $response;
exit;
}
echo $fallback_body;
exit;
}
/**
* @return array<string,mixed>
*/
private static function settings(): array {
if (class_exists('\AKSRT\Settings\Settings')) {
return \AKSRT\Settings\Settings::get_all();
}
return [];
}
/**
* @return array<string,mixed>
*/
private static function logger_config(): array {
$enabled = defined('WP_DEBUG') && WP_DEBUG;
$file = '';
if ($enabled && function_exists('wp_upload_dir')) {
$uploads = wp_upload_dir();
if (empty($uploads['error']) && !empty($uploads['basedir'])) {
$dir = trailingslashit((string) $uploads['basedir']) . 'aksrt-pay';
if (!is_dir($dir)) {
wp_mkdir_p($dir);
}
$file = trailingslashit($dir) . 'pay.log';
}
}
return [
'enable' => $enabled && $file !== '',
'file' => $file,
'level' => 'info',
'type' => 'single',
'max_file' => 30,
];
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class Order {
public const STATUS_CREATED = \AKSRT\Core\DomainModel::ORDER_STATUS_CREATED;
public const STATUS_PENDING = \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING;
public const STATUS_PAID = \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
public const STATUS_DELIVERED = \AKSRT\Core\DomainModel::ORDER_STATUS_DELIVERED;
public const STATUS_COMPLETED = \AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED;
public const STATUS_CANCELLED = \AKSRT\Core\DomainModel::ORDER_STATUS_CANCELLED;
public const STATUS_EXPIRED = \AKSRT\Core\DomainModel::ORDER_STATUS_EXPIRED;
public const STATUS_REFUNDING = \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING;
public const STATUS_REFUNDED = \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED;
public const STATUS_PENDING_REVIEW = \AKSRT\Core\DomainModel::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\Core\DomainModel::order_status_labels();
}
public static function can_transition(string $from, string $to): bool {
return \AKSRT\Core\DomainModel::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(): OrderRepository {
return OrderRepository::get_instance();
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
/**
* Public REST for order-center actions (pay / cancel / detail).
*
* Registered outside the admin SPA bootstrap so front-end order center
* always has routes even if an admin controller fails to load.
*
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class OrderCenterRest {
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('rest_api_init', [$this, 'register_routes'], 20);
}
public function register_routes(): void {
$ns = 'aksrt/v1';
// Allow common order_number charset (AKS… + optional separators).
$on = '(?P<order_number>[A-Za-z0-9_-]+)';
register_rest_route($ns, '/shop/frontend/orders/' . $on . '/pay', [
'methods' => 'POST',
'callback' => [$this, 'pay'],
'permission_callback' => [$this, 'require_logged_in'],
]);
register_rest_route($ns, '/shop/frontend/orders/' . $on . '/cancel', [
'methods' => 'POST',
'callback' => [$this, 'cancel'],
'permission_callback' => [$this, 'require_logged_in'],
]);
register_rest_route($ns, '/shop/frontend/orders/' . $on, [
[
'methods' => 'GET',
'callback' => [$this, 'get_order'],
'permission_callback' => [$this, 'require_logged_in'],
],
[
// order-center.js historically POSTed for refresh — accept both.
'methods' => 'POST',
'callback' => [$this, 'get_order'],
'permission_callback' => [$this, 'require_logged_in'],
],
]);
}
/**
* @return true|\WP_Error
*/
public function require_logged_in() {
if (!is_user_logged_in()) {
return new \WP_Error(
'aksrt_rest_forbidden',
__('请先登录', 'aksrt'),
['status' => 401]
);
}
return true;
}
public function pay(\WP_REST_Request $request): \WP_REST_Response {
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
$params = $request->get_json_params();
if (!is_array($params)) {
$params = [];
}
$payment_method = sanitize_key((string) ($params['payment_method'] ?? ''));
if (!class_exists(UserOrders::class)) {
return $this->error(__('订单服务不可用', 'aksrt'), 500);
}
$result = UserOrders::get_instance()->pay_order(get_current_user_id(), $order_number, $payment_method);
if (empty($result['success'])) {
return $this->error((string) ($result['message'] ?? __('支付失败', 'aksrt')), 400);
}
return $this->success(['data' => $result['data'] ?? []]);
}
public function cancel(\WP_REST_Request $request): \WP_REST_Response {
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
if (!class_exists(UserOrders::class)) {
return $this->error(__('订单服务不可用', 'aksrt'), 500);
}
$result = UserOrders::get_instance()->cancel_order(get_current_user_id(), $order_number);
if (empty($result['success'])) {
return $this->error((string) ($result['message'] ?? __('取消订单失败', 'aksrt')), 400);
}
return $this->success(['data' => $result['data'] ?? []]);
}
public function get_order(\WP_REST_Request $request): \WP_REST_Response {
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
$order = null;
if (!class_exists(UserOrders::class)) {
return $this->error(__('订单服务不可用', 'aksrt'), 500);
}
$order = UserOrders::get_instance()->get_order_detail_view(get_current_user_id(), $order_number);
if (!$order) {
return $this->error(__('订单不存在', 'aksrt'), 404);
}
return $this->success(['data' => $order]);
}
private function success(array $data = [], int $status = 200): \WP_REST_Response {
return new \WP_REST_Response(array_merge(['success' => true], $data), $status);
}
private function error(string $message, int $status = 400): \WP_REST_Response {
return new \WP_REST_Response([
'success' => false,
'message' => $message,
], $status);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class OrderRepository {
use OrderRepositoryCore;
use OrderRepositoryAdmin;
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
}
+534
View File
@@ -0,0 +1,534 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
trait OrderRepositoryAdmin {
/**
* Build a stable admin-facing order context payload for workbench pages.
*/
public function get_admin_order_context(string $order_number): array {
$order_number = sanitize_text_field($order_number);
$contexts = $this->get_admin_order_context_map([$order_number]);
return $contexts[$order_number] ?? $this->get_empty_admin_order_context($order_number);
}
/**
* Build admin-facing order context payloads in bulk to avoid per-row order queries.
*
* @param array<int, string> $order_numbers
* @return array<string, array<string, mixed>>
*/
public function get_admin_order_context_map(array $order_numbers): array {
global $wpdb;
$order_numbers = array_values(array_unique(array_filter(array_map(
static function ($order_number): string {
return sanitize_text_field((string) $order_number);
},
$order_numbers
))));
if (empty($order_numbers)) {
return [];
}
$contexts = [];
foreach ($order_numbers as $order_number) {
$contexts[$order_number] = $this->get_empty_admin_order_context($order_number);
}
$placeholders = implode(', ', array_fill(0, count($order_numbers), '%s'));
$sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders})";
$rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A);
if (empty($rows)) {
return $contexts;
}
$orders = [];
foreach ((array) $rows as $row) {
$order = $this->normalize_row($row);
$orders[(string) ($order['order_number'] ?? '')] = $order;
}
$resolved_order_numbers = array_keys($orders);
foreach ($orders as $order_number => $order) {
$contexts[$order_number] = $this->build_admin_order_context($order, []);
}
return $contexts;
}
/**
* 更新订单状态(带状态机验证)
*/
/**
* Merge operator admin note into order note meta without destroying JSON payload.
*/
public function update_admin_note(string $order_number, string $admin_note): bool {
global $wpdb;
$order = $this->get($order_number);
if (!$order) {
return false;
}
$raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
$decoded = $raw !== '' ? json_decode($raw, true) : null;
$admin_note = sanitize_textarea_field($admin_note);
if (is_array($decoded)) {
$decoded['admin_note'] = $admin_note;
$new_note = wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} elseif ($raw !== '') {
$new_note = wp_json_encode([
'customer_note' => $raw,
'admin_note' => $admin_note,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} else {
$new_note = wp_json_encode([
'admin_note' => $admin_note,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$result = $wpdb->update(
$this->table(),
['note' => $new_note],
['order_number' => $order_number],
['%s'],
['%s']
);
return $result !== false;
}
/**
* 管理后台订单列表 — 支持 search(order_number / product_type 模糊匹配)
*/
public function get_orders_admin(array $args = [], string $search = ''): array {
global $wpdb;
[$where, $params] = $this->build_admin_order_filters($args, $search);
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return array_map([$this, 'normalize_row'], (array) $rows);
}
/**
* 管理后台订单计数 — 与 get_orders_admin 共用过滤条件
*/
public function get_orders_count(array $args = [], string $search = ''): int {
global $wpdb;
[$where, $params] = $this->build_admin_order_filters($args, $search);
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
if (!empty($params)) {
return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
}
return (int) $wpdb->get_var($sql);
}
/**
* Aggregate paid content (resource) sales by product_id (post ID).
*
* @param array{
* product_type?: string,
* product_ids?: int[],
* status_in?: string[],
* limit?: int,
* offset?: int,
* orderby?: string,
* order?: string
* } $args
* @return array{items: list<array{product_id:int,product_type:string,sales_count:int,avg_amount:float,revenue:float}>, total: int}
*/
public function get_content_sales_summary(array $args = []): array {
global $wpdb;
$product_type = sanitize_key((string) ($args['product_type'] ?? \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE));
if ($product_type === '') {
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE;
}
$status_in = $args['status_in'] ?? \AKSRT\Core\DomainModel::paid_order_statuses();
if (!is_array($status_in) || empty($status_in)) {
$status_in = \AKSRT\Core\DomainModel::paid_order_statuses();
}
$status_in = array_values(array_filter(array_map('sanitize_key', $status_in)));
if (empty($status_in)) {
return ['items' => [], 'total' => 0];
}
$where = ['product_type = %s', 'product_id > 0'];
$params = [$product_type];
$status_placeholders = implode(', ', array_fill(0, count($status_in), '%s'));
$where[] = "status IN ({$status_placeholders})";
foreach ($status_in as $st) {
$params[] = $st;
}
$product_ids = $args['product_ids'] ?? [];
if (is_array($product_ids) && !empty($product_ids)) {
$ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) {
return $id > 0;
})));
if (!empty($ids)) {
$id_ph = implode(', ', array_fill(0, count($ids), '%d'));
$where[] = "product_id IN ({$id_ph})";
foreach ($ids as $id) {
$params[] = $id;
}
}
}
$where_sql = 'WHERE ' . implode(' AND ', $where);
$table = $this->table();
$total_sql = "SELECT COUNT(DISTINCT product_id) FROM {$table} {$where_sql}";
$total = (int) $wpdb->get_var($wpdb->prepare($total_sql, $params));
$orderby_map = [
'sales_count' => 'sales_count',
'revenue' => 'revenue',
'avg_amount' => 'avg_amount',
'product_id' => 'product_id',
];
$orderby_key = sanitize_key((string) ($args['orderby'] ?? 'sales_count'));
$orderby_col = $orderby_map[$orderby_key] ?? 'sales_count';
$order_dir = strtoupper((string) ($args['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$list_sql = "SELECT product_id,
product_type,
COUNT(*) AS sales_count,
AVG(amount) AS avg_amount,
SUM(amount) AS revenue
FROM {$table}
{$where_sql}
GROUP BY product_id, product_type
ORDER BY {$orderby_col} {$order_dir}
LIMIT %d OFFSET %d";
$list_params = $params;
$list_params[] = $limit;
$list_params[] = $offset;
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
$items = [];
foreach ((array) $rows as $row) {
$items[] = [
'product_id' => (int) ($row['product_id'] ?? 0),
'product_type' => (string) ($row['product_type'] ?? $product_type),
'sales_count' => (int) ($row['sales_count'] ?? 0),
'avg_amount' => round((float) ($row['avg_amount'] ?? 0), 2),
'revenue' => round((float) ($row['revenue'] ?? 0), 2),
];
}
return [
'items' => $items,
'total' => $total,
];
}
/**
* Paid order totals for a set of product IDs (resource content).
*
* @param int[] $product_ids
* @return array<int, array{sales_count:int,avg_amount:float,revenue:float}>
*/
public function get_content_sales_map(array $product_ids, string $product_type = ''): array {
$ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) {
return $id > 0;
})));
if (empty($ids)) {
return [];
}
if ($product_type === '') {
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE;
}
$summary = $this->get_content_sales_summary([
'product_type' => $product_type,
'product_ids' => $ids,
'limit' => max(count($ids), 1),
'offset' => 0,
'orderby' => 'product_id',
'order' => 'ASC',
]);
$map = [];
foreach ($summary['items'] as $item) {
$pid = (int) ($item['product_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$map[$pid] = [
'sales_count' => (int) ($item['sales_count'] ?? 0),
'avg_amount' => (float) ($item['avg_amount'] ?? 0),
'revenue' => (float) ($item['revenue'] ?? 0),
];
}
return $map;
}
/**
* Shared WHERE builder for admin order list/count.
*
* @return array{0: string[], 1: array<int, mixed>}
*/
private function build_admin_order_filters(array $args, string $search = ''): array {
global $wpdb;
$where = [];
$params = [];
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '' && $status !== 'all') {
$where[] = 'status = %s';
$params[] = $status;
}
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
if ($product_type !== '') {
$where[] = 'product_type = %s';
$params[] = $product_type;
}
$product_id = (int) ($args['product_id'] ?? 0);
if ($product_id > 0) {
$where[] = 'product_id = %d';
$params[] = $product_id;
}
$user_id = (int) ($args['user_id'] ?? 0);
if ($user_id > 0) {
$where[] = 'user_id = %d';
$params[] = $user_id;
}
$payment_method = sanitize_key((string) ($args['payment_method'] ?? ''));
if ($payment_method !== '') {
$where[] = 'payment_method = %s';
$params[] = $payment_method;
}
if ($search !== '') {
// Numeric search may mean user_id or product_id; also match order number / type.
if (ctype_digit($search)) {
$where[] = '(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d)';
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = (int) $search;
$params[] = (int) $search;
} else {
$where[] = '(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s)';
$like = '%' . $wpdb->esc_like($search) . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
}
return [$where, $params];
}
private function get_empty_admin_order_context(string $order_number): array {
return [
'exists' => false,
'summary' => [
'order_number' => $order_number,
'status' => '',
'status_label' => '',
'amount' => 0.0,
'currency_symbol' => '¥',
'created_at' => '',
'paid_at' => '',
],
'buyer' => [],
'address' => [],
'payment_method' => [],
'shipping_method' => [],
'shipping' => [
'count' => 0,
'latest' => [],
],
'after_sale' => [
'count' => 0,
'latest' => [],
],
'coupon' => [],
'pricing' => [
'subtotal' => 0.0,
'shipping_fee' => 0.0,
'discount' => 0.0,
'amount' => 0.0,
],
'items' => [],
'customer_note' => '',
'raw_order' => null,
];
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $related
* @return array<string, mixed>
*/
private function build_admin_order_context(array $order, array $related = []): array {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$user = get_userdata((int) ($order['user_id'] ?? 0));
$status = (string) ($order['status'] ?? '');
$address = is_array($meta['address'] ?? null) ? $meta['address'] : [];
$payment_method = is_array($meta['payment_method'] ?? null) ? $meta['payment_method'] : [];
$shipping_method = is_array($meta['shipping_method'] ?? null) ? $meta['shipping_method'] : [];
$coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : [];
$items = is_array($meta['items'] ?? null) ? $meta['items'] : [];
$shipping_fee = (float) ($meta['shipping_fee'] ?? 0);
$discount = (float) ($meta['discount'] ?? 0);
$amount = (float) ($order['amount'] ?? 0);
$subtotal = $this->calculate_order_subtotal($items, max(0, $amount + $discount - $shipping_fee));
$address['full_text'] = $this->build_address_text($address);
return [
'exists' => true,
'summary' => [
'order_number' => (string) ($order['order_number'] ?? ''),
'status' => $status,
'status_label' => class_exists('AKSRT\\Payment\\Order') ? (Order::get_valid_statuses()[$status] ?? $status) : $status,
'amount' => $amount,
'currency_symbol' => '¥',
'created_at' => (string) ($order['created_at'] ?? ''),
'paid_at' => (string) ($order['paid_at'] ?? ''),
'product_type' => (string) ($order['product_type'] ?? ''),
'product_id' => (int) ($order['product_id'] ?? 0),
],
'buyer' => [
'id' => (int) ($order['user_id'] ?? 0),
'display_name' => $user ? (string) $user->display_name : '',
'user_login' => $user ? (string) $user->user_login : '',
'user_email' => $user ? (string) $user->user_email : '',
],
'address' => $address,
'payment_method' => [
'value' => (string) ($payment_method['value'] ?? $order['payment_method'] ?? ''),
'label' => (string) ($payment_method['label'] ?? $order['payment_method'] ?? ''),
],
'shipping_method' => [
'value' => (string) ($shipping_method['value'] ?? ''),
'label' => (string) ($shipping_method['label'] ?? ''),
'fee' => $shipping_fee,
],
'shipping' => [
'count' => 0,
'latest' => [],
],
'after_sale' => [
'count' => 0,
'latest' => [],
],
'coupon' => $coupon,
'pricing' => [
'subtotal' => $subtotal,
'shipping_fee' => $shipping_fee,
'discount' => $discount,
'amount' => $amount,
],
'items' => $items,
'customer_note' => (string) ($meta['customer_note'] ?? $order['note_text'] ?? ''),
'raw_order' => $order,
];
}
/**
* @param array<int, array<string, mixed>> $items
*/
private function calculate_order_subtotal(array $items, float $fallback): float {
if (empty($items)) {
return round($fallback, 2);
}
$subtotal = 0.0;
foreach ($items as $item) {
if (isset($item['line_total'])) {
$subtotal += (float) $item['line_total'];
continue;
}
$subtotal += (float) ($item['price'] ?? 0) * max(1, (int) ($item['quantity'] ?? 1));
}
if ($subtotal <= 0) {
return round($fallback, 2);
}
return round($subtotal, 2);
}
private function build_address_text(array $address): string {
$segments = [];
foreach (['name', 'phone', 'province', 'city', 'district', 'address'] as $key) {
$value = trim((string) ($address[$key] ?? ''));
if ($value !== '') {
$segments[] = $value;
}
}
if (!empty($address['full_text']) && is_string($address['full_text'])) {
return (string) $address['full_text'];
}
return implode(' / ', $segments);
}
}
+747
View File
@@ -0,0 +1,747 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
trait OrderRepositoryCore {
private function table(): string {
return \AKSRT\Core\DBMigrator::table('order');
}
/* ------------------------------------------------------------------
* CRUD
* ----------------------------------------------------------------*/
/**
* 创建订单(写入表)
*/
public function create(array $data): string {
global $wpdb;
$order_number = $data['order_number'] ?? $this->generate_order_number();
// Preserve JSON pay meta (VIP/content/coupon). Only strip tags for plain text notes.
$note_raw = (string) ($data['note'] ?? '');
$note_trim = trim($note_raw);
if ($note_trim !== '' && ($note_trim[0] === '{' || $note_trim[0] === '[')) {
$decoded = json_decode($note_trim, true);
$note = is_array($decoded)
? (string) wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
: sanitize_textarea_field($note_raw);
} else {
$note = sanitize_textarea_field($note_raw);
}
$row = [
'order_number' => $order_number,
'user_id' => (int) ($data['user_id'] ?? 0),
'product_type' => sanitize_key((string) ($data['product_type'] ?? '')),
'product_id' => (int) ($data['product_id'] ?? 0),
'amount' => (float) ($data['amount'] ?? 0),
'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')),
'status' => \AKSRT\Core\DomainModel::normalize_order_status((string) ($data['status'] ?? \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING)),
'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')),
'payment_transaction_id' => sanitize_text_field((string) ($data['payment_transaction_id'] ?? '')),
'created_at' => $data['created_at'] ?? current_time('mysql'),
'paid_at' => $data['paid_at'] ?? null,
'note' => $note,
];
$formats = ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
// Honour configured expiry when callers pass expires_at (shop checkout etc.).
if (array_key_exists('expires_at', $data) && $data['expires_at'] !== null && $data['expires_at'] !== '') {
$row['expires_at'] = (string) $data['expires_at'];
$formats[] = '%s';
}
$wpdb->insert($this->table(), $row, $formats);
if ($wpdb->insert_id) {
return $order_number;
}
return '';
}
/**
* 获取单个订单
*/
public function get(string $order_number): ?array {
global $wpdb;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE order_number = %s",
$order_number
), ARRAY_A);
if ($row) {
return $this->normalize_row($row);
}
return null;
}
/**
* 获取用户订单列表
*/
public function get_user_orders(int $user_id, array $args = []): array {
global $wpdb;
$where = [];
$params = [];
if ($user_id > 0) {
$where[] = 'user_id = %d';
$params[] = $user_id;
}
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
if ($product_type !== '') {
$where[] = 'product_type = %s';
$params[] = $product_type;
}
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
$offset = max(0, (int) ($args['offset'] ?? 0));
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
$params[] = $limit;
$params[] = $offset;
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
return array_map([$this, 'normalize_row'], (array) $rows);
}
/**
* 获取单个用户订单。
*/
public function get_user_order(int $user_id, string $order_number): ?array {
$order = $this->get($order_number);
if (!$order) {
return null;
}
if ($user_id > 0 && (int) ($order['user_id'] ?? 0) !== $user_id) {
return null;
}
return $order;
}
/**
* 获取用户订单总数。
*/
public function count_user_orders(int $user_id, array $args = []): int {
global $wpdb;
if ($user_id <= 0) {
return 0;
}
$where = ['user_id = %d'];
$params = [$user_id];
$status = sanitize_key((string) ($args['status'] ?? ''));
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
if ($product_type !== '') {
$where[] = 'product_type = %s';
$params[] = $product_type;
}
$where_sql = 'WHERE ' . implode(' AND ', $where);
$sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
}
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
global $wpdb;
$valid = \AKSRT\Core\DomainModel::order_status_labels();
if (!isset($valid[$new_status])) return false;
$order = $this->get($order_number);
if (!$order) return false;
$current_status = $order['status'] ?? 'created';
if (empty($extra['force']) && !\AKSRT\Core\DomainModel::can_transition_order_status($current_status, $new_status)) {
return false;
}
$update_data = ['status' => $new_status];
$format = ['%s'];
// Stamp lifecycle timestamps. Some statuses share a column (e.g. expired → cancelled_at;
// pending_review still counts as a completion-like milestone for the timeline).
$time_fields = [
\AKSRT\Core\DomainModel::ORDER_STATUS_PAID => 'paid_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_DELIVERED => 'delivered_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED => 'completed_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_PENDING_REVIEW => 'completed_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_CANCELLED => 'cancelled_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_EXPIRED => 'cancelled_at',
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED => 'refunded_at',
];
if (isset($time_fields[$new_status])) {
$field = $time_fields[$new_status];
// Do not overwrite an existing timestamp (idempotent callbacks / force updates).
$existing = is_string($order[$field] ?? null) ? trim((string) $order[$field]) : '';
if ($existing === '' || str_starts_with($existing, '0000-00-00')) {
$update_data[$field] = current_time('mysql');
$format[] = '%s';
}
}
// When jumping past paid (force admin transitions), backfill paid_at so the
// progress chain is never stuck on "订单创建" alone.
if (
in_array($new_status, [
\AKSRT\Core\DomainModel::ORDER_STATUS_DELIVERED,
\AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED,
\AKSRT\Core\DomainModel::ORDER_STATUS_PENDING_REVIEW,
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING,
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED,
], true)
) {
$existing_paid = is_string($order['paid_at'] ?? null) ? trim((string) $order['paid_at']) : '';
if ($existing_paid === '' || str_starts_with($existing_paid, '0000-00-00')) {
if (!isset($update_data['paid_at'])) {
$update_data['paid_at'] = current_time('mysql');
$format[] = '%s';
}
}
}
if (!empty($extra['payment_method'])) {
$update_data['payment_method'] = sanitize_key($extra['payment_method']);
$format[] = '%s';
}
if (!empty($extra['payment_transaction_id'])) {
$update_data['payment_transaction_id'] = sanitize_text_field($extra['payment_transaction_id']);
$format[] = '%s';
}
if (!empty($extra['note'])) {
// Preserve JSON pay meta (coupon/topic_pay/content_pay/…); stash human text as status_note.
$status_note = sanitize_textarea_field((string) $extra['note']);
$raw_note = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
$decoded_note = $raw_note !== '' ? json_decode($raw_note, true) : null;
if (is_array($decoded_note)) {
$decoded_note['status_note'] = $status_note;
$merged = wp_json_encode($decoded_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$update_data['note'] = is_string($merged) ? $merged : $status_note;
} else {
$update_data['note'] = $status_note;
}
$format[] = '%s';
}
// Compare-and-swap the status so concurrent callbacks cannot both win the
// first paid transition and trigger fulfillment twice.
$result = $wpdb->update(
$this->table(),
$update_data,
['order_number' => $order_number, 'status' => $current_status],
$format,
['%s', '%s']
);
if ($result === false) {
return false;
}
if ($result === 0) {
if (!empty($extra['require_transition'])) {
return false;
}
$latest = $this->get($order_number);
return is_array($latest) && ($latest['status'] ?? '') === $new_status;
}
if ($result > 0) {
// 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。
// 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID && $current_status !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
$paid_order = array_merge($order, [
'status' => $new_status,
'payment_method' => $update_data['payment_method'] ?? ($order['payment_method'] ?? ''),
'payment_transaction_id' => $update_data['payment_transaction_id'] ?? ($order['payment_transaction_id'] ?? ''),
]);
if (isset($update_data['note'])) {
// Keep pre-update JSON payload for listeners (coupon burn etc.);
// only attach status_note if callers mutated note.
$pre_meta = [];
$pre_raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
if ($pre_raw !== '') {
$pre_decoded = json_decode($pre_raw, true);
if (is_array($pre_decoded)) {
$pre_meta = $pre_decoded;
}
}
if ($pre_meta !== []) {
$paid_order['meta_payload'] = $pre_meta;
// Prefer merged DB note (JSON + status_note) for downstream reads.
$paid_order['note'] = $update_data['note'];
}
}
/**
* 订单到账。
*
* @param string $order_number 订单号
* @param array $paid_order 订单数据(status=paid)
*/
do_action('aksrt_order_paid', $order_number, $paid_order);
}
}
return $result !== false;
}
/**
* Delete closed (cancelled/expired) orders older than $days days.
* Mirrors zibll ZibPay::clear_order(14).
*
* @return int Number of rows deleted.
*/
public function clear_closed_orders(int $days = 14): int {
global $wpdb;
$days = max(1, min(3650, $days));
$table = $this->table();
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
// Prefer cancelled_at when set; fall back to created_at for older rows.
$sql = "DELETE FROM {$table}
WHERE status IN ('cancelled', 'expired')
AND COALESCE(NULLIF(cancelled_at, ''), created_at) < %s";
$deleted = $wpdb->query($wpdb->prepare($sql, $cutoff));
return $deleted === false ? 0 : (int) $deleted;
}
/**
* Hard-delete a single order by order_number (admin hygiene).
*/
public function delete_order(string $order_number): bool {
global $wpdb;
$order_number = sanitize_text_field($order_number);
if ($order_number === '') {
return false;
}
$ok = $wpdb->delete($this->table(), ['order_number' => $order_number], ['%s']);
return $ok !== false && $ok > 0;
}
/**
* 获取订单统计
*/
public function get_stats(int $user_id = 0): array {
global $wpdb;
$where = '';
$params = [];
if ($user_id > 0) {
$where = 'WHERE user_id = %d';
$params[] = $user_id;
}
$sql = "SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid,
SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) as delivered,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'pending_review' THEN 1 ELSE 0 END) as pending_review,
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) as refunding,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled,
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded,
SUM(CASE WHEN status IN ('paid','delivered','completed','pending_review') THEN amount ELSE 0 END) as revenue
FROM {$this->table()} {$where}";
if (!empty($params)) {
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
} else {
$row = $wpdb->get_row($sql, ARRAY_A);
}
return [
'total' => (int) ($row['total'] ?? 0),
'pending' => (int) ($row['pending'] ?? 0),
'paid' => (int) ($row['paid'] ?? 0),
'delivered' => (int) ($row['delivered'] ?? 0),
'completed' => (int) ($row['completed'] ?? 0),
'pending_review' => (int) ($row['pending_review'] ?? 0),
'refunding' => (int) ($row['refunding'] ?? 0),
'cancelled' => (int) ($row['cancelled'] ?? 0),
'refunded' => (int) ($row['refunded'] ?? 0),
'revenue' => (float) ($row['revenue'] ?? 0),
];
}
public function has_paid_access(int $user_id, string $product_type, int $product_id): bool {
if ($user_id <= 0 || $product_id <= 0) {
return false;
}
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
}
public function get_valid_vip_order(int $user_id): ?array {
return $this->find_latest_success_order($user_id, \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP, 0, true);
}
/* ------------------------------------------------------------------
* Migration bridge: option → table
* ----------------------------------------------------------------*/
/**
* 一次性迁移:把 option 中的订单搬到表中。
* 幂等 — 已存在的 order_number 不会重复插入。
*/
public function migrate_from_option(): int {
global $wpdb;
$orders = get_option('aksrt_orders', []);
if (!is_array($orders) || empty($orders)) return 0;
$migrated = 0;
foreach ($orders as $order_number => $data) {
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$this->table()} WHERE order_number = %s",
$order_number
));
if ($existing) continue;
$wpdb->insert($this->table(), [
'order_number' => $order_number,
'user_id' => (int) ($data['user_id'] ?? 0),
'product_type' => sanitize_key((string) ($data['product_type'] ?? '')),
'product_id' => (int) ($data['product_id'] ?? 0),
'amount' => (float) ($data['amount'] ?? 0),
'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')),
'status' => \AKSRT\Core\DomainModel::normalize_order_status((string) ($data['status'] ?? \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING)),
'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')),
'created_at' => $data['created_at'] ?? current_time('mysql'),
'paid_at' => $data['paid_at'] ?? null,
'delivered_at' => $data['delivered_at'] ?? null,
'completed_at' => $data['completed_at'] ?? null,
'cancelled_at' => $data['cancelled_at'] ?? null,
'refunded_at' => $data['refunded_at'] ?? null,
'expires_at' => $data['expires_at'] ?? null,
'note' => '',
], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
if ($wpdb->insert_id) $migrated++;
}
update_option(\AKSRT\Core\DomainModel::ORDER_MIGRATED_OPTION, time());
return $migrated;
}
/* ------------------------------------------------------------------
* Internal helpers
* ----------------------------------------------------------------*/
private function normalize_row(array $row): array {
$row['id'] = (int) $row['id'];
$row['user_id'] = (int) $row['user_id'];
$row['product_id'] = (int) $row['product_id'];
$row['amount'] = (float) $row['amount'];
$row['status'] = \AKSRT\Core\DomainModel::normalize_order_status((string) ($row['status'] ?? ''));
$row['meta_payload'] = [];
if (!empty($row['note']) && is_string($row['note'])) {
$decoded = json_decode($row['note'], true);
if (is_array($decoded)) {
$row['meta_payload'] = $decoded;
if (isset($decoded['customer_note']) && is_string($decoded['customer_note'])) {
$row['note_text'] = $decoded['customer_note'];
}
} else {
$row['note_text'] = $row['note'];
}
}
return $row;
}
private function find_latest_success_order(int $user_id, string $product_type, int $product_id = 0, bool $ignore_product_id = false): ?array {
global $wpdb;
if ($user_id <= 0 || $product_type === '') {
return null;
}
$status_placeholders = implode(', ', array_fill(0, count(\AKSRT\Core\DomainModel::paid_order_statuses()), '%s'));
$params = array_merge(
[$user_id, sanitize_key($product_type)],
$ignore_product_id ? [] : [$product_id],
\AKSRT\Core\DomainModel::paid_order_statuses()
);
$product_sql = $ignore_product_id ? '' : ' AND product_id = %d';
$sql = "SELECT * FROM {$this->table()}
WHERE user_id = %d
AND product_type = %s{$product_sql}
AND status IN ({$status_placeholders})
ORDER BY COALESCE(paid_at, created_at) DESC, id DESC
LIMIT 1";
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
return is_array($row) ? $this->normalize_row($row) : null;
}
/**
* Build a unique order number using the admin-configured prefix.
*
* Column + major gateways (WeChat out_trade_no) cap at 32 chars, so the
* prefix is trimmed to leave room for {YmdHis}{4 digits} (=18).
*/
private function generate_order_number(): string {
$prefix = 'AKSRT';
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_order_settings')) {
$settings = \AKSRT\Settings\Settings::get_order_settings();
$configured = sanitize_text_field((string) ($settings['prefix'] ?? ''));
if ($configured !== '') {
$prefix = $configured;
}
}
// Keep only gateway-safe chars (REST + WeChat/Alipay out_trade_no).
$prefix = preg_replace('/[^A-Za-z0-9_-]/', '', $prefix);
if (!is_string($prefix) || $prefix === '') {
$prefix = 'AKSRT';
}
$body = date('YmdHis') . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
// Reserve 18 for body; leave ≥1 char of prefix so numbers stay human-readable.
$max_prefix = max(1, 32 - strlen($body));
if (strlen($prefix) > $max_prefix) {
$prefix = substr($prefix, 0, $max_prefix);
}
return $prefix . $body;
}
/**
* Merge keys into the order note JSON payload (meta_payload).
*
* @param array<string,mixed> $patch
*/
public function merge_meta(string $order_number, array $patch): bool {
global $wpdb;
$order = $this->get($order_number);
if (!$order) {
return false;
}
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
foreach ($patch as $key => $value) {
$meta[(string) $key] = $value;
}
$encoded = wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($encoded)) {
return false;
}
$updated = $wpdb->update(
$this->table(),
['note' => $encoded],
['order_number' => $order_number],
['%s'],
['%s']
);
return $updated !== false;
}
/**
* Resolve a local order from a provider out_trade_no (base or attempt-suffixed).
*
* The authoritative mapping is the order's own meta (provider_out_trade_no /
* provider_out_trade_nos); the prefix heuristic is only a fallback and must
* never be the sole resolver, otherwise a truncated attempt number would
* strand a real payment.
*/
public function resolve_by_out_trade_no(string $out_trade_no): ?array {
global $wpdb;
$out_trade_no = sanitize_text_field($out_trade_no);
if ($out_trade_no === '') {
return null;
}
$direct = $this->get($out_trade_no);
if ($direct) {
return $direct;
}
// Authoritative: the attempt number was recorded in the order note JSON.
$needle = '"provider_out_trade_no":"' . $out_trade_no . '"';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE note LIKE %s ORDER BY id DESC LIMIT 1",
'%' . $wpdb->esc_like($needle) . '%'
),
ARRAY_A
);
if (is_array($row)) {
return $this->normalize_row($row);
}
// Superseded attempts stay in the history array.
$history_needle = '"' . $out_trade_no . '"';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$this->table()}
WHERE note LIKE %s AND note LIKE %s
ORDER BY id DESC LIMIT 1",
'%' . $wpdb->esc_like('"provider_out_trade_nos"') . '%',
'%' . $wpdb->esc_like($history_needle) . '%'
),
ARRAY_A
);
if (is_array($row)) {
return $this->normalize_row($row);
}
// Last resort: {order_number}P{n} prefix heuristic.
if (preg_match('/^(.+)P([0-9]{1,3})$/', $out_trade_no, $m)) {
$base = $this->get($m[1]);
if ($base) {
return $base;
}
}
return null;
}
/**
* Provider out_trade_no currently bound to this order (falls back to order_number).
*
* @param array<string,mixed> $order
*/
public static function provider_out_trade_no(array $order): string {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$out = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
if ($out !== '') {
return $out;
}
return sanitize_text_field((string) ($order['order_number'] ?? ''));
}
/**
* Allocate a new provider out_trade_no for a payment attempt.
* First attempt uses bare order_number; later attempts append Pn (WeChat limit 32).
*
* @return array{out_trade_no:string,attempt:int,previous:string}|null
*/
public function allocate_payment_attempt(string $order_number): ?array {
$order = $this->get($order_number);
if (!$order) {
return null;
}
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$previous = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
$history = is_array($meta['provider_out_trade_nos'] ?? null) ? $meta['provider_out_trade_nos'] : [];
// First provider submission keeps the bare order number.
// Re-pay (provider_out_trade_no already set) always mints a new suffix so
// WeChat/Alipay/Epay accept the order when amount/subject/notify changed.
if ($previous === '') {
$attempt = 1;
$out = $order_number;
} else {
$attempt = max(1, (int) ($meta['pay_attempt'] ?? 1)) + 1;
$suffix = 'P' . $attempt;
$base = $order_number;
if (strlen($base) + strlen($suffix) > 32) {
$base = substr($base, 0, 32 - strlen($suffix));
}
$out = $base . $suffix;
if ($previous !== '' && !in_array($previous, $history, true)) {
$history[] = $previous;
}
}
if (!in_array($out, $history, true)) {
$history[] = $out;
}
if (count($history) > 20) {
$history = array_slice($history, -20);
}
$ok = $this->merge_meta($order_number, [
'pay_attempt' => $attempt,
'provider_out_trade_no' => $out,
'provider_out_trade_nos' => array_values($history),
]);
if (!$ok) {
return null;
}
return [
'out_trade_no' => $out,
'attempt' => $attempt,
'previous' => $previous,
];
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
/**
* Cross-request lock for creating a payment attempt for one order.
*/
class PaymentAttemptLock {
private const TTL = 120;
/**
* @return string|false Lock token, or false when another request owns it.
*/
public static function acquire(string $order_number) {
$key = self::key($order_number);
$token = wp_generate_uuid4();
$value = [
'token' => $token,
'expires' => time() + self::TTL,
];
if (add_option($key, $value, '', false)) {
return $token;
}
$current = get_option($key, []);
if (is_array($current) && (int) ($current['expires'] ?? 0) < time()) {
global $wpdb;
$serialized_current = maybe_serialize($current);
$serialized_value = maybe_serialize($value);
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->options}
SET option_value = %s
WHERE option_name = %s AND option_value = %s",
$serialized_value,
$key,
$serialized_current
));
if ($updated === 1) {
wp_cache_delete($key, 'options');
return $token;
}
}
return false;
}
public static function release(string $order_number, string $token): void {
global $wpdb;
$key = self::key($order_number);
$current = get_option($key, []);
if (!is_array($current) || !hash_equals((string) ($current['token'] ?? ''), $token)) {
return;
}
$deleted = $wpdb->query($wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
$key,
maybe_serialize($current)
));
if ($deleted === 1) {
wp_cache_delete($key, 'options');
}
}
private static function key(string $order_number): string {
return 'aksrt_pay_lock_' . hash('sha256', $order_number);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
/**
* Single execution path for "create a payment attempt for an existing order".
*
* Every cashier (shop / content / VIP / forum / recharge / points) must route
* through here so that all of them get:
* 1. a cross-request attempt lock (no double deduction on double-click)
* 2. a fresh provider out_trade_no on re-pay (providers reject reused numbers)
* 3. best-effort close of the previous provider trade
* 4. strict CAS on the paid transition + rollback of synchronous deductions
*
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class PaymentOrchestrator {
/** Methods settled synchronously in-process (no provider round trip). */
private const SYNC_METHODS = ['balance', 'points'];
/**
* @param array<string,mixed> $order Order row plus subject/amount overrides.
* @param array<string,mixed> $opts paid_note: string
*
* @return array{
* success:bool,
* code?:string,
* message:string,
* redirect?:bool,
* payment_url?:string,
* qr_content?:string,
* transaction_id?:string,
* out_trade_no?:string
* }
*/
public static function pay(GatewayInterface $gateway, array $order, array $opts = []): array {
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
if ($order_number === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
return self::fail('aksrt_pay_failed', __('订单号无效。', 'aksrt'));
}
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
if ($method === '') {
return self::fail('aksrt_pay_failed', __('支付方式无效。', 'aksrt'));
}
$lock = PaymentAttemptLock::acquire($order_number);
if ($lock === false) {
return self::fail('aksrt_pay_locked', __('该订单正在发起支付,请稍后重试。', 'aksrt'));
}
try {
$repo = OrderRepository::get_instance();
// A pending order remains bound to the gateway selected at creation.
// Switching it would make a late callback from the previous gateway look
// invalid after that provider may already have collected the money.
$stored = $repo->get($order_number);
$stored_status = sanitize_key((string) ($stored['status'] ?? ''));
if (!in_array($stored_status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
return self::fail('aksrt_order_not_payable', __('当前订单状态不可支付。', 'aksrt'));
}
$stored_method = sanitize_key((string) ($stored['payment_method'] ?? ''));
if ($stored_method !== '' && !hash_equals($stored_method, $method)) {
return self::fail('aksrt_pay_method_mismatch', __('支付方式与订单不一致,请重新下单。', 'aksrt'));
}
// External providers refuse a second submission that reuses the same
// merchant order number with changed params — mint a new one per attempt.
if (!in_array($method, self::SYNC_METHODS, true)) {
$attempt = $repo->allocate_payment_attempt($order_number);
if ($attempt === null) {
return self::fail('aksrt_pay_failed', __('无法生成支付单号,请稍后重试。', 'aksrt'));
}
$order['out_trade_no'] = (string) $attempt['out_trade_no'];
$previous = (string) ($attempt['previous'] ?? '');
if ($previous !== '' && $previous !== $order['out_trade_no'] && method_exists($gateway, 'close_trade')) {
try {
$gateway->close_trade($previous);
} catch (\Throwable $e) {
// Best-effort: the new attempt already uses a fresh number.
}
}
}
$result = $gateway->create_payment($order);
if (empty($result['success'])) {
$message = (string) ($result['message'] ?? __('支付失败。', 'aksrt'));
return self::fail(self::failure_code($method, $message), $message);
}
$payment_url = (string) ($result['payment_url'] ?? '');
$qr_content = (string) ($result['payment_data']['code_url'] ?? '');
if ($payment_url !== '' || $qr_content !== '') {
// Redirect/scan gateways: the order stays pending until the signed
// notify/return callback confirms it. Never pre-mark as paid here.
return [
'success' => true,
'redirect' => true,
'payment_url' => $payment_url,
'qr_content' => $qr_content,
'out_trade_no' => (string) ($order['out_trade_no'] ?? $order_number),
'message' => (string) ($result['message'] ?? __('请在新页面完成支付。', 'aksrt')),
];
}
// Synchronous gateway (balance): funds already moved inside create_payment().
$transaction_id = sanitize_text_field((string) ($result['transaction_id'] ?? ''));
$paid = $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $method,
'payment_transaction_id' => $transaction_id,
'note' => (string) ($opts['paid_note'] ?? __('用户完成支付。', 'aksrt')),
// Require a real status transition: if a callback already moved the
// order, this attempt must roll its deduction back instead of
// silently reporting success.
'require_transition' => true,
]);
if (!$paid) {
$rolled_back = self::rollback($gateway, $order_number, (float) ($order['amount'] ?? 0), $method);
return self::fail(
'aksrt_order_confirm_failed',
$rolled_back
? __('订单状态已变化,扣款已退回。', 'aksrt')
: __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')
);
}
return [
'success' => true,
'redirect' => false,
'transaction_id' => $transaction_id,
'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')),
];
} finally {
PaymentAttemptLock::release($order_number, $lock);
}
}
/**
* Resolve + validate the gateway for a cashier request.
*
* @return array{gateway:GatewayInterface}|array{error_code:string,message:string}
*/
public static function resolve_gateway(string $payment_method): array {
if (!class_exists('AKSRT\\Payment\\Gateway')) {
return ['error_code' => 'aksrt_pay_unavailable', 'message' => __('支付网关不可用。', 'aksrt')];
}
$gateway = Gateway::get_instance()->get_gateway(sanitize_key($payment_method));
if (!$gateway) {
return ['error_code' => 'aksrt_gateway_unavailable', 'message' => __('当前支付方式不可用。', 'aksrt')];
}
if (!$gateway->is_configured()) {
return ['error_code' => 'aksrt_gateway_unconfigured', 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
}
return ['gateway' => $gateway];
}
/**
* Compensate a synchronous deduction that could not be committed.
*/
private static function rollback(GatewayInterface $gateway, string $order_number, float $amount, string $method): bool {
if ($amount <= 0) {
return true;
}
$request_id = 'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24);
try {
$compensation = $gateway->refund($order_number, $amount, $request_id);
} catch (\Throwable $e) {
$compensation = ['success' => false, 'message' => $e->getMessage()];
}
if (!empty($compensation['success'])) {
return true;
}
error_log(sprintf(
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
$order_number,
$method,
(string) ($compensation['message'] ?? 'unknown error')
));
return false;
}
private static function failure_code(string $method, string $message): string {
if ($method === 'balance' && (str_contains($message, '余额') || str_contains($message, '不足'))) {
return 'aksrt_balance_insufficient';
}
return 'aksrt_pay_failed';
}
/**
* @return array{success:false,code:string,message:string}
*/
private static function fail(string $code, string $message): array {
return ['success' => false, 'code' => $code, 'message' => $message];
}
}
+185
View File
@@ -0,0 +1,185 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class 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\Core\DBMigrator::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\Credits')) {
\AKSRT\Credits\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),
];
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class 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\Core\DBMigrator::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),
];
}
}
+720
View File
@@ -0,0 +1,720 @@
<?php
/**
* Front-end "My Orders" view-model (all product_types).
*
* Shop logistics/review stay shop-only; content/VIP/recharge get type-aware cards.
*
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class UserOrders {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Paginated user order center payload.
*
* @param array{page?:int,per_page?:int,status?:string,product_type?:string} $args
* @return array{items:array,total:int,total_pages:int,page:int,stats:array,product_types:array}
*/
public function get_order_center_view(int $user_id, array $args = []): array {
if ($user_id <= 0 || !class_exists(OrderRepository::class)) {
return [
'items' => [],
'total' => 0,
'total_pages' => 1,
'page' => 1,
'stats' => $this->empty_stats(),
'product_types' => $this->product_type_filter_options(),
];
}
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = max(1, min(50, (int) ($args['per_page'] ?? 20)));
$status = sanitize_key((string) ($args['status'] ?? ''));
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
$repo_args = [
'limit' => $per_page,
'offset' => ($page - 1) * $per_page,
];
if ($status !== '') {
$repo_args['status'] = $status;
}
if ($product_type !== '' && $product_type !== 'all') {
$repo_args['product_type'] = $product_type;
}
$repo = OrderRepository::get_instance();
$orders = $repo->get_user_orders($user_id, $repo_args);
$total = $repo->count_user_orders($user_id, $repo_args);
$items = [];
foreach ($orders as $order) {
$items[] = $this->decorate_order_view($order, true);
}
return [
'items' => $items,
'total' => $total,
'total_pages' => max(1, (int) ceil(max(0, $total) / $per_page)),
'page' => $page,
'stats' => $this->get_user_order_stats($user_id),
'product_types' => $this->product_type_filter_options(),
];
}
public function get_order_detail_view(int $user_id, string $order_number): ?array {
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
return null;
}
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number);
if (!$order) {
return null;
}
if (class_exists(Gateway::class)) {
$order = Gateway::get_instance()->reconcile_order($order_number, $order) ?: $order;
}
return $this->decorate_order_view($order, true);
}
/**
* @return array{total:int,pending:int,paid:int}
*/
public function get_user_order_stats(int $user_id): array {
if ($user_id <= 0 || !class_exists(OrderRepository::class)) {
return $this->empty_stats();
}
$repo = OrderRepository::get_instance();
return [
'total' => $repo->count_user_orders($user_id, []),
'pending' => $repo->count_user_orders($user_id, ['status' => Order::STATUS_PENDING]),
'paid' => $repo->count_user_orders($user_id, ['status' => Order::STATUS_PAID]),
];
}
/**
* @return array<string, string>
*/
public function product_type_filter_options(): array {
$labels = \AKSRT\Core\DomainModel::order_product_type_labels();
return array_merge(['' => __('全部类型', 'aksrt')], $labels);
}
/**
* @param array<string, mixed> $order Normalized order row.
* @return array<string, mixed>
*/
public function decorate_order_view(array $order, bool $with_detail = false): array {
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
$order_number = (string) ($order['order_number'] ?? '');
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
$status = (string) ($order['status'] ?? Order::STATUS_PENDING);
$product_id = (int) ($order['product_id'] ?? 0);
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
$is_points = $currency === 'points'
|| sanitize_key((string) ($order['payment_method'] ?? '')) === 'points'
|| (!empty($meta['pay_modo']) && (string) $meta['pay_modo'] === 'points');
$type_labels = \AKSRT\Core\DomainModel::order_product_type_labels();
$type_label = $type_labels[$product_type] ?? ($product_type !== '' ? $product_type : __('订单', 'aksrt'));
$title = $this->resolve_order_title($order, $meta, $product_type, $product_id, $type_label);
$image = $this->resolve_order_image($order, $meta, $product_type, $product_id);
$permalink = $this->resolve_order_permalink($product_type, $product_id, $meta);
$order_items = [];
$has_structured_items = !empty($order_items);
$product = [];
// Shop product cards were removed with the shop feature; content/VIP
// orders fall through to the shared meta-based fields below.
if (!empty($product['title'])) {
$title = (string) $product['title'];
}
if (!empty($product['image'])) {
$image = (string) $product['image'];
}
if (!empty($product['permalink'])) {
$permalink = (string) $product['permalink'];
}
$shipping_records = [];
$after_sale_items = [];
$subtotal = (float) ($meta['items'][0]['line_total'] ?? $order['amount'] ?? 0);
$shipping_fee = (float) ($meta['shipping_fee'] ?? 0);
$discount = (float) ($meta['discount'] ?? 0);
$expires_at = (string) ($order['expires_at'] ?? '');
$payment_value = sanitize_key((string) ($meta['payment_method']['value'] ?? $order['payment_method'] ?? 'balance'));
$can_pay = in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)
&& $payment_value !== 'manual_review';
$can_cancel = in_array($status, [Order::STATUS_PENDING, Order::STATUS_CREATED, Order::STATUS_EXPIRED], true);
$can_after_sale = false;
$can_receipt = false;
$can_review = false;
$can_rebuy = false;
// Order detail modal is always available; product permalink is optional.
$can_view = true;
$can_view_product = $permalink !== '' && in_array($status, [
Order::STATUS_PAID,
Order::STATUS_DELIVERED,
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
], true);
$view = [
'id' => (int) ($order['id'] ?? 0),
'order_number' => $order_number,
'amount' => (float) ($order['amount'] ?? 0),
'currency' => $currency,
'currency_symbol' => $is_points
? (class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt'))
: '¥',
'is_points' => $is_points,
'status' => $status,
'status_label' => Order::get_valid_statuses()[$status] ?? $status,
'product_type' => $product_type,
'product_type_label' => $type_label,
'product_id' => $product_id,
'product_name' => $title,
'product' => $product ?: [
'title' => $title,
'image' => $image,
'permalink' => $permalink,
],
'image' => $image,
'permalink' => $permalink,
'created_at' => (string) ($order['created_at'] ?? ''),
'paid_at' => (string) ($order['paid_at'] ?? ''),
'expires_at' => $expires_at,
'subtotal' => $subtotal,
'shipping_fee' => $shipping_fee,
'discount' => $discount,
'address' => $meta['address'] ?? [],
'payment_method' => $meta['payment_method'] ?? [
'value' => (string) ($order['payment_method'] ?? ''),
'label' => $this->payment_method_label((string) ($order['payment_method'] ?? '')),
],
'coupon' => $meta['coupon'] ?? [],
'items' => $has_structured_items ? $order_items : ($meta['items'] ?? []),
'shipping_method' => $meta['shipping_method'] ?? [],
'customer_note' => $meta['customer_note'] ?? ($order['note_text'] ?? ''),
'shipping_records' => $shipping_records,
'pay_url' => '#',
'actions' => [
'can_pay' => $can_pay,
'can_cancel' => $can_cancel,
'can_after_sale' => $can_after_sale,
'can_receipt' => $can_receipt,
'can_review' => $can_review,
'can_rebuy' => $can_rebuy,
'can_view' => $can_view,
'can_view_product' => $can_view_product,
],
'quantity' => $this->resolve_order_quantity($order_items, $meta),
'time_left' => $this->get_order_countdown($expires_at, $status),
'secondary_url' => $this->resolve_secondary_url($product_type),
'secondary_label' => $this->resolve_secondary_label($product_type),
];
if ($with_detail) {
$view['after_sale_records'] = $after_sale_items;
$view['timeline'] = $this->build_order_timeline($order, $shipping_records, $after_sale_items);
}
return $view;
}
/**
* Cancel pending order (any product_type).
*
* @return array{success:bool,message?:string,data?:array}
*/
public function cancel_order(int $user_id, string $order_number): array {
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
$repo = OrderRepository::get_instance();
$order = $repo->get_user_order($user_id, $order_number);
if (!$order) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
$status = (string) ($order['status'] ?? '');
if (!in_array($status, [Order::STATUS_PENDING, Order::STATUS_CREATED, Order::STATUS_EXPIRED], true)) {
return ['success' => false, 'message' => __('当前订单不可取消。', 'aksrt')];
}
$repo->update_status($order_number, Order::STATUS_CANCELLED, [
'force' => true,
'note' => __('用户在前台取消订单。', 'aksrt'),
]);
return [
'success' => true,
'data' => [
'order' => $this->get_order_detail_view($user_id, $order_number),
],
];
}
/**
* Resume payment via configured gateway (any product_type).
*
* @return array{success:bool,message?:string,data?:array}
*/
public function pay_order(int $user_id, string $order_number, string $payment_method = ''): array {
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
// Resume via shared gateway stack (any product_type; shop removed).
$repo = OrderRepository::get_instance();
$order = $repo->get_user_order($user_id, $order_number);
if (!$order) {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
$status = (string) ($order['status'] ?? '');
if (!in_array($status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
}
$stored_method = sanitize_key((string) ($order['payment_method'] ?? ''));
$payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance'));
if ($stored_method !== '' && $payment_method !== $stored_method) {
return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')];
}
$gateway_manager = class_exists(Gateway::class) ? Gateway::get_instance() : null;
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
if (!$gateway) {
return ['success' => false, 'message' => __('当前支付方式不可用。', 'aksrt')];
}
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
}
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
$order['payment_method'] = $payment_method;
$payment_result = PaymentOrchestrator::pay($gateway, $order, [
'paid_note' => __('用户在前台完成支付。', 'aksrt'),
]);
if (empty($payment_result['success'])) {
return ['success' => false, 'message' => $payment_result['message']];
}
if (!empty($payment_result['redirect'])) {
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'message' => $payment_result['message'],
],
];
}
return [
'success' => true,
'data' => [
'order' => $this->get_order_detail_view($user_id, $order_number),
'message' => $payment_result['message'],
],
];
}
private function empty_stats(): array {
return ['total' => 0, 'pending' => 0, 'paid' => 0];
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $meta
*/
private function resolve_order_title(array $order, array $meta, string $product_type, int $product_id, string $type_label): string {
foreach (['product_title', 'title', 'package_title'] as $key) {
if (!empty($meta[$key]) && is_string($meta[$key])) {
return (string) $meta[$key];
}
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
$pkg = (string) ($meta['package_title'] ?? '');
if ($pkg !== '') {
return $pkg;
}
return __('VIP 会员', 'aksrt');
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
return (string) ($meta['title'] ?? __('余额充值', 'aksrt'));
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
return (string) ($meta['title'] ?? __('购买积分', 'aksrt'));
}
if ($product_id > 0) {
$post_title = get_the_title($product_id);
if (is_string($post_title) && $post_title !== '') {
return $post_title;
}
}
return $type_label;
}
/**
* @param array<string, mixed> $order
* @param array<string, mixed> $meta
*/
private function resolve_order_image(array $order, array $meta, string $product_type, int $product_id): string {
if (!empty($meta['thumb']) && is_string($meta['thumb'])) {
return esc_url_raw((string) $meta['thumb']);
}
if ($product_id > 0 && has_post_thumbnail($product_id)) {
$url = get_the_post_thumbnail_url($product_id, 'thumbnail');
if (is_string($url) && $url !== '') {
return $url;
}
}
return '';
}
/**
* @param array<string, mixed> $meta
*/
private function resolve_order_permalink(string $product_type, int $product_id, array $meta): string {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
return function_exists('aksrt_get_user_center_tab_url')
? aksrt_get_user_center_tab_url('vip')
: '';
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|| $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
return function_exists('aksrt_get_user_center_tab_url')
? aksrt_get_user_center_tab_url('credits')
: '';
}
$post_id = $product_id > 0 ? $product_id : (int) ($meta['post_id'] ?? 0);
if ($post_id > 0) {
$link = get_permalink($post_id);
return is_string($link) ? $link : '';
}
return '';
}
private function resolve_secondary_url(string $product_type): string {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('vip') : '';
}
if (in_array($product_type, [
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE,
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY,
], true)) {
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('credits') : '';
}
return '';
}
private function resolve_secondary_label(string $product_type): string {
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
return __('我的会员', 'aksrt');
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
return __('我的余额', 'aksrt');
}
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
return class_exists('\AKSRT\Settings\Settings')
? sprintf(__('我的%s', 'aksrt'), \AKSRT\Settings\Settings::get_currency_name())
: __('我的积分', 'aksrt');
}
return '';
}
private function payment_method_label(string $method): string {
$method = sanitize_key($method);
$map = [
'balance' => __('站内余额', 'aksrt'),
'points' => __('积分支付', 'aksrt'),
'alipay' => __('支付宝', 'aksrt'),
'wechatpay' => __('微信支付', 'aksrt'),
'wechat' => __('微信支付', 'aksrt'),
'paypal' => 'PayPal',
'stripe' => 'Stripe',
'epay_alipay' => __('易支付·支付宝', 'aksrt'),
'epay_wxpay' => __('易支付·微信', 'aksrt'),
];
return $map[$method] ?? ($method !== '' ? $method : __('—', 'aksrt'));
}
/**
* Build chronological order progress for the detail modal.
*
* Driven by current status + available timestamps / related records so the
* chain advances even when historical rows lack paid_at/delivered_at.
*
* @param array<string, mixed> $order
* @param array<int, array> $shipping_records
* @param array<int, array> $after_sale_records
* @return array<int, array{time:string,label:string,key?:string}>
*/
private function build_order_timeline(array $order, array $shipping_records, array $after_sale_records): array {
$status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING));
$created_at = $this->timeline_time((string) ($order['created_at'] ?? ''));
$paid_at = $this->timeline_time((string) ($order['paid_at'] ?? ''));
$delivered_at = $this->timeline_time((string) ($order['delivered_at'] ?? ''));
$completed_at = $this->timeline_time((string) ($order['completed_at'] ?? ''));
$cancelled_at = $this->timeline_time((string) ($order['cancelled_at'] ?? ''));
$refunded_at = $this->timeline_time((string) ($order['refunded_at'] ?? ''));
$shipping_created = '';
foreach ($shipping_records as $record) {
$t = $this->timeline_time((string) ($record['created_at'] ?? ''));
if ($t !== '' && ($shipping_created === '' || strcmp($t, $shipping_created) < 0)) {
$shipping_created = $t;
}
}
if ($delivered_at === '' && $shipping_created !== '') {
$delivered_at = $shipping_created;
}
$paid_like = [
Order::STATUS_PAID,
Order::STATUS_DELIVERED,
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
Order::STATUS_REFUNDING,
Order::STATUS_REFUNDED,
];
$ship_like = [
Order::STATUS_DELIVERED,
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
];
$done_like = [
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
];
$timeline = [];
$push = static function (array &$out, string $key, string $label, string $time): void {
if ($label === '') {
return;
}
$out[] = [
'key' => $key,
'label' => $label,
'time' => $time,
];
};
// 1) Always: order created.
$push(
$timeline,
'created',
__('订单创建', 'aksrt'),
$created_at !== '' ? $created_at : ''
);
// 2) Paid (status-driven; fall back to created_at when paid_at missing).
if ($paid_at !== '' || in_array($status, $paid_like, true)) {
$push(
$timeline,
'paid',
__('完成支付', 'aksrt'),
$paid_at !== '' ? $paid_at : $created_at
);
}
// 3) Shipped / delivered — only when logistics exists or status reached it.
$has_shipping = $shipping_records !== [];
if (
$delivered_at !== ''
|| $has_shipping
|| in_array($status, $ship_like, true)
) {
// Virtual / content flows often jump paid → completed without logistics;
// only show the ship step when we have evidence of shipping.
if ($delivered_at !== '' || $has_shipping || $status === Order::STATUS_DELIVERED) {
$push(
$timeline,
'delivered',
__('订单发货', 'aksrt'),
$delivered_at !== '' ? $delivered_at : ($paid_at !== '' ? $paid_at : $created_at)
);
}
}
// 4) Completed / pending review.
if ($completed_at !== '' || in_array($status, $done_like, true)) {
$done_label = $status === Order::STATUS_PENDING_REVIEW
? __('待评价', 'aksrt')
: __('交易完成', 'aksrt');
$push(
$timeline,
'completed',
$done_label,
$completed_at !== ''
? $completed_at
: ($delivered_at !== '' ? $delivered_at : ($paid_at !== '' ? $paid_at : $created_at))
);
}
// 5) Terminal cancel / expire / refund.
if ($status === Order::STATUS_CANCELLED || $cancelled_at !== '') {
$push(
$timeline,
'cancelled',
__('订单取消', 'aksrt'),
$cancelled_at !== '' ? $cancelled_at : $created_at
);
}
if ($status === Order::STATUS_EXPIRED) {
$push(
$timeline,
'expired',
__('订单过期', 'aksrt'),
$cancelled_at !== '' ? $cancelled_at : $created_at
);
}
if ($status === Order::STATUS_REFUNDING) {
$push(
$timeline,
'refunding',
__('退款处理中', 'aksrt'),
$refunded_at !== '' ? $refunded_at : ($paid_at !== '' ? $paid_at : $created_at)
);
}
if ($status === Order::STATUS_REFUNDED || $refunded_at !== '') {
$push(
$timeline,
'refunded',
__('完成退款', 'aksrt'),
$refunded_at !== '' ? $refunded_at : ($paid_at !== '' ? $paid_at : $created_at)
);
}
// Extra logistics / after-sale events (detail lines).
foreach ($shipping_records as $record) {
$tracking = trim((string) ($record['tracking_no'] ?? ''));
$carrier = trim((string) ($record['carrier_label'] ?? $record['carrier'] ?? ''));
$status_l = trim((string) ($record['status_label'] ?? $record['status'] ?? ''));
$parts = array_filter([$carrier, $tracking, $status_l], static fn($v) => $v !== '');
$label = $parts !== []
? sprintf(__('物流更新:%s', 'aksrt'), implode(' / ', $parts))
: __('物流更新', 'aksrt');
$push(
$timeline,
'shipping',
$label,
$this->timeline_time((string) ($record['created_at'] ?? $record['updated_at'] ?? ''))
);
}
foreach ($after_sale_records as $record) {
$reason = trim((string) ($record['reason'] ?? ''));
$type_l = trim((string) ($record['type_label'] ?? $record['type'] ?? ''));
$as_status = trim((string) ($record['status_label'] ?? $record['status'] ?? ''));
$parts = array_filter([$type_l, $as_status, $reason], static fn($v) => $v !== '');
$label = $parts !== []
? sprintf(__('售后:%s', 'aksrt'), implode(' — ', $parts))
: __('售后申请', 'aksrt');
$push(
$timeline,
'after_sale',
$label,
$this->timeline_time((string) ($record['created_at'] ?? $record['updated_at'] ?? ''))
);
}
usort($timeline, static function ($a, $b) {
$ta = (string) ($a['time'] ?? '');
$tb = (string) ($b['time'] ?? '');
if ($ta === $tb) {
// Keep canonical pipeline order when times collide / missing.
$rank = [
'created' => 10,
'paid' => 20,
'delivered' => 30,
'shipping' => 35,
'completed' => 40,
'refunding' => 50,
'refunded' => 60,
'cancelled' => 70,
'expired' => 80,
'after_sale'=> 90,
];
$ra = $rank[(string) ($a['key'] ?? '')] ?? 99;
$rb = $rank[(string) ($b['key'] ?? '')] ?? 99;
return $ra <=> $rb;
}
if ($ta === '') {
return 1;
}
if ($tb === '') {
return -1;
}
return strcmp($ta, $tb);
});
return $timeline;
}
/**
* Normalize a mysql datetime for timeline display (empty when invalid/zero).
*/
private function timeline_time(string $value): string {
$value = trim($value);
if ($value === '' || str_starts_with($value, '0000-00-00')) {
return '';
}
return $value;
}
private function get_order_countdown(string $expires_at, string $status): int {
if ($expires_at === '' || !in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)) {
return 0;
}
$left = strtotime($expires_at) - time();
return max(0, (int) $left);
}
/**
* Sum line quantities for display (shop multi-item or single meta item).
*
* @param array<int, array<string, mixed>> $order_items
* @param array<string, mixed> $meta
*/
private function resolve_order_quantity(array $order_items, array $meta): int {
$qty = 0;
$source = !empty($order_items) ? $order_items : (is_array($meta['items'] ?? null) ? $meta['items'] : []);
foreach ($source as $row) {
if (!is_array($row)) {
continue;
}
$qty += max(1, (int) ($row['quantity'] ?? $row['qty'] ?? 1));
}
if ($qty <= 0) {
$qty = max(1, (int) ($meta['quantity'] ?? $meta['qty'] ?? 1));
}
return $qty;
}
}
+268
View File
@@ -0,0 +1,268 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class WeChatNotify {
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\Settings')) {
return \AKSRT\Settings\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]);
}
}
+413
View File
@@ -0,0 +1,413 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Payment;
defined('ABSPATH') || exit;
class 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\Core\DBMigrator::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\Credits\Balance')
&& $user_id > 0
) {
$gross = $this->resolve_gross_amount($row, $meta);
if ($gross > 0) {
$refund = \AKSRT\Credits\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\Messages\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\Credits\Balance') ? \AKSRT\Credits\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\Messages\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;
}
}