'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 */ public function get_public_options(): array { $balance = ['enabled' => false, 'desc' => '', 'only_password' => true]; $vip = ['enabled' => false, 'desc' => '', 'only_password' => true]; if (class_exists('AKSRT_Settings')) { if (method_exists('AKSRT_Settings', 'get_payment_recharge_settings')) { $recharge = AKSRT_Settings::get_payment_recharge_settings(); $pc = is_array($recharge['pass_charge'] ?? null) ? $recharge['pass_charge'] : []; $balance = [ 'enabled' => !empty($pc['enabled']), 'desc' => (string) ($pc['desc'] ?? ''), 'only_password' => !isset($pc['only_password']) || !empty($pc['only_password']), ]; } if (method_exists('AKSRT_Settings', 'get_payment_vip_settings')) { $vip_s = AKSRT_Settings::get_payment_vip_settings(); $benefit = is_array($vip_s['benefit'] ?? null) ? $vip_s['benefit'] : []; $vip = [ 'enabled' => !empty($benefit['pass_charge']), 'desc' => (string) ($benefit['pass_charge_desc'] ?? ''), 'only_password' => !isset($benefit['pass_charge_only_password']) || !empty($benefit['pass_charge_only_password']), ]; } } // Points card-pass: zibll points_pass_exchange_* (stored in aksrt_settings). $credits = ['enabled' => false, 'desc' => '', 'only_password' => true]; $all = class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'get_all') ? AKSRT_Settings::get_all() : (array) get_option('aksrt_settings', []); if (!empty($all['points_pass_exchange_s'])) { // Require credits system on when available. $credits_on = !class_exists('AKSRT_Settings') || !method_exists('AKSRT_Settings', 'credits_enabled') || AKSRT_Settings::credits_enabled(); $credits = [ 'enabled' => $credits_on, 'desc' => sanitize_textarea_field((string) ($all['points_pass_exchange_desc'] ?? '')), 'only_password' => !isset($all['points_pass_exchange_only_password']) || !empty($all['points_pass_exchange_only_password']), ]; } return [ 'balance' => $balance, 'vip' => $vip, 'credits' => $credits, 'labels' => [ 'title' => __('卡密兑换', 'aksrt'), 'password' => __('卡密', 'aksrt'), 'card' => __('卡号', 'aksrt'), 'submit_balance' => __('兑换余额', 'aksrt'), 'submit_vip' => __('兑换会员', 'aksrt'), 'submit_credits' => __('兑换积分', 'aksrt'), 'placeholder_pw' => __('请输入卡密', 'aksrt'), 'placeholder_card' => __('请输入卡号', 'aksrt'), ], ]; } /** * Create one card. * * Accepted $data keys: * - password|code (required unless auto) * - card (optional) * - type balance|vip|credits * - amount (balance) * - level, days (vip; days 0 = permanent) * - points (credits) * - meta (array override) * - expires_at, note */ public function create(array $data): int { global $wpdb; $type = sanitize_key((string) ($data['type'] ?? self::TYPE_BALANCE)); if (!in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) { $type = self::TYPE_BALANCE; } $password = sanitize_text_field((string) ($data['password'] ?? $data['code'] ?? '')); if ($password === '') { return 0; } $card = sanitize_text_field((string) ($data['card'] ?? '')); $meta = $this->normalize_meta($type, $data); if ($meta === null) { return 0; } // Uniqueness: password alone (only_password mode common). $exists = $wpdb->get_var($wpdb->prepare( "SELECT id FROM {$this->table()} WHERE password = %s LIMIT 1", $password )); if ($exists) { return 0; } $expires_at = sanitize_text_field((string) ($data['expires_at'] ?? '')); $note = sanitize_textarea_field((string) ($data['note'] ?? '')); $amount_col = (float) ($meta['amount'] ?? $meta['points'] ?? 0); $result = $wpdb->insert($this->table(), [ 'card' => $card, 'password' => $password, 'type' => $type, 'amount' => $amount_col, 'balance' => $amount_col, 'status' => self::STATUS_ACTIVE, 'meta' => wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'order_number' => '', 'used_by' => null, 'used_at' => null, 'expires_at' => $expires_at !== '' ? $expires_at : null, 'created_at' => current_time('mysql'), 'note' => $note, ]); return $result ? (int) $wpdb->insert_id : 0; } /** * @param array $meta_fields * @return array passwords/codes generated */ public function batch_create(string $type, array $meta_fields, int $count, int $length = 16, string $prefix = '', string $expires_at = '', string $note = ''): array { $type = sanitize_key($type); if (!in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) { $type = self::TYPE_BALANCE; } $count = max(1, min(500, $count)); $length = max(8, min(32, $length)); $prefix = sanitize_text_field($prefix); $codes = []; for ($i = 0; $i < $count; $i++) { $password = $prefix . strtoupper(wp_generate_password($length, false)); $id = $this->create(array_merge($meta_fields, [ 'type' => $type, 'password' => $password, 'expires_at' => $expires_at, 'note' => $note, ])); if ($id > 0) { $codes[] = $password; } } return $codes; } /** * @return array{success:bool,message:string,type?:string,amount?:float,level?:int,days?:int,order_number?:string,balance?:int}|WP_Error */ public function redeem_for_user(int $user_id, string $password, string $card = '', string $expected_type = '') { $user_id = max(0, $user_id); $password = sanitize_text_field($password); $card = sanitize_text_field($card); if ($user_id <= 0) { return new WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); } if ($password === '') { return new WP_Error('aksrt_card_empty', __('请输入卡密', 'aksrt'), ['status' => 400]); } // Simple per-user throttle. $throttle_key = 'aksrt_card_pass_rl_' . $user_id; $hits = (int) get_transient($throttle_key); if ($hits >= 20) { return new WP_Error('aksrt_card_rate', __('操作过于频繁,请稍后再试', 'aksrt'), ['status' => 429]); } set_transient($throttle_key, $hits + 1, MINUTE_IN_SECONDS); $row = $this->find_active($password, $card, $expected_type); if (!$row) { return new WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]); } $type = sanitize_key((string) ($row['type'] ?? self::TYPE_BALANCE)); if ($expected_type !== '' && $type !== $expected_type) { return new WP_Error('aksrt_card_type', __('当前卡密不能用于该兑换类型', 'aksrt'), ['status' => 400]); } $gate = $this->assert_type_enabled($type); if (is_wp_error($gate)) { return $gate; } // only_password: if feature requires card number and empty, fail. $opts = $this->get_public_options(); $only_password = true; if ($type === self::TYPE_BALANCE) { $only_password = !empty($opts['balance']['only_password']); } elseif ($type === self::TYPE_VIP) { $only_password = !empty($opts['vip']['only_password']); } if (!$only_password && $card === '' && (string) ($row['card'] ?? '') !== '') { return new WP_Error('aksrt_card_need_no', __('请输入卡号', 'aksrt'), ['status' => 400]); } if (!$only_password && $card !== '' && (string) ($row['card'] ?? '') !== '' && (string) $row['card'] !== $card) { return new WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]); } if (!empty($row['expires_at']) && strtotime((string) $row['expires_at']) < time()) { global $wpdb; $wpdb->update($this->table(), ['status' => self::STATUS_EXPIRED], ['id' => (int) $row['id']], ['%s'], ['%d']); return new WP_Error('aksrt_card_expired', __('卡密已过期', 'aksrt'), ['status' => 400]); } $meta = $this->decode_meta($row); $card_id = (int) $row['id']; // Create audit order first (pending), then CAS mark used, then fulfill + mark paid. $order_number = $this->create_redeem_order($user_id, $type, $meta, $card_id, $password); if ($order_number === '') { return new WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]); } if (!$this->mark_used_cas($card_id, $user_id, $order_number)) { // Race lost. if (class_exists('AKSRT_Order')) { AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_CANCELLED, [ 'note' => __('卡密已被使用,订单取消', 'aksrt'), ]); } return new WP_Error('aksrt_card_used', __('该卡密已使用', 'aksrt'), ['status' => 400]); } $fulfill = $this->fulfill($user_id, $type, $meta, $order_number, $card_id); if (is_wp_error($fulfill)) { return $fulfill; } if (class_exists('AKSRT_Order')) { AKSRT_Order::get_instance()->update_status($order_number, AKSRT_Order::STATUS_PAID, [ 'payment_method' => 'card_pass', 'payment_transaction_id' => 'card_pass_' . $card_id, 'note' => (string) ($fulfill['message'] ?? __('卡密兑换成功', 'aksrt')), ]); } $out = array_merge([ 'success' => true, 'type' => $type, 'order_number' => $order_number, 'reload' => true, ], $fulfill); if (class_exists('AKSRT_Credits')) { $out['balance'] = AKSRT_Credits::balance($user_id); } return $out; } /** * @return array|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 $meta * @return array{message:string,amount?:float,level?:int,days?:int}|WP_Error */ private function fulfill(int $user_id, string $type, array $meta, string $order_number, int $card_id) { if ($type === self::TYPE_BALANCE) { $amount = round((float) ($meta['amount'] ?? 0), 2); if ($amount <= 0) { return new WP_Error('aksrt_card_amount', __('当前卡密可充值金额为0', 'aksrt'), ['status' => 400]); } // Cash wallet first (Zibll pay_balance); fall back to points if wallet class missing. if (class_exists('AKSRT_Balance')) { $new = AKSRT_Balance::add($user_id, $amount, 'card_pass_balance', [ 'type' => 'charge_card', 'id' => $card_id, 'order' => $order_number, ], sprintf(__('卡密充值 %s 元', 'aksrt'), (string) $amount)); if (is_wp_error($new)) { return new WP_Error('aksrt_balance_fail', $new->get_error_message() ?: __('余额入账失败', 'aksrt'), ['status' => 500]); } return [ 'message' => sprintf(__('卡密充值成功,充值金额:%s 元', 'aksrt'), (string) $amount), 'amount' => (float) $amount, ]; } if (!class_exists('AKSRT_Credits')) { return new WP_Error('aksrt_card_amount', __('余额系统不可用', 'aksrt'), ['status' => 500]); } $pts = (int) round($amount); $new = AKSRT_Credits::add($user_id, $pts, 'card_pass_balance', [ 'ref_type' => 'charge_card', 'ref_id' => $card_id, 'order' => $order_number, ], sprintf(__('卡密充值 %d', 'aksrt'), $pts)); if ($new === null || $new === false || is_wp_error($new)) { return new WP_Error('aksrt_credit_fail', __('余额入账失败', 'aksrt'), ['status' => 500]); } return [ 'message' => sprintf(__('卡密充值成功,充值金额:%s', 'aksrt'), (string) $pts), 'amount' => (float) $pts, ]; } if ($type === self::TYPE_VIP) { $level = max(1, min(2, (int) ($meta['level'] ?? 1))); $days = max(0, (int) ($meta['days'] ?? 0)); if (!class_exists('AKSRT_Vip_Service')) { return new WP_Error('aksrt_vip_unavailable', __('会员系统不可用', 'aksrt'), ['status' => 500]); } AKSRT_Vip_Service::get_instance()->activate_vip($user_id, $level, $days); $msg = $days > 0 ? sprintf( /* translators: 1: level 2: days */ __('卡密兑换成功:VIP%s %d 天', 'aksrt'), (string) $level, $days ) : sprintf( /* translators: %s: level */ __('卡密兑换成功:VIP%s 永久', 'aksrt'), (string) $level ); return [ 'message' => $msg, 'level' => $level, 'days' => $days, ]; } if ($type === self::TYPE_CREDITS) { $points = (int) ($meta['points'] ?? 0); if ($points <= 0 || !class_exists('AKSRT_Credits')) { return new WP_Error('aksrt_card_points', __('当前卡密可兑换积分为0', 'aksrt'), ['status' => 400]); } AKSRT_Credits::add($user_id, $points, 'card_pass_credits', [ 'ref_type' => 'charge_card', 'ref_id' => $card_id, 'order' => $order_number, ], sprintf(__('卡密兑换积分 %d', 'aksrt'), $points)); return [ 'message' => sprintf(__('卡密兑换成功,积分:%d', 'aksrt'), $points), 'amount' => (float) $points, ]; } return new WP_Error('aksrt_card_type', __('不支持的卡密类型', 'aksrt'), ['status' => 400]); } /** * @param array $meta */ private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password): string { if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Domain_Model')) { return 'CARD' . $card_id . '_' . time(); } $product_type = AKSRT_Domain_Model::ORDER_PRODUCT_RECHARGE; $product_id = $card_id; $amount = 0.0; $currency = 'CNY'; if ($type === self::TYPE_VIP) { $product_type = AKSRT_Domain_Model::ORDER_PRODUCT_VIP; $amount = 0.0; } elseif ($type === self::TYPE_BALANCE) { $amount = round((float) ($meta['amount'] ?? 0), 2); $currency = 'CNY'; } elseif ($type === self::TYPE_CREDITS) { $amount = (float) ($meta['points'] ?? 0); $currency = 'points'; } $note = wp_json_encode([ 'card_pass' => 1, 'card_id' => $card_id, 'card_type' => $type, 'meta' => $meta, 'password' => substr(md5($password), 0, 8), ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return AKSRT_Order_Repository::get_instance()->create([ 'user_id' => $user_id, 'product_type' => $product_type, 'product_id' => $product_id, 'amount' => $amount, 'currency' => $currency, 'status' => class_exists('AKSRT_Order') ? AKSRT_Order::STATUS_PENDING : 'pending', 'payment_method' => 'card_pass', 'note' => is_string($note) ? $note : '', 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), ]); } /** * @return true|WP_Error */ private function assert_type_enabled(string $type) { $opts = $this->get_public_options(); if ($type === self::TYPE_BALANCE && empty($opts['balance']['enabled'])) { return new WP_Error('aksrt_card_disabled', __('余额卡密兑换未开启', 'aksrt'), ['status' => 403]); } if ($type === self::TYPE_VIP && empty($opts['vip']['enabled'])) { return new WP_Error('aksrt_card_disabled', __('会员卡密兑换未开启', 'aksrt'), ['status' => 403]); } if ($type === self::TYPE_CREDITS && empty($opts['credits']['enabled'])) { return new WP_Error('aksrt_card_disabled', __('积分卡密兑换未开启', 'aksrt'), ['status' => 403]); } return true; } /** * @param array $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) { $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 $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; } } } // 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 */ 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), ]; } }