fix(user): repair phone binding and profile fields

This commit is contained in:
2026-10-05 20:55:20 +08:00
parent 0cf4639d95
commit f646fa8573
15 changed files with 627 additions and 162 deletions
@@ -184,13 +184,14 @@ test('native user profile omits ledger tables and renders canonical images eager
const credits = read('src/User/CreditsAdmin.php');
const balance = read('src/User/BalanceAdmin.php');
const avatar = read('inc/helpers/aksrt-avatar.php');
const profileAdmin = read('src/User/ProfileAdmin.php');
const level = read('src/User/LevelAdmin.php');
assert.doesNotMatch(credits, /LedgerQuery::get_instance\(\)->history/);
assert.doesNotMatch(credits, /近期%s流水|近期积分流水/);
assert.doesNotMatch(balance, /LedgerQuery::get_instance\(\)->history/);
assert.doesNotMatch(balance, /近期流水/);
assert.match(avatar, /get_avatar\(\$user->ID, 96, '', \$user->display_name, \['loading' => 'eager'\]\)/);
assert.match(profileAdmin, /get_avatar\(\$user_id, 96, '', \$user->display_name, \['loading' => 'eager'\]\)/);
assert.doesNotMatch(avatar, /aksrt_get_avatar_html\(\$user->ID, 96/);
assert.match(level, /Level::get_level_icon_url\(\$level\)/);
assert.match(level, /<img class="aksrt-user-level-icon aksrt-user-level-icon--admin" src="/);
@@ -0,0 +1,56 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const { execFileSync } = require('node:child_process');
const root = path.resolve(__dirname, '../..');
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
test('phone persistence, uniqueness, and native profile saves pass the PHP harness', () => {
const output = execFileSync('php', [path.join(root, 'tests/widget-system/user-profile-phone-harness.php')], { encoding: 'utf8' });
assert.match(output, /User profile phone harness passed/);
});
test('phone binding, registration, and sign-in share phone_number as the only phone value key', () => {
const service = read('src/Auth/Service.php');
const registrationAndBinding = read('src/Auth/ServicePart1b.php');
const handlers = read('src/Admin/UserControllerAuthPhoneOperations.php');
const account = read('inc/helpers/user-center-renderers/settings-account.php');
const admin = read('src/User/ProfileAdmin.php');
const runtime = [service, registrationAndBinding, handlers, account, admin].join('\n');
assert.match(service, /'meta_key'\s*=>\s*'phone_number'/);
assert.match(registrationAndBinding, /update_user_meta\(\$user_id, 'phone_number', \$phone\)/);
assert.match(registrationAndBinding, /'meta_key'\s*=>\s*'phone_number'/);
assert.match(handlers, /'meta_key'\s*=>\s*'phone_number'/);
assert.match(account, /get_user_meta\(\$account_user_id, 'phone_number', true\)/);
assert.doesNotMatch(runtime, /['"]aksrt_phone['"]/);
assert.match(admin, /aksrt_phone_verified/);
assert.match(admin, /wp_verify_nonce/);
assert.match(admin, /current_user_can\('edit_user'/);
});
test('native profile exposes the reference fields and continues using canonical media/profile metadata', () => {
const admin = read('src/User/ProfileAdmin.php');
const avatar = read('inc/helpers/aksrt-avatar.php');
const profile = read('inc/helpers/aksrt-user-center.php');
const form = read('inc/helpers/user-center-renderers/profile-form.php');
const publicProfile = read('templates/parts/user/author-hero.php');
['自定义头像', '自定义封面', '性别', 'QQ', '微信', '微博', 'GitHub', '住址', '绑定手机号'].forEach((label) => assert.ok(admin.includes(label), `missing ${label}`));
assert.match(admin, /'aksrt_avatar_attachment_id'/);
assert.match(admin, /'aksrt_background_attachment_id'/);
assert.match(admin, /'aksrt_profile_details'/);
assert.match(profile, /'weibo'\s*=>/);
assert.match(profile, /'address'\s*=>/);
assert.match(profile, /\$existing_details\['weibo'\]/);
assert.match(profile, /\$existing_details\['address'\]/);
assert.match(form, /name="weibo"/);
assert.match(form, /name="address"/);
assert.match(publicProfile, /\$profile_details\['weibo'\]/);
assert.match(publicProfile, /\$profile_details\['address'\]/);
assert.doesNotMatch(avatar, /aksrt_render_admin_avatar_field|aksrt_save_admin_avatar_field/);
});
@@ -0,0 +1,238 @@
<?php
/** Phone binding and native profile persistence contract harness. */
declare(strict_types=1);
namespace {
define('ABSPATH', __DIR__ . '/');
$GLOBALS['aksrt_profile_meta'] = [];
$GLOBALS['aksrt_profile_users'] = [];
$GLOBALS['aksrt_nonce_valid'] = true;
$GLOBALS['aksrt_can_edit_user'] = true;
$GLOBALS['aksrt_transients'] = [];
$GLOBALS['aksrt_expected_sms_code'] = '246810';
final class WP_User {
public function __construct(public int $ID) {}
}
final class WP_Error {
private array $errors = [];
public function add(string $code, string $message): void { $this->errors[$code][] = $message; }
public function get_error_codes(): array { return array_keys($this->errors); }
}
final class WP_REST_Request {
public function __construct(private array $params = [], private string $nonce = 'ok') {}
public function get_json_params(): array { return $this->params; }
public function get_header(string $name): string { return $name === 'X-WP-Nonce' ? $this->nonce : ''; }
}
final class WP_REST_Response {
public function __construct(public array $data, public int $status = 200) {}
}
function is_wp_error($value): bool { return $value instanceof WP_Error; }
function is_admin(): bool { return true; }
function current_user_can(string $capability, ...$args): bool { return $GLOBALS['aksrt_can_edit_user']; }
function is_user_logged_in(): bool { return true; }
function get_current_user_id(): int { return 7; }
function get_user_meta(int $user_id, string $key, bool $single = false) {
$value = $GLOBALS['aksrt_profile_meta'][$user_id][$key] ?? '';
return $single ? $value : ($value === '' ? [] : [$value]);
}
function update_user_meta(int $user_id, string $key, $value): bool {
$GLOBALS['aksrt_profile_meta'][$user_id][$key] = $value;
return true;
}
function get_users(array $args = []): array {
$results = [];
foreach ($GLOBALS['aksrt_profile_meta'] as $user_id => $meta) {
if (in_array((int) $user_id, array_map('intval', $args['exclude'] ?? []), true)) continue;
if (($meta[$args['meta_key'] ?? ''] ?? null) !== ($args['meta_value'] ?? null)) continue;
$results[] = ($args['fields'] ?? '') === 'ids' ? (int) $user_id : new WP_User((int) $user_id);
if (count($results) >= (int) ($args['number'] ?? 1)) break;
}
return $results;
}
function sanitize_text_field($value): string { return trim(strip_tags((string) $value)); }
function sanitize_textarea_field($value): string { return sanitize_text_field($value); }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? ''; }
function wp_unslash($value) { return $value; }
function esc_url_raw($value): string {
$value = trim((string) $value);
return $value === '' || filter_var($value, FILTER_VALIDATE_URL) ? $value : '';
}
function wp_verify_nonce(string $nonce, string $action): bool { return $GLOBALS['aksrt_nonce_valid'] && $nonce === 'ok'; }
function aksrt_require_captcha(string $scene, array $payload): bool { return true; }
function get_transient(string $key) { return $GLOBALS['aksrt_transients'][$key] ?? false; }
function set_transient(string $key, $value, int $expiration = 0): bool { $GLOBALS['aksrt_transients'][$key] = $value; return true; }
function delete_transient(string $key): bool { unset($GLOBALS['aksrt_transients'][$key]); return true; }
function wp_check_password(string $password, string $hash): bool { return $hash === 'fixture-hash' && $password === $GLOBALS['aksrt_expected_sms_code']; }
function wp_update_user(array $data) { return (int) ($data['ID'] ?? 0); }
function do_action(...$args): void {}
function add_action(...$args): void {}
function add_filter(...$args): void {}
function __(string $text, string $domain = ''): string { return $text; }
function esc_attr(string $value): string { return htmlspecialchars($value, ENT_QUOTES); }
function esc_html_e(string $text, string $domain = ''): void { echo htmlspecialchars($text, ENT_QUOTES); }
function esc_attr_e(string $text, string $domain = ''): void { echo htmlspecialchars($text, ENT_QUOTES); }
function esc_url(string $value): string { return $value; }
function selected($selected, $current = true, bool $echo = true): string { return (string) $selected === (string) $current ? 'selected="selected"' : ''; }
function wp_nonce_field(string $action, string $name): void {}
function get_avatar(...$args): string { return '<img alt="">'; }
function wp_max_upload_size(): int { return 2097152; }
function size_format(int $bytes): string { return (string) $bytes; }
function wp_check_filetype_and_ext(...$args): array { return []; }
function wp_match_mime_types(...$args): bool { return true; }
function wp_getimagesize(...$args): array|false { return false; }
function wp_get_attachment_image_url(...$args): string|false { return false; }
function get_post(...$args) { return null; }
function get_post_meta(...$args) { return ''; }
function wp_delete_attachment(...$args): bool { return true; }
function get_current_screen() { return null; }
require dirname(__DIR__, 2) . '/inc/helpers/aksrt-user-center.php';
}
namespace AKSRT\Sms {
final class SMS {
public static function is_phonenumber(string $phone): bool { return (bool) preg_match('/^1[3-9]\d{9}$/', $phone); }
}
}
namespace AKSRT\Auth {
require dirname(__DIR__, 2) . '/src/Auth/ServicePart1b.php';
final class PhoneHarnessService { use ServicePart1b; }
final class Service { use ServicePart1b; }
}
namespace AKSRT\Admin {
require dirname(__DIR__, 2) . '/src/Admin/UserControllerAuthPhoneOperations.php';
final class PhoneOperationsHarness {
use UserControllerAuthPhoneOperations;
public function get_json_params(\WP_REST_Request $request): array { return $request->get_json_params(); }
public function error(string $message, int $status = 400): \WP_REST_Response { return new \WP_REST_Response(['success' => false, 'message' => $message], $status); }
public function success(array $data): \WP_REST_Response { return new \WP_REST_Response(array_merge(['success' => true], $data), 200); }
}
}
namespace {
require dirname(__DIR__, 2) . '/src/User/ProfileAdmin.php';
function check(bool $condition, string $message): void {
if (!$condition) throw new RuntimeException($message);
}
$GLOBALS['aksrt_profile_meta'][7] = [
'aksrt_phone' => '13900000000',
'aksrt_profile_details' => [
'privacy' => 'public', 'gender' => 'female', 'website' => 'https://example.test',
'qq' => '123456', 'wechat' => 'wxid', 'weibo' => 'https://weibo.com/user',
'github' => 'https://github.com/user', 'address' => 'Shanghai',
],
];
$bad_format = \AKSRT\Auth\PhoneHarnessService::bind_phone(['phone' => 'bad'], true);
check(empty($bad_format['success']), 'phone binding accepted malformed number');
$unverified = \AKSRT\Auth\PhoneHarnessService::bind_phone(['phone' => '13800000007'], false);
check(empty($unverified['success']), 'phone binding accepted missing verification');
$GLOBALS['aksrt_profile_meta'][8]['phone_number'] = '13800000008';
$duplicate = \AKSRT\Auth\PhoneHarnessService::bind_phone(['phone' => '13800000008'], true);
check(empty($duplicate['success']), 'phone binding accepted a number owned by another user');
$bound = \AKSRT\Auth\PhoneHarnessService::bind_phone(['phone' => '13800000007'], true);
check(!empty($bound['success']), 'verified phone binding failed');
check(get_user_meta(7, 'phone_number', true) === '13800000007', 'verified phone was not stored in canonical meta');
check(get_user_meta(7, 'aksrt_phone_verified', true) === '1', 'verified marker was not stored');
check(get_user_meta(7, 'aksrt_phone', true) === '13900000000', 'legacy phone key was unexpectedly migrated or rewritten');
$same_phone = \AKSRT\Auth\PhoneHarnessService::bind_phone(['phone' => '13800000007'], true);
check(empty($same_phone['success']), 'binding accepted the current phone as a change');
$phone_to_bind = '13800000010';
$otp_key = 'aksrt_auth_code_' . md5('bind_phone|' . strtolower(trim($phone_to_bind)));
$operations = new \AKSRT\Admin\PhoneOperationsHarness();
$GLOBALS['aksrt_transients'][$otp_key] = ['hash' => 'fixture-hash', 'attempts' => 0, 'expires' => time() + 300];
$GLOBALS['aksrt_expected_sms_code'] = '246810';
$wrong_code = $operations->user_bind_phone(new WP_REST_Request(['phone' => $phone_to_bind, 'code' => '000000']));
check(empty($wrong_code->data['success']), 'REST phone binding accepted an incorrect SMS code');
check(($GLOBALS['aksrt_transients'][$otp_key]['attempts'] ?? 0) === 1, 'incorrect SMS code attempt was not recorded');
$GLOBALS['aksrt_transients'][$otp_key] = ['hash' => 'fixture-hash', 'attempts' => 0, 'expires' => time() - 1];
$expired_code = $operations->user_bind_phone(new WP_REST_Request(['phone' => $phone_to_bind, 'code' => '246810']));
check(empty($expired_code->data['success']), 'REST phone binding accepted an expired SMS code');
check(!isset($GLOBALS['aksrt_transients'][$otp_key]), 'expired SMS code was not discarded');
$GLOBALS['aksrt_transients'][$otp_key] = ['hash' => 'fixture-hash', 'attempts' => 0, 'expires' => time() + 300];
$valid_code = $operations->user_bind_phone(new WP_REST_Request(['phone' => $phone_to_bind, 'code' => '246810']));
check(!empty($valid_code->data['success']), 'REST phone binding rejected the correct SMS code');
check(get_user_meta(7, 'phone_number', true) === $phone_to_bind, 'REST phone binding did not store the canonical phone meta');
check(!isset($GLOBALS['aksrt_transients'][$otp_key]), 'valid SMS code was not consumed');
$_POST = [
'aksrt_profile_admin_nonce' => 'ok',
'aksrt_admin_avatar_nonce' => 'ok',
'aksrt_profile_gender' => 'male',
'aksrt_profile_qq' => '7654321',
'aksrt_profile_wechat' => 'new-wechat',
'aksrt_profile_weibo' => 'https://weibo.com/new-user',
'aksrt_profile_github' => 'https://github.com/new-user',
'aksrt_profile_website' => 'https://new.example.test',
'aksrt_profile_address' => 'Beijing',
'phone_number' => '13800000007',
'aksrt_phone_edit_unlocked' => '0',
];
$_FILES = [];
\AKSRT\User\ProfileAdmin::save(7);
$details = aksrt_get_user_profile_details(7);
check($details['privacy'] === 'public', 'admin profile update erased social privacy setting');
check($details['gender'] === 'male' && $details['weibo'] === 'https://weibo.com/new-user' && $details['address'] === 'Beijing', 'admin profile fields did not persist');
check($details['website'] === 'https://new.example.test', 'personal website field did not persist');
$front_save = aksrt_save_user_center_profile(7, ['nickname' => 'Profile User']);
check($front_save === true, 'user-center profile save failed');
$details = aksrt_get_user_profile_details(7);
check($details['weibo'] === 'https://weibo.com/new-user' && $details['address'] === 'Beijing', 'front profile save erased admin-added fields');
$details['privacy'] = 'private';
update_user_meta(7, 'aksrt_profile_details', $details);
$private_view = aksrt_get_visible_user_profile_details(7, 9);
check($private_view['weibo'] === '' && $private_view['address'] === '', 'private social details leaked to another viewer');
$owner_view = aksrt_get_visible_user_profile_details(7, 7);
check($owner_view['weibo'] === 'https://weibo.com/new-user' && $owner_view['address'] === 'Beijing', 'owner cannot read private social details');
$details['privacy'] = 'public';
update_user_meta(7, 'aksrt_profile_details', $details);
$public_view = aksrt_get_visible_user_profile_details(7, 9);
check($public_view['weibo'] === 'https://weibo.com/new-user' && $public_view['address'] === 'Beijing', 'public social details are not visible to another viewer');
$_POST = ['aksrt_profile_admin_nonce' => 'ok', 'phone_number' => 'not-a-phone', 'aksrt_phone_edit_unlocked' => '1'];
$invalid_errors = new WP_Error();
\AKSRT\User\ProfileAdmin::validate($invalid_errors, true, new WP_User(7));
check(in_array('aksrt_profile_phone', $invalid_errors->get_error_codes(), true), 'invalid admin phone format was not rejected');
$_POST = ['aksrt_profile_admin_nonce' => 'ok', 'phone_number' => '13800000008', 'aksrt_phone_edit_unlocked' => '1'];
$duplicate_errors = new WP_Error();
\AKSRT\User\ProfileAdmin::validate($duplicate_errors, true, new WP_User(7));
check(in_array('aksrt_profile_phone_exists', $duplicate_errors->get_error_codes(), true), 'admin profile accepted a duplicate phone');
$_POST = ['aksrt_profile_admin_nonce' => 'wrong', 'phone_number' => '13800000009', 'aksrt_phone_edit_unlocked' => '1'];
$before_phone = get_user_meta(7, 'phone_number', true);
$before_details = get_user_meta(7, 'aksrt_profile_details', true);
\AKSRT\User\ProfileAdmin::save(7);
check(get_user_meta(7, 'phone_number', true) === $before_phone, 'invalid admin nonce changed the phone');
check(get_user_meta(7, 'aksrt_profile_details', true) === $before_details, 'invalid admin nonce changed profile details');
$_POST = ['aksrt_profile_admin_nonce' => 'ok', 'phone_number' => '13800000009', 'aksrt_phone_edit_unlocked' => '1'];
\AKSRT\User\ProfileAdmin::save(7);
check(get_user_meta(7, 'phone_number', true) === '13800000009', 'unlocked admin phone change was not saved');
check(get_user_meta(7, 'aksrt_phone_verified', true) === '1', 'admin phone change cleared its verified marker');
$_POST['phone_number'] = '';
\AKSRT\User\ProfileAdmin::save(7);
check(get_user_meta(7, 'phone_number', true) === '13800000009', 'blank admin phone unexpectedly cleared the binding');
$before_details = get_user_meta(7, 'aksrt_profile_details', true);
$GLOBALS['aksrt_can_edit_user'] = false;
$_POST = ['aksrt_profile_admin_nonce' => 'ok', 'aksrt_profile_address' => 'Unauthorized change'];
\AKSRT\User\ProfileAdmin::save(7);
$GLOBALS['aksrt_can_edit_user'] = true;
check(get_user_meta(7, 'aksrt_profile_details', true) === $before_details, 'user without edit permission changed profile details');
echo "User profile phone harness passed\n";
}