refactor: flatten src/AKSRT → src (drop theme-identifier dir)

Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
This commit is contained in:
kate522 committed 2026-08-02 22:06:06 +08:00
1 parent 56f1a8175f
commit 8c5b1555c9
285 files changed
+26 -3600

No files matched your search

+376
View File
@@ -0,0 +1,376 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
class Balance {
public const META_KEY = 'aksrt_balance';
/**
* Current cash balance (yuan).
*/
public static function balance(int $user_id): float {
if ($user_id <= 0) {
return 0.0;
}
$val = get_user_meta($user_id, self::META_KEY, true);
return is_numeric($val) ? round((float) $val, 2) : 0.0;
}
public static function get_balance(int $user_id): float {
return self::balance($user_id);
}
/**
* Credit cash wallet.
*
* @param array<string,mixed> $ref
* @return float|\WP_Error New balance.
*/
public static function add(int $user_id, float $delta, string $reason, array $ref = [], string $remark = '', bool $dispatch_hooks = true) {
if ($user_id <= 0) {
return new \WP_Error('aksrt_balance_user', __('用户无效', 'aksrt'));
}
$delta = round((float) $delta, 2);
if ($delta <= 0) {
return new \WP_Error('aksrt_balance_delta', __('增加余额必须为正数', 'aksrt'));
}
$new = self::atomic_update($user_id, $delta, false);
if ($new === null) {
return new \WP_Error('aksrt_balance_db', __('余额写入失败', 'aksrt'));
}
self::log($user_id, $delta, $new, $reason, $ref, $remark);
if ($dispatch_hooks) {
do_action('aksrt_balance_added', $user_id, $delta, $reason, $new);
}
return $new;
}
/**
* Debit cash wallet.
*
* @param array<string,mixed> $ref
* @return float|\WP_Error New balance.
*/
public static function deduct(int $user_id, float $cost, string $reason, array $ref = [], string $remark = '') {
if ($user_id <= 0) {
return new \WP_Error('aksrt_balance_user', __('用户无效', 'aksrt'));
}
$cost = round((float) $cost, 2);
if ($cost < 0) {
return new \WP_Error('aksrt_balance_delta', __('扣减余额必须为非负数', 'aksrt'));
}
if ($cost === 0.0) {
return self::balance($user_id);
}
$new = self::atomic_update($user_id, $cost, true);
if ($new === null) {
$current = self::balance($user_id);
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), [
'balance' => $current,
'cost' => $cost,
]);
}
self::log($user_id, -$cost, $new, $reason, $ref, $remark);
do_action('aksrt_balance_deducted', $user_id, $cost, $reason, $new);
return $new;
}
/**
* @return float|null
*/
private static function atomic_update(int $user_id, float $abs_delta, bool $is_deduct) {
global $wpdb;
$abs_delta = round(abs($abs_delta), 2);
$meta_key = self::META_KEY;
$table = $wpdb->usermeta;
// Ensure meta row exists.
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT umeta_id FROM {$table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
$user_id,
$meta_key
));
if (!$exists) {
add_user_meta($user_id, $meta_key, '0', true);
}
if ($is_deduct) {
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$table}
SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(18,2)) - %f, 2) AS CHAR)
WHERE user_id = %d AND meta_key = %s
AND CAST(meta_value AS DECIMAL(18,2)) >= %f",
$abs_delta,
$user_id,
$meta_key,
$abs_delta
));
} else {
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$table}
SET meta_value = CAST(ROUND(CAST(meta_value AS DECIMAL(18,2)) + %f, 2) AS CHAR)
WHERE user_id = %d AND meta_key = %s",
$abs_delta,
$user_id,
$meta_key
));
}
if ($updated !== 1) {
return null;
}
wp_cache_delete($user_id, 'user_meta');
return self::balance($user_id);
}
/**
* @param array<string,mixed> $ref
*/
public static function log(int $user_id, float $delta, float $balance_after, string $reason, array $ref = [], string $remark = ''): void {
global $wpdb;
if (!class_exists('\AKSRT\Core\DBMigrator')) {
return;
}
$table = \AKSRT\Core\DBMigrator::table('balance_log');
if ($table === '') {
return;
}
$ip = '';
if (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR']));
}
$ua = '';
if (!empty($_SERVER['HTTP_USER_AGENT'])) {
$ua = substr(sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_USER_AGENT'])), 0, 255);
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->insert(
$table,
[
'user_id' => $user_id,
'delta' => round($delta, 2),
'balance_after' => round($balance_after, 2),
'reason' => sanitize_key(substr($reason, 0, 64)),
'remark' => $remark !== '' ? sanitize_text_field(substr($remark, 0, 255)) : null,
'ref_type' => isset($ref['type']) ? sanitize_key((string) $ref['type']) : null,
'ref_id' => isset($ref['id']) ? (int) $ref['id'] : (isset($ref['ref_id']) ? (int) $ref['ref_id'] : null),
'ip' => $ip !== '' ? $ip : null,
'ua' => $ua !== '' ? $ua : null,
'created_at' => current_time('mysql', true),
],
['%d', '%f', '%f', '%s', '%s', '%s', '%d', '%s', '%s', '%s']
);
}
/**
* @param array<string,mixed> $args
* @return array{items:array<int,array<string,mixed>>,total:int}
*/
public static function get_log(array $args = []): array {
global $wpdb;
if (!class_exists('\AKSRT\Core\DBMigrator')) {
return ['items' => [], 'total' => 0];
}
$table = \AKSRT\Core\DBMigrator::table('balance_log');
if ($table === '') {
return ['items' => [], 'total' => 0];
}
$user_id = max(0, (int) ($args['user_id'] ?? 0));
$page = max(1, (int) ($args['page'] ?? 1));
$per_page = max(1, min(50, (int) ($args['per_page'] ?? 10)));
$offset = ($page - 1) * $per_page;
$where = '1=1';
$params = [];
if ($user_id > 0) {
$where .= ' AND user_id = %d';
$params[] = $user_id;
}
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$total_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$total = (int) ($params ? $wpdb->get_var($wpdb->prepare($total_sql, $params)) : $wpdb->get_var($total_sql));
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$list_sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY id DESC LIMIT %d OFFSET %d";
$list_params = array_merge($params, [$per_page, $offset]);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
if (!is_array($rows)) {
$rows = [];
}
$items = [];
foreach ($rows as $row) {
$items[] = [
'id' => (int) ($row['id'] ?? 0),
'user_id' => (int) ($row['user_id'] ?? 0),
'delta' => round((float) ($row['delta'] ?? 0), 2),
'balance_after' => round((float) ($row['balance_after'] ?? 0), 2),
'reason' => (string) ($row['reason'] ?? ''),
'remark' => (string) ($row['remark'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
return ['items' => $items, 'total' => $total];
}
/**
* Whether cash wallet feature is enabled (Zibll pay_balance_s).
*/
public static function enabled(): bool {
if (!class_exists('\AKSRT\Settings\Settings')) {
return false;
}
if (method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
$cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
return !empty($cfg['enabled']);
}
return !empty(\AKSRT\Settings\Settings::get_all()['pay_balance_s']);
}
/**
* Atomically transfer cash balance between two users.
*
* @param array<string,mixed> $ref
* @return array{sender_balance:float,recipient_balance:float}|\WP_Error
*/
public static function transfer(int $sender_id, int $recipient_id, float $amount, string $reason_out = 'tip_sent', string $reason_in = 'tip_received', array $ref = [], string $remark = '') {
global $wpdb;
$amount = round($amount, 2);
if ($sender_id <= 0 || $recipient_id <= 0 || $sender_id === $recipient_id) {
return new \WP_Error('aksrt_balance_transfer_user', __('转账用户无效', 'aksrt'));
}
if ($amount <= 0) {
return new \WP_Error('aksrt_balance_transfer_amount', __('转账金额必须大于零', 'aksrt'));
}
if (!class_exists('\AKSRT\Core\DBMigrator')) {
return new \WP_Error('aksrt_balance_transfer_unavailable', __('余额服务不可用', 'aksrt'));
}
foreach ([$sender_id, $recipient_id] as $user_id) {
if (!metadata_exists('user', $user_id, self::META_KEY)) {
add_user_meta($user_id, self::META_KEY, '0', true);
}
}
$meta_table = $wpdb->usermeta;
$log_table = \AKSRT\Core\DBMigrator::table('balance_log');
$locked_ids = [$sender_id, $recipient_id];
sort($locked_ids, SORT_NUMERIC);
$wpdb->query('START TRANSACTION');
try {
foreach ($locked_ids as $user_id) {
$wpdb->get_var($wpdb->prepare(
"SELECT umeta_id FROM {$meta_table} WHERE user_id = %d AND meta_key = %s FOR UPDATE",
$user_id,
self::META_KEY
));
}
$sender_before = (float) $wpdb->get_var($wpdb->prepare(
"SELECT meta_value FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
$sender_id,
self::META_KEY
));
if ($sender_before < $amount) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'));
}
$sender_after = round($sender_before - $amount, 2);
$recipient_before = (float) $wpdb->get_var($wpdb->prepare(
"SELECT meta_value FROM {$meta_table} WHERE user_id = %d AND meta_key = %s LIMIT 1",
$recipient_id,
self::META_KEY
));
$recipient_after = round($recipient_before + $amount, 2);
$sender_updated = $wpdb->update($meta_table, ['meta_value' => (string) $sender_after], ['user_id' => $sender_id, 'meta_key' => self::META_KEY], ['%s'], ['%d', '%s']);
$recipient_updated = $wpdb->update($meta_table, ['meta_value' => (string) $recipient_after], ['user_id' => $recipient_id, 'meta_key' => self::META_KEY], ['%s'], ['%d', '%s']);
if ($sender_updated !== 1 || $recipient_updated !== 1) {
throw new \RuntimeException('balance_update_failed');
}
$ref_type = isset($ref['type']) ? sanitize_key((string) $ref['type']) : 'tip';
$ref_id = isset($ref['id']) ? (int) $ref['id'] : null;
$created_at = current_time('mysql', true);
$log_rows = [
[$sender_id, -$amount, $sender_after, $reason_out],
[$recipient_id, $amount, $recipient_after, $reason_in],
];
foreach ($log_rows as [$user_id, $delta, $balance_after, $reason]) {
$inserted = $wpdb->insert($log_table, [
'user_id' => $user_id,
'delta' => $delta,
'balance_after' => $balance_after,
'reason' => sanitize_key($reason),
'remark' => $remark !== '' ? sanitize_text_field(substr($remark, 0, 255)) : null,
'ref_type' => $ref_type,
'ref_id' => $ref_id,
'created_at' => $created_at,
], ['%d', '%f', '%f', '%s', '%s', '%s', '%d', '%s']);
if ($inserted !== 1) {
throw new \RuntimeException('balance_log_failed');
}
}
$wpdb->query('COMMIT');
wp_cache_delete($sender_id, 'user_meta');
wp_cache_delete($recipient_id, 'user_meta');
do_action('aksrt_balance_transferred', $sender_id, $recipient_id, $amount, $ref);
return ['sender_balance' => $sender_after, 'recipient_balance' => $recipient_after];
} catch (\Throwable $e) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_balance_transfer_failed', __('打赏入账失败,请稍后重试', 'aksrt'));
}
}
/**
* Format yuan for display.
*/
public static function format(float $amount): string {
return number_format_i18n(round($amount, 2), 2);
}
/**
* Reason label for ledger UI.
*/
public static function reason_label(string $reason): string {
$map = [
'card_pass_balance' => __('卡密充值', 'aksrt'),
'order_payment' => __('订单支付', 'aksrt'),
'order_refund' => __('订单退款', 'aksrt'),
'recharge' => __('余额充值', 'aksrt'),
'transfer_in' => __('转入', 'aksrt'),
'transfer_out' => __('转出', 'aksrt'),
'tip_sent' => __('打赏支出', 'aksrt'),
'tip_received' => __('收到打赏', 'aksrt'),
'admin' => __('管理员调整', 'aksrt'),
'exchange_from_credits' => __('积分兑换', 'aksrt'),
'withdraw_hold' => __('提现冻结', 'aksrt'),
'withdraw_refund' => __('提现退回', 'aksrt'),
];
$key = sanitize_key($reason);
return $map[$key] ?? ($reason !== '' ? $reason : __('余额变动', 'aksrt'));
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
class BalanceService {
use BalanceServiceRest;
use BalanceServiceCharge;
use BalanceServicePoints;
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('rest_api_init', [$this, 'register_routes']);
add_action('aksrt_order_paid', [$this, 'on_order_paid'], 20, 2);
}
}
+393
View File
@@ -0,0 +1,393 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
trait BalanceServiceCharge {
/**
* When a recharge order is paid, credit the wallet.
*
* @param string $order_number Order number.
* @param array<string,mixed> $order Order row.
*/
public function on_order_paid(string $order_number, array $order = []): void {
if ($order === [] && class_exists('\AKSRT\Payment\OrderRepository')) {
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number) ?: [];
}
if (!$order) {
return;
}
$type = sanitize_key((string) ($order['product_type'] ?? ''));
$user_id = (int) ($order['user_id'] ?? 0);
if ($user_id <= 0) {
return;
}
// Points purchase fulfill (zibll order_type=9).
if ($type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
if (!class_exists('AKSRT\\Credits\\Credits')) {
return;
}
$note = json_decode((string) ($order['note'] ?? ''), true);
if (!is_array($note)) {
$note = [];
}
if (!empty($note['points_credited'])) {
return;
}
$rate = 100;
if (class_exists('\AKSRT\Settings\Settings')) {
$rate = max(1, (int) (\AKSRT\Settings\Settings::get_all()['points_rate'] ?? 100));
}
$points = isset($note['points_amount'])
? (int) $note['points_amount']
: (int) round((float) ($order['amount'] ?? 0) * $rate);
if ($points <= 0) {
return;
}
global $wpdb;
if ($wpdb->query('START TRANSACTION') === false) {
return;
}
$add = Credits::add(
$user_id,
$points,
'points_buy',
['type' => 'order', 'order' => $order_number],
sprintf(__('购买积分订单 %s', 'aksrt'), $order_number),
false
);
if (is_wp_error($add)) {
$wpdb->query('ROLLBACK');
return;
}
$note['points_credited'] = 1;
$note['credited_points'] = $points;
$updated = $wpdb->update(
$wpdb->prefix . 'aksrt_order',
['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)],
['order_number' => $order_number],
['%s'],
['%s']
);
if ($updated === false) {
$wpdb->query('ROLLBACK');
wp_cache_delete($user_id, 'user_meta');
return;
}
if ($wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
wp_cache_delete($user_id, 'user_meta');
return;
}
do_action('aksrt_credits_added', $user_id, $points, 'points_buy', (int) $add);
if (class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('购买积分已到账。', 'aksrt')]
);
}
return;
}
if ($type !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
return;
}
if (!class_exists('AKSRT\\Credits\\Balance')) {
return;
}
// Honor pay_auto_to_balance (default on). When off, leave order paid without wallet credit.
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
$recharge_cfg = \AKSRT\Settings\Settings::get_payment_recharge_settings();
if (isset($recharge_cfg['auto_to_balance']) && empty($recharge_cfg['auto_to_balance'])) {
return;
}
}
$note = json_decode((string) ($order['note'] ?? ''), true);
if (!is_array($note)) {
$note = [];
}
if (!empty($note['balance_credited'])) {
return;
}
$credit_amount = isset($note['credit_amount'])
? round((float) $note['credit_amount'], 2)
: round((float) ($order['amount'] ?? 0), 2);
if ($credit_amount <= 0) {
return;
}
global $wpdb;
if ($wpdb->query('START TRANSACTION') === false) {
return;
}
$result = Balance::add(
$user_id,
$credit_amount,
'recharge',
['type' => 'order', 'order' => $order_number],
sprintf(__('余额充值订单 %s', 'aksrt'), $order_number),
false
);
if (is_wp_error($result)) {
$wpdb->query('ROLLBACK');
return;
}
$note['balance_credited'] = 1;
$note['credited_amount'] = $credit_amount;
$updated = $wpdb->update(
$wpdb->prefix . 'aksrt_order',
['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)],
['order_number' => $order_number],
['%s'],
['%s']
);
if ($updated === false) {
$wpdb->query('ROLLBACK');
wp_cache_delete($user_id, 'user_meta');
return;
}
if ($wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
wp_cache_delete($user_id, 'user_meta');
return;
}
do_action('aksrt_balance_added', $user_id, $credit_amount, 'recharge', (float) $result);
if (class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('充值金额已到账。', 'aksrt')]
);
}
}
/**
* @return array<string,mixed>
*/
public function get_charge_view(int $user_id): array {
$cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
$packages = $this->parse_packages((string) ($cfg['packages_raw'] ?? ''));
$custom_s = !isset($cfg['product']['custom_s']) || !empty($cfg['product']['custom_s']);
$custom_limit = max(1, (int) ($cfg['product']['custom_limit'] ?? 9999));
$methods = [];
if (function_exists('aksrt_list_cash_payment_methods')) {
// Exclude balance gateway for recharge (cannot pay wallet with wallet).
foreach (aksrt_list_cash_payment_methods(true) as $m) {
$id = is_array($m) ? (string) ($m['id'] ?? '') : (string) $m;
if ($id === '' || $id === 'balance') {
continue;
}
$methods[] = is_array($m) ? $m : ['id' => $id, 'name' => $id];
}
}
return [
'enabled' => !empty($cfg['enabled']),
'desc' => (string) ($cfg['desc'] ?? ''),
'wallet' => class_exists('AKSRT\\Credits\\Balance') ? Balance::balance($user_id) : 0.0,
'packages' => $packages,
'custom_s' => $custom_s,
'custom_limit' => $custom_limit,
'currency_mark' => (string) ($cfg['product']['tag'] ?? '元'),
'payment_methods' => $methods,
'transfer' => [
'enabled' => !empty($cfg['transfer']['enabled']),
'service_charge' => (int) ($cfg['transfer']['service_charge'] ?? 0),
'desc' => (string) ($cfg['transfer']['desc'] ?? ''),
],
'exchange' => [
'enabled' => !empty($cfg['exchange']['enabled']),
'rate' => (int) ($cfg['exchange']['rate'] ?? 100),
'desc' => (string) ($cfg['exchange']['desc'] ?? ''),
],
'pass_charge' => is_array($cfg['pass_charge'] ?? null) ? $cfg['pass_charge'] : [],
];
}
/**
* @return array<int,array{key:string,price:float,pay_price:float,title:string,tag:string}>
*/
public function parse_packages(string $raw): array {
$out = [];
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$i = 0;
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
// pay|credit|title OR credit|pay|title — Zibll uses price(credit)|pay_price|tag
// Admin help: 支付金额|到账金额|标题
$parts = array_map('trim', explode('|', $line));
if (count($parts) < 2) {
continue;
}
$pay = (float) $parts[0];
$credit = (float) $parts[1];
$title = $parts[2] ?? '';
if ($pay <= 0 || $credit <= 0) {
continue;
}
$out[] = [
'key' => (string) $i,
'pay_price' => round($pay, 2),
'price' => round($credit, 2),
'title' => $title !== '' ? $title : sprintf(__('充值 %s 元', 'aksrt'), (string) $credit),
'tag' => $title,
];
$i++;
}
return $out;
}
/**
* @return array<string,mixed>|\WP_Error
*/
public function create_charge_order(int $user_id, string $package_key, float $custom_price, string $payment_method) {
if ($user_id <= 0) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Payment\Order')) {
return new \WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]);
}
$view = $this->get_charge_view($user_id);
$credit_amount = 0.0;
$pay_amount = 0.0;
$title = __('余额充值', 'aksrt');
if ($package_key === 'custom' || $package_key === '') {
if (empty($view['custom_s'])) {
return new \WP_Error('aksrt_custom_closed', __('未开放自定义金额', 'aksrt'), ['status' => 400]);
}
$custom_price = round($custom_price, 2);
$min = 1.0;
$max = (float) $view['custom_limit'];
if ($custom_price < $min || $custom_price > $max) {
return new \WP_Error('aksrt_custom_limit', sprintf(__('充值金额需在 %s ~ %s 之间', 'aksrt'), (string) $min, (string) $max), ['status' => 400]);
}
$pay_amount = $custom_price;
$credit_amount = $custom_price;
$title = sprintf(__('自定义充值 %s 元', 'aksrt'), (string) $custom_price);
$package_key = 'custom';
} else {
$found = null;
foreach ((array) $view['packages'] as $pkg) {
if ((string) ($pkg['key'] ?? '') === (string) $package_key) {
$found = $pkg;
break;
}
}
if (!$found) {
return new \WP_Error('aksrt_invalid_package', __('充值套餐无效', 'aksrt'), ['status' => 400]);
}
$pay_amount = (float) $found['pay_price'];
$credit_amount = (float) $found['price'];
$title = (string) $found['title'];
}
if ($pay_amount <= 0 || $credit_amount <= 0) {
return new \WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]);
}
$payment_method = sanitize_key($payment_method);
if ($payment_method === '' || $payment_method === 'balance') {
// Prefer first non-balance method.
$methods = $view['payment_methods'] ?? [];
$payment_method = !empty($methods[0]['id']) ? (string) $methods[0]['id'] : 'alipay';
}
$note = wp_json_encode([
'balance_recharge' => 1,
'package' => $package_key,
'credit_amount' => $credit_amount,
'pay_amount' => $pay_amount,
'title' => $title,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
'user_id' => $user_id,
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE,
'product_id' => 0,
'amount' => $pay_amount,
'currency' => 'CNY',
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
'payment_method' => $payment_method,
'note' => is_string($note) ? $note : '',
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
]);
if ($order_number === '') {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
if (!$order) {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
$order['subject'] = $title;
$order['payment_method'] = $payment_method;
if (!class_exists('\AKSRT\Payment\Gateway')) {
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]);
}
$resolved = \AKSRT\Payment\PaymentOrchestrator::resolve_gateway($payment_method);
if (empty($resolved['gateway'])) {
return new \WP_Error(
(string) ($resolved['error_code'] ?? 'aksrt_gateway_unavailable'),
(string) ($resolved['message'] ?? __('当前支付方式不可用', 'aksrt')),
['status' => 400, 'order_number' => $order_number]
);
}
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
'paid_note' => __('余额充值支付完成', 'aksrt'),
]);
if (empty($payment_result['success'])) {
return new \WP_Error(
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
['status' => 402, 'order_number' => $order_number]
);
}
if (!empty($payment_result['redirect'])) {
return [
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'amount' => $pay_amount,
'credit_amount' => $credit_amount,
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
];
}
// Synchronous gateway: PaymentOrchestrator's paid transition already fired
// aksrt_order_paid, so fulfillment has run through the single hook path.
return [
'redirect' => false,
'order_number' => $order_number,
'amount' => $pay_amount,
'credit_amount' => $credit_amount,
'wallet' => Balance::balance($user_id),
'message' => __('充值成功', 'aksrt'),
'reload' => true,
];
}
}
+241
View File
@@ -0,0 +1,241 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
trait BalanceServicePoints {
public function points_buy_enabled(): bool {
if (!class_exists('\AKSRT\Settings\Settings') || !\AKSRT\Settings\Settings::credits_enabled()) {
return false;
}
$all = \AKSRT\Settings\Settings::get_all();
return !empty($all['points_pay_s']);
}
/**
* @return array<string,mixed>
*/
public function get_points_buy_view(int $user_id): array {
$all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
$rate = max(1, (int) ($all['points_rate'] ?? 100));
$packages_raw = (string) ($all['pay_points_product'] ?? '');
$packages = $this->parse_points_packages($packages_raw);
$custom_s = !isset($all['pay_points_product_custom_s']) || !empty($all['pay_points_product_custom_s']);
$custom_limit = max(1, (int) ($all['pay_points_product_custom_limit'] ?? 99999));
$methods = [];
if (function_exists('aksrt_list_cash_payment_methods')) {
foreach (aksrt_list_cash_payment_methods(true) as $m) {
$id = is_array($m) ? (string) ($m['id'] ?? '') : (string) $m;
if ($id === '') {
continue;
}
if ($id === 'balance' && !(class_exists('AKSRT\\Credits\\Balance') && Balance::enabled())) {
continue;
}
$methods[] = is_array($m) ? $m : ['id' => $id, 'name' => $id];
}
}
if (class_exists('AKSRT\\Credits\\Balance') && Balance::enabled()) {
$has_bal = false;
foreach ($methods as $m) {
if (($m['id'] ?? '') === 'balance') {
$has_bal = true;
break;
}
}
if (!$has_bal) {
array_unshift($methods, ['id' => 'balance', 'name' => __('站内余额', 'aksrt')]);
}
}
return [
'enabled' => $this->points_buy_enabled(),
'desc' => (string) ($all['pay_points_desc'] ?? ''),
'rate' => $rate,
'points' => class_exists('AKSRT\\Credits\\Credits') ? Credits::balance($user_id) : 0,
'packages' => $packages,
'custom_s' => $custom_s,
'custom_limit' => $custom_limit,
'payment_methods' => $methods,
];
}
/**
* @return array<int,array{key:string,points:int,pay_price:float,title:string}>
*/
public function parse_points_packages(string $raw): array {
$out = [];
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$i = 0;
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
// 支付金额|积分数量|标题
$parts = array_map('trim', explode('|', $line));
if (count($parts) < 2) {
continue;
}
$pay = (float) $parts[0];
$points = (int) $parts[1];
$title = $parts[2] ?? '';
if ($pay <= 0 || $points <= 0) {
continue;
}
$out[] = [
'key' => (string) $i,
'pay_price' => round($pay, 2),
'points' => $points,
'title' => $title !== '' ? $title : sprintf(__('%d 积分', 'aksrt'), $points),
];
$i++;
}
return $out;
}
/**
* @return array<string,mixed>|\WP_Error
*/
public function create_points_buy_order(int $user_id, string $package_key, int $custom_points, string $payment_method) {
if ($user_id <= 0) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Payment\Order')) {
return new \WP_Error('aksrt_pay_unavailable', __('订单系统不可用', 'aksrt'), ['status' => 500]);
}
$view = $this->get_points_buy_view($user_id);
$rate = max(1, (int) ($view['rate'] ?? 100));
$points_amount = 0;
$pay_amount = 0.0;
$title = __('购买积分', 'aksrt');
if ($package_key === 'custom' || $package_key === '') {
if (empty($view['custom_s'])) {
return new \WP_Error('aksrt_custom_closed', __('未开放自定义数量', 'aksrt'), ['status' => 400]);
}
$custom_points = max(0, $custom_points);
$max = (int) ($view['custom_limit'] ?? 99999);
if ($custom_points < $rate || $custom_points > $max) {
return new \WP_Error('aksrt_custom_limit', sprintf(__('购买积分需在 %d ~ %d 之间', 'aksrt'), $rate, $max), ['status' => 400]);
}
$points_amount = $custom_points;
$pay_amount = round($custom_points / $rate, 2);
if ($pay_amount <= 0) {
$pay_amount = 0.01;
}
$title = sprintf(__('购买 %d 积分', 'aksrt'), $points_amount);
$package_key = 'custom';
} else {
$found = null;
foreach ((array) $view['packages'] as $pkg) {
if ((string) ($pkg['key'] ?? '') === (string) $package_key) {
$found = $pkg;
break;
}
}
if (!$found) {
return new \WP_Error('aksrt_invalid_package', __('积分套餐无效', 'aksrt'), ['status' => 400]);
}
$points_amount = (int) $found['points'];
$pay_amount = (float) $found['pay_price'];
$title = (string) $found['title'];
}
if ($points_amount <= 0 || $pay_amount <= 0) {
return new \WP_Error('aksrt_invalid_price', __('金额无效', 'aksrt'), ['status' => 400]);
}
$payment_method = sanitize_key($payment_method);
if ($payment_method === '') {
$methods = $view['payment_methods'] ?? [];
$payment_method = !empty($methods[0]['id']) ? (string) $methods[0]['id'] : 'alipay';
}
$note = wp_json_encode([
'points_buy' => 1,
'package' => $package_key,
'points_amount' => $points_amount,
'pay_amount' => $pay_amount,
'title' => $title,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
'user_id' => $user_id,
'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY,
'product_id' => 0,
'amount' => $pay_amount,
'currency' => 'CNY',
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
'payment_method' => $payment_method,
'note' => is_string($note) ? $note : '',
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
]);
if ($order_number === '') {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
if (!$order) {
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
}
$order['subject'] = $title;
$order['payment_method'] = $payment_method;
if (!class_exists('\AKSRT\Payment\Gateway')) {
return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]);
}
$resolved = \AKSRT\Payment\PaymentOrchestrator::resolve_gateway($payment_method);
if (empty($resolved['gateway'])) {
return new \WP_Error(
(string) ($resolved['error_code'] ?? 'aksrt_gateway_unavailable'),
(string) ($resolved['message'] ?? __('当前支付方式不可用', 'aksrt')),
['status' => 400, 'order_number' => $order_number]
);
}
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [
'paid_note' => __('购买积分支付完成', 'aksrt'),
]);
if (empty($payment_result['success'])) {
return new \WP_Error(
(string) ($payment_result['code'] ?? 'aksrt_pay_failed'),
(string) ($payment_result['message'] ?? __('支付失败', 'aksrt')),
['status' => 402, 'order_number' => $order_number]
);
}
if (!empty($payment_result['redirect'])) {
return [
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
'order_number' => $order_number,
'amount' => $pay_amount,
'points_amount' => $points_amount,
'message' => $payment_result['message'],
'reload' => false,
'poll' => true,
];
}
// PaymentOrchestrator's paid transition already dispatched the fulfillment hook.
return [
'redirect' => false,
'order_number' => $order_number,
'amount' => $pay_amount,
'points_amount' => $points_amount,
'points' => Credits::balance($user_id),
'message' => __('购买成功', 'aksrt'),
'reload' => true,
];
}
}
+449
View File
@@ -0,0 +1,449 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
trait BalanceServiceRest {
public function register_routes(): void {
register_rest_route('aksrt/v1', '/pay/balance/charge', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_charge_options'],
'permission_callback' => static function () {
return is_user_logged_in();
},
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_charge_create'],
'permission_callback' => static function () {
return is_user_logged_in();
},
],
]);
register_rest_route('aksrt/v1', '/pay/transfer', [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_transfer'],
'permission_callback' => static function () {
return is_user_logged_in();
},
],
]);
register_rest_route('aksrt/v1', '/pay/transfer/search', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_transfer_search'],
'permission_callback' => static function () {
return is_user_logged_in();
},
],
]);
register_rest_route('aksrt/v1', '/pay/balance/exchange', [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_exchange_from_credits'],
'permission_callback' => static function () {
return is_user_logged_in();
},
],
]);
register_rest_route('aksrt/v1', '/pay/points/buy', [
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'rest_points_buy_options'],
'permission_callback' => static function () {
return is_user_logged_in();
},
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_points_buy_create'],
'permission_callback' => static function () {
return is_user_logged_in();
},
],
]);
register_rest_route('aksrt/v1', '/pay/balance/withdraw', [
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [$this, 'rest_balance_withdraw'],
'permission_callback' => static function () {
return is_user_logged_in();
},
],
]);
}
/**
* GET charge options (packages + methods + limits).
*/
public function rest_charge_options(\WP_REST_Request $request) {
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
}
$user_id = get_current_user_id();
return rest_ensure_response([
'success' => true,
'data' => $this->get_charge_view($user_id),
]);
}
/**
* POST create recharge order and initiate payment.
*/
public function rest_charge_create(\WP_REST_Request $request) {
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
}
$user_id = get_current_user_id();
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$package_key = sanitize_text_field((string) ($params['package'] ?? 'custom'));
$custom_price = isset($params['custom_price']) ? (float) $params['custom_price'] : 0.0;
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay'));
$result = $this->create_charge_order($user_id, $package_key, $custom_price, $payment_method);
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response(['success' => true, 'data' => $result]);
}
/**
* Transfer balance or points between users.
*/
public function rest_transfer(\WP_REST_Request $request) {
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$type = sanitize_key((string) ($params['type'] ?? 'balance'));
$recipient = (int) ($params['recipient'] ?? 0);
$amount_raw = $params['amount'] ?? $params['price'] ?? 0;
$from_id = get_current_user_id();
if (!in_array($type, ['balance', 'points'], true)) {
return new \WP_Error('aksrt_transfer_type', __('转账类型无效', 'aksrt'), ['status' => 400]);
}
if ($from_id <= 0) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
if ($recipient <= 0 || $recipient === $from_id) {
return new \WP_Error('aksrt_transfer_user', __('请选择有效的收款用户', 'aksrt'), ['status' => 400]);
}
if (!get_userdata($recipient)) {
return new \WP_Error('aksrt_transfer_user', __('收款用户不存在', 'aksrt'), ['status' => 404]);
}
$cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
if ($type === 'balance') {
if (empty($cfg['enabled']) || empty($cfg['transfer']['enabled'])) {
return new \WP_Error('aksrt_transfer_closed', __('余额转账未开启', 'aksrt'), ['status' => 403]);
}
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_transfer', $from_id)) {
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账余额', 'aksrt'), ['status' => 403]);
}
$amount = round((float) $amount_raw, 2);
if ($amount <= 0) {
return new \WP_Error('aksrt_transfer_amount', __('请输入有效金额', 'aksrt'), ['status' => 400]);
}
if (!class_exists('AKSRT\\Credits\\Balance')) {
return new \WP_Error('aksrt_balance_unavailable', __('余额系统不可用', 'aksrt'), ['status' => 500]);
}
$from_bal = Balance::balance($from_id);
if ($from_bal < $amount) {
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]);
}
$fee_rate = max(0, min(50, (int) ($cfg['transfer']['service_charge'] ?? 0)));
$credit = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount;
$from_user = wp_get_current_user();
$to_user = get_userdata($recipient);
$deduct = Balance::deduct(
$from_id,
$amount,
'transfer_out',
['type' => 'user', 'id' => $recipient],
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient)
);
if (is_wp_error($deduct)) {
return $deduct;
}
Balance::add(
$recipient,
$credit,
'transfer_in',
['type' => 'user', 'id' => $from_id],
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id)
);
return rest_ensure_response([
'success' => true,
'data' => [
'message' => __('转账成功', 'aksrt'),
'reload' => true,
'balance' => Balance::balance($from_id),
],
]);
}
// points transfer
$points_transfer_on = !empty(\AKSRT\Settings\Settings::get_all()['points_transfer_s']);
if (!class_exists('\AKSRT\Settings\Settings') || !\AKSRT\Settings\Settings::credits_enabled() || !$points_transfer_on) {
return new \WP_Error('aksrt_transfer_closed', __('积分转账未开启', 'aksrt'), ['status' => 403]);
}
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('points_transfer', $from_id)) {
return new \WP_Error('aksrt_transfer_forbidden', __('当前账号无权转账积分', 'aksrt'), ['status' => 403]);
}
$amount = (int) $amount_raw;
if ($amount <= 0) {
return new \WP_Error('aksrt_transfer_amount', __('请输入有效积分', 'aksrt'), ['status' => 400]);
}
if (!class_exists('AKSRT\\Credits\\Credits')) {
return new \WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]);
}
$fee_rate = max(0, min(50, (int) (\AKSRT\Settings\Settings::get_all()['points_service_charge'] ?? 0)));
$credit = $fee_rate > 0 ? (int) floor($amount * (100 - $fee_rate) / 100) : $amount;
$from_user = wp_get_current_user();
$to_user = get_userdata($recipient);
$deduct = Credits::deduct(
$from_id,
$amount,
'transfer_out',
['type' => 'user', 'id' => $recipient],
sprintf(__('转账给用户[%s]', 'aksrt'), $to_user ? $to_user->display_name : (string) $recipient)
);
if (is_wp_error($deduct)) {
return $deduct;
}
Credits::add(
$recipient,
$credit,
'transfer_in',
['type' => 'user', 'id' => $from_id],
sprintf(__('来自用户[%s]的转账', 'aksrt'), $from_user->display_name ?: (string) $from_id)
);
return rest_ensure_response([
'success' => true,
'data' => [
'message' => __('转账成功', 'aksrt'),
'reload' => true,
'balance' => Credits::balance($from_id),
],
]);
}
public function rest_transfer_search(\WP_REST_Request $request) {
$s = sanitize_text_field((string) $request->get_param('s'));
if (mb_strlen($s) < 1) {
return rest_ensure_response(['success' => true, 'data' => ['users' => []]]);
}
$exclude = [get_current_user_id()];
$q = new \WP_User_Query([
'search' => '*' . $s . '*',
'search_columns' => ['user_login', 'user_nicename', 'display_name', 'user_email'],
'exclude' => $exclude,
'number' => 20,
'fields' => ['ID', 'display_name', 'user_login'],
]);
$users = [];
foreach ((array) $q->get_results() as $u) {
$users[] = [
'id' => (int) $u->ID,
'display_name' => (string) $u->display_name,
'user_login' => (string) $u->user_login,
'avatar' => get_avatar_url((int) $u->ID, ['size' => 48]),
];
}
return rest_ensure_response(['success' => true, 'data' => ['users' => $users]]);
}
/**
* Exchange points → cash wallet (pay_balance_exchange_*).
*/
public function rest_exchange_from_credits(\WP_REST_Request $request) {
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
}
$cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_payment_recharge_settings() : [];
if (empty($cfg['exchange']['enabled'])) {
return new \WP_Error('aksrt_exchange_closed', __('积分兑换余额未开启', 'aksrt'), ['status' => 403]);
}
if (!class_exists('AKSRT\\Credits\\Credits') || !\AKSRT\Settings\Settings::credits_enabled()) {
return new \WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 403]);
}
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$points = (int) ($params['points'] ?? 0);
$rate = max(1, (int) ($cfg['exchange']['rate'] ?? 100)); // N points = 1 yuan
if ($points < $rate) {
return new \WP_Error('aksrt_exchange_min', sprintf(__('最少兑换 %d 积分', 'aksrt'), $rate), ['status' => 400]);
}
// Floor to whole yuan.
$yuan = (int) floor($points / $rate);
$cost = $yuan * $rate;
if ($yuan <= 0 || $cost <= 0) {
return new \WP_Error('aksrt_exchange_amount', __('兑换数量无效', 'aksrt'), ['status' => 400]);
}
$user_id = get_current_user_id();
$deduct = Credits::deduct(
$user_id,
$cost,
'exchange_to_balance',
['type' => 'balance'],
sprintf(__('积分兑换余额 %d 元', 'aksrt'), $yuan)
);
if (is_wp_error($deduct)) {
return $deduct;
}
$add = Balance::add(
$user_id,
(float) $yuan,
'exchange_from_credits',
['type' => 'credits'],
sprintf(__('积分兑换:消耗 %d 积分', 'aksrt'), $cost)
);
if (is_wp_error($add)) {
// best-effort refund points
Credits::add($user_id, $cost, 'exchange_refund', [], __('兑换失败退回积分', 'aksrt'));
return $add;
}
return rest_ensure_response([
'success' => true,
'data' => [
'message' => sprintf(__('兑换成功:+%d 元', 'aksrt'), $yuan),
'wallet_balance' => Balance::balance($user_id),
'credit_balance' => Credits::balance($user_id),
'reload' => true,
],
]);
}
public function rest_points_buy_options(\WP_REST_Request $request) {
if (!$this->points_buy_enabled()) {
return new \WP_Error('aksrt_points_buy_disabled', __('购买积分未开启', 'aksrt'), ['status' => 403]);
}
return rest_ensure_response([
'success' => true,
'data' => $this->get_points_buy_view(get_current_user_id()),
]);
}
public function rest_points_buy_create(\WP_REST_Request $request) {
if (!$this->points_buy_enabled()) {
return new \WP_Error('aksrt_points_buy_disabled', __('购买积分未开启', 'aksrt'), ['status' => 403]);
}
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$package_key = sanitize_text_field((string) ($params['package'] ?? 'custom'));
$custom_points = (int) ($params['custom_points'] ?? $params['points'] ?? 0);
$payment_method = sanitize_key((string) ($params['payment_method'] ?? 'alipay'));
$result = $this->create_points_buy_order(get_current_user_id(), $package_key, $custom_points, $payment_method);
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response(['success' => true, 'data' => $result]);
}
public function rest_balance_withdraw(\WP_REST_Request $request) {
if (!class_exists('AKSRT\\Credits\\Balance') || !Balance::enabled()) {
return new \WP_Error('aksrt_balance_disabled', __('余额功能未启用', 'aksrt'), ['status' => 403]);
}
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
if (empty($settings['pay_balance_withdraw_s'])) {
return new \WP_Error('aksrt_withdraw_closed', __('余额提现未开启', 'aksrt'), ['status' => 403]);
}
if (!class_exists('\AKSRT\Payment\Withdrawal')) {
return new \WP_Error('aksrt_withdraw_unavailable', __('提现系统不可用', 'aksrt'), ['status' => 500]);
}
$params = $request->get_json_params();
if (!is_array($params)) {
$params = $request->get_params();
}
$user_id = get_current_user_id();
if (class_exists('\AKSRT\Settings\Capabilities') && !\AKSRT\Settings\Capabilities::user_can('balance_withdraw', $user_id)) {
return new \WP_Error('aksrt_withdraw_forbidden', __('当前账号无权提现', 'aksrt'), ['status' => 403]);
}
$amount = round((float) ($params['amount'] ?? 0), 2);
$method = sanitize_key((string) ($params['method'] ?? 'alipay'));
$account = sanitize_textarea_field((string) ($params['account_info'] ?? $params['account'] ?? ''));
$lowest = max(1, (float) ($settings['pay_balance_withdraw_lowest_money'] ?? $settings['pay_rebate_withdraw_lowest_money'] ?? 50));
if ($amount < $lowest) {
return new \WP_Error('aksrt_withdraw_min', sprintf(__('最低提现 %s 元', 'aksrt'), (string) $lowest), ['status' => 400]);
}
if ($account === '') {
return new \WP_Error('aksrt_withdraw_account', __('请填写收款账号信息', 'aksrt'), ['status' => 400]);
}
// One open balance withdrawal at a time (Zibll parity).
if (\AKSRT\Payment\Withdrawal::get_instance()->has_open_withdrawal($user_id)) {
return new \WP_Error('aksrt_withdraw_pending', __('您有处理中的提现申请,请等待完成后再申请', 'aksrt'), ['status' => 409]);
}
$wallet = Balance::balance($user_id);
if ($wallet < $amount) {
return new \WP_Error('aksrt_balance_insufficient', __('余额不足', 'aksrt'), ['status' => 402]);
}
$deduct = Balance::deduct($user_id, $amount, 'withdraw_hold', ['type' => 'withdraw'], __('余额提现申请冻结', 'aksrt'));
if (is_wp_error($deduct)) {
return $deduct;
}
$fee_rate = max(0, min(50, (int) ($settings['withdraw_service_charge'] ?? 0)));
$net = $fee_rate > 0 ? round($amount * (100 - $fee_rate) / 100, 2) : $amount;
$account_payload = wp_json_encode([
'account' => $account,
'gross_amount' => $amount,
'fee_rate' => $fee_rate,
'source' => 'balance',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: $account;
$wid = \AKSRT\Payment\Withdrawal::get_instance()->create($user_id, $net, $method !== '' ? $method : 'alipay', $account_payload);
if ($wid <= 0) {
Balance::add($user_id, $amount, 'withdraw_refund', [], __('提现申请失败退回', 'aksrt'));
return new \WP_Error('aksrt_withdraw_failed', __('提现申请失败', 'aksrt'), ['status' => 500]);
}
return rest_ensure_response([
'success' => true,
'data' => [
'message' => __('提现申请已提交,请等待处理', 'aksrt'),
'withdrawal_id' => $wid,
'amount' => $amount,
'net_amount' => $net,
'wallet_balance' => Balance::balance($user_id),
'reload' => true,
],
]);
}
}
+305
View File
@@ -0,0 +1,305 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
class 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 = '', bool $dispatch_hooks = true) {
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);
if ($dispatch_hooks) {
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\Core\DBMigrator::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\Core\DBMigrator::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);
}
}
+194
View File
@@ -0,0 +1,194 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
class Earn {
private static $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('user_register', [$this, 'on_user_register'], 10, 1);
add_action('wp_login', [$this, 'on_wp_login'], 20, 2);
add_action('wp_insert_comment', [$this, 'on_comment_inserted'], 10, 2);
add_action('transition_comment_status', [$this, 'on_comment_transition'], 10, 3);
add_action('transition_post_status', [$this, 'on_post_status'], 10, 3);
add_action('aksrt_post_liked', [$this, 'on_post_liked'], 10, 3);
add_action('aksrt_post_favorited', [$this, 'on_post_favorited'], 10, 3);
add_action('aksrt_comment_liked', [$this, 'on_comment_liked'], 10, 3);
add_action('aksrt_user_followed', [$this, 'on_user_followed'], 10, 2);
}
public function on_user_register(int $user_id): void {
if (!class_exists('AKSRT\\Credits\\PointsFree')) {
return;
}
PointsFree::grant($user_id, 'sign_up', 0, ['type' => 'user', 'id' => $user_id]);
}
/**
* Daily first login free points.
*
* @param string $user_login
* @param \WP_User $user
*/
public function on_wp_login($user_login, $user = null): void {
unset($user_login);
$user_id = $user instanceof \WP_User ? (int) $user->ID : 0;
if ($user_id <= 0 || !class_exists('AKSRT\\Credits\\PointsFree')) {
return;
}
$today = (new \DateTimeImmutable('today', wp_timezone()))->format('Y-m-d');
$last = (string) get_user_meta($user_id, PointsFree::META_LOGIN, true);
if ($last === $today) {
return;
}
$granted = PointsFree::grant($user_id, 'login', 0, ['type' => 'user', 'id' => $user_id]);
if (!is_wp_error($granted) && $granted >= 0) {
update_user_meta($user_id, PointsFree::META_LOGIN, $today);
}
}
/**
* @param string $new_status
* @param string $old_status
* @param \WP_Post $post
*/
public function on_post_status($new_status, $old_status, $post): void {
if (!($post instanceof \WP_Post) || $post->post_type !== 'post') {
return;
}
if ($new_status !== 'publish' || $old_status === 'publish') {
return;
}
$user_id = (int) $post->post_author;
if ($user_id <= 0 || !class_exists('AKSRT\\Credits\\PointsFree')) {
return;
}
if (get_post_meta($post->ID, 'aksrt_credit_post_rewarded', true)) {
return;
}
$granted = PointsFree::grant($user_id, 'post_new', 0, ['type' => 'post', 'id' => (int) $post->ID]);
if (!is_wp_error($granted) && $granted > 0) {
update_post_meta($post->ID, 'aksrt_credit_post_rewarded', 1);
}
}
public function on_comment_inserted(int $comment_id, $comment): void {
$cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_credit_settings() : [];
$reward = is_array($cfg['comment_reward'] ?? null) ? $cfg['comment_reward'] : [];
// Legacy on_approved=true → wait for transition; default true.
$on_approved = !isset($reward['on_approved']) || !empty($reward['on_approved']);
if ($on_approved) {
return;
}
$comment = $comment instanceof \WP_Comment ? $comment : get_comment($comment_id);
if (!($comment instanceof \WP_Comment) || (int) $comment->comment_approved !== 1) {
return;
}
$this->reward_comment($comment);
}
public function on_comment_transition($new_status, $old_status, $comment): void {
unset($old_status);
if ($new_status !== 'approved' || !($comment instanceof \WP_Comment)) {
return;
}
$this->reward_comment($comment);
}
private function reward_comment(\WP_Comment $comment): void {
$user_id = (int) $comment->user_id;
if ($user_id <= 0 || !class_exists('AKSRT\\Credits\\PointsFree')) {
return;
}
if (get_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', true)) {
return;
}
$cfg = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_credit_settings() : [];
$reward = is_array($cfg['comment_reward'] ?? null) ? $cfg['comment_reward'] : [];
$min_length = (int) ($reward['min_length'] ?? 0);
if ($min_length > 0 && mb_strlen(wp_strip_all_tags((string) $comment->comment_content)) < $min_length) {
return;
}
$granted = PointsFree::grant($user_id, 'comment_new', 0, [
'type' => 'comment',
'id' => (int) $comment->comment_ID,
]);
if (!is_wp_error($granted)) {
update_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', $granted > 0 ? 1 : 'capped');
}
}
/**
* @param int $post_id
* @param int $actor_id liker
* @param bool $active
*/
public function on_post_liked(int $post_id, int $actor_id, bool $active): void {
if (!$active || $post_id <= 0 || !class_exists('AKSRT\\Credits\\PointsFree')) {
return;
}
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') {
return;
}
$author = (int) $post->post_author;
if ($author <= 0 || $author === $actor_id) {
return;
}
PointsFree::grant($author, 'post_like', 0, ['type' => 'post', 'id' => $post_id]);
}
public function on_post_favorited(int $post_id, int $actor_id, bool $active): void {
if (!$active || $post_id <= 0 || !class_exists('AKSRT\\Credits\\PointsFree')) {
return;
}
$post = get_post($post_id);
if (!$post || $post->post_type !== 'post') {
return;
}
$author = (int) $post->post_author;
if ($author <= 0 || $author === $actor_id) {
return;
}
PointsFree::grant($author, 'post_favorite', 0, ['type' => 'post', 'id' => $post_id]);
}
public function on_comment_liked(int $comment_id, int $actor_id, bool $active): void {
if (!$active || $comment_id <= 0 || !class_exists('AKSRT\\Credits\\PointsFree')) {
return;
}
$comment = get_comment($comment_id);
if (!$comment) {
return;
}
$author = (int) $comment->user_id;
if ($author <= 0 || $author === $actor_id) {
return;
}
PointsFree::grant($author, 'comment_like', 0, ['type' => 'comment', 'id' => $comment_id]);
}
public function on_user_followed(int $follower_id, int $following_id): void {
if ($following_id <= 0 || $follower_id === $following_id || !class_exists('AKSRT\\Credits\\PointsFree')) {
return;
}
PointsFree::grant($following_id, 'followed', 0, ['type' => 'user', 'id' => $follower_id]);
}
}
+669
View File
@@ -0,0 +1,669 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
class Invite {
const COOKIE_NAME = 'aksrt_invite';
const COOKIE_TTL_DAYS = 30;
const META_INVITE_CODE = 'aksrt_invite_code';
const OPT_STANDALONE_CODES = 'aksrt_standalone_invite_codes';
/**
* 反向映射 option:code => user_id
*
* 注意:option 大小受限(默认非 autoload),10 万级用户没问题,
* 百万级仍可走,性能瓶颈在 update_option 写入;此处采用懒写策略:
* 仅在 get_or_create_code 生成新码时同步写入。
*/
const OPT_REVERSE_MAP = 'aksrt_invite_code_map';
private static $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
public function init(): void {
// 在请求最早期捕获邀请码
add_action('init', [$this, 'capture_invite_cookie'], 1);
// 注册时记录邀请关系
add_action('user_register', [$this, 'on_user_register'], 20, 1);
// 邮箱验证后兑现
add_action('aksrt_email_verified', [$this, 'on_email_verified'], 10, 1);
// 用户删除时清理邀请码映射
add_action('delete_user', [$this, 'on_delete_user'], 10, 1);
add_action('rest_api_init', [$this, 'register_routes']);
}
public function register_routes(): void {
register_rest_route('aksrt/v1', '/invite/me', [
'methods' => 'GET',
'callback' => [$this, 'rest_me'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('aksrt/v1', '/invite/list', [
'methods' => 'GET',
'callback' => [$this, 'rest_list'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
}
/** 捕获 ?invite=XYZ */
public function capture_invite_cookie(): void {
if (is_admin()) return;
if (empty($_GET['invite'])) return;
// 邀请注册功能关闭时不处理
if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) return;
$code = sanitize_key((string) $_GET['invite']);
if ($code === '') return;
// 校验邀请码是否对应真实用户
$inviter_id = $this->resolve_code($code);
if ($inviter_id <= 0) return;
// 已登录用户不能用自己的邀请码
if (is_user_logged_in() && $inviter_id === get_current_user_id()) return;
// 不覆盖已存在的(防多次访问被覆盖)
if (!empty($_COOKIE[self::COOKIE_NAME])) return;
$expires = time() + DAY_IN_SECONDS * self::COOKIE_TTL_DAYS;
// 不覆盖 secure 标记的检测
$secure = is_ssl();
// headers_sent 检查
if (!headers_sent()) {
setcookie(self::COOKIE_NAME, $code, [
'expires' => $expires,
'path' => COOKIEPATH ?: '/',
'domain' => COOKIE_DOMAIN,
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
$_COOKIE[self::COOKIE_NAME] = $code;
}
}
/** 注册:写入 aksrt_invite 表 */
public function on_user_register(int $invitee_id): void {
// 邀请注册功能关闭时不记录邀请关系
if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) return;
// 优先级 1:表单显式填写的邀请码(由 \AKSRT\Auth\Service 注入)
$code = '';
$inviter_id = 0;
if (class_exists('\AKSRT\Auth\Service')) {
$ctx = \AKSRT\Auth\Service::take_pending_invite_context();
if (!empty($ctx['inviter_id']) && $ctx['inviter_id'] !== $invitee_id) {
$inviter_id = (int) $ctx['inviter_id'];
$code = (string) $ctx['code'];
}
}
// 优先级 2:fallback 到 cookie
if ($inviter_id <= 0) {
if (empty($_COOKIE[self::COOKIE_NAME])) return;
$code = sanitize_key((string) $_COOKIE[self::COOKIE_NAME]);
if ($code === '') return;
$inviter_id = $this->get_user_by_code($code);
}
if ($inviter_id <= 0 || $inviter_id === $invitee_id) return;
$this->record_invite_registration($inviter_id, $invitee_id, $code, 'registered');
// 清 cookie,避免再次触发
if (!headers_sent()) {
setcookie(self::COOKIE_NAME, '', time() - 3600, COOKIEPATH ?: '/', COOKIE_DOMAIN);
}
// 若配置不要求邮箱验证就立即兑现
$invite_cfg = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_invite_reward_settings()
: [
'require_verify' => true,
];
if (empty($invite_cfg['require_verify'])) {
$this->reward_pair($inviter_id, $invitee_id);
}
}
/** 邮箱验证后兑现奖励(钩子由 \AKSRT\Auth\Service 触发,可后续接入) */
public function on_email_verified(int $user_id): void {
// 邀请注册功能关闭时不兑现奖励
if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) return;
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('invite');
if (!$table) return;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE invitee_id = %d AND status IN ('registered', 'verified') LIMIT 1",
$user_id
), ARRAY_A);
if (!$row) return;
$wpdb->update(
$table,
['status' => 'verified'],
['id' => (int) $row['id']],
['%s'],
['%d']
);
if ((int) $row['inviter_id'] <= 0) {
return;
}
$this->reward_pair((int) $row['inviter_id'], $user_id);
}
/** 用户被删除时清理邀请码映射 */
public function on_delete_user(int $user_id): void {
$code = (string) get_user_meta($user_id, self::META_INVITE_CODE, true);
if ($code === '') return;
$map = $this->load_reverse_map();
if (isset($map[$code]) && (int) $map[$code] === $user_id) {
unset($map[$code]);
update_option(self::OPT_REVERSE_MAP, $map, false);
}
}
/** 双向发放奖励 */
private function reward_pair(int $inviter_id, int $invitee_id): void {
if (!class_exists('AKSRT\\Credits\\Credits') || !class_exists('\AKSRT\Settings\Settings')) return;
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('invite');
if (!$table) return;
$claimed = $wpdb->update(
$table,
['status' => 'rewarding'],
['inviter_id' => $inviter_id, 'invitee_id' => $invitee_id, 'status' => 'verified'],
['%s'],
['%d', '%d', '%s']
);
if ($claimed !== 1) return;
$invite_cfg = \AKSRT\Settings\Settings::get_invite_reward_settings();
if (empty($invite_cfg['enabled']) || !$this->ip_daily_check($invite_cfg)) {
$wpdb->update($table, ['status' => 'verified'], ['inviter_id' => $inviter_id, 'invitee_id' => $invitee_id], ['%s'], ['%d', '%d']);
return;
}
$inviter_amount = (int) ($invite_cfg['inviter_amount'] ?? 0);
$invitee_amount = (int) ($invite_cfg['invitee_amount'] ?? 0);
$level_integral = max(0, (int) ($invite_cfg['invit_reward_level'] ?? 0));
if ($inviter_amount > 0) {
Credits::add($inviter_id, $inviter_amount, 'invite', ['type' => 'user', 'id' => $invitee_id]);
}
if ($invitee_amount > 0) {
Credits::add($invitee_id, $invitee_amount, 'invitee', ['type' => 'user', 'id' => $inviter_id]);
}
if ($level_integral > 0 && class_exists('\AKSRT\User\Level')) {
\AKSRT\User\Level::add($inviter_id, 'invitation', $level_integral, true);
}
// 标记 rewarded
$wpdb->update(
$table,
[
'status' => 'rewarded',
'rewarded_at' => current_time('mysql', true),
],
[
'inviter_id' => $inviter_id,
'invitee_id' => $invitee_id,
],
['%s', '%s'],
['%d', '%d']
);
}
/** 同 IP 当日邀请兑现次数限制 */
private function ip_daily_check(array $invite_cfg): bool {
$cap = (int) ($invite_cfg['ip_daily_cap'] ?? 3);
if ($cap <= 0) return true;
$ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : '';
if (!$ip) return true;
global $wpdb;
$log_table = \AKSRT\Core\DBMigrator::table('credit_log');
if (!$log_table) return true;
$today_utc = (new \DateTimeImmutable('today', wp_timezone()))->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
$count = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$log_table} WHERE reason = 'invite' AND ip = %s AND created_at >= %s",
$ip,
$today_utc
));
return $count < $cap;
}
/** 取或生成用户的邀请码 */
public function get_or_create_code(int $user_id): string {
$code = (string) get_user_meta($user_id, self::META_INVITE_CODE, true);
// 邀请码长度(后台可配置,4-12,默认 6)
$code_length = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::invite_code_length()
: max(4, min(12, (int) aksrt_get_setting('invite_code_length', 6)));
// 兼容配置变更:已有码只要长度合理就复用
if ($code !== '' && strlen($code) <= $code_length + 2) return $code;
// 生成唯一码
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
$map = $this->load_reverse_map();
for ($i = 0; $i < 5; $i++) {
$code = '';
for ($j = 0; $j < $code_length; $j++) {
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
if (!isset($map[$code]) && !$this->code_exists_legacy($code)) break;
}
update_user_meta($user_id, self::META_INVITE_CODE, $code);
// 同步反向映射
$map[$code] = $user_id;
update_option(self::OPT_REVERSE_MAP, $map, false);
return $code;
}
/**
* 公开 API:根据邀请码反查邀请人 user_id(不存在返回 0)。
* 给注册流程在 wp_create_user 之前做"邀请码是否合法"的预校验用。
*/
/**
* Resolve affiliate / invite referrer for a buyer (zibll-style).
*
* Priority:
* 1. Logged-in: inviter_id from aksrt_invite table for this invitee
* 2. Cookie invite code → resolve_code
*
* Respects pay_rebate_self_s when available (via affiliate settings).
*/
public function get_referrer(int $user_id = 0): int {
$uid = $user_id > 0 ? $user_id : get_current_user_id();
$referrer_id = 0;
if ($uid > 0) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('invite');
if ($table) {
$referrer_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT inviter_id FROM {$table} WHERE invitee_id = %d AND inviter_id > 0 ORDER BY id DESC LIMIT 1",
$uid
));
}
}
if ($referrer_id <= 0) {
$code = '';
if (!empty($_COOKIE[self::COOKIE_NAME])) {
$code = sanitize_key((string) $_COOKIE[self::COOKIE_NAME]);
}
if ($code !== '') {
$referrer_id = $this->resolve_code($code);
}
}
if ($referrer_id <= 0) {
return 0;
}
// Self-purchase rebate gate (default allow, matching pay_rebate_self_s default true).
$allow_self = true;
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_payment_affiliate_settings')) {
$aff = \AKSRT\Settings\Settings::get_payment_affiliate_settings();
$allow_self = !isset($aff['self_s']) || !empty($aff['self_s']);
}
if ($uid > 0 && $referrer_id === $uid && !$allow_self) {
return 0;
}
return $referrer_id;
}
public function resolve_code(string $code): int {
$code = sanitize_key($code);
if ($code === '') return 0;
return $this->get_user_by_code($code);
}
/**
* 校验邀请码是否合法。
*
* 用户邀请码与后台批量生成的独立邀请码都视为有效。
*/
public function validate_code(string $code): bool {
$code = sanitize_key($code);
if ($code === '') {
return false;
}
if ($this->get_user_by_code($code) > 0) {
return true;
}
return $this->find_standalone_code_index($code) >= 0;
}
/**
* 注册完成后处理邀请码。
*
* - 用户邀请码:沿用 inviter/invitee canonical 关系
* - 独立邀请码:记录使用人并标记为已消耗,不参与奖励
*/
public function handle_registration_invite(int $invitee_id, string $invite_code): void {
$invitee_id = (int) $invitee_id;
$invite_code = sanitize_key($invite_code);
if ($invitee_id <= 0 || $invite_code === '') {
return;
}
if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) {
return;
}
$inviter_id = $this->get_user_by_code($invite_code);
if ($inviter_id > 0) {
if ($inviter_id === $invitee_id) {
return;
}
$this->record_invite_registration($inviter_id, $invitee_id, $invite_code, 'registered');
return;
}
$index = $this->find_standalone_code_index($invite_code);
if ($index < 0) {
return;
}
$requires_verify = class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::registration_requires_email_verification();
$status = $requires_verify ? 'registered' : 'verified';
if ($this->record_invite_registration(0, $invitee_id, $invite_code, $status)) {
$this->mark_standalone_code_used($invite_code, $invitee_id);
}
}
/** 反查邀请码对应的用户(O(1) option 查表,回退 user_meta 扫描) */
private function get_user_by_code(string $code): int {
if ($code === '') return 0;
$map = $this->load_reverse_map();
if (isset($map[$code])) {
$uid = (int) $map[$code];
if ($uid > 0) {
// 校验:确保该用户的 meta 仍然指向该 code(用户可能已被删除或 meta 被清空)
$meta_code = (string) get_user_meta($uid, self::META_INVITE_CODE, true);
if ($meta_code === $code) {
return $uid;
}
// 失配 → 修正映射
unset($map[$code]);
update_option(self::OPT_REVERSE_MAP, $map, false);
}
}
// 回退 + 自愈:扫描 user_meta(应只在迁移期或映射损坏时触发)
$uid = $this->code_exists_legacy($code);
if ($uid > 0) {
$map[$code] = $uid;
update_option(self::OPT_REVERSE_MAP, $map, false);
}
return $uid;
}
private function load_reverse_map(): array {
$map = get_option(self::OPT_REVERSE_MAP, []);
return is_array($map) ? $map : [];
}
/**
* 写入邀请关系记录。
*/
private function record_invite_registration(int $inviter_id, int $invitee_id, string $code, string $status = 'registered'): bool {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('invite');
if (!$table || $invitee_id <= 0 || $code === '') {
return false;
}
$existing_code = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE invite_code = %s LIMIT 1",
$code
));
if ($existing_code) {
return false;
}
$existing_invitee = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE invitee_id = %d LIMIT 1",
$invitee_id
));
if ($existing_invitee) {
return false;
}
$result = $wpdb->insert(
$table,
[
'inviter_id' => max(0, $inviter_id),
'invitee_id' => $invitee_id,
'invite_code' => $code,
'status' => $status,
'created_at' => current_time('mysql', true),
],
['%d', '%d', '%s', '%s', '%s']
);
return (bool) $result;
}
private function load_standalone_codes(): array {
$codes = get_option(self::OPT_STANDALONE_CODES, []);
return is_array($codes) ? array_values($codes) : [];
}
private function save_standalone_codes(array $codes): void {
update_option(self::OPT_STANDALONE_CODES, array_values($codes), false);
}
private function normalize_standalone_code(array $entry): array {
$code = sanitize_key((string) ($entry['code'] ?? ''));
$used_by = (int) ($entry['used_by'] ?? 0);
$used = !empty($entry['used']) || $used_by > 0;
return [
'code' => $code,
'created_at' => sanitize_text_field((string) ($entry['created_at'] ?? '')),
'used' => $used,
'used_by' => $used_by,
'used_at' => sanitize_text_field((string) ($entry['used_at'] ?? '')),
'disabled' => !empty($entry['disabled']),
'note' => sanitize_textarea_field((string) ($entry['note'] ?? '')),
'batch_id' => sanitize_text_field((string) ($entry['batch_id'] ?? '')),
'created_by' => (int) ($entry['created_by'] ?? 0),
];
}
private function find_standalone_code_index(string $code): int {
$code = sanitize_key($code);
$codes = $this->load_standalone_codes();
foreach ($codes as $index => $entry) {
$normalized = $this->normalize_standalone_code((array) $entry);
if ($normalized['code'] !== $code) {
continue;
}
if ($normalized['disabled'] || $normalized['used']) {
return -1;
}
return $index;
}
return -1;
}
private function mark_standalone_code_used(string $code, int $invitee_id): void {
$code = sanitize_key($code);
$codes = $this->load_standalone_codes();
foreach ($codes as $index => $entry) {
$normalized = $this->normalize_standalone_code((array) $entry);
if ($normalized['code'] !== $code) {
continue;
}
$normalized['used'] = true;
$normalized['used_by'] = $invitee_id;
$normalized['used_at'] = current_time('mysql', true);
$codes[$index] = $normalized;
$this->save_standalone_codes($codes);
return;
}
}
/** 旧路径:通过 user_meta 反查(保留作为自愈兜底) */
private function code_exists_legacy(string $code): int {
$users = get_users([
'meta_key' => self::META_INVITE_CODE,
'meta_value' => $code,
'number' => 1,
'fields' => 'ID',
]);
if (empty($users)) return 0;
return (int) $users[0];
}
private function code_exists(string $code): bool {
return $this->get_user_by_code($code) > 0;
}
// ---------- REST ----------
public function rest_me(\WP_REST_Request $request) {
if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) {
return new \WP_REST_Response(['code' => 'error', 'message' => '邀请注册功能已关闭'], 403);
}
$user_id = get_current_user_id();
$code = $this->get_or_create_code($user_id);
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('invite');
$stats = [
'registered' => 0,
'verified' => 0,
'rewarded' => 0,
];
if ($table) {
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT status, COUNT(*) AS c FROM {$table} WHERE inviter_id = %d GROUP BY status",
$user_id
), ARRAY_A);
if (is_array($rows)) {
foreach ($rows as $r) {
$status = (string) $r['status'];
$count = (int) $r['c'];
// The stored status is terminal; dashboard stats are milestone totals.
if ($status === 'registered') {
$stats['registered'] += $count;
} elseif ($status === 'verified') {
$stats['registered'] += $count;
$stats['verified'] += $count;
} elseif ($status === 'rewarded') {
$stats['registered'] += $count;
$stats['verified'] += $count;
$stats['rewarded'] += $count;
}
}
}
}
$invite_url = add_query_arg('invite', $code, aksrt_get_register_url());
return new \WP_REST_Response([
'code' => $code,
'invite_url' => $invite_url,
'stats' => $stats,
], 200);
}
public function rest_list(\WP_REST_Request $request) {
if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::invite_registration_enabled()) {
return new \WP_REST_Response(['code' => 'error', 'message' => '邀请注册功能已关闭'], 403);
}
global $wpdb;
$user_id = get_current_user_id();
$page = max(1, (int) $request->get_param('page'));
$per_page = min(50, max(1, (int) $request->get_param('per_page') ?: 20));
$offset = ($page - 1) * $per_page;
$table = \AKSRT\Core\DBMigrator::table('invite');
if (!$table) {
return new \WP_REST_Response(['items' => [], 'total' => 0], 200);
}
$total = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE inviter_id = %d",
$user_id
));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$table} WHERE inviter_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d",
$user_id,
$per_page,
$offset
), ARRAY_A);
$items = [];
foreach ((array) $rows as $r) {
$invitee = $r['invitee_id'] ? get_userdata((int) $r['invitee_id']) : null;
$items[] = [
'id' => (int) $r['id'],
'invitee_id' => (int) $r['invitee_id'],
'invitee_name' => $invitee ? $invitee->display_name : '',
'invitee_avatar' => $invitee ? get_avatar_url((int) $invitee->ID, ['size' => 64]) : '',
'status' => (string) $r['status'],
'created_at' => (string) $r['created_at'],
'rewarded_at' => (string) $r['rewarded_at'],
];
}
return new \WP_REST_Response([
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $per_page,
], 200);
}
}
+333
View File
@@ -0,0 +1,333 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
class PointsFree {
public const META_DETAIL = 'aksrt_free_points_detail';
public const META_LOGIN = 'aksrt_free_points_login_date';
public const META_SIGNIN = 'aksrt_free_points_signin_date';
public const META_TASKS = 'aksrt_free_points_task_dates';
/**
* Task catalog: key => [label, short_desc, default_points]
*
* @return array<string, array{0:string,1:string,2:int}>
*/
public static function catalog(): array {
return [
'sign_up' => [__('注册奖励', 'aksrt'), __('新用户注册成功后发放', 'aksrt'), 10],
'sign_in' => [__('每日签到', 'aksrt'), __('完成每日签到', 'aksrt'), 1],
'login' => [__('每日登录', 'aksrt'), __('每日首次登录', 'aksrt'), 1],
'followed' => [__('被关注', 'aksrt'), __('被其他用户关注', 'aksrt'), 1],
'post_new' => [__('发布文章', 'aksrt'), __('发布并通过审核的文章', 'aksrt'), 5],
'post_like' => [__('文章被赞', 'aksrt'), __('你的文章被点赞', 'aksrt'), 1],
'post_favorite' => [__('文章被收藏', 'aksrt'), __('你的文章被收藏', 'aksrt'), 1],
'comment_new' => [__('发表评论', 'aksrt'), __('发表并通过审核的评论', 'aksrt'), 1],
'comment_like' => [__('评论被赞', 'aksrt'), __('你的评论被点赞', 'aksrt'), 1],
];
}
/**
* Runtime free-task settings from theme options.
*
* @return array{day_max:int,tasks:array<string,int>}
*/
public static function settings(): array {
$all = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
$credit = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_credit_settings() : [];
$tasks = [];
foreach (self::catalog() as $key => $meta) {
$flat_key = 'points_free_' . $key;
if (array_key_exists($flat_key, $all)) {
$tasks[$key] = max(0, (int) $all[$flat_key]);
continue;
}
// Fallback mapping from legacy / parallel settings.
$tasks[$key] = self::legacy_task_points($key, $all, $credit, (int) $meta[2]);
}
$day_max = array_key_exists('points_earn_daily_limit', $all)
? max(0, (int) $all['points_earn_daily_limit'])
: 50;
return [
'day_max' => $day_max,
'tasks' => $tasks,
];
}
/**
* Points configured for a task (0 = disabled).
*/
public static function task_points(string $key): int {
$cfg = self::settings();
return max(0, (int) ($cfg['tasks'][$key] ?? 0));
}
/**
* Whether free points system can run (credits enabled).
*/
public static function enabled(): bool {
return class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled() && class_exists('AKSRT\\Credits\\Credits');
}
/**
* Today's free points total.
*/
public static function today_total(int $user_id): int {
if ($user_id <= 0) {
return 0;
}
$detail = self::detail_map($user_id);
$today = self::today_key();
return max(0, (int) ($detail[$today] ?? 0));
}
/**
* Remaining free points allowed today (PHP_INT_MAX if unlimited).
*/
public static function remaining_today(int $user_id): int {
$day_max = (int) self::settings()['day_max'];
if ($day_max <= 0) {
return PHP_INT_MAX;
}
return max(0, $day_max - self::today_total($user_id));
}
/**
* Daily free points detail (newest first).
*
* @return array<string,int>
*/
public static function date_detail(int $user_id): array {
$detail = self::detail_map($user_id);
krsort($detail, SORT_STRING);
return $detail;
}
/**
* Grant free points for a task. Respects day_max; clamps amount.
*
* @param array{type?:string,id?:int} $ref
* @return int|\WP_Error granted amount (0 if skipped)
*/
public static function grant(int $user_id, string $task_key, int $points = 0, array $ref = []) {
if (!self::enabled() || $user_id <= 0) {
return 0;
}
$task_key = sanitize_key($task_key);
if ($task_key === '') {
return 0;
}
if ($points <= 0) {
$points = self::task_points($task_key);
}
$points = max(0, (int) $points);
if ($points <= 0) {
return 0;
}
$remaining = self::remaining_today($user_id);
if ($remaining <= 0) {
return 0;
}
if ($points > $remaining) {
$points = $remaining;
}
$reason = 'free_' . $task_key;
$result = Credits::add(
$user_id,
$points,
$reason,
$ref,
self::catalog()[$task_key][0] ?? $task_key
);
if (is_wp_error($result)) {
return $result;
}
self::bump_detail($user_id, $points);
self::mark_task_done_today($user_id, $task_key);
return $points;
}
/**
* Grant with explicit amount (e.g. signin streak reward), still under day_max.
*
* @return int|\WP_Error
*/
public static function grant_amount(int $user_id, int $points, string $task_key, array $ref = []) {
return self::grant($user_id, $task_key, $points, $ref);
}
/**
* Task list DTO for user center UI.
*
* @return list<array{key:string,title:string,desc:string,points:int,enabled:bool}>
*/
public static function task_list_for_user(int $user_id = 0): array {
$cfg = self::settings();
$list = [];
$today = self::today_key();
$login_done = false;
$signin_done = false;
$task_dates = [];
if ($user_id > 0) {
$login_done = (string) get_user_meta($user_id, self::META_LOGIN, true) === $today;
$signin_done = (string) get_user_meta($user_id, self::META_SIGNIN, true) === $today;
$raw = get_user_meta($user_id, self::META_TASKS, true);
$task_dates = is_array($raw) ? $raw : [];
}
$passive = ['post_like', 'post_favorite', 'followed', 'comment_like'];
$active_once = ['post_new', 'comment_new'];
foreach (self::catalog() as $key => $meta) {
$pts = (int) ($cfg['tasks'][$key] ?? 0);
if ($key === 'sign_up') {
continue;
}
if ($pts <= 0) {
continue;
}
$status = 'todo';
$progress = null;
$target = null;
if ($key === 'login' && $login_done) {
$status = 'done_today';
$progress = 1;
$target = 1;
} elseif ($key === 'sign_in' && $signin_done) {
$status = 'done_today';
$progress = 1;
$target = 1;
} elseif (in_array($key, $passive, true)) {
$status = 'passive';
} elseif (in_array($key, $active_once, true)) {
$target = 1;
$done_day = is_array($task_dates[$key] ?? null) ? (string) ($task_dates[$key]['day'] ?? '') : (string) ($task_dates[$key] ?? '');
if ($done_day === $today) {
$status = 'done_today';
$progress = 1;
} else {
$progress = 0;
}
}
$list[] = [
'key' => $key,
'title' => $meta[0],
'desc' => $meta[1],
'points' => $pts,
'enabled' => true,
'status' => $status,
'progress' => $progress,
'target' => $target,
];
}
return $list;
}
/**
* Mark a free-task as completed today (for progress DTO).
*/
public static function mark_task_done_today(int $user_id, string $task_key): void {
if ($user_id <= 0) {
return;
}
$task_key = sanitize_key($task_key);
$today = self::today_key();
if ($task_key === 'login') {
update_user_meta($user_id, self::META_LOGIN, $today);
return;
}
if ($task_key === 'sign_in') {
update_user_meta($user_id, self::META_SIGNIN, $today);
return;
}
$raw = get_user_meta($user_id, self::META_TASKS, true);
$map = is_array($raw) ? $raw : [];
$map[$task_key] = ['day' => $today, 'ts' => time()];
if (count($map) > 40) {
$map = array_slice($map, -40, null, true);
}
update_user_meta($user_id, self::META_TASKS, $map);
}
private static function today_key(): string {
return (new \DateTimeImmutable('today', wp_timezone()))->format('Y-m-d');
}
/**
* @return array<string,int>
*/
private static function detail_map(int $user_id): array {
$raw = get_user_meta($user_id, self::META_DETAIL, true);
if (!is_array($raw)) {
return [];
}
$out = [];
foreach ($raw as $day => $val) {
$day = is_string($day) ? $day : (string) $day;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $day)) {
continue;
}
$out[$day] = max(0, (int) $val);
}
return $out;
}
private static function bump_detail(int $user_id, int $points): void {
if ($points <= 0) {
return;
}
$detail = self::detail_map($user_id);
$today = self::today_key();
$detail[$today] = max(0, (int) ($detail[$today] ?? 0)) + $points;
// Keep ~50 newest days.
krsort($detail, SORT_STRING);
$detail = array_slice($detail, 0, 50, true);
update_user_meta($user_id, self::META_DETAIL, $detail);
}
/**
* @param array<string,mixed> $all
* @param array<string,mixed> $credit
*/
private static function legacy_task_points(string $key, array $all, array $credit, int $default): int {
switch ($key) {
case 'sign_up':
return max(0, (int) ($credit['register_bonus'] ?? $default));
case 'sign_in':
$signin = is_array($credit['daily_signin'] ?? null) ? $credit['daily_signin'] : [];
if (isset($signin['enabled']) && empty($signin['enabled'])) {
return 0;
}
return max(0, (int) ($signin['base'] ?? $default));
case 'login':
return max(0, (int) ($all['points_earn_login'] ?? $default));
case 'post_new':
return max(0, (int) ($all['points_earn_post'] ?? $default));
case 'post_like':
return max(0, (int) ($all['points_earn_like_received'] ?? $default));
case 'comment_new':
if (isset($all['points_earn_comment'])) {
return max(0, (int) $all['points_earn_comment']);
}
$cr = is_array($credit['comment_reward'] ?? null) ? $credit['comment_reward'] : [];
if (isset($cr['enabled']) && empty($cr['enabled'])) {
return 0;
}
return max(0, (int) ($cr['amount'] ?? $default));
default:
return $default;
}
}
}
+447
View File
@@ -0,0 +1,447 @@
<?php
/**
* @package AKSRT
*/
namespace AKSRT\Credits;
defined('ABSPATH') || exit;
class Signin {
private static $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
public function init(): void {
add_action('rest_api_init', [$this, 'register_routes']);
add_action('wp_ajax_aksrt_checkin_details_modal', [$this, 'ajax_details_modal']);
}
public function register_routes(): void {
register_rest_route('aksrt/v1', '/signin/today', [
'methods' => 'GET',
'callback' => [$this, 'rest_today'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('aksrt/v1', '/signin', [
'methods' => 'POST',
'callback' => [$this, 'rest_signin'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('aksrt/v1', '/signin/history', [
'methods' => 'GET',
'callback' => [$this, 'rest_history'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
register_rest_route('aksrt/v1', '/signin/details', [
'methods' => 'GET',
'callback' => [$this, 'rest_details_html'],
'permission_callback' => function () {
return is_user_logged_in();
},
]);
}
/** GET /signin/today */
public function rest_today(\WP_REST_Request $request) {
$user_id = get_current_user_id();
$signin_cfg = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_signin_reward_settings()
: [
'enabled' => false,
'base' => 0,
'streak' => [],
];
$today_str = $this->today_date();
$rec = $this->get_record($user_id, $today_str);
$current_streak = $this->compute_current_streak($user_id, $today_str);
$next_streak = $current_streak + ($rec ? 0 : 1);
return new \WP_REST_Response([
'enabled' => !empty($signin_cfg['enabled']),
'signed' => (bool) $rec,
'streak' => $current_streak,
'reward_today' => $rec ? (int) $rec['reward'] : 0,
'reward_if_signin' => $rec ? 0 : $this->calc_reward($next_streak, $signin_cfg),
'date' => $today_str,
], 200);
}
/** POST /signin */
public function rest_signin(\WP_REST_Request $request) {
global $wpdb;
$user_id = get_current_user_id();
$signin_cfg = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_signin_reward_settings()
: [
'enabled' => false,
'base' => 0,
'streak' => [],
];
if (empty($signin_cfg['enabled'])) {
return new \WP_Error('aksrt_signin_disabled', __('签到功能未启用', 'aksrt'), ['status' => 403]);
}
$today_str = $this->today_date();
if ($this->get_record($user_id, $today_str)) {
return new \WP_Error('aksrt_signin_dup', __('今日已签到', 'aksrt'), ['status' => 409]);
}
// 人机验证(若启用)
if (function_exists('aksrt_require_captcha')) {
$body = $request->get_json_params();
$cap_payload = is_array($body) && isset($body['captcha']) && is_array($body['captcha'])
? $body['captcha']
: (is_array($body) ? $body : []);
$verify = aksrt_require_captcha('signin', $cap_payload);
if ($verify !== true) {
$msg = is_string($verify) ? $verify : __('人机验证失败', 'aksrt');
return new \WP_Error('aksrt_captcha_failed', $msg, ['status' => 400]);
}
}
$current_streak = $this->compute_current_streak($user_id, $today_str);
$new_streak = $current_streak + 1;
$reward = $this->calc_reward($new_streak, $signin_cfg);
$table = \AKSRT\Core\DBMigrator::table('signin_log');
if (!$table) {
return new \WP_Error('aksrt_signin_db', __('数据表不存在', 'aksrt'), ['status' => 500]);
}
$inserted = $wpdb->insert(
$table,
[
'user_id' => $user_id,
'signin_date' => $today_str,
'streak' => $new_streak,
'reward' => $reward,
],
['%d', '%s', '%d', '%d']
);
if ($inserted === false) {
return new \WP_Error('aksrt_signin_dup', __('签到失败', 'aksrt'), ['status' => 409]);
}
do_action('aksrt_user_signed_in', $user_id, $reward);
if ($reward > 0) {
if (class_exists('AKSRT\\Credits\\PointsFree')) {
$granted = PointsFree::grant_amount($user_id, $reward, 'sign_in', ['type' => 'user', 'id' => $user_id]);
if (is_wp_error($granted)) {
if (class_exists('AKSRT\\Credits\\Credits')) {
Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
}
} else {
$reward = (int) $granted;
}
} elseif (class_exists('AKSRT\\Credits\\Credits')) {
Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]);
}
}
// Keep lightweight meta for header/badge consumers (zibll checkin_all_day).
update_user_meta($user_id, 'aksrt_signin_' . $today_str, 1);
$all_day = (int) get_user_meta($user_id, 'aksrt_checkin_all_day', true);
if ($all_day <= 0) {
// Fallback to zibll key if migrated users only have that meta.
$all_day = (int) get_user_meta($user_id, 'checkin_all_day', true);
}
$all_day = $all_day + 1;
update_user_meta($user_id, 'aksrt_checkin_all_day', $all_day);
update_user_meta($user_id, 'checkin_all_day', $all_day); // zibll alias
update_user_meta($user_id, 'aksrt_checkin_continuous_day', $new_streak);
update_user_meta($user_id, 'checkin_continuous_day', $new_streak); // zibll alias
do_action('aksrt_checkin_done', $user_id);
return new \WP_REST_Response([
'success' => true,
'message' => sprintf(
/* translators: 1: reward points 2: streak days */
__('签到成功,积分 +%1$d(连续 %2$d 天)', 'aksrt'),
(int) $reward,
(int) $new_streak
),
'streak' => $new_streak,
'reward' => $reward,
'balance' => class_exists('AKSRT\Credits\Credits') ? Credits::balance($user_id) : 0,
], 200);
}
/** GET /signin/history — last N days for calendar/modals. */
public function rest_history(\WP_REST_Request $request) {
$user_id = get_current_user_id();
$days = max(7, min(90, (int) ($request->get_param('days') ?: 30)));
$rows = $this->get_recent_signins($user_id, $days);
$dates = [];
foreach ($rows as $row) {
if (!empty($row['signin_date'])) {
$dates[] = (string) $row['signin_date'];
}
}
$today = $this->today_date();
return new \WP_REST_Response([
'dates' => $dates,
'streak' => $this->compute_current_streak($user_id, $today),
'total' => $this->get_total_days($user_id),
'signed' => (bool) $this->get_record($user_id, $today),
'all_day' => (int) get_user_meta($user_id, 'aksrt_checkin_all_day', true) ?: $this->get_total_days($user_id),
], 200);
}
/** GET /signin/details — HTML fragment for remote modal. */
public function rest_details_html(\WP_REST_Request $request) {
$html = $this->render_details_modal_html(get_current_user_id());
return new \WP_REST_Response(['success' => true, 'html' => $html], 200);
}
/** admin-ajax HTML (remote modal fetch often hits admin-ajax). */
public function ajax_details_modal(): void {
if (!is_user_logged_in()) {
wp_send_json_error(['message' => __('请先登录', 'aksrt')], 401);
}
$html = $this->render_details_modal_html(get_current_user_id());
wp_send_json_success(['html' => $html]);
}
public function is_signed_today(int $user_id = 0): bool {
$user_id = $user_id > 0 ? $user_id : get_current_user_id();
if ($user_id <= 0) {
return false;
}
$today = $this->today_date();
if ((bool) get_user_meta($user_id, 'aksrt_signin_' . $today, true)) {
return true;
}
return (bool) $this->get_record($user_id, $today);
}
public function get_total_days(int $user_id): int {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('signin_log');
if (!$table) {
return (int) get_user_meta($user_id, 'aksrt_checkin_all_day', true);
}
$n = (int) $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM {$table} WHERE user_id = %d",
$user_id
));
return $n;
}
/**
* zibll checkin_details_modal lite HTML.
*/
public function render_details_modal_html(int $user_id): string {
$user = get_userdata($user_id);
if (!$user) {
return '<div class="aksrt-checkin-modal"><p>' . esc_html__('用户不存在', 'aksrt') . '</p></div>';
}
$cfg = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_signin_reward_settings()
: ['enabled' => false, 'base' => 0, 'streak' => []];
$today = $this->today_date();
$rec = $this->get_record($user_id, $today);
$streak = $this->compute_current_streak($user_id, $today);
$total = $this->get_total_days($user_id);
$balance = class_exists('\AKSRT\Credits\Credits') ? (int) Credits::balance($user_id) : 0;
$currency = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_currency_name()
: __('积分', 'aksrt');
$base = (int) ($cfg['base'] ?? 0);
$avatar = function_exists('aksrt_get_avatar_url')
? aksrt_get_avatar_url($user_id, ['size' => 64])
: get_avatar_url($user_id, ['size' => 64]);
$center = function_exists('aksrt_get_user_center_url')
? aksrt_get_user_center_url('assets')
: home_url('/');
$html = '<div class="aksrt-checkin-modal">';
$html .= '<div class="aksrt-checkin-modal__head">';
$html .= '<i class="fa fa-calendar-check-o" aria-hidden="true"></i>';
$html .= '<strong>' . esc_html__('签到领奖励', 'aksrt') . '</strong>';
$html .= '<button type="button" class="aksrt-remote-modal__close" data-aksrt-remote-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">&times;</button>';
$html .= '</div>';
$html .= '<div class="aksrt-checkin-modal__user">';
$html .= '<img class="aksrt-checkin-modal__avatar" src="' . esc_url($avatar) . '" alt="" width="40" height="40" />';
$html .= '<div class="aksrt-checkin-modal__user-meta">';
$html .= '<div class="aksrt-checkin-modal__name">' . esc_html($user->display_name) . '</div>';
$html .= '<div class="aksrt-checkin-modal__sub">' . esc_html(sprintf(__('累计签到 %d 天', 'aksrt'), $total)) . '</div>';
$html .= '</div></div>';
$html .= '<div class="aksrt-checkin-modal__wallet">';
$html .= '<a class="aksrt-checkin-modal__wallet-item" href="' . esc_url($center) . '">';
$html .= '<span class="muted">' . esc_html($currency) . '</span>';
$html .= '<b>' . esc_html(number_format_i18n($balance)) . '</b>';
$html .= '</a></div>';
$html .= '<div class="aksrt-checkin-modal__card">';
$html .= '<div class="aksrt-checkin-modal__card-main">';
$html .= '<div class="aksrt-checkin-modal__card-title"><i class="fas fa-gift" aria-hidden="true"></i> ' . esc_html__('每日签到奖励', 'aksrt') . '</div>';
$html .= '<div class="aksrt-checkin-modal__badge">' . esc_html($currency) . ' +' . esc_html((string) $base) . '</div>';
$html .= '</div>';
if ($rec) {
$html .= '<div class="aksrt-checkin-modal__done"><i class="fa fa-calendar-check-o" aria-hidden="true"></i> ' . esc_html__('今日已签到', 'aksrt');
$html .= '<div class="em09">+' . esc_html((string) (int) ($rec['reward'] ?? 0)) . '</div></div>';
} else {
$html .= '<button type="button" class="but c-blue aksrt-checkin-modal__btn" data-aksrt-checkin="1">' . esc_html__('立即签到', 'aksrt') . '</button>';
}
$html .= '</div>';
$streak_table = is_array($cfg['streak'] ?? null) ? $cfg['streak'] : [];
$max = 7;
foreach (array_keys($streak_table) as $k) {
$max = max($max, (int) $k);
}
$max = min(14, max(2, $max));
$html .= '<div class="aksrt-checkin-modal__streak">';
$html .= '<div class="aksrt-checkin-modal__streak-head"><span>' . esc_html__('连续签到奖励', 'aksrt') . '</span>';
if ($streak > 1) {
$html .= '<badge class="c-blue">' . esc_html(sprintf(__('已连续 %d 天', 'aksrt'), $streak)) . '</badge>';
}
$html .= '</div><div class="aksrt-checkin-modal__ladder">';
for ($d = 1; $d <= $max; $d++) {
$extra = 0;
foreach ($streak_table as $k => $v) {
if ((int) $k === $d) {
$extra = (int) $v;
}
}
$pts = $base + $extra;
$is_done = $streak >= $d;
$html .= '<div class="aksrt-checkin-modal__day' . ($is_done ? ' is-done' : '') . '">';
$html .= '<div class="aksrt-checkin-modal__day-n">D' . (int) $d . '</div>';
$html .= '<div class="aksrt-checkin-modal__day-p">+' . esc_html((string) $pts) . '</div>';
$html .= '</div>';
}
$html .= '</div></div>';
$dates = [];
foreach ($this->get_recent_signins($user_id, 30) as $row) {
if (!empty($row['signin_date'])) {
$dates[] = (string) $row['signin_date'];
}
}
$html .= '<div class="aksrt-checkin-modal__cal-title">' . esc_html__('最近 30 天', 'aksrt') . '</div>';
$html .= '<div data-aksrt-signin-calendar=\'' . esc_attr(wp_json_encode(array_values($dates))) . '\'></div>';
$html .= '</div>';
return $html;
}
// ---------- 工具方法 ----------
/** 站点时区下的今日日期 'Y-m-d' */
private function today_date(): string {
return (new \DateTimeImmutable('today', wp_timezone()))->format('Y-m-d');
}
/** 取签到记录 */
private function get_record(int $user_id, string $date): ?array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('signin_log');
if (!$table) return null;
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE user_id = %d AND signin_date = %s",
$user_id,
$date
), ARRAY_A);
return $row ?: null;
}
/**
* 获取最近 N 天的签到记录
*
* @param int $user_id
* @param int $days
* @return array
*/
public function get_recent_signins(int $user_id, int $days = 30): array {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('signin_log');
if (!$table) return [];
$from_date = (new \DateTimeImmutable("-{$days} days", wp_timezone()))->format('Y-m-d');
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT signin_date FROM {$table} WHERE user_id = %d AND signin_date >= %s ORDER BY signin_date ASC",
$user_id,
$from_date
), ARRAY_A);
return $rows ?: [];
}
/**
* 算"今天之前"的连续签到天数。
*/
private function compute_current_streak(int $user_id, string $today_str): int {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('signin_log');
if (!$table) return 0;
// 拿最近 60 天的签到记录降序
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT signin_date FROM {$table} WHERE user_id = %d AND signin_date <= %s ORDER BY signin_date DESC LIMIT 60",
$user_id,
$today_str
), ARRAY_A);
if (empty($rows)) return 0;
$dates = array_map(fn($r) => $r['signin_date'], $rows);
$today = new \DateTimeImmutable($today_str);
$tz = wp_timezone();
// 起点:若今天已签则从今天回看,否则从昨天回看
$cursor = in_array($today_str, $dates, true)
? $today
: $today->sub(new \DateInterval('P1D'));
$count = 0;
foreach ($dates as $d) {
if ($d === $cursor->format('Y-m-d')) {
$count++;
$cursor = $cursor->sub(new \DateInterval('P1D'));
} else {
break;
}
}
return $count;
}
/**
* 根据连续天数计算奖励 = base + streak 段额外奖励。
*/
private function calc_reward(int $streak, array $cfg): int {
$base = (int) ($cfg['base'] ?? 1);
$extra = 0;
$streak_table = is_array($cfg['streak'] ?? null) ? $cfg['streak'] : [];
// streak_table 是 { N: extra } —— 当 streak 恰好命中 N 时给 extra
// 对 array_keys 不稳定的存储格式做容错
$normalized = [];
foreach ($streak_table as $k => $v) {
$normalized[(int) $k] = (int) $v;
}
if (isset($normalized[$streak])) {
$extra = $normalized[$streak];
}
return max(0, $base + $extra);
}
}