Files
wordpress-theme-aksrt/src/Payment/ChargeCard.php
T

1040 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 TYPE_CUSTOM = 'custom';
public const TYPE_KEYS = [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS, self::TYPE_CUSTOM];
/** 可被前台兑换的类型(custom 仅后台管理)。 */
public const REDEEMABLE_TYPES = [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_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/redeem', [
'methods' => 'POST',
'callback' => [$this, 'rest_redeem'],
'permission_callback' => [$this, 'check_logged_in_rest'],
'args' => [
'password' => ['type' => 'string', 'required' => true, 'minLength' => 1, 'maxLength' => 80, 'sanitize_callback' => 'sanitize_text_field'],
'card' => ['type' => 'string', 'default' => '', 'maxLength' => 60, 'sanitize_callback' => 'sanitize_text_field'],
'type' => ['type' => 'string', 'required' => true, 'enum' => [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS]],
],
]);
}
/**
* @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_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'] ?? ''));
$card = sanitize_text_field((string) ($params['card'] ?? ''));
// 弹窗各带类型(余额充值 / 积分购买 / 会员购买),不再自动识别。
$type = sanitize_key((string) ($params['type'] ?? ''));
if (!in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
return new \WP_Error('aksrt_card_type', __('卡密兑换类型无效', 'aksrt'), ['status' => 400]);
}
$operation_key = \AKSRT\Ledger\OperationKey::from_request($request, 'card-redeem', get_current_user_id());
if (is_wp_error($operation_key)) {
return $operation_key;
}
$result = $this->redeem_for_user(get_current_user_id(), $password, $card, $type, $operation_key);
if (is_wp_error($result)) {
return $result;
}
return new \WP_REST_Response([
'success' => true,
'data' => $result,
], 200);
}
/**
* 前台卡密兑换开关视图。
*
* 三个独立类型闸门(后台 余额充值 → 卡密兑换):
* 「启用卡密兑换余额功能」「启用卡密兑换积分功能」「启用卡密兑换会员功能」,
* 并共同受收款接口 card_pass 主开关(任一开关开启即生效)约束;
* 积分卡额外要求积分系统可用(系统级依赖)。
* 各类型仅在自身开启时进入对应购买弹窗的支付方式。
*
* @return array<string,mixed>
*/
public function get_public_options(): array {
// 收款接口 card_pass 主开关;关闭时所有卡密兑换一并失效。
$channel_on = !class_exists('\AKSRT\Settings\PaymentRuntime')
|| \AKSRT\Settings\PaymentRuntime::is_gateway_enabled('card_pass');
$types = [
'balance' => ['enabled' => false, 'desc' => '', 'only_password' => false],
'credits' => ['enabled' => false, 'desc' => '', 'only_password' => false],
'vip' => ['enabled' => false, 'desc' => '', 'only_password' => false],
];
if ($channel_on && class_exists('\AKSRT\Settings\Settings')) {
if (method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
$recharge = \AKSRT\Settings\Settings::get_payment_recharge_settings();
$pass_charge = is_array($recharge['pass_charge'] ?? null) ? $recharge['pass_charge'] : [];
foreach (array_keys($types) as $type_key) {
$cfg = is_array($pass_charge[$type_key] ?? null) ? $pass_charge[$type_key] : [];
$types[$type_key] = [
'enabled' => !empty($cfg['enabled']),
'desc' => (string) ($cfg['desc'] ?? ''),
'only_password' => !empty($cfg['only_password']),
];
}
}
if ($types['credits']['enabled']) {
// 积分卡额外要求积分系统可用(系统级依赖,非独立开关)。
$types['credits']['enabled'] = !method_exists('\AKSRT\Settings\Settings', 'credits_enabled')
|| \AKSRT\Settings\Settings::credits_enabled();
}
}
return $types;
}
/**
* Create one card.
*
* Accepted $data keys:
* - password (required)
* - 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_KEYS, true)) {
$type = self::TYPE_BALANCE;
}
$password = sanitize_text_field((string) ($data['password'] ?? ''));
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 = (string) ($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;
}
/**
* 批量自动生成卡密(参考主题 zibpay_generate_pass_card 同款行为)。
*
* 每张卡同时生成随机卡号与随机密码;$meta 由 normalize_meta 校验,
* 与「卡密管理」页面的自动生成表单一一对应。
*
* @return int 成功生成的张数
*/
public function generate_cards(string $type, int $num, array $meta, int $rand_number = 20, int $rand_password = 35, string $note = ''): int {
$type = sanitize_key($type);
if (!in_array($type, self::TYPE_KEYS, true)) {
return 0;
}
$num = max(1, min(1000, $num));
$rand_number = max(0, min(50, $rand_number));
$rand_password = max(1, min(50, $rand_password));
$created = 0;
for ($i = 1; $i <= $num; $i++) {
$id = $this->create([
'card' => self::rand_number($rand_number),
'password' => self::rand_password($rand_password),
'type' => $type,
'meta' => $meta,
'note' => $note,
]);
if ($id > 0) {
$created++;
}
}
return $created;
}
/**
* 随机卡号(纯数字,参考主题同款算法)。
*/
public static function rand_number(int $limit = 20): string {
if ($limit <= 0) {
return '';
}
$chars = (string) mt_rand(100, 999) . (string) mt_rand(100, 999) . (string) mt_rand(100, 999);
if ($limit > 27) {
$chars = $chars . $chars;
}
$chars = str_shuffle($chars . $chars . $chars);
return substr($chars, 0, $limit);
}
/**
* 随机密码(字母+数字混合,参考主题同款算法)。
*/
public static function rand_password(int $limit = 35): string {
$chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890';
if ($limit > 60) {
$chars = $chars . $chars;
}
$chars = str_shuffle($chars);
return substr($chars, 0, $limit);
}
/**
* @return array{success:bool,message:string,type?:string,amount?:string|int,level?:int,days?:int,order_number?:string,balance?:string|int}|\WP_Error
*/
public function redeem_for_user(int $user_id, string $password, string $card = '', string $expected_type = '', string $operation_key = '') {
$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 = !empty($opts[$type]['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 ((string) ($row['status'] ?? '') === self::STATUS_ACTIVE && !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'];
if (in_array($type, [self::TYPE_BALANCE, self::TYPE_CREDITS], true)) {
if ($operation_key === '') {
return new \WP_Error('aksrt_idempotency_key', __('缺少操作标识', 'aksrt'), ['status' => 400]);
}
return $this->redeem_asset_card($user_id, $row, $meta, $card, $operation_key);
}
if ($operation_key === '') {
return new \WP_Error('aksrt_idempotency_key', __('缺少操作标识', 'aksrt'), ['status' => 400]);
}
return $this->redeem_vip_card($user_id, $row, $meta, $card, $operation_key);
}
/**
* @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;
}
return $row;
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed> $meta
* @return array<string,mixed>|\WP_Error
*/
private function redeem_asset_card(int $user_id, array $row, array $meta, string $submitted_card, string $operation_key) {
$type = sanitize_key((string) ($row['type'] ?? ''));
$card_id = (int) ($row['id'] ?? 0);
$asset = $type === self::TYPE_BALANCE ? \AKSRT\Ledger\AssetType::BALANCE : \AKSRT\Ledger\AssetType::POINTS;
if ($asset === \AKSRT\Ledger\AssetType::BALANCE) {
try {
$amount_minor = \AKSRT\Ledger\AssetAmount::balance(is_string($meta['amount'] ?? null) ? $meta['amount'] : '')->minor();
} catch (\InvalidArgumentException $e) {
return new \WP_Error('aksrt_card_amount', __('当前卡密充值金额无效', 'aksrt'), ['status' => 400]);
}
$reason = 'card_pass_balance';
$remark = sprintf(__('卡密充值 %s 元', 'aksrt'), \AKSRT\Ledger\AssetAmount::from_minor($asset, $amount_minor)->decimal());
} else {
$amount_minor = (int) ($meta['points'] ?? 0);
$reason = 'card_pass_credits';
$remark = sprintf(__('卡密兑换积分 %d', 'aksrt'), $amount_minor);
}
if ($card_id <= 0 || $amount_minor <= 0) {
return new \WP_Error('aksrt_card_amount', __('当前卡密可兑换资产为 0', 'aksrt'), ['status' => 400]);
}
$order_number = $this->allocate_redeem_order_number();
if (is_wp_error($order_number)) {
return $order_number;
}
$guard = function () use ($card_id, $user_id, $submitted_card, $type) {
global $wpdb;
$current = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE", $card_id), ARRAY_A);
if (!is_array($current)
|| (string) ($current['status'] ?? '') !== self::STATUS_ACTIVE
|| sanitize_key((string) ($current['type'] ?? '')) !== $type
) {
return new \WP_Error('aksrt_card_used', __('该卡密已使用或不可用', 'aksrt'), ['status' => 409]);
}
if ((string) ($current['card'] ?? '') !== '' && $submitted_card !== '' && !hash_equals((string) $current['card'], $submitted_card)) {
return new \WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]);
}
if (!empty($current['expires_at']) && strtotime((string) $current['expires_at']) < time()) {
return new \WP_Error('aksrt_card_expired', __('卡密已过期', 'aksrt'), ['status' => 400]);
}
if ((int) ($current['used_by'] ?? 0) > 0 && (int) $current['used_by'] !== $user_id) {
return new \WP_Error('aksrt_card_used', __('该卡密已使用', 'aksrt'), ['status' => 409]);
}
return true;
};
$mutation = function () use ($user_id, $type, $meta, $card_id, $order_number, $operation_key) {
global $wpdb;
$created = $this->create_redeem_order($user_id, $type, $meta, $card_id, '', $operation_key, $order_number, Order::STATUS_PAID);
if ($created !== $order_number) {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
$used = $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 $used === 1 ? true : new \WP_Error('aksrt_card_used', __('该卡密已使用', 'aksrt'), ['status' => 409]);
};
$result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
$asset,
$amount_minor,
$operation_key,
$reason,
['type' => 'charge_card', 'id' => $card_id],
$remark,
$user_id,
['card_type' => $type],
$mutation,
$guard
);
if (is_wp_error($result)) {
return $result;
}
if ($result->replayed) {
// 重放不会重新建单,上面分配的订单号并未落库;这里改从既有订单取回真实号,
// 避免把一个新号回给前端。
$existing_order = OrderRepository::get_instance()->get_by_request_key($operation_key);
if (is_array($existing_order) && (string) ($existing_order['order_number'] ?? '') !== '') {
$order_number = (string) $existing_order['order_number'];
}
}
$balance = $result->balance($user_id, $asset);
return [
'success' => true,
'type' => $type,
'order_number' => $order_number,
'replayed' => $result->replayed,
'message' => $asset === \AKSRT\Ledger\AssetType::BALANCE
? sprintf(__('卡密充值成功,充值金额:%s 元', 'aksrt'), \AKSRT\Ledger\AssetAmount::from_minor($asset, $amount_minor)->decimal())
: sprintf(__('卡密兑换成功,积分:%d', 'aksrt'), $amount_minor),
'amount' => $asset === \AKSRT\Ledger\AssetType::BALANCE
? \AKSRT\Ledger\AssetAmount::from_minor($asset, $amount_minor)->decimal()
: $amount_minor,
'balance' => $asset === \AKSRT\Ledger\AssetType::BALANCE
? \AKSRT\Ledger\AssetAmount::from_minor($asset, $balance)->decimal()
: $balance,
'reload' => true,
];
}
/**
* Redeem a VIP card with the card row, order row, and membership metadata in one transaction.
*
* @param array<string,mixed> $row
* @param array<string,mixed> $meta
* @return array<string,mixed>|\WP_Error
*/
private function redeem_vip_card(int $user_id, array $row, array $meta, string $submitted_card, string $operation_key) {
global $wpdb;
if (!class_exists('\AKSRT\Content\VipService') || !class_exists(OrderRepository::class)) {
return new \WP_Error('aksrt_vip_unavailable', __('会员系统不可用', 'aksrt'), ['status' => 500]);
}
$card_id = (int) ($row['id'] ?? 0);
$level = max(1, min(2, (int) ($meta['level'] ?? 1)));
$days = max(0, (int) ($meta['days'] ?? 0));
$order_table = \AKSRT\Core\DBMigrator::table('order');
if ($card_id <= 0 || sanitize_key((string) ($row['type'] ?? '')) !== self::TYPE_VIP) {
return new \WP_Error('aksrt_card_type', __('当前卡密不是会员卡', 'aksrt'), ['status' => 400]);
}
$order_number = $this->allocate_redeem_order_number();
if (is_wp_error($order_number)) {
return $order_number;
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
if ($wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_card_db', __('无法开启卡密兑换事务', 'aksrt'), ['status' => 500]);
}
try {
$current = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$this->table()} WHERE id = %d FOR UPDATE",
$card_id
), ARRAY_A);
$existing = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$order_table} WHERE request_key = %s FOR UPDATE",
$operation_key
), ARRAY_A);
if (is_array($existing)) {
$replay = $this->vip_card_replay_result($existing, $current, $user_id, $card_id, $level, $days, $operation_key);
if (is_wp_error($replay)) {
throw new \AKSRT\Ledger\LedgerAbort($replay);
}
if ($wpdb->query('COMMIT') === false) {
throw new \RuntimeException('vip_card_replay_commit_failed');
}
return $replay;
}
if (!is_array($current)
|| (string) ($current['status'] ?? '') !== self::STATUS_ACTIVE
|| sanitize_key((string) ($current['type'] ?? '')) !== self::TYPE_VIP
|| !hash_equals((string) ($row['password'] ?? ''), (string) ($current['password'] ?? ''))
) {
throw new \AKSRT\Ledger\LedgerAbort(new \WP_Error('aksrt_card_used', __('该卡密已使用或不可用', 'aksrt'), ['status' => 409]));
}
if ((string) ($current['card'] ?? '') !== '' && $submitted_card !== '' && !hash_equals((string) $current['card'], $submitted_card)) {
throw new \AKSRT\Ledger\LedgerAbort(new \WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]));
}
if (!empty($current['expires_at']) && strtotime((string) $current['expires_at']) < time()) {
throw new \AKSRT\Ledger\LedgerAbort(new \WP_Error('aksrt_card_expired', __('卡密已过期', 'aksrt'), ['status' => 400]));
}
$vip_meta_keys = ['aksrt_vip_level', 'aksrt_vip_expires', 'aksrt_vip_base_level', 'aksrt_vip_base_expires', 'aksrt_vip_level_expired'];
$placeholders = implode(',', array_fill(0, count($vip_meta_keys), '%s'));
$wpdb->get_results($wpdb->prepare(
"SELECT umeta_id FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key IN ({$placeholders}) FOR UPDATE",
array_merge([$user_id], $vip_meta_keys)
));
$current_level = max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true));
$current_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
$activation_base = max($current_expiry, time());
$vip_service = \AKSRT\Content\VipService::get_instance();
$vip_before = $vip_service->get_membership_snapshot($user_id);
$created = $this->create_redeem_order(
$user_id,
self::TYPE_VIP,
['level' => $level, 'days' => $days],
$card_id,
(string) ($current['password'] ?? ''),
$operation_key,
$order_number,
Order::STATUS_PAID
);
if ($created !== $order_number) {
throw new \RuntimeException('vip_card_order_create_failed');
}
$vip_service->activate_vip($user_id, $level, $days);
$actual_level = max(0, (int) get_user_meta($user_id, 'aksrt_vip_level', true));
$actual_expiry = max(0, (int) get_user_meta($user_id, 'aksrt_vip_expires', true));
$permanent_preserved = $days > 0 && $current_level > 0 && $level <= $current_level && $current_expiry === 0;
$expiry_valid = $days <= 0
? $actual_expiry === 0
: ($permanent_preserved ? $actual_expiry === 0 : $actual_expiry >= $activation_base + ($days * DAY_IN_SECONDS));
if ($actual_level < max($current_level, $level) || !$expiry_valid) {
throw new \RuntimeException('vip_card_fulfillment_failed');
}
$vip_after = $vip_service->get_membership_snapshot($user_id);
if (!OrderRepository::get_instance()->merge_meta($order_number, ['vip_before' => $vip_before, 'vip_after' => $vip_after])) {
throw new \RuntimeException('vip_card_snapshot_failed');
}
$used = $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
));
if ($used !== 1) {
throw new \AKSRT\Ledger\LedgerAbort(new \WP_Error('aksrt_card_used', __('该卡密已使用', 'aksrt'), ['status' => 409]));
}
if ($wpdb->query('COMMIT') === false) {
throw new \RuntimeException('vip_card_commit_failed');
}
$message = $this->vip_card_message($level, $days);
$paid_order = OrderRepository::get_instance()->get($order_number);
if (is_array($paid_order)) {
try {
do_action('aksrt_order_status_changed', $order_number, Order::STATUS_PAID, $paid_order);
do_action('aksrt_order_paid', $order_number, $paid_order);
} catch (\Throwable $event_error) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT VIP Card Event] ' . $event_error->getMessage());
}
}
}
return [
'success' => true,
'type' => self::TYPE_VIP,
'order_number' => $order_number,
'replayed' => false,
'message' => $message,
'level' => $level,
'days' => $days,
'reload' => true,
];
} catch (\AKSRT\Ledger\LedgerAbort $e) {
$wpdb->query('ROLLBACK');
clean_user_cache($user_id);
return $e->error;
} catch (\Throwable $e) {
$errno = $this->last_db_errno();
$wpdb->query('ROLLBACK');
clean_user_cache($user_id);
$replay_order = OrderRepository::get_instance()->get_by_request_key($operation_key);
if (is_array($replay_order)) {
$replay_card = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $card_id), ARRAY_A);
return $this->vip_card_replay_result($replay_order, $replay_card, $user_id, $card_id, $level, $days, $operation_key);
}
if (in_array($errno, [1205, 1213], true) && $attempt < 3) {
usleep(random_int(10000, 50000));
continue;
}
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[AKSRT VIP Card] ' . $e->getMessage());
}
return new \WP_Error('aksrt_card_db', __('卡密兑换失败,请稍后重试', 'aksrt'), ['status' => 500]);
}
}
return new \WP_Error('aksrt_card_db', __('卡密兑换失败,请稍后重试', 'aksrt'), ['status' => 500]);
}
/** @return array<string,mixed>|\WP_Error */
private function vip_card_replay_result($order, $card, int $user_id, int $card_id, int $level, int $days, string $operation_key) {
$order = is_array($order) ? $order : [];
$card = is_array($card) ? $card : [];
$note = json_decode((string) ($order['note'] ?? ''), true);
$note = is_array($note) ? $note : [];
$stored_meta = is_array($note['meta'] ?? null) ? $note['meta'] : [];
$same = (string) ($order['request_key'] ?? '') === $operation_key
&& (int) ($order['user_id'] ?? 0) === $user_id
&& sanitize_key((string) ($order['product_type'] ?? '')) === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP
&& (int) ($order['product_id'] ?? 0) === $card_id
&& sanitize_key((string) ($order['payment_method'] ?? '')) === 'card_pass'
&& (string) ($order['status'] ?? '') === Order::STATUS_PAID
&& (int) ($note['card_id'] ?? 0) === $card_id
&& sanitize_key((string) ($note['card_type'] ?? '')) === self::TYPE_VIP
&& (int) ($stored_meta['level'] ?? 0) === $level
&& (int) ($stored_meta['days'] ?? -1) === $days
&& (string) ($card['status'] ?? '') === self::STATUS_USED
&& (int) ($card['used_by'] ?? 0) === $user_id
&& (string) ($card['order_number'] ?? '') === (string) ($order['order_number'] ?? '');
if (!$same) {
return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他卡密兑换请求', 'aksrt'), ['status' => 409]);
}
return [
'success' => true,
'type' => self::TYPE_VIP,
'order_number' => (string) $order['order_number'],
'replayed' => true,
'message' => $this->vip_card_message($level, $days),
'level' => $level,
'days' => $days,
'reload' => true,
];
}
private function vip_card_message(int $level, int $days): string {
return $days > 0
? sprintf(__('卡密兑换成功:VIP%s %d 天', 'aksrt'), (string) $level, $days)
: sprintf(__('卡密兑换成功:VIP%s 永久', 'aksrt'), (string) $level);
}
private function last_db_errno(): int {
global $wpdb;
if (isset($wpdb->dbh) && $wpdb->dbh instanceof \mysqli) {
return mysqli_errno($wpdb->dbh);
}
if (isset($wpdb->last_errno)) {
return (int) $wpdb->last_errno;
}
return preg_match('/\\b(1205|1213)\\b/', (string) ($wpdb->last_error ?? ''), $matches) ? (int) $matches[1] : 0;
}
/**
* 卡密核销订单号:走统一的流水号分配器,不再使用任何可配置前缀
* (见 {@see \AKSRT\Finance\BusinessNumber::allocate()})。
*
* @return string|\WP_Error
*/
private function allocate_redeem_order_number() {
if (!class_exists('AKSRT\\Payment\\OrderRepository')) {
return new \WP_Error('aksrt_order_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]);
}
$order_number = OrderRepository::get_instance()->allocate_order_number();
if (!is_string($order_number) || $order_number === '') {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
return $order_number;
}
/**
* @param array<string,mixed> $meta
*/
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password, string $request_key = '', string $order_number = '', string $status = ''): string {
if (!class_exists('AKSRT\\Payment\\OrderRepository') || !class_exists('\AKSRT\Core\DomainModel')) {
return '';
}
// 卡密核销不发生真实支付(现金在售卡时已确认),订单金额恒为 0;
// 卡密面额/时长记录在 note.meta,订单名称由 UserOrders 按 note 生成。
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE;
$product_id = $card_id;
$amount = '0.00';
$currency = 'CNY';
if ($type === self::TYPE_VIP) {
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP;
} elseif ($type === self::TYPE_CREDITS) {
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS;
$amount = '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);
$order_status = $status !== '' ? $status : Order::STATUS_PENDING;
$order_data = [
'order_number' => $order_number !== '' ? $order_number : null,
'user_id' => $user_id,
'product_type' => $product_type,
'product_id' => $product_id,
'amount' => $amount,
'currency' => $currency,
'status' => $order_status,
'payment_method' => 'card_pass',
'payment_transaction_id' => '',
'paid_at' => $order_status === Order::STATUS_PAID ? current_time('mysql') : null,
'note' => is_string($note) ? $note : '',
'request_key' => $request_key,
];
if ($order_status === Order::STATUS_PENDING) {
$order_data['expires_at'] = \AKSRT\Settings\PaymentRuntime::get_order_expiry_at();
}
return OrderRepository::get_instance()->create($order_data);
}
/**
* @return true|\WP_Error
*/
private function assert_type_enabled(string $type) {
$opts = $this->get_public_options();
// 积分卡:积分系统不可用优先提示(get_public_options 已把它折叠进 enabled)。
if ($type === self::TYPE_CREDITS && empty($opts['credits']['enabled'])) {
$toggle_on = false;
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
$recharge = \AKSRT\Settings\Settings::get_payment_recharge_settings();
$toggle_on = !empty($recharge['pass_charge']['credits']['enabled'] ?? false);
}
return new \WP_Error('aksrt_card_disabled', $toggle_on
? __('积分系统未开启,无法兑换积分卡密', 'aksrt')
: __('卡密兑换积分功能未开启', 'aksrt'), ['status' => 403]);
}
// 三个独立类型闸门:各类型受自己的「启用卡密兑换XX功能」开关控制
// (开关内部已含收款接口 card_pass 主开关判断)。
if (empty($opts[$type]['enabled'])) {
$messages = [
self::TYPE_BALANCE => __('卡密兑换余额功能未开启', 'aksrt'),
self::TYPE_CREDITS => __('卡密兑换积分功能未开启', 'aksrt'),
self::TYPE_VIP => __('卡密兑换会员功能未开启', 'aksrt'),
];
return new \WP_Error('aksrt_card_disabled', (string) ($messages[$type] ?? __('卡密兑换功能未开启', '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) {
$value = $raw['amount'] ?? $data['amount'] ?? '';
if (!is_string($value)) {
return null;
}
try {
$amount = \AKSRT\Ledger\AssetAmount::balance($value);
} catch (\InvalidArgumentException $e) {
return null;
}
if ($amount->minor() <= 0) {
return null;
}
return ['amount' => $amount->decimal()];
}
if ($type === self::TYPE_VIP) {
$level = max(1, min(2, (int) ($raw['level'] ?? $data['level'] ?? 1)));
$days = max(0, (int) ($raw['days'] ?? $data['days'] ?? 0));
$meta = ['level' => $level, 'days' => $days];
// 卡密管理页按参考主题保存 time/unit(Permanent|天数 + day|month),
// 仅作展示与导出使用;兑换仍以 days 为准。
if (isset($raw['time'])) {
$meta['time'] = sanitize_text_field((string) $raw['time']);
}
if (isset($raw['unit'])) {
$meta['unit'] = sanitize_key((string) $raw['unit']) === 'month' ? 'month' : 'day';
}
return $meta;
}
if ($type === self::TYPE_CREDITS) {
$points = max(0, (int) ($raw['points'] ?? $data['points'] ?? 0));
if ($points <= 0) {
return null;
}
return ['points' => $points];
}
if ($type === self::TYPE_CUSTOM) {
$meta = [];
$custom_name = $raw['custom_name'] ?? $data['custom_name'] ?? [];
if (is_array($custom_name)) {
$card_name = trim(sanitize_text_field((string) ($custom_name['card_name'] ?? '')));
$password_name = trim(sanitize_text_field((string) ($custom_name['password_name'] ?? '')));
if ($card_name !== '') {
$meta['custom_name']['card_name'] = $card_name;
}
if ($password_name !== '') {
$meta['custom_name']['password_name'] = $password_name;
}
}
return $meta;
}
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;
}
}
}
return $meta;
}
/**
* Admin list helper.
*
* Supported args: limit, offset, status, type, search/s, other(标识),
* orderby(白名单,参考主题别名自动映射), order(ASC|DESC).
*
* @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'] ?? ''));
$other = sanitize_text_field((string) ($args['other'] ?? ''));
$where = [];
$params = [];
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
if ($type !== '') {
$where[] = 'type = %s';
$params[] = $type;
}
if ($other !== '') {
$where[] = 'note = %s';
$params[] = $other;
}
if ($search !== '') {
$like = '%' . $wpdb->esc_like($search) . '%';
$where[] = '(password LIKE %s OR card LIKE %s OR note LIKE %s OR meta LIKE %s OR CAST(id AS CHAR) = %s)';
$params[] = $like;
$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);
$orderby = $this->resolve_orderby((string) ($args['orderby'] ?? ''));
$order = strtoupper((string) ($args['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY {$orderby} {$order} 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),
];
}
/**
* 排序字段白名单;兼容参考主题的列名别名。
* modified_time 以 used_at 兜底(未使用卡等于创建时间)。
*/
private function resolve_orderby(string $orderby): string {
$map = [
'id' => 'id',
'card' => 'card',
'password' => 'password',
'type' => 'type',
'status' => 'status',
'note' => 'note',
'other' => 'note',
'create_time' => 'created_at',
'created_at' => 'created_at',
'modified_time' => 'COALESCE(used_at, created_at)',
'modified' => 'COALESCE(used_at, created_at)',
];
$key = sanitize_key(trim($orderby));
return $map[$key] ?? 'COALESCE(used_at, created_at)';
}
/**
* 按主键批量删除。
*
* @param array<int,int> $ids
*/
public function delete_by_ids(array $ids): int {
global $wpdb;
$ids = array_values(array_unique(array_filter(array_map('absint', $ids))));
if (!$ids) {
return 0;
}
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$this->table()} WHERE id IN ({$placeholders})", $ids));
}
/**
* 导出用全量行(不限分页)。
*
* @return array<int,array<string,mixed>>
*/
public function get_export_rows(string $type = '', string $status = ''): array {
global $wpdb;
$type = sanitize_key($type);
$status = sanitize_key($status);
$where = [];
$params = [];
if ($type !== '' && in_array($type, self::TYPE_KEYS, true)) {
$where[] = 'type = %s';
$params[] = $type;
}
if ($status !== '') {
$where[] = 'status = %s';
$params[] = $status;
}
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT card, password, type, meta, note, status FROM {$this->table()} {$where_sql} ORDER BY id ASC";
$rows = $params ? $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A);
return is_array($rows) ? $rows : [];
}
/**
* @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' => (string) ($row['total_amount'] ?? '0.00'),
'used_amount' => (string) ($row['used_amount'] ?? '0.00'),
];
}
}