Move ~94 root class shells and root *-traits into domain directories (core, auth, user, credits, content, payment/gateways, forum, shop, sms, ai, messages, extensions, widgets, settings, admin). Update bootstrap require paths, co-locate traits with owners, fix forum/user relative requires, drop duplicate shop controller requires, and wire home-modules load. Path-only; class names and init order unchanged.
316 lines
11 KiB
PHP
316 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* AKSRT Credits Service
|
|
*
|
|
* 积分系统核心:余额读取、加减、日志写入。
|
|
*
|
|
* 数据存储:
|
|
* - 余额:user_meta key = 'aksrt_credits' (int)
|
|
* - 流水:表 {prefix}aksrt_credit_log
|
|
*
|
|
* 并发安全:
|
|
* - 所有加减操作通过 user_meta 直读 + DB 更新,
|
|
* 使用 wp_cache 失效 + 短事务,避免高并发下的丢失。
|
|
* - 同一用户短时间多次扣减时仍可能出现少量不一致,
|
|
* 可通过 P5 阶段的 token 唯一性兜底(同一资源不会被重复扣费)。
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
class AKSRT_Credits {
|
|
|
|
/** user_meta 键名 */
|
|
const META_KEY = 'aksrt_credits';
|
|
|
|
/**
|
|
* 取用户余额。
|
|
*/
|
|
public static function balance(int $user_id): int {
|
|
if ($user_id <= 0) return 0;
|
|
$val = get_user_meta($user_id, self::META_KEY, true);
|
|
return is_numeric($val) ? (int) $val : 0;
|
|
}
|
|
|
|
/**
|
|
* Alias used by forum unlock/purchase paths.
|
|
*/
|
|
public static function get_balance(int $user_id): int {
|
|
return self::balance($user_id);
|
|
}
|
|
|
|
/**
|
|
* 增加积分。
|
|
*
|
|
* @param int $user_id
|
|
* @param int $delta 必须 > 0
|
|
* @param string $reason 例 register/signin/comment/invite/admin
|
|
* @param array $ref ['type' => 'post|comment|user|invite', 'id' => int]
|
|
* @return int|WP_Error 新余额或错误
|
|
*/
|
|
public static function add(int $user_id, int $delta, string $reason, array $ref = [], string $remark = '') {
|
|
if ($user_id <= 0) return new WP_Error('aksrt_credits_user', __('用户无效', 'aksrt'));
|
|
if ($delta <= 0) return new WP_Error('aksrt_credits_delta', __('增加积分必须为正数', 'aksrt'));
|
|
|
|
$new_balance = self::atomic_update($user_id, $delta, false);
|
|
if ($new_balance === null) {
|
|
return new WP_Error('aksrt_credits_db', __('积分写入失败', 'aksrt'));
|
|
}
|
|
self::log($user_id, $delta, $new_balance, $reason, $ref, $remark);
|
|
|
|
do_action('aksrt_credits_added', $user_id, $delta, $reason, $new_balance);
|
|
return $new_balance;
|
|
}
|
|
|
|
/**
|
|
* 扣减积分。
|
|
*
|
|
* @return int|WP_Error 新余额或错误
|
|
*/
|
|
public static function deduct(int $user_id, int $cost, string $reason, array $ref = [], string $remark = '') {
|
|
if ($user_id <= 0) return new WP_Error('aksrt_credits_user', __('用户无效', 'aksrt'));
|
|
if ($cost < 0) return new WP_Error('aksrt_credits_delta', __('扣减积分必须为非负数', 'aksrt'));
|
|
if ($cost === 0) {
|
|
return self::balance($user_id);
|
|
}
|
|
|
|
$new_balance = self::atomic_update($user_id, $cost, true);
|
|
if ($new_balance === null) {
|
|
$current = self::balance($user_id);
|
|
return new WP_Error('aksrt_credits_insufficient', __('积分不足', 'aksrt'), [
|
|
'balance' => $current,
|
|
'cost' => $cost,
|
|
]);
|
|
}
|
|
|
|
self::log($user_id, -$cost, $new_balance, $reason, $ref, $remark);
|
|
|
|
do_action('aksrt_credits_deducted', $user_id, $cost, $reason, $new_balance);
|
|
return $new_balance;
|
|
}
|
|
|
|
/**
|
|
* 原子加减:用单条 SQL UPDATE 修改 user_meta,避免 read-modify-write 竞态。
|
|
*
|
|
* @param int $user_id
|
|
* @param int $abs_delta 绝对值
|
|
* @param bool $is_deduct true=扣减(带余额检查),false=增加
|
|
* @return int|null 新余额;失败(余额不足或 DB 错误)返回 null
|
|
*/
|
|
private static function atomic_update(int $user_id, int $abs_delta, bool $is_deduct): ?int {
|
|
global $wpdb;
|
|
|
|
// 确保 meta 行存在(首次扣费可能没有 row)
|
|
$existing = get_user_meta($user_id, self::META_KEY, true);
|
|
if ($existing === '' || $existing === false || $existing === null) {
|
|
update_user_meta($user_id, self::META_KEY, 0);
|
|
}
|
|
|
|
$meta_key = self::META_KEY;
|
|
|
|
if ($is_deduct) {
|
|
// 扣减:仅当 meta_value >= cost 时才更新
|
|
$sql = $wpdb->prepare(
|
|
"UPDATE {$wpdb->usermeta}
|
|
SET meta_value = CAST(meta_value AS SIGNED) - %d
|
|
WHERE user_id = %d
|
|
AND meta_key = %s
|
|
AND CAST(meta_value AS SIGNED) >= %d",
|
|
$abs_delta,
|
|
$user_id,
|
|
$meta_key,
|
|
$abs_delta
|
|
);
|
|
} else {
|
|
$sql = $wpdb->prepare(
|
|
"UPDATE {$wpdb->usermeta}
|
|
SET meta_value = CAST(meta_value AS SIGNED) + %d
|
|
WHERE user_id = %d
|
|
AND meta_key = %s",
|
|
$abs_delta,
|
|
$user_id,
|
|
$meta_key
|
|
);
|
|
}
|
|
|
|
$affected = $wpdb->query($sql);
|
|
if ($affected === false || $affected === 0) {
|
|
return null;
|
|
}
|
|
|
|
// 失效 user meta cache,下次 get_user_meta 会重读
|
|
wp_cache_delete($user_id, 'user_meta');
|
|
|
|
$new = (int) get_user_meta($user_id, self::META_KEY, true);
|
|
return $new;
|
|
}
|
|
|
|
/**
|
|
* 写一条流水。
|
|
*
|
|
* @param int $user_id
|
|
* @param int $delta 本次变动(正/负/0)
|
|
* @param int $balance_after 操作后余额
|
|
* @param string $reason
|
|
* @param array $ref
|
|
*/
|
|
public static function log(int $user_id, int $delta, int $balance_after, string $reason, array $ref = [], string $remark = ''): void {
|
|
global $wpdb;
|
|
|
|
$table = AKSRT_DB_Migrator::table('credit_log');
|
|
if (!$table) return;
|
|
|
|
$ref_type = isset($ref['type']) ? sanitize_key((string) $ref['type']) : null;
|
|
$ref_id = isset($ref['id']) ? (int) $ref['id'] : null;
|
|
|
|
$wpdb->insert(
|
|
$table,
|
|
[
|
|
'user_id' => $user_id,
|
|
'delta' => $delta,
|
|
'balance_after' => $balance_after,
|
|
'reason' => substr($reason, 0, 64),
|
|
'remark' => $remark !== '' ? substr($remark, 0, 255) : null,
|
|
'ref_type' => $ref_type,
|
|
'ref_id' => $ref_id,
|
|
'ip' => self::client_ip(),
|
|
'ua' => self::client_ua(),
|
|
'created_at' => current_time('mysql', true),
|
|
],
|
|
['%d', '%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 取流水(管理员页面用)。
|
|
*
|
|
* @param array $args { user_id, reason, page, per_page, date_from, date_to }
|
|
* @return array { items, total, page, per_page }
|
|
*/
|
|
public static function get_log(array $args = []): array {
|
|
global $wpdb;
|
|
|
|
$table = AKSRT_DB_Migrator::table('credit_log');
|
|
if (!$table) {
|
|
return ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20];
|
|
}
|
|
|
|
$page = max(1, (int) ($args['page'] ?? 1));
|
|
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
|
|
$offset = ($page - 1) * $per_page;
|
|
|
|
$where = ['1=1'];
|
|
$params = [];
|
|
|
|
if (!empty($args['user_id'])) {
|
|
$where[] = 'user_id = %d';
|
|
$params[] = (int) $args['user_id'];
|
|
}
|
|
if (!empty($args['reason'])) {
|
|
$where[] = 'reason = %s';
|
|
$params[] = sanitize_key((string) $args['reason']);
|
|
}
|
|
if (!empty($args['date_from'])) {
|
|
$where[] = 'created_at >= %s';
|
|
$params[] = (string) $args['date_from'];
|
|
}
|
|
if (!empty($args['date_to'])) {
|
|
$where[] = 'created_at <= %s';
|
|
$params[] = (string) $args['date_to'];
|
|
}
|
|
|
|
$where_sql = implode(' AND ', $where);
|
|
|
|
// total
|
|
$count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}";
|
|
$total = (int) ($params
|
|
? $wpdb->get_var($wpdb->prepare($count_sql, $params))
|
|
: $wpdb->get_var($count_sql));
|
|
|
|
// items
|
|
$items_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d";
|
|
$items_params = array_merge($params, [$per_page, $offset]);
|
|
$items = $wpdb->get_results($wpdb->prepare($items_sql, $items_params), ARRAY_A);
|
|
if (!is_array($items)) $items = [];
|
|
|
|
// user 名字预填
|
|
$uid_set = array_unique(array_map(fn($r) => (int) $r['user_id'], $items));
|
|
$names = [];
|
|
foreach ($uid_set as $uid) {
|
|
$u = get_userdata($uid);
|
|
if ($u) $names[$uid] = $u->display_name;
|
|
}
|
|
foreach ($items as &$row) {
|
|
$row['user_display_name'] = $names[(int) $row['user_id']] ?? '#' . $row['user_id'];
|
|
$row['delta'] = (int) $row['delta'];
|
|
$row['balance_after'] = (int) $row['balance_after'];
|
|
}
|
|
|
|
return [
|
|
'items' => $items,
|
|
'total' => $total,
|
|
'page' => $page,
|
|
'per_page' => $per_page,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 查询用户余额列表(管理员)。
|
|
*
|
|
* @param array $args { search, page, per_page }
|
|
* @return array
|
|
*/
|
|
public static function list_users(array $args = []): array {
|
|
$page = max(1, (int) ($args['page'] ?? 1));
|
|
$per_page = min(100, max(1, (int) ($args['per_page'] ?? 20)));
|
|
|
|
$q = [
|
|
'number' => $per_page,
|
|
'paged' => $page,
|
|
'orderby' => 'registered',
|
|
'order' => 'DESC',
|
|
];
|
|
if (!empty($args['search'])) {
|
|
$q['search'] = '*' . esc_attr((string) $args['search']) . '*';
|
|
$q['search_columns'] = ['user_login', 'user_email', 'display_name', 'user_nicename'];
|
|
}
|
|
$user_query = new WP_User_Query($q);
|
|
$users = $user_query->get_results();
|
|
$items = [];
|
|
foreach ($users as $u) {
|
|
$items[] = [
|
|
'id' => (int) $u->ID,
|
|
'login' => $u->user_login,
|
|
'email' => $u->user_email,
|
|
'display_name' => $u->display_name,
|
|
'balance' => self::balance((int) $u->ID),
|
|
'registered' => $u->user_registered,
|
|
];
|
|
}
|
|
return [
|
|
'items' => $items,
|
|
'total' => (int) $user_query->get_total(),
|
|
'page' => $page,
|
|
'per_page' => $per_page,
|
|
];
|
|
}
|
|
|
|
// ---------- 工具方法 ----------
|
|
|
|
private static function client_ip(): string {
|
|
if (function_exists('aksrt_get_client_ip')) {
|
|
return (string) aksrt_get_client_ip();
|
|
}
|
|
if (!empty($_SERVER['REMOTE_ADDR'])) {
|
|
return (string) $_SERVER['REMOTE_ADDR'];
|
|
}
|
|
return '';
|
|
}
|
|
|
|
private static function client_ua(): string {
|
|
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
|
|
return substr($ua, 0, 255);
|
|
}
|
|
}
|