Files
wordpress-theme-aksrt/src/Credits/Signin.php
T
kate522andClaude Opus 5 5c6b422866 fix(frontend): route every user-center points label through the custom currency name
后台「积分配置 → 自定义货币名称」改名后,用户中心仍有大量硬编码
「积分」:做任务赚积分 / 购买积分 / 签到领积分、积分记录 / 积分任务、
每日可免费获取 XX 积分、我的积分、积分+1、创作中心积分收益等。

get_currency_name() 早已存在且可用,问题只是这些文案没有走它。
本PR 全部替换,无双路、无迁移:

服务端渲染(用户中心 renderers)
- credits-balance.php:购买/做任务赚/签到领 + 整个购买弹窗
- profile-credits.php:积分记录 / 积分任务 页签
- credits-points-tasks.php:每日上限三态与空态
- sidebar-creator.php / profile-creator.php:积分收益
- transfer-modal.php:积分转账提示
- Signin.php:签到详情弹窗(我的积分 / 奖励 tooltip / 积分+N)
- VipServiceModal.php:我的积分 / 积分兑换会员
- download-permission-card.php:购买价格

客户端渲染(JS 无法调用 PHP,注入 currencyName 到 aksrtData)
- signin.js:签到状态行「已签到 N 天 +1」
- credits.js:转账标题/金额标签 + 购买提示 toast
- cms-download.js:积分不足视图、我的积分 入口、余额不足文案
- pay-vip.js:积分兑换会员入口(优先 catalog.currency_name)
- order-center.js:订单行单位回退

契约测试同步改为断言模板构造而非死字符串,并新增两例:
改名后跟随、未注入时回退「积分」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 15:09:49 +08:00

473 lines
21 KiB
PHP

<?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']);
}
public function register_routes(): void {
register_rest_route('aksrt/v1', '/signin/today', [
'methods' => 'GET',
'callback' => [$this, 'rest_today'],
'permission_callback' => [$this, 'check_rest_permission'],
]);
register_rest_route('aksrt/v1', '/signin', [
'methods' => 'POST',
'callback' => [$this, 'rest_signin'],
'permission_callback' => [$this, 'check_rest_permission'],
]);
register_rest_route('aksrt/v1', '/signin/details', [
'methods' => 'GET',
'callback' => [$this, 'rest_details_html'],
'permission_callback' => [$this, 'check_rest_permission'],
]);
}
public function check_rest_permission(\WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
}
$nonce = (string) $request->get_header('X-WP-Nonce');
if ($nonce === '' || !wp_verify_nonce($nonce, 'wp_rest')) {
return new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
}
return true;
}
/** 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();
// 人机验证(若启用)
$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) {
$code = is_wp_error($verify) ? $verify->get_error_code() : 'aksrt_captcha_failed';
$msg = is_wp_error($verify) ? $verify->get_error_message() : __('人机验证失败', 'aksrt');
return new \WP_Error($code ?: 'aksrt_captcha_failed', $msg, ['status' => 400]);
}
$existing_record = $this->get_record($user_id, $today_str);
if (is_array($existing_record)) {
$new_streak = max(1, (int) ($existing_record['streak'] ?? 1));
$reward = max(0, (int) ($existing_record['reward'] ?? 0));
} else {
$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]);
}
$operation_key = \AKSRT\Ledger\OperationKey::deterministic('signin', [$user_id, $today_str]);
$replayed = false;
if ($reward > 0) {
$guard = static function () use ($wpdb, $table, $user_id, $today_str) {
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND signin_date = %s FOR UPDATE",
$user_id,
$today_str
));
return $existing === null
? true
: new \WP_Error('aksrt_signin_dup', __('今日已签到', 'aksrt'), ['status' => 409]);
};
$mutation = static function () use ($wpdb, $table, $user_id, $today_str, $new_streak, $reward) {
$inserted = $wpdb->insert($table, [
'user_id' => $user_id,
'signin_date' => $today_str,
'streak' => $new_streak,
'reward' => $reward,
], ['%d', '%s', '%d', '%d']);
return $inserted === 1
? true
: new \WP_Error('aksrt_signin_db', __('签到记录写入失败', 'aksrt'), ['status' => 500]);
};
$ledger_result = \AKSRT\Ledger\LedgerService::get_instance()->credit(
$user_id,
\AKSRT\Ledger\AssetType::POINTS,
$reward,
$operation_key,
'signin',
['type' => 'signin', 'key' => $today_str],
__('每日签到奖励', 'aksrt'),
$user_id,
['signin_date' => $today_str, 'streak' => $new_streak],
$mutation,
$guard
);
if (is_wp_error($ledger_result)) {
return $ledger_result;
}
$replayed = $ledger_result->replayed;
} else {
$stored = $this->store_zero_reward_signin($user_id, $today_str, $new_streak);
if (is_wp_error($stored)) {
return $stored;
}
$replayed = $stored;
}
if (!$replayed) {
$this->after_signin_committed($user_id, $today_str, $new_streak, $reward);
}
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' => \AKSRT\Ledger\LedgerService::get_instance()->balance($user_id, \AKSRT\Ledger\AssetType::POINTS),
'replayed' => $replayed,
], 200);
}
/** @return bool|\WP_Error True means the existing row was replayed. */
private function store_zero_reward_signin(int $user_id, string $date, int $streak) {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('signin_log');
if ($wpdb->query('START TRANSACTION') === false) {
return new \WP_Error('aksrt_signin_db', __('无法开启签到事务', 'aksrt'), ['status' => 500]);
}
$existing = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE user_id = %d AND signin_date = %s FOR UPDATE",
$user_id,
$date
));
if ($existing !== null) {
$wpdb->query('COMMIT');
return true;
}
$inserted = $wpdb->insert($table, [
'user_id' => $user_id,
'signin_date' => $date,
'streak' => $streak,
'reward' => 0,
], ['%d', '%s', '%d', '%d']);
if ($inserted !== 1 || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return new \WP_Error('aksrt_signin_db', __('签到记录写入失败', 'aksrt'), ['status' => 500]);
}
return false;
}
private function after_signin_committed(int $user_id, string $date, int $streak, int $reward): void {
update_user_meta($user_id, 'aksrt_signin_' . $date, 1);
update_user_meta($user_id, 'aksrt_checkin_all_day', $this->get_total_days($user_id));
update_user_meta($user_id, 'aksrt_checkin_continuous_day', $streak);
do_action('aksrt_user_signed_in', $user_id, $reward);
do_action('aksrt_checkin_done', $user_id);
}
/** 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);
}
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;
}
/**
* 签到详情弹窗 HTML(参考主题 zib_get_user_checkin_details_modal 1:1):
* 炫彩头部 + 用户卡片(我的{货币名}/我的等级)+ 每日签到奖励卡 + 连续签到奖励 7 天迷你卡。
*/
public function render_details_modal_html(int $user_id): string {
$user = get_userdata($user_id);
if (!$user) {
return '<div class="modal-body"><p class="muted-color padding-10 text-center">' . 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);
$base = (int) ($cfg['base'] ?? 0);
$currency = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_currency_name()
: __('积分', 'aksrt');
$points_svg = static fn(string $class = 'icon'): string =>
'<svg class="' . esc_attr($class) . '" aria-hidden="true"><use xlink:href="#icon-points"></use></svg>';
$gift_svg = static fn(string $class = 'icon'): string =>
'<svg class="' . esc_attr($class) . '" aria-hidden="true"><use xlink:href="#icon-gift-color"></use></svg>';
$signed_today = (bool) $rec;
// 炫彩头部
$html = '<div class="modal-body">';
$html .= '<div class="modal-colorful-header colorful-bg c-blue">';
$html .= '<button class="close" data-aksrt-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '"><svg class="ic-close" aria-hidden="true"><use xlink:href="#icon-close"></use></svg></button>';
$html .= '<div class="colorful-make"></div>';
$html .= '<div class="text-center">';
$html .= '<div class="em2x"><i class="fa fas fa-calendar-check" aria-hidden="true"></i></div>';
$html .= '<div class="mt10 em12 padding-w10">' . esc_html__('签到领奖励', 'aksrt') . '</div>';
$html .= '</div></div>';
// 用户卡片(头像/昵称 + 我的{货币名} + 我的等级)
$points_svg_mini = $points_svg('px12 mr6 muted-2-color');
$my_points_label = sprintf(__('我的%s', 'aksrt'), $currency);
$user_card = '<a rel="nofollow" class="block flex1 muted-box padding-10" href="' . esc_url(aksrt_get_user_center_url('credits')) . '">
<div class="muted-2-color em09 mb6">' . esc_html($my_points_label) . '</div>
<div class="em12">' . $points_svg_mini . esc_html(number_format_i18n(aksrt_user_points($user_id))) . '<i class="muted-2-color px12 fa fas fa-angle-right ml6" aria-hidden="true"></i></div>
</a>';
$level_s = class_exists('\AKSRT\User\Level')
&& \AKSRT\User\Level::settings()['enabled']
&& \AKSRT\User\Level::settings()['public_display'];
if ($level_s) {
$level_badge = \AKSRT\User\Level::get_badge_html($user_id, 'em12 mr10');
$user_card .= '<a rel="nofollow" class="block flex1 muted-box padding-10 ml6" href="' . esc_url(aksrt_get_user_center_url('level')) . '">
<div class="muted-2-color em09 mb6 flex jsb ac"><div>' . esc_html__('我的等级', 'aksrt') . '</div>' . $level_badge . '</div>
<div class="em12">' . esc_html(number_format_i18n(\AKSRT\User\Level::get_integral($user_id))) . '<span class="px12 ml6 muted-2-color">' . esc_html__('经验值', 'aksrt') . '<i class="fa fas fa-angle-right ml6" aria-hidden="true"></i></span></div>
</a>';
}
$checkin_all_day = $total ? '<div class="mb6 em09">' . sprintf(__('累计签到%s天', 'aksrt'), '<badge class="c-blue">' . esc_html((string) $total) . '</badge>') . '</div>' : '';
// 会员图标不再挂头像角标,改到昵称行最前;禁封徽章依旧占据会员图标位。
$avatar_img = aksrt_get_avatar_box($user_id, 'avatar-img avatar-mini mr6', false, false);
$name_badge = function_exists('aksrt_get_user_ban_name_badge') ? aksrt_get_user_ban_name_badge($user_id) : '';
if ($name_badge === '' && function_exists('aksrt_get_vip_icon')) {
$name_badge = aksrt_get_vip_icon($user_id);
}
$html .= '<div class="mb20"><div class="flex ac jsb"><div class="mb10">' . $avatar_img . esc_html($user->display_name) . $name_badge . '</div>' . $checkin_all_day . '</div><div class="flex ab jsb">' . $user_card . '</div></div>';
// 每日签到奖励卡片(参考主题:货币徽章 + 经验值徽章,经验值随等级系统展示)
// tooltip 先拼入数值再转义,避免把 %s 送进 esc_attr。
$reward_badge = $base
? '<badge class=" p2-10 mr6 c-green" data-toggle="tooltip" title="' . esc_attr(sprintf(__('%s奖励%s', 'aksrt'), $currency, $base)) . '">' . $points_svg('icon') . '+' . esc_html((string) $base) . '</badge>'
: '';
$reward_badge .= ($level_s && $base)
? '<badge class=" p2-10 mr6 c-purple" data-toggle="tooltip" title="' . esc_attr(sprintf(__('经验值奖励%s', 'aksrt'), $base)) . '"><i class="fa fas fa-chart-line" aria-hidden="true"></i>+' . esc_html((string) $base) . '</badge>'
: '';
if ($signed_today) {
$theday_reward = sprintf(__('%s', 'aksrt') . '+%d', $currency, (int) ($rec['reward'] ?? 0));
if ($level_s) {
// 签到经验值与积分奖励同额发放(Level::on_checkin)。
$theday_reward .= ' ' . sprintf(__('经验值', 'aksrt') . '+%d', (int) ($rec['reward'] ?? 0));
}
$checkin_btn = '<div class="em09 c-blue">
<div class="mb10"><i class="fa fas fa-calendar-check mr6" aria-hidden="true"></i>' . esc_html__('今日已签到', 'aksrt') . '</div>
<div class="px12">' . esc_html($theday_reward) . '</div>
</div>';
} else {
$checkin_btn = '<button type="button" class="but c-blue ml10 p2-10 radius" data-signin-btn'
. ' data-signin-done-label="' . esc_attr__('今日已签到', 'aksrt') . '">'
. '<span data-signin-label>' . esc_html__('立即签到', 'aksrt') . '</span></button>';
}
$card_1 = '<div class="zib-widget flex ac jsb">
<div class="mr10">
<div class="em09 mb10">' . $gift_svg('mr6 em12') . esc_html__('每日签到奖励', 'aksrt') . '</div>
<div class="flex ac jsb">' . $reward_badge . '</div>
</div>
' . $checkin_btn . '
</div>';
// 连续签到奖励卡片(参考主题固定 7 天)
$streak_table = is_array($cfg['streak'] ?? null) ? $cfg['streak'] : [];
$reward_continuous_day_html = $streak > 1 ? '<div class="mb6 em09">' . sprintf(__('已连续签到%s天', 'aksrt'), '<badge class="c-blue">' . esc_html((string) $streak) . '</badge>') . '</div>' : '';
$continuous_lists = '';
$max_day = 7;
for ($d = 1; $d <= $max_day; $d++) {
$extra = 0;
foreach ($streak_table as $k => $v) {
if ((int) $k === $d) {
$extra = (int) $v;
}
}
$_points = $base + $extra;
$is_ed = $signed_today && $streak >= $d;
$_mini_card = $_points ? '<div class="badg badg-sm ' . ($is_ed ? 'b' : 'c') . '-green" data-toggle="tooltip" title="' . esc_attr(sprintf(__('%s奖励%s', 'aksrt'), $currency, $_points)) . '">' . $points_svg('icon') . '<br/>+' . esc_html((string) $_points) . '</div>' : '';
if ($level_s && $_points) {
// 经验值与积分奖励同额(Level::on_checkin),参考主题为紫色经验值迷你卡。
$_mini_card .= '<div class="badg badg-sm ' . ($is_ed ? 'b' : 'c') . '-purple" data-toggle="tooltip" title="' . esc_attr(sprintf(__('经验值奖励%s', 'aksrt'), $_points)) . '"><i class="fa fas fa-chart-line" aria-hidden="true"></i><br/>+' . esc_html((string) $_points) . '</div>';
}
$continuous_lists .= '<div class="flex1 text-center" style="flex: 1;">
<div class="mb6 checkin-mini-box">' . $_mini_card . '</div>
<div class="muted-2-color smail' . ($is_ed ? ' c-blue' : '') . '">' . ($is_ed ? '<i class="fa fas fa-check-circle" aria-hidden="true"></i>' : '') . sprintf(__('第%d天', 'aksrt'), $d) . '</div>
</div>';
}
$card_2 = '<div class="mt10">
<div class="flex ac jsb"><div class="em09 mb10">' . $gift_svg('mr6 em12') . esc_html__('连续签到奖励', 'aksrt') . '</div>' . $reward_continuous_day_html . '</div>
<div class="flex ac jsb">' . $continuous_lists . '</div>
</div>';
$html .= $card_1;
$html .= $card_2;
$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;
}
/**
* 算"今天之前"的连续签到天数。
*/
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);
}
}