feat(invite-code): one-time invite codes with rewards, usage log, import/export
- 一次性邀请码:批量生成(次数/有效期/备注/四类奖励)、原子消费防超发、 开启注册验证时奖励延迟发放(账本幂等键) - Zibll 兼容文本导入(points/balance/vip/vip_time/level_integral,分隔符可自定义, 第三段标识存为备注)、CSV 全字段导出与可回流文本导出 - 使用记录表 aksrt_invite_code_usage + 后台使用者列;注册表单 400ms 防抖实时校验 - 注册门控三模式(close/open/must)设置链路(PHP schema + admin SPA schema) - 契约测试覆盖建表/消费竞态/导入导出/备注全链路
This commit is contained in:
1 parent
20387fdb06
commit
3dd3f71c97
16 files changed
+1285
-1
No files matched your search
@@ -0,0 +1,663 @@
|
||||
<?php
|
||||
/**
|
||||
* One-time invite codes: registration gating, atomic consumption, per-batch rewards.
|
||||
*
|
||||
* 消费走单条条件 UPDATE(affected_rows === 1 判定),并发注册不可能超发。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class InviteCode {
|
||||
|
||||
private const META_PENDING = 'aksrt_invite_code_pending';
|
||||
private const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
private const CODE_LENGTH = 10;
|
||||
|
||||
private static ?self $instance = null;
|
||||
/** 注册请求内待消费的邀请码(经服务层校验后写入)。 */
|
||||
private static string $pending_code = '';
|
||||
|
||||
public static function get_instance(): self {
|
||||
return self::$instance ??= new self();
|
||||
}
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
public function init(): void {
|
||||
add_action('user_register', [$this, 'handle_registration'], 30, 1);
|
||||
add_action('aksrt_email_verified', [$this, 'handle_email_verified'], 10, 1);
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Settings
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/** @return array{enabled:bool,register_field:string,open_desc:string,required_desc:string} */
|
||||
public function registration_settings(): array {
|
||||
return class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_invite_code_registration_settings()
|
||||
: ['enabled' => false, 'register_field' => 'hidden', 'open_desc' => '', 'required_desc' => ''];
|
||||
}
|
||||
|
||||
public function field_mode(): string {
|
||||
$settings = $this->registration_settings();
|
||||
return !empty($settings['enabled']) ? (string) $settings['register_field'] : 'hidden';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Validation & consumption
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
private function table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('invite_code');
|
||||
}
|
||||
|
||||
/** 码是否可用(存在、启用、未过期、未超发)。 */
|
||||
public function is_valid(string $code): bool {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
$code = sanitize_key($code);
|
||||
if ($code === '' || $table === '') {
|
||||
return false;
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE code = %s AND status = 'active' AND (max_uses = 0 OR used_count < max_uses) AND (expires_at IS NULL OR expires_at > %s)",
|
||||
$code,
|
||||
current_time('mysql')
|
||||
));
|
||||
return $row !== null;
|
||||
}
|
||||
|
||||
public function clear_registration_context(): void {
|
||||
self::$pending_code = '';
|
||||
}
|
||||
|
||||
public function set_registration_context(string $code): void {
|
||||
self::$pending_code = sanitize_key($code);
|
||||
}
|
||||
|
||||
/** user_register 钩子:原子消费注册上下文中的邀请码,按需延迟或立即发奖。 */
|
||||
public function handle_registration(int $user_id): void {
|
||||
$code = self::$pending_code;
|
||||
self::$pending_code = '';
|
||||
if ($code === '' || $user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
$row = $this->consume($code, $user_id);
|
||||
if (!is_array($row)) {
|
||||
return;
|
||||
}
|
||||
if (class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::signup_verification_enabled()) {
|
||||
// 站点要求注册前验证:奖励延迟到邮箱/手机验证通过,防止占坑刷奖。
|
||||
update_user_meta($user_id, self::META_PENDING, (int) $row['id']);
|
||||
return;
|
||||
}
|
||||
$this->grant_reward($user_id, (int) $row['id'], (string) $row['rewards']);
|
||||
}
|
||||
|
||||
/** aksrt_email_verified 钩子:补发延迟奖励。 */
|
||||
public function handle_email_verified(int $user_id): void {
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
$code_id = (int) get_user_meta($user_id, self::META_PENDING, true);
|
||||
if ($code_id <= 0) {
|
||||
return;
|
||||
}
|
||||
delete_user_meta($user_id, self::META_PENDING);
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($table === '') {
|
||||
return;
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT id, rewards FROM {$table} WHERE id = %d", $code_id), ARRAY_A);
|
||||
if (is_array($row)) {
|
||||
$this->grant_reward($user_id, (int) $row['id'], (string) $row['rewards']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 原子消费:单条条件 UPDATE,affected_rows === 1 才算成功;同时写入使用记录。
|
||||
*
|
||||
* @return array{id:int,rewards:string}|\null
|
||||
*/
|
||||
private function consume(string $code, int $user_id = 0): ?array {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
$code = sanitize_key($code);
|
||||
if ($code === '' || $table === '') {
|
||||
return null;
|
||||
}
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET used_count = used_count + 1 WHERE code = %s AND status = 'active' AND (max_uses = 0 OR used_count < max_uses) AND (expires_at IS NULL OR expires_at > %s)",
|
||||
$code,
|
||||
current_time('mysql')
|
||||
));
|
||||
if ($updated !== 1) {
|
||||
return null;
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT id, rewards FROM {$table} WHERE code = %s", $code), ARRAY_A);
|
||||
if (!is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
if ($user_id > 0) {
|
||||
$usage_table = \AKSRT\Core\DBMigrator::table('invite_code_usage');
|
||||
if ($usage_table !== '') {
|
||||
$wpdb->insert($usage_table, [
|
||||
'code_id' => (int) $row['id'],
|
||||
'user_id' => $user_id,
|
||||
'used_at' => current_time('mysql'),
|
||||
], ['%d', '%d', '%s']);
|
||||
}
|
||||
}
|
||||
return ['id' => (int) $row['id'], 'rewards' => (string) $row['rewards']];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Rewards
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/** @return array{points:int,balance_minor:int,vip_level:int,vip_days:int,level_points:int} */
|
||||
private function normalize_rewards(string $rewards_json): array {
|
||||
$raw = json_decode($rewards_json, true);
|
||||
$raw = is_array($raw) ? $raw : [];
|
||||
$clamp = static fn($value): int => max(0, (int) $value);
|
||||
return [
|
||||
'points' => $clamp($raw['points'] ?? 0),
|
||||
'balance_minor' => $clamp(round(((float) ($raw['balance'] ?? 0)) * 100)),
|
||||
'vip_level' => min(2, $clamp($raw['vip_level'] ?? 0)),
|
||||
'vip_days' => $clamp($raw['vip_days'] ?? 0),
|
||||
'level_points' => $clamp($raw['level_points'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function grant_reward(int $user_id, int $code_id, string $rewards_json): void {
|
||||
$rewards = $this->normalize_rewards($rewards_json);
|
||||
if ($user_id <= 0 || $code_id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($rewards['points'] > 0) {
|
||||
\AKSRT\Ledger\LedgerService::get_instance()->credit(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::POINTS,
|
||||
$rewards['points'],
|
||||
\AKSRT\Ledger\OperationKey::deterministic('invite-reward', [$code_id, $user_id, 'points']),
|
||||
'invite_code',
|
||||
['type' => 'invite_code', 'id' => $code_id],
|
||||
__('邀请码注册奖励', 'aksrt')
|
||||
);
|
||||
}
|
||||
|
||||
if ($rewards['balance_minor'] > 0) {
|
||||
\AKSRT\Ledger\LedgerService::get_instance()->credit(
|
||||
$user_id,
|
||||
\AKSRT\Ledger\AssetType::BALANCE,
|
||||
$rewards['balance_minor'],
|
||||
\AKSRT\Ledger\OperationKey::deterministic('invite-reward', [$code_id, $user_id, 'balance']),
|
||||
'invite_code',
|
||||
['type' => 'invite_code', 'id' => $code_id],
|
||||
__('邀请码注册奖励', 'aksrt')
|
||||
);
|
||||
}
|
||||
|
||||
if ($rewards['vip_level'] > 0 && $rewards['vip_days'] > 0 && class_exists('\AKSRT\Content\VipService')) {
|
||||
\AKSRT\Content\VipService::get_instance()->activate_vip($user_id, $rewards['vip_level'], $rewards['vip_days']);
|
||||
}
|
||||
|
||||
if ($rewards['level_points'] > 0 && class_exists('\AKSRT\User\Level')) {
|
||||
\AKSRT\User\Level::add($user_id, 'invite_code', $rewards['level_points'], true);
|
||||
}
|
||||
|
||||
do_action('aksrt_invite_code_rewarded', $user_id, $code_id, $rewards);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Admin operations
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 批量生成邀请码。
|
||||
*
|
||||
* @return array<string>|\WP_Error 生成的码列表
|
||||
*/
|
||||
public function generate_batch(array $args) {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($table === '') {
|
||||
return new \WP_Error('aksrt_invite_db', __('数据表不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$count = min(500, max(1, (int) ($args['count'] ?? 1)));
|
||||
$max_uses = max(0, (int) ($args['max_uses'] ?? 1));
|
||||
$expires_days = max(0, (int) ($args['expires_days'] ?? 0));
|
||||
$rewards = $this->normalize_rewards((string) wp_json_encode($args['rewards'] ?? []));
|
||||
if (max($rewards['points'], $rewards['balance_minor'], $rewards['vip_level'] * $rewards['vip_days'], $rewards['level_points']) === 0) {
|
||||
$rewards = ['points' => 0, 'balance_minor' => 0, 'vip_level' => 0, 'vip_days' => 0, 'level_points' => 0];
|
||||
}
|
||||
$rewards_json = (string) wp_json_encode($rewards, JSON_UNESCAPED_UNICODE);
|
||||
$remark = sanitize_text_field((string) ($args['remark'] ?? ''));
|
||||
$batch_id = 'B' . gmdate('YmdHis') . wp_generate_password(4, false, false);
|
||||
$expires_at = $expires_days > 0 ? gmdate('Y-m-d H:i:s', time() + $expires_days * DAY_IN_SECONDS) : null;
|
||||
$created_by = get_current_user_id();
|
||||
$created_at = current_time('mysql');
|
||||
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$code = '';
|
||||
for ($attempt = 0; $attempt < 20; $attempt++) {
|
||||
$candidate = '';
|
||||
for ($j = 0; $j < self::CODE_LENGTH; $j++) {
|
||||
$candidate .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
|
||||
}
|
||||
$exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE code = %s", $candidate));
|
||||
if ($exists === null) {
|
||||
$code = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'code' => $code,
|
||||
'batch_id' => $batch_id,
|
||||
'remark' => $remark,
|
||||
'rewards' => $rewards_json,
|
||||
'max_uses' => $max_uses,
|
||||
'status' => 'active',
|
||||
'expires_at' => $expires_at,
|
||||
'created_by' => $created_by,
|
||||
'created_at' => $created_at,
|
||||
], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s']);
|
||||
if ($inserted === 1) {
|
||||
$codes[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
if ($codes === []) {
|
||||
return new \WP_Error('aksrt_invite_generate', __('邀请码生成失败,请重试', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本导入邀请码(对齐 Zibll 格式):一行一码,`邀请码<分隔符>奖励<分隔符>标识`。
|
||||
* 奖励参数串:points=20&balance=30&vip=1&vip_time=5&level_integral=100(balance 单位为元),"无" 或留空表示无奖励。
|
||||
*
|
||||
* @return array{imported:int,failed:int,failed_codes:array<string>}|\WP_Error
|
||||
*/
|
||||
public function import_codes(array $args) {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($table === '') {
|
||||
return new \WP_Error('aksrt_invite_db', __('数据表不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$raw = (string) ($args['data'] ?? '');
|
||||
$division = trim((string) ($args['division'] ?? ' '));
|
||||
if ($division === '') {
|
||||
$division = ' ';
|
||||
}
|
||||
$max_uses = max(0, (int) ($args['max_uses'] ?? 1));
|
||||
$expires_days = max(0, (int) ($args['expires_days'] ?? 0));
|
||||
$batch_id = 'I' . gmdate('YmdHis') . wp_generate_password(4, false, false);
|
||||
$expires_at = $expires_days > 0 ? gmdate('Y-m-d H:i:s', time() + $expires_days * DAY_IN_SECONDS) : null;
|
||||
$created_by = get_current_user_id();
|
||||
$created_at = current_time('mysql');
|
||||
|
||||
$lines = array_slice(preg_split('/\r\n|\r|\n/', $raw) ?: [], 0, 1000);
|
||||
$imported = 0;
|
||||
$failed = 0;
|
||||
$failed_codes = [];
|
||||
foreach ($lines as $line) {
|
||||
$line = trim((string) $line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
$parts = $division === ' ' ? preg_split('/\s+/', $line, 3) : explode($division, (string) $line, 3);
|
||||
$code = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($parts[0] ?? '')));
|
||||
if ($code === '') {
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
$exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE code = %s", $code));
|
||||
if ($exists !== null) {
|
||||
$failed++;
|
||||
$failed_codes[] = $code;
|
||||
continue;
|
||||
}
|
||||
$rewards_json = $this->parse_reward_string(trim((string) ($parts[1] ?? '')));
|
||||
$remark = sanitize_text_field(trim((string) ($parts[2] ?? '')));
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'code' => $code,
|
||||
'batch_id' => $batch_id,
|
||||
'remark' => $remark,
|
||||
'rewards' => $rewards_json,
|
||||
'max_uses' => $max_uses,
|
||||
'status' => 'active',
|
||||
'expires_at' => $expires_at,
|
||||
'created_by' => $created_by,
|
||||
'created_at' => $created_at,
|
||||
], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s']);
|
||||
if ($inserted === 1) {
|
||||
$imported++;
|
||||
} else {
|
||||
$failed++;
|
||||
$failed_codes[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
if ($imported === 0 && $failed === 0) {
|
||||
return new \WP_Error('aksrt_invite_import_empty', __('请粘贴需要导入的数据', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
return ['imported' => $imported, 'failed' => $failed, 'failed_codes' => array_slice($failed_codes, 0, 20)];
|
||||
}
|
||||
|
||||
/** Zibll 兼容奖励参数串 → 归一化奖励 JSON("无"/空串 → 全零)。 */
|
||||
private function parse_reward_string(string $reward): string {
|
||||
$reward = trim($reward);
|
||||
if ($reward === '' || in_array($reward, ['无', 'none', '0'], true)) {
|
||||
return (string) wp_json_encode(['points' => 0, 'balance_minor' => 0, 'vip_level' => 0, 'vip_days' => 0, 'level_points' => 0], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$map = ['points' => 'points', 'balance' => 'balance', 'vip' => 'vip_level', 'vip_time' => 'vip_days', 'level_integral' => 'level_points'];
|
||||
$parsed = [];
|
||||
foreach (explode('&', $reward) as $pair) {
|
||||
[$key, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
|
||||
$key = strtolower(trim($key));
|
||||
if (isset($map[$key]) && is_numeric($value)) {
|
||||
$parsed[$map[$key]] = (int) $value;
|
||||
}
|
||||
}
|
||||
return (string) wp_json_encode($parsed, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/** 奖励 JSON → 导入格式参数串(零奖励返回"无"),与 parse_reward_string 互逆。 */
|
||||
private function reward_param_string(string $rewards_json): string {
|
||||
$rewards = $this->normalize_rewards($rewards_json);
|
||||
$parts = [];
|
||||
if ($rewards['points'] > 0) {
|
||||
$parts[] = 'points=' . $rewards['points'];
|
||||
}
|
||||
if ($rewards['balance_minor'] > 0) {
|
||||
$parts[] = 'balance=' . rtrim(rtrim((string) ($rewards['balance_minor'] / 100), '0'), '.');
|
||||
}
|
||||
if ($rewards['vip_level'] > 0) {
|
||||
$parts[] = 'vip=' . $rewards['vip_level'] . '&vip_time=' . $rewards['vip_days'];
|
||||
}
|
||||
if ($rewards['level_points'] > 0) {
|
||||
$parts[] = 'level_integral=' . $rewards['level_points'];
|
||||
}
|
||||
return $parts === [] ? '无' : implode('&', $parts);
|
||||
}
|
||||
|
||||
public function set_status(int $id, string $status): bool {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($id <= 0 || $table === '' || !in_array($status, ['active', 'disabled'], true)) {
|
||||
return false;
|
||||
}
|
||||
return $wpdb->update($table, ['status' => $status], ['id' => $id], ['%s'], ['%d']) === 1;
|
||||
}
|
||||
|
||||
/** @return array{items:array<int,array<string,mixed>>,total:int,page:int,per_page:int,max_pages:int} */
|
||||
public function list_codes(array $args): array {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = max(1, min(100, (int) ($args['per_page'] ?? 20)));
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$search = trim((string) ($args['search'] ?? ''));
|
||||
if ($table === '') {
|
||||
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $per_page, 'max_pages' => 1];
|
||||
}
|
||||
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
if (in_array($status, ['active', 'disabled'], true)) {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
if ($search !== '') {
|
||||
$where[] = '(code LIKE %s OR batch_id LIKE %s)';
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
array_push($params, $like, $like);
|
||||
}
|
||||
$where_sql = implode(' AND ', $where);
|
||||
|
||||
$total = (int) ($params
|
||||
? $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where_sql}", $params))
|
||||
: $wpdb->get_var("SELECT COUNT(*) FROM {$table}"));
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d",
|
||||
array_merge($params, [$per_page, ($page - 1) * $per_page])
|
||||
), ARRAY_A);
|
||||
|
||||
$items = [];
|
||||
$code_ids = array_map(static fn($row) => (int) $row['id'], (array) $rows);
|
||||
$recent_users = $this->recent_users_map($code_ids);
|
||||
foreach ((array) $rows as $row) {
|
||||
$rewards = $this->normalize_rewards((string) ($row['rewards'] ?? ''));
|
||||
$items[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'code' => (string) $row['code'],
|
||||
'batch_id' => (string) $row['batch_id'],
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
'rewards' => $rewards,
|
||||
'max_uses' => (int) $row['max_uses'],
|
||||
'used_count' => (int) $row['used_count'],
|
||||
'recent_users' => $recent_users[(int) $row['id']] ?? [],
|
||||
'status' => (string) $row['status'],
|
||||
'expired' => !empty($row['expires_at']) && strtotime((string) $row['expires_at']) <= time(),
|
||||
'expires_at' => (string) ($row['expires_at'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
'max_pages' => max(1, (int) ceil($total / $per_page)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量取每个码最近的使用者(最多 3 个),用于运营列表展示。
|
||||
*
|
||||
* @param array<int,int> $code_ids
|
||||
* @return array<int,array<int,array{user_id:int,name:string,used_at:string}>>
|
||||
*/
|
||||
private function recent_users_map(array $code_ids): array {
|
||||
global $wpdb;
|
||||
if ($code_ids === []) {
|
||||
return [];
|
||||
}
|
||||
$usage_table = \AKSRT\Core\DBMigrator::table('invite_code_usage');
|
||||
if ($usage_table === '') {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($code_ids), '%d'));
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
// 最近三名使用者:按 code_id 分组取时间最新的记录。
|
||||
"SELECT u.code_id, u.user_id, u.used_at, wu.display_name
|
||||
FROM {$usage_table} u
|
||||
INNER JOIN {$wpdb->users} wu ON wu.ID = u.user_id
|
||||
INNER JOIN {$usage_table} u2 ON u2.code_id = u.code_id AND u2.used_at <= u.used_at
|
||||
WHERE u.code_id IN ({$placeholders})
|
||||
GROUP BY u.code_id, u.user_id, u.used_at, wu.display_name
|
||||
HAVING COUNT(*) <= 3
|
||||
ORDER BY u.code_id ASC, u.used_at DESC",
|
||||
$code_ids
|
||||
), ARRAY_A);
|
||||
$map = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$map[(int) $row['code_id']][] = [
|
||||
'user_id' => (int) $row['user_id'],
|
||||
'name' => (string) $row['display_name'],
|
||||
'used_at' => (string) $row['used_at'],
|
||||
];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* REST
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
public function register_routes(): void {
|
||||
// 公开校验:注册表单实时检查邀请码是否可用(只读、无敏感信息)。
|
||||
register_rest_route('aksrt/v1', '/invite-code/verify', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response([
|
||||
'valid' => $this->is_valid((string) $request['code']),
|
||||
]),
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'code' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
|
||||
],
|
||||
]);
|
||||
|
||||
$admin_guard = fn() => current_user_can('edit_users')
|
||||
? true
|
||||
: new \WP_Error('aksrt_invite_forbidden', __('无权执行此操作', 'aksrt'), ['status' => 403]);
|
||||
$write_guard = function () use ($admin_guard) {
|
||||
$allowed = $admin_guard();
|
||||
if (is_wp_error($allowed)) {
|
||||
return $allowed;
|
||||
}
|
||||
$nonce = (string) isset($_SERVER['HTTP_X_WP_NONCE']) ? sanitize_text_field((string) wp_unslash($_SERVER['HTTP_X_WP_NONCE'])) : '';
|
||||
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest')
|
||||
? true
|
||||
: new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
};
|
||||
|
||||
register_rest_route('aksrt/v1', '/invite-codes', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->list_codes($request->get_params())),
|
||||
'permission_callback' => $admin_guard,
|
||||
'args' => [
|
||||
'page' => ['type' => 'integer', 'default' => 1, 'minimum' => 1],
|
||||
'per_page' => ['type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100],
|
||||
'search' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'status' => ['type' => 'string', 'default' => '', 'enum' => ['', 'active', 'disabled']],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/invite-codes/generate', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => fn(\WP_REST_Request $request) => $this->rest_generate($request),
|
||||
'permission_callback' => $write_guard,
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/invite-codes/import', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => function (\WP_REST_Request $request) {
|
||||
$result = $this->import_codes([
|
||||
'data' => (string) $request->get_param('data'),
|
||||
'division' => (string) $request->get_param('division'),
|
||||
'max_uses' => (int) $request->get_param('max_uses'),
|
||||
'expires_days' => (int) $request->get_param('expires_days'),
|
||||
]);
|
||||
return is_wp_error($result) ? $result : new \WP_REST_Response(['success' => true] + $result, 201);
|
||||
},
|
||||
'permission_callback' => $write_guard,
|
||||
'args' => [
|
||||
'data' => ['type' => 'string', 'required' => true],
|
||||
'division' => ['type' => 'string', 'default' => ' ', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'max_uses' => ['type' => 'integer', 'default' => 1, 'minimum' => 0, 'maximum' => 9999],
|
||||
'expires_days' => ['type' => 'integer', 'default' => 0, 'minimum' => 0, 'maximum' => 3650],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/invite-codes/export', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_export'],
|
||||
'permission_callback' => $admin_guard,
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/invite-codes/export-text', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_export_text'],
|
||||
'permission_callback' => $admin_guard,
|
||||
'args' => [
|
||||
'division' => ['type' => 'string', 'default' => ' ', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
]);
|
||||
|
||||
foreach (['disable' => 'disabled', 'enable' => 'active'] as $action => $status) {
|
||||
register_rest_route('aksrt/v1', '/invite-codes/(?P<id>\d+)/' . $action, [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => function (\WP_REST_Request $request) use ($status) {
|
||||
$updated = $this->set_status((int) $request['id'], $status);
|
||||
return $updated
|
||||
? rest_ensure_response(['success' => true, 'id' => (int) $request['id'], 'status' => $status])
|
||||
: new \WP_Error('aksrt_invite_status', __('邀请码状态更新失败', 'aksrt'), ['status' => 409]);
|
||||
},
|
||||
'permission_callback' => $write_guard,
|
||||
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function rest_generate(\WP_REST_Request $request) {
|
||||
$rewards = $request->get_param('rewards');
|
||||
$codes = $this->generate_batch([
|
||||
'count' => (int) $request->get_param('count'),
|
||||
'max_uses' => (int) $request->get_param('max_uses'),
|
||||
'expires_days' => (int) $request->get_param('expires_days'),
|
||||
'remark' => (string) $request->get_param('remark'),
|
||||
'rewards' => is_array($rewards) ? $rewards : [],
|
||||
]);
|
||||
if (is_wp_error($codes)) {
|
||||
return $codes;
|
||||
}
|
||||
return new \WP_REST_Response(['success' => true, 'codes' => $codes, 'count' => count($codes)], 201);
|
||||
}
|
||||
|
||||
public function rest_export(): \WP_REST_Response {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
$rows = $wpdb->get_results("SELECT code, batch_id, remark, rewards, max_uses, used_count, status, expires_at, created_at FROM {$table} ORDER BY id DESC LIMIT 10000", ARRAY_A);
|
||||
$lines = ['code,batch_id,remark,rewards,max_uses,used_count,status,expires_at,created_at'];
|
||||
foreach ((array) $rows as $row) {
|
||||
$row['rewards'] = $this->reward_param_string((string) ($row['rewards'] ?? ''));
|
||||
$lines[] = implode(',', array_map(static fn($value) => '"' . str_replace('"', '""', (string) $value) . '"', array_values($row)));
|
||||
}
|
||||
$response = new \WP_REST_Response(implode("\n", $lines), 200);
|
||||
$response->set_headers([
|
||||
'Content-Type' => 'text/csv; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="aksrt-invite-codes.csv"',
|
||||
]);
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** 文本导出:与导入格式互通(`码 分隔符 奖励 标识`),导出即可回流导入。 */
|
||||
public function rest_export_text(\WP_REST_Request $request): \WP_REST_Response {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
$division = trim((string) ($request['division'] ?? ' '));
|
||||
if ($division === '') {
|
||||
$division = ' ';
|
||||
}
|
||||
$rows = $wpdb->get_results("SELECT code, remark, rewards FROM {$table} ORDER BY id DESC LIMIT 10000", ARRAY_A);
|
||||
$lines = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$line = $row['code'] . $division . $this->reward_param_string((string) ($row['rewards'] ?? ''));
|
||||
if ((string) ($row['remark'] ?? '') !== '') {
|
||||
$line .= $division . $row['remark'];
|
||||
}
|
||||
$lines[] = $line;
|
||||
}
|
||||
$response = new \WP_REST_Response(implode("\n", $lines), 200);
|
||||
$response->set_headers([
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="aksrt-invite-codes.txt"',
|
||||
]);
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user