'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 */ 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|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 $row * @param array $meta * @return array|\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 $row * @param array $meta * @return array|\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|\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 $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 $data * @return array|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 $row * @return array */ 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 $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> */ 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 */ 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'), ]; } }