feat(payment): 卡密管理改为独立页面并对齐参考主题

- 移除「余额充值」设置中的充值卡(card_pass_*)配置组,PHP schema 与 SPA mirror 双轨同步
- 重写后台卡密管理页:列表过滤/排序/搜索/批量删除/同款分页/点击复制、自动生成与导入、text/xls 导出
- ChargeCard 服务新增 generate_cards/rand_number/rand_password/delete_by_ids/get_export_rows,get_list 支持参考主题排序别名与标识过滤
- 删除无消费方的 /payment/charge-cards REST 路由及 batch_create/disable 死代码,数据层零迁移
This commit is contained in:
2026-09-16 22:05:37 +08:00
parent ba1c8c2822
commit 678143e7b9
9 changed files with 1227 additions and 788 deletions
+156 -27
View File
@@ -14,6 +14,12 @@ class ChargeCard {
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';
@@ -208,7 +214,7 @@ class ChargeCard {
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)) {
if (!in_array($type, self::TYPE_KEYS, true)) {
$type = self::TYPE_BALANCE;
}
@@ -256,32 +262,63 @@ class ChargeCard {
}
/**
* @param array<string,mixed> $meta_fields
* @return array<int,string> passwords/codes generated
* 批量自动生成卡密(参考主题 zibpay_generate_pass_card 同款行为)。
*
* 每张卡同时生成随机卡号与随机密码;$meta 由 normalize_meta 校验,
* 与「卡密管理」页面的自动生成表单一一对应。
*
* @return int 成功生成的张数
*/
public function batch_create(string $type, array $meta_fields, int $count, int $length = 16, string $prefix = '', string $expires_at = '', string $note = ''): array {
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_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
$type = self::TYPE_BALANCE;
if (!in_array($type, self::TYPE_KEYS, true)) {
return 0;
}
$count = max(1, min(500, $count));
$length = max(8, min(32, $length));
$prefix = sanitize_text_field($prefix);
$codes = [];
$num = max(1, min(1000, $num));
$rand_number = max(0, min(50, $rand_number));
$rand_password = max(1, min(50, $rand_password));
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,
]));
$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) {
$codes[] = $password;
$created++;
}
}
return $codes;
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);
}
/**
@@ -794,7 +831,16 @@ class ChargeCard {
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));
return ['level' => $level, 'days' => $days];
$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));
@@ -803,6 +849,21 @@ class ChargeCard {
}
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;
}
@@ -828,6 +889,9 @@ class ChargeCard {
/**
* 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 {
@@ -838,6 +902,7 @@ class ChargeCard {
$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 = [];
@@ -849,9 +914,14 @@ class ChargeCard {
$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 CAST(id AS CHAR) = %s)';
$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;
@@ -864,7 +934,10 @@ class ChargeCard {
? (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";
$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);
@@ -877,11 +950,67 @@ class ChargeCard {
];
}
public function disable(int $id): bool {
/**
* 排序字段白名单;兼容参考主题的列名别名。
* 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;
return (bool) $wpdb->update($this->table(), [
'status' => self::STATUS_DISABLED,
], ['id' => $id], ['%s'], ['%d']);
$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 : [];
}
/**