diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 1536699b8..3b4cf7c4d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -386,7 +386,6 @@ module.exports = { 'src/data/**', 'src/prompts/**', 'apps/admin-web/src/pages/AdminCreationEntrySwitchPage*', - 'apps/admin-web/src/pages/AdminGrayReleaseConfigPage*', 'apps/admin-web/src/pages/AdminWorkVisibilityPage*', 'src/services/recommendedRuntimeGuestLaunch.test.ts', 'src/data/sceneEncounterPreviews.ts', diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 68fbc4a54..97ec8bbb6 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -3,11 +3,13 @@ import { afterEach, expect, test, vi } from 'vitest'; import { createAdminAccount, executeAdminRechargeRefund, + getAdminFeatureGateConfig, getAdminUserDetail, listAdminRechargeOrders, resolveAdminRechargeRefundManualReview, updateAdminAccount, uploadAdminEditorShowcaseCampaignImage, + upsertAdminFeatureGateConfig, } from './adminApiClient'; afterEach(() => { @@ -57,6 +59,52 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => ); }); +test('灰度配置读写只使用通用 feature-gates 管理接口', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ gates: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await getAdminFeatureGateConfig('gray-token'); + await upsertAdminFeatureGateConfig('gray-token', { + gateKey: 'image-editor:agent-sidebar', + enabled: true, + rolloutPercent: 25, + allowUserIds: ['user-1'], + allowUserTags: ['beta'], + denyUserIds: ['blocked-1'], + description: '画布 Agent 入口灰度', + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('/admin/api/feature-gates'); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }), + }), + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/feature-gates'); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }), + body: JSON.stringify({ + gateKey: 'image-editor:agent-sidebar', + enabled: true, + rolloutPercent: 25, + allowUserIds: ['user-1'], + allowUserTags: ['beta'], + denyUserIds: ['blocked-1'], + description: '画布 Agent 入口灰度', + }), + }), + ); +}); + test('活动卡图片上传成功后先确认正式私有对象再返回图片引用', async () => { const closeBitmap = vi.fn(); vi.stubGlobal( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index c5f17d5b5..e3697bb90 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -23,6 +23,7 @@ import type { AdminEditorShowcaseListQuery, AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest, + AdminFeatureGateConfigResponse, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, @@ -41,6 +42,7 @@ import type { AdminUpdateAccountResponse, AdminUploadedEditorShowcaseCampaignImage, AdminUpsertEditorShowcaseCampaignRequest, + AdminUpsertFeatureGateConfigRequest, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, @@ -267,6 +269,23 @@ export function listAdminTrackingEventKeys(token: string) { ); } +export function getAdminFeatureGateConfig(token: string) { + return request('/admin/api/feature-gates', { + token, + }); +} + +export function upsertAdminFeatureGateConfig( + token: string, + payload: AdminUpsertFeatureGateConfigRequest, +) { + return request('/admin/api/feature-gates', { + method: 'PUT', + token, + body: payload, + }); +} + export function getAdminEditorGenerationPricing(token: string) { return request( '/admin/api/editor-generation-pricing', diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 879af5c51..3900ab01d 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -286,6 +286,26 @@ export interface AdminTrackingEventListQuery { exportAll?: boolean; } +export interface AdminFeatureGateConfigPayload { + gateKey: string; + enabled: boolean; + rolloutPercent: number; + allowUserIds: string[]; + allowUserTags: string[]; + denyUserIds: string[]; + description: string; + updatedAt: string; +} + +export interface AdminFeatureGateConfigResponse { + gates: AdminFeatureGateConfigPayload[]; +} + +export type AdminUpsertFeatureGateConfigRequest = Omit< + AdminFeatureGateConfigPayload, + 'updatedAt' +>; + /** 图片画布生成模型泥点定价配置。 */ export type EditorGenerationPricingUnitPayload = 'perGeneration' | 'perSecond'; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 1ee731fc7..de33c8761 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -24,6 +24,7 @@ import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage'; import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage'; import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage'; import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage'; +import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; import { AdminOverviewPage } from '../pages/AdminOverviewPage'; @@ -227,6 +228,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'gray-release' ? ( + + ) : null} {activeRouteId === 'redeem' ? ( { ); }); +test('后台灰度发布路由可通过导航和 hash 访问', () => { + expect(adminRoutes).toContainEqual({ + id: 'gray-release', + label: '灰度发布', + hash: '#gray-release', + }); + expect(resolveAdminRoute('#gray-release')).toBe('gray-release'); + expect(routeHash('gray-release')).toBe('#gray-release'); +}); + test('后台不再暴露旧创作模板管理路由', () => { expect(resolveAdminRoute('#creation-entry')).toBe('dashboard'); expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard'); - expect(resolveAdminRoute('#gray-release')).toBe('dashboard'); expect(resolveAdminRoute('#work-visibility')).toBe('dashboard'); }); + test('后台素材查询路由可通过导航和 hash 访问', () => { expect(adminRoutes).toContainEqual({ id: 'editor-assets', @@ -93,6 +103,17 @@ test('member 只访问已分配 Tab 且无权 hash 回落到第一项', () => { ); }); +test('member 可单独获得灰度发布 Tab 权限', () => { + const routes = getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['gray-release'], + }); + expect(routes.map((route) => route.id)).toEqual(['gray-release']); + expect(resolveAccessibleAdminRoute('#gray-release', routes)).toBe( + 'gray-release', + ); +}); + test('零权限 member 不回落到 Dashboard', () => { const routes = getAccessibleAdminRoutes({ accountRole: 'member', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index faf97b083..b3bad3b50 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -1,10 +1,11 @@ -/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */ +/** 后台单页应用可导航的路由标识。 */ export type AdminRouteId = | 'dashboard' | 'overview' | 'tables' | 'debug' | 'tracking' + | 'gray-release' | 'redeem' | 'invite' | 'profile-wallet' @@ -32,6 +33,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'tables', label: '表查询', hash: '#tables' }, { id: 'debug', label: 'API 调试', hash: '#debug' }, { id: 'tracking', label: '埋点数据', hash: '#tracking' }, + { id: 'gray-release', label: '灰度发布', hash: '#gray-release' }, { id: 'redeem', label: '兑换码', hash: '#redeem' }, { id: 'invite', label: '邀请码', hash: '#invite' }, { id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet' }, diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx index e7f82b84e..a48273fae 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx @@ -5,21 +5,16 @@ import userEvent from '@testing-library/user-event'; import { beforeEach, expect, test, vi } from 'vitest'; import { - getAdminCreationEntryConfig, getAdminFeatureGateConfig, upsertAdminFeatureGateConfig, } from '../api/adminApiClient'; -import type { - AdminCreationEntryConfigResponse, - AdminFeatureGateConfigResponse, -} from '../api/adminApiTypes'; +import type { AdminFeatureGateConfigResponse } from '../api/adminApiTypes'; import { AdminGrayReleaseConfigPage } from './AdminGrayReleaseConfigPage'; vi.mock('../api/adminApiClient', () => ({ formatAdminApiError: vi.fn((error: unknown) => error instanceof Error ? error.message : '请求失败', ), - getAdminCreationEntryConfig: vi.fn(), getAdminFeatureGateConfig: vi.fn(), isAdminApiError: vi.fn(() => false), upsertAdminFeatureGateConfig: vi.fn(), @@ -50,48 +45,8 @@ const configResponse: AdminFeatureGateConfigResponse = { ], }; -const creationEntryResponse: AdminCreationEntryConfigResponse = { - entries: [ - { - id: 'puzzle', - title: '拼图', - subtitle: '', - badge: '', - imageSrc: '', - visible: true, - open: true, - sortOrder: 10, - categoryId: 'default', - categoryLabel: '默认', - categorySortOrder: 0, - updatedAtMicros: 0, - unifiedCreationSpec: null, - }, - { - id: 'match3d', - title: '3D 消除', - subtitle: '', - badge: '', - imageSrc: '', - visible: true, - open: true, - sortOrder: 20, - categoryId: 'default', - categoryLabel: '默认', - categorySortOrder: 0, - updatedAtMicros: 0, - unifiedCreationSpec: null, - }, - ], - eventBanners: [], - publicWorkInteractions: [], -}; - beforeEach(() => { vi.clearAllMocks(); - vi.mocked(getAdminCreationEntryConfig).mockResolvedValue( - creationEntryResponse, - ); vi.mocked(getAdminFeatureGateConfig).mockResolvedValue(configResponse); vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValue(configResponse); }); @@ -109,7 +64,6 @@ test('灰度发布页加载并展示 gate 列表', async () => { ).toBeTruthy(); expect(screen.getByText('25%')).toBeTruthy(); expect(getAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token'); - expect(getAdminCreationEntryConfig).toHaveBeenCalledWith('admin-token'); }); test('灰度发布页可选择已有 gate 编辑', async () => { @@ -152,12 +106,11 @@ test('灰度发布页选择新 target 时重置旧 gate 规则', async () => { await screen.findByRole('button', { name: 'editor.new-toolbar' }), ); await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [ - 'creation-entry', + 'image-editor', ]); - await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['match3d']); expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe( - 'creation-entry:match3d', + 'image-editor:agent-sidebar', ); expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe( false, @@ -175,27 +128,7 @@ test('灰度发布页选择新 target 时重置旧 gate 规则', async () => { (screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value, ).toBe(''); expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe( - '3D 消除创作入口灰度', - ); -}); - -test('灰度发布页可通过创作入口生成 Gate Key', async () => { - const user = userEvent.setup(); - render( - , - ); - - await screen.findByRole('button', { name: 'editor.new-toolbar' }); - await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [ - 'creation-entry', - ]); - await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['puzzle']); - - expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe( - 'creation-entry:puzzle', - ); - expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe( - '拼图创作入口灰度', + '画布 Agent 入口灰度', ); }); @@ -281,7 +214,6 @@ test('灰度发布页保存时转换数组和百分比', async () => { test('灰度发布页无 token 时不请求配置', () => { render(); - expect(getAdminCreationEntryConfig).not.toHaveBeenCalled(); expect(getAdminFeatureGateConfig).not.toHaveBeenCalled(); expect(upsertAdminFeatureGateConfig).not.toHaveBeenCalled(); }); diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx index 38d0e1de6..00d6d742f 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx @@ -1,13 +1,11 @@ import { Plus, RefreshCcw, Save } from 'lucide-react'; -import { FormEvent, useEffect, useState } from 'react'; +import { type FormEvent, useEffect, useState } from 'react'; import { - getAdminCreationEntryConfig, getAdminFeatureGateConfig, upsertAdminFeatureGateConfig, } from '../api/adminApiClient'; import type { - AdminCreationEntryTypeConfigPayload, AdminFeatureGateConfigPayload, AdminUpsertFeatureGateConfigRequest, } from '../api/adminApiTypes'; @@ -28,7 +26,6 @@ interface GateTargetOption { } const GATE_PREFIX_LABELS: Record = { - 'creation-entry': '创作入口', 'image-editor': '画布', }; @@ -47,9 +44,6 @@ export function AdminGrayReleaseConfigPage({ onUnauthorized, }: AdminGrayReleaseConfigPageProps) { const [gates, setGates] = useState([]); - const [creationEntries, setCreationEntries] = useState< - AdminCreationEntryTypeConfigPayload[] - >([]); const [selectedGateKey, setSelectedGateKey] = useState(''); const [gatePrefix, setGatePrefix] = useState(''); const [gateKey, setGateKey] = useState(''); @@ -74,7 +68,6 @@ export function AdminGrayReleaseConfigPage({ const requestToken = token.trim(); if (!requestToken) { setGates([]); - setCreationEntries([]); setListErrorMessage(''); setIsLoading(false); return; @@ -83,12 +76,8 @@ export function AdminGrayReleaseConfigPage({ setIsLoading(true); setListErrorMessage(''); try { - const [featureGateResponse, creationEntryResponse] = await Promise.all([ - getAdminFeatureGateConfig(requestToken), - getAdminCreationEntryConfig(requestToken), - ]); + const featureGateResponse = await getAdminFeatureGateConfig(requestToken); setGates(featureGateResponse.gates); - setCreationEntries(creationEntryResponse.entries); const selectedGate = featureGateResponse.gates.find( (gate) => gate.gateKey === selectedGateKey, ); @@ -240,7 +229,7 @@ export function AdminGrayReleaseConfigPage({ const canSave = gateKey.trim().length > 0 && isRolloutPercentInputValid(rolloutPercent); - const gateTargetOptions = buildGateTargetOptions(creationEntries); + const gateTargetOptions = FIXED_GATE_TARGETS; const gatePrefixOptions = buildGatePrefixOptions(gateTargetOptions); const gateTargetsForPrefix = gateTargetOptions.filter( (option) => option.prefix === gatePrefix, @@ -466,27 +455,6 @@ function isRolloutPercentInputValid(value: string) { return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100; } -function creationEntryGateKey(entryId: string) { - return `creation-entry:${entryId.trim()}`; -} - -function buildGateTargetOptions( - creationEntries: AdminCreationEntryTypeConfigPayload[], -): GateTargetOption[] { - return [ - ...creationEntries.map((entry) => ({ - prefix: 'creation-entry', - suffix: entry.id, - key: creationEntryGateKey(entry.id), - label: entry.title.trim() || entry.id, - description: entry.title.trim() - ? `${entry.title.trim()}创作入口灰度` - : '创作入口灰度', - })), - ...FIXED_GATE_TARGETS, - ]; -} - function buildGatePrefixOptions(gateTargetOptions: GateTargetOption[]) { const seen = new Set(); return gateTargetOptions.flatMap((option) => { diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json index 54fad2320..ad730bdf1 100644 --- a/apps/admin-web/tsconfig.json +++ b/apps/admin-web/tsconfig.json @@ -19,8 +19,6 @@ "exclude": [ "src/pages/AdminCreationEntrySwitchPage.tsx", "src/pages/AdminCreationEntrySwitchPage.test.tsx", - "src/pages/AdminGrayReleaseConfigPage.tsx", - "src/pages/AdminGrayReleaseConfigPage.test.tsx", "src/pages/AdminWorkVisibilityPage.tsx" ] } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index af571d627..82b99b8be 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4406,3 +4406,17 @@ - 动态与可用性边界:`useLayoutEffect` 首次同步测量,`ResizeObserver + requestAnimationFrame` 在容器变宽、卡高变化、筛选重排和 cursor 追加后全量重排。只有当宽度、卡数和所有高度完整时才进入 absolute ready;否则保留 Grid fallback,防止卡片重叠和分页 sentinel 提前触发。DOM/Tab/读屏顺序始终不变,容器与卡片显式为 list/listitem。 - 兼容边界:保留现有 `.creation-landing__asset-waterfall` 类名、筛选、排序、cursor 分页、预览与点赞链路;只替换布局算法。该决策覆盖 2026-07-07 multi-column 及本日早先 row-major Grid 的布局部分,不改变精选仍是动态素材流的产品定位。 - 验证方式:纯函数测试锁定容器临界宽度、循环列序、列内 top 和容器高度;`src/index.test.ts` 锁定 Grid fallback 与 Masonry ready。Playwright 在同一 viewport 中变更容器宽度,核对 3/2/1 列、每列 gap、容器高度、DOM 顺序、无重叠/横溢出和 console/page error。 + +## 2026-07-23 恢复通用灰度发布后台控制面 + +- 背景:旧创作模板退役时,后台灰度页因同时加载 `creation-entry:*` 动态目标与现役 `image-editor:agent-sidebar` 固定目标,被整页从路由、TypeScript、ESLint 和 Vitest 编译链摘除;通用 feature gate 后端、权限和现役画布 Agent 判定仍在,形成有 API 无正式控制面的不一致。 +- 决策:恢复后台 `#gray-release` 导航、member Tab 权限展示、前端 DTO/client、页面渲染和页面测试;页面只读取和写入 `GET/PUT /admin/api/feature-gates`,不再请求已退役 `/admin/api/creation-entry/config`。 +- 目标边界:固定目标列表只登记现役 `image-editor:agent-sidebar`;管理员仍可直接输入其他通用 Gate Key。不得恢复 `creation-entry:*` 动态目标、入口公告、入口开关、旧作品可见性页面或任何旧模板接口。 +- 运行语义:环境变量继续是画布 Agent 总开关,feature gate 只在总开关开启后做黑名单、白名单、标签和稳定百分比受众限制;本次不修改 SpacetimeDB schema、灰度优先级或后端契约。 +- 验证方式:后台路由与灰度页面 Vitest、`npm run admin-web:typecheck`、定向 ESLint、`npm run check:encoding`、`git diff --check`。 + +## 2026-07-23 手机号认证统一使用国家码与纯号码双字段 + +- 决策:普通手机号认证请求统一使用可选 `countryCode` 与必填 `purePhoneNumber`,省略国家码时默认中国大陆 `86`,直接替换旧 `phone` 字段。前端把浏览器 E.164 自动填充值拆成这两个字段;后端先验证国家码,再复用纯手机号规范化并生成 E.164 存储。 +- 微信边界:小程序客户端仍只上传 `wechatPhoneCode`;`platform-auth` 必须要求微信成功响应中的 `phoneNumber`、`countryCode` 与 `purePhoneNumber` 均存在且非空,但只使用后两项执行国家码校验和 E.164 构造。腾讯官方仅说明境外 `phoneNumber` 会带区号,并未承诺 E.164 格式,中国号码示例中它与纯号码相同,因此不得校验 `phoneNumber == +{countryCode}{purePhoneNumber}`。微信字段缺失时失败关闭,不能使用普通请求的 `86` 默认值。 +- 数据边界:认证投影与 SpacetimeDB 的 `phone_number_e164` 保持不变,不新增国家码或纯号码列,也不需要 schema 迁移或 bindings 生成。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 7bd8c2c13..e31c1c8cd 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1807,6 +1807,14 @@ - 验证:即使 `/api/auth/login-options` 返回空、失败或只返回 `["password"]`,登录弹窗也应同时显示 `短信登录`、`密码登录`、`验证码` 输入和“获取验证码”按钮;短信发送真实可用性再通过 `POST /api/auth/phone/send-code` 验证。 - 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/LoginScreen.tsx`、`src/components/auth/AuthGate.test.tsx`、`scripts/dev-utils.mjs`、`scripts/dev.mjs`。 +## 浏览器自动填充手机号带 `+86` + +- 现象:登录弹窗的手机号被浏览器回填为 `+86 1xxxxxxxxxx`,点击获取验证码或登录后返回“手机号格式不正确”。 +- 原因:`autocomplete="tel"` 允许浏览器回填含国家码的完整电话号码,`inputMode="numeric"` 只提示软键盘布局,不会过滤自动填充;如果把完整号码和纯号码混在一个 `phone` 字段中,微信 `purePhoneNumber` 又与 `countryCode` 分开传递,后端容易在国家码丢失后把境外号码误判为 `+86`。 +- 处理:手机号字段保留 `autocomplete="tel"`;`authService` 在请求前把 `+86 1xxxxxxxxxx`、`86 1xxxxxxxxxx` 拆为 `countryCode=86 + purePhoneNumber=1xxxxxxxxxx`。普通认证请求缺少 `countryCode` 时默认 `86`,但微信授权必须使用 provider 真实返回的 `countryCode + purePhoneNumber`,不能默认国家码。`module-auth` 先校验国家码,再用原纯手机号规则校验 `purePhoneNumber` 并生成 E.164;数据库仍只保存 E.164。 +- 验证:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、定向 `api-server` 认证测试和 `npm run test -- src/services/authService.test.ts src/components/auth/AuthGate.test.tsx`,覆盖省略 / 显式 `86`、境外国家码、浏览器 `+86` 自动填充以及微信 provider 国家码路径。 +- 关联:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`server-rs/crates/api-server/src/wechat/auth.rs`、`src/services/authService.ts`、`src/components/auth/LoginScreen.tsx`。 + ## 本地短信收不到验证码先查 provider - 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。 @@ -3313,3 +3321,11 @@ - 验证与回滚:重启后先跑真实 PR 的四个 job,再清理旧镜像。失败时先把 workflow `runs-on` 改回 `ubuntu-latest`,再恢复 runner config 备份并重启;不在 Git、共享文档或日志中记录 config 备份路径、注册信息或 token。 - 重启边界:`docker restart --timeout 660` 只设置容器停止宽限,不能替代 Runner drain。rootless DinD supervisor 可能与 runner 同时停止内层 dockerd,使仍在收尾的 job 因连接关闭被标记失败;切换前必须同时确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。误触发时只重跑受影响的失败 job,不重跑已成功项。 - 关联:`deploy/container/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/project-memory/shared-memory/development-workflow.md`。 + +## 通用灰度后台页不能依赖已退役业务配置 + +- 现象:通用 `feature_gate_config`、`/admin/api/feature-gates` 和现役功能 gate 仍在,但后台“灰度发布”Tab 随旧创作模板入口一起消失;Rust 权限仍可授予 `gray-release`,前端却没有对应路由。 +- 原因:灰度页同时请求通用 gate 与旧 `/admin/api/creation-entry/config`,并把 `creation-entry:*` 动态目标和现役固定目标混在同一页面;按页面清理旧入口时连带摘除了通用控制面。 +- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。 +- 验证:`adminRoutes` 必须包含 `gray-release`,admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。 +- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/app/adminRoutes.ts`、`server-rs/crates/api-server/src/modules/admin.rs`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 diff --git a/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md b/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md index d6d31d693..e53d43ace 100644 --- a/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md +++ b/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md @@ -1,6 +1,6 @@ # 后台管理多账号与 Tab 访问权限方案 -更新时间:`2026-07-14` +更新时间:`2026-07-23` ## 1. 文档定位 @@ -10,12 +10,12 @@ ## 2. 当前基线与目标 -当前后台由 `GENARRATIVE_ADMIN_USERNAME`、`GENARRATIVE_ADMIN_PASSWORD` 提供唯一管理员账号,`apps/admin-web/src/app/adminRoutes.ts` 定义 18 个一级 Tab,`server-rs/crates/api-server/src/modules/admin.rs` 中的后台路由只校验统一的管理员 JWT。 +当前后台由 `GENARRATIVE_ADMIN_USERNAME`、`GENARRATIVE_ADMIN_PASSWORD` 提供唯一管理员账号,`apps/admin-web/src/app/adminRoutes.ts` 定义 15 个可分配业务 Tab,并另有 owner-only 的“账号管理”Tab;`server-rs/crates/api-server/src/modules/admin.rs` 中的后台路由只校验统一的管理员 JWT。 改造后的目标如下: 1. 现有环境变量账号升级为 `owner`,仍由部署环境提供,不迁移、不复制到 SpacetimeDB。 -2. owner 始终拥有全部 18 个业务 Tab 权限,并独占“账号管理”Tab 和账号管理 API。 +2. owner 始终拥有全部 15 个业务 Tab 权限,并独占“账号管理”Tab 和账号管理 API。 3. owner 可以创建、修改、启停 member;member 保存在 SpacetimeDB 私有表 `admin_account`。 4. member 按一级 Tab 分配权限;获得一个 Tab 权限即获得该页面内全部读写能力,页面内部二级 Tab、弹窗和操作区继承一级权限。 5. member JWT 每次请求都重新读取当前账号并校验 `enabled`、`token_version` 和实时权限,权限、密码或启停变更应立即让旧 JWT 失效。 @@ -26,7 +26,7 @@ - owner 用户名和密码继续读取 `GENARRATIVE_ADMIN_USERNAME`、`GENARRATIVE_ADMIN_PASSWORD`。 - owner 是环境变量构造的虚拟账号,不写入 `admin_account`,不允许通过后台改名、改密、禁用或删除。 -- owner 始终拥有本文列出的全部 18 个可分配权限,不能在前端取消,也不从数据库加载权限。 +- owner 始终拥有本文列出的全部 15 个可分配权限,不能在前端取消,也不从数据库加载权限。 - “账号管理”是 owner-only 能力。它可以作为新增一级路由 `accounts` / `#accounts` 展示,但 `accounts` 不进入 `ADMIN_TAB_PERMISSIONS`,不能写入 member 的 `permissions_json`。 - owner 会话返回 `accountRole = "owner"`、`roles = ["admin", "owner"]`;账号管理权限必须根据服务端确认的 `accountRole` 判断,不能只相信前端角色字符串。 - owner 配置缺失时,后台整体保持未启用状态;不能依赖数据库中的 member 绕过 owner 引导配置启动后台。 @@ -41,7 +41,7 @@ ## 4. 权限标识 -`ADMIN_TAB_PERMISSIONS` 必须是 shared-contracts 与 admin-web 共用的闭合集合,值与现有 `AdminRouteId` 一致。18 个可分配权限如下,顺序同时作为前端寻找“第一可访问项”的稳定顺序: +`ADMIN_TAB_PERMISSIONS` 必须是 shared-contracts 与 admin-web 共用的闭合集合,值与现有 `AdminRouteId` 一致。15 个可分配权限如下,顺序同时作为前端寻找“第一可访问项”的稳定顺序: | permission id | 一级 Tab | hash | | --- | --- | --- | @@ -60,9 +60,6 @@ | `editor-generation-pricing` | 模型定价 | `#editor-generation-pricing` | | `editor-showcase` | 精选审核 | `#editor-showcase` | | `editor-assets` | 素材查询 | `#editor-assets` | -| `creation-announcement` | 入口公告 | `#creation-announcement` | -| `creation-entry` | 入口开关 | `#creation-entry` | -| `work-visibility` | 作品可见性 | `#work-visibility` | 权限数组必须去重并按上表顺序规范化后保存。保存时拒绝未知值和 `accounts`;读取旧数据时遇到未知值应忽略并记录告警,绝不能将未知值解释为全权限。空数组合法,表示 member 可以登录但没有业务页面权限。 @@ -83,7 +80,7 @@ | `username` | `String` | `unique`;登录名,创建后不可修改;按 `trim + ASCII lowercase` 规范化 | | `display_name` | `String` | 展示名,去除首尾空白后 1 至 64 字符 | | `password_hash` | `String` | Argon2id PHC 字符串;只在内部登录查询中返回给 api-server,永不进入 HTTP DTO、日志或前端状态 | -| `permissions_json` | `String` | 规范化后的 Tab permission JSON;只允许第 4 节 18 个值,空数组为 `[]` | +| `permissions_json` | `String` | 规范化后的 Tab permission JSON;只允许第 4 节 15 个值,空数组为 `[]` | | `enabled` | `bool` | 是否允许登录和继续使用现有 JWT | | `token_version` | `u64` | 初始为 `1`;权限、密码或启停状态发生有效变化时加 `1` | | `created_by` | `String` | 创建者后台 subject;当前只能是 owner subject | @@ -163,7 +160,7 @@ accountRole: "owner" | "member" tabPermissions: string[] ``` -owner 返回全部 18 个 permission id;member 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。 +owner 返回全部 15 个 permission id;member 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。 后台所有面向运营展示的管理员身份统一使用 `displayName`。审计表继续保存稳定 subject,例如 owner subject 或 `admin-account-`;api-server 在返回兑换码、邀请码等操作记录时,按 owner 运行态和 `admin_account` 批量解析显示名称,同时兼容历史用户名记录。已无法解析的历史主体统一展示“已停用管理员”,前端不得直接渲染 `operatorUserId`、账号 ID 或登录用户名代替显示名称。对写接口,显示名目录必须在主事务前加载,或在主事务成功后降级为占位文案;不得因二次读取失败把已提交写入伪装成失败。 @@ -196,10 +193,6 @@ owner 返回全部 18 个 permission id;member 返回数据库中的实时规 | `GET` | `/admin/api/tracking/event-keys` | `tracking OR tasks` | | `GET` | `/admin/api/database/tables` | `tables` | | `GET` | `/admin/api/database/tables/{table_name}/rows` | `tables` | -| `GET` | `/admin/api/creation-entry/config` | `gray-release OR creation-announcement OR creation-entry` | -| `POST` | `/admin/api/creation-entry/config` | `creation-entry` | -| `POST` | `/admin/api/creation-entry/config/banners` | `creation-announcement` | -| `POST` | `/admin/api/creation-entry/config/interactions` | `creation-entry` | | `GET` | `/admin/api/feature-gates` | `gray-release` | | `PUT` | `/admin/api/feature-gates` | `gray-release` | | `GET` | `/admin/api/editor-generation-pricing` | `editor-generation-pricing` | @@ -212,8 +205,6 @@ owner 返回全部 18 个 permission id;member 返回数据库中的实时规 | `GET` | `/admin/api/editor-showcase/campaign` | `editor-showcase` | | `POST` | `/admin/api/editor-showcase/campaign` | `editor-showcase` | | `POST` | `/admin/api/editor-showcase/campaign/image-upload-ticket` | `editor-showcase` | -| `GET` | `/admin/api/works/visibility` | `work-visibility` | -| `POST` | `/admin/api/works/visibility` | `work-visibility` | | `GET` | `/admin/api/profile/redeem-codes` | `redeem` | | `POST` | `/admin/api/profile/redeem-codes` | `redeem` | | `POST` | `/admin/api/profile/redeem-codes/disable` | `redeem` | @@ -231,17 +222,18 @@ owner 返回全部 18 个 permission id;member 返回数据库中的实时规 | `POST` | `/admin/api/profile/recharge-refunds/execute` | `recharge-orders` | | `POST` | `/admin/api/profile/recharge-refunds/register` | `recharge-orders` | | `POST` | `/admin/api/profile/recharge-refunds/manual-review/resolve` | `recharge-orders` | -| `GET` | `/admin/api/profile/users/detail` | `tables OR tracking OR recharge-orders OR editor-showcase OR editor-assets OR work-visibility` | +| `GET` | `/admin/api/profile/users/detail` | `tables OR tracking OR recharge-orders OR editor-showcase OR editor-assets` | | `POST` | `/admin/api/profile/wallet-restriction` | `recharge-orders` | | `GET` | `/admin/api/accounts` | owner-only | | `POST` | `/admin/api/accounts` | owner-only | | `PUT` | `/admin/api/accounts/{account_id}` | owner-only | -三个共享读取接口必须按 OR 规则实现,不能为了复用简单中间件扩大成“任意 member 可访问”: +两个共享读取接口必须按 OR 规则实现,不能为了复用简单中间件扩大成“任意 member 可访问”: - `/admin/api/assets/read-url` 只服务素材查询和精选审核。 -- `/admin/api/profile/users/detail` 只服务当前实际包含用户详情入口的表查询、埋点数据、充值管理、精选审核、素材查询和作品可见性页面。 -- `GET /admin/api/creation-entry/config` 同时为灰度发布、入口公告和入口开关提供页面初始化数据;写操作仍按具体页面单独收紧。 +- `/admin/api/profile/users/detail` 只服务当前实际包含用户详情入口的表查询、埋点数据、充值管理、精选审核和素材查询页面。 + +`gray-release` 页面只调用 `GET/PUT /admin/api/feature-gates`;旧 `/admin/api/creation-entry/config*` 已退役,不能再作为灰度页面初始化依赖。页面固定目标只登记现役功能,其他通用 gate 仍可通过 Gate Key 直接管理。 ## 10. 账号管理 HTTP 契约 @@ -303,7 +295,7 @@ accounts: Array<{ ### 11.1 路由与导航 -- `adminRoutes` 增加权限元数据;18 个业务路由使用同名 permission id。 +- `adminRoutes` 增加权限元数据;15 个业务路由使用同名 permission id。 - `accounts` 路由只在 `admin.accountRole === "owner"` 时加入侧栏和移动底栏,不属于 member 可分配列表。 - member 导航只渲染 `admin.tabPermissions` 包含的业务路由。页面组件也必须只在当前路由已授权时挂载,避免隐藏导航后仍发起无权限 API。 - owner 渲染全部业务路由和账号管理路由。 @@ -315,13 +307,13 @@ accounts: Array<{ 1. 当前 hash 对应可访问路由时保持不变。 2. hash 未知、属于无权限业务 Tab,或 member 访问 `#accounts` 时,使用 `replaceState` 回落到按第 4 节顺序找到的第一可访问业务 Tab。 3. member 权限为空时,不回落 Dashboard;渲染独立的零权限空态,只保留账号信息和退出登录,不挂载任何业务页,也不发起业务 API。 -4. owner 的默认项仍可保持 Dashboard;账号管理不改变 18 个业务路由的排序。 +4. owner 的默认项仍可保持 Dashboard;账号管理不改变 15 个业务路由的排序。 后端返回 `403` 时,前端重新请求 `/me` 获取实时权限并执行上述回落。即使前端状态陈旧或被篡改,后端权限 middleware 仍必须拒绝越权请求。 ### 11.3 账号管理页 -- 权限编辑器展示 18 个明确的 checkbox,每项使用现有 Tab 中文名称;不能展示或提交 `accounts`。 +- 权限编辑器展示 15 个明确的 checkbox,每项使用现有 Tab 中文名称;不能展示或提交 `accounts`。 - 创建和编辑使用独立弹窗或抽屉,不在列表下方追加表单。 - 编辑时密码字段默认空,空表示请求中省略 `password`;页面永不展示现有密码或 hash。 - 停用使用开关并二次确认。保存成功后以 API 返回 account snapshot 更新列表。 @@ -391,12 +383,12 @@ spacetime publish \ - 权限、密码、启停更新各自会递增 `token_version`;同一次请求修改多项只递增一次;仅改展示名不递增。 - member 被停用、改密或改权限后,旧 JWT 下一次请求返回 401;重新登录后获得实时权限。 - API-to-Tab 矩阵逐路由覆盖 `modules/admin.rs`,每条路由至少测试 owner 成功、具备权限的 member 成功、缺权限 member 返回 403。 -- 三个共享读取接口分别覆盖每个允许 permission 的成功用例,以及无关 permission 的 403 用例。 +- 两个共享读取接口分别覆盖每个允许 permission 的成功用例,以及无关 permission 的 403 用例。 - owner-only 账号 API 对任意 member 都返回 403,即使其 `permissions_json` 被污染为包含 `accounts`。 ### 14.2 前端 -- owner 看到 18 个业务 Tab 和账号管理;member 只看到被分配的业务 Tab。 +- owner 看到 15 个业务 Tab 和账号管理;member 只看到被分配的业务 Tab。 - 每个一级 Tab 内的二级 Tab、弹窗和写操作继承一级权限并正常使用,不出现“页面可见但内部 API 403”的错误映射。 - 直接输入无权限 hash 自动替换为第一可访问项,不短暂挂载无权限页面。 - 当前 Tab 权限被 owner 收回后,下一请求触发重新登录;新会话恢复后落到第一可访问项。 diff --git a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md index de40cc649..fa81d9c6a 100644 --- a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md +++ b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md @@ -25,6 +25,7 @@ - `migration.rs` 中相关表的迁移白名单、表名兼容和字段目录。 - 为历史审计、迁移、资产归属核对所必需的最小只读表定义;不得借兼容读取重新暴露旧创作、发布、公开详情或运行接口。 - 编辑器、项目、账号、钱包、资产、HostBridge、运维和安全等平台公共能力。 +- 通用 `feature_gate_config`、`GET/PUT /admin/api/feature-gates` 与后台 `#gray-release` 控制页。灰度页只读取通用 gate,不再请求旧 `/admin/api/creation-entry/config`,固定目标只登记现役功能;不得恢复 `creation-entry:*` 动态目标。 - 新版 `/creation` 创作工具主页、`/project` 项目入口、稳定的 `/profile` 个人页路由、`creation-home` 展示组件与现役静态资产。桌面端保留“创作 / 项目 / 我的”公共侧边栏,移动端保留同样三项的底部 dock;“我的”保留头像 / 昵称编辑、陶泥号复制、钱包与账单、统计、充值、兑换码、玩家社区、反馈、通用设置、开发者 API Key 和法律信息,不恢复旧模板入口、旧作品架或生成队列。 - `runtime_setting` 是账号级公共设置事实,不属于旧模板运行态。原表结构和数据不变,继续由鉴权后的 `GET/PUT /api/runtime/settings`、`get_runtime_setting_or_default` 与 `upsert_runtime_setting_and_return` procedure 支撑音乐音量和平台主题读写。 - 旧页面、测试、素材、handler、service、worker、生成 bindings 和纯业务 crate 的源码目录;它们仅用于历史追溯,不属于任何正式入口或编译目标。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 7d98b96cf..80a9ade1f 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -59,6 +59,7 @@ npm run check:server-rs-ddd - 健康检查:`GET /healthz`、`GET /readyz`。 - 后台管理:`/admin/api/*`,现役路由包括登录与账号管理、Dashboard / 概览、HTTP debug、埋点、表查询、通用 feature gate、编辑器定价与素材 / 精选管理,以及账号侧兑换码、邀请码、任务、钱包、充值与退款管理;不再挂载旧创作入口配置、旧作品互动或旧玩法运营路由。环境变量管理员固定作为 owner,持久化 member 每次请求按当前 `enabled`、`token_version` 和一级 Tab 权限实时校验;账号管理仅 owner 可访问,未登记权限映射的新后台路由对 member 默认拒绝。完整权限矩阵见 [`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md),Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。 +- 通用灰度控制面固定为后台 `#gray-release` 与 `GET/PUT /admin/api/feature-gates`;页面固定目标只登记现役功能,不读取旧 `/admin/api/creation-entry/config`,也不恢复 `creation-entry:*` 动态目标。 - 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。 - 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。 - 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 @@ -100,6 +101,7 @@ npm run check:server-rs-ddd ### 认证态用户与会话摘要下发口径 +- `/api/auth/entry`、`/api/auth/phone/*`、`/api/auth/password/reset` 与 `/api/auth/wechat/bind-phone` 的普通手机号请求统一使用 `purePhoneNumber` 与可选 `countryCode`,不再接受旧 `phone` 字段;`countryCode` 未提供时默认中国大陆 `86`,显式值必须使用微信同口径的无加号国家码并且当前只允许 `86`。`module-auth` 先校验国家码,再复用纯手机号规范化规则,最终统一以 `+86` E.164 写入认证投影。微信小程序 `getPhoneNumber` 链路仍只接收客户端 `wechatPhoneCode`,后端必须要求微信 provider 成功响应中的 `phoneNumber`、`purePhoneNumber` 与 `countryCode` 均存在且非空,并只使用后两项执行国家码校验和 E.164 构造;腾讯未承诺 `phoneNumber` 为 E.164,不得依赖其前缀格式,也不得在微信链路默认 `86`。 - `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id`、`publicUserCode`、`displayName`、`avatarUrl`、`phoneNumber`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`、`wechatDisplayName`、`wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料,或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 可接收 `displayName`;`/api/auth/wechat/miniprogram-login` 额外返回 `created`,供小程序壳在快捷登录后判断是否需要补采集微信昵称。`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。 - `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId`、`sessionIds`、`sessionCount`、`clientLabel`、`ipMasked`、`isCurrent`、`createdAt`、`lastSeenAt`、`expiresAt`。 - 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO,而不是把账号 / 会话快照恢复为全量对象。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 1d40696c2..082347839 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -55,6 +55,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自快捷登录后按需展示的原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。 10. H5 登录态从未登录变为已登录,或从已登录变为未登录后,必须刷新当前页面一次,确保推荐运行态、作品架、个人缓存和私有 query 都按新身份重新初始化;普通 access token 续期、账号资料更新和同一登录态内的设置变化不得触发整页刷新。 11. 同一账号允许多端同时在线。新增登录和单设备退出只影响对应 refresh session,不得提升账号级 `tokenVersion` 让其它设备的 access token 失效;只有“退出全部设备”、修改密码、重置密码等明确安全动作才吊销全端 refresh session 并提升 `tokenVersion`。 +12. 手机号认证只支持中国大陆号码:验证码、密码登录、绑定、换绑和重置密码请求统一提交 `purePhoneNumber` 与可选 `countryCode`,省略国家码时默认 `86`,旧 `phone` 字段不再接受;显式国家码必须为无加号的 `86`,其他值返回“仅支持中国大陆手机号(+86)”。主站输入框保留 `autocomplete="tel"` 与浏览器默认电话号码回填能力,认证 service 把浏览器可能回填的 `+86 1xxxxxxxxxx` 或 `86 1xxxxxxxxxx` 拆成 `{ countryCode: "86", purePhoneNumber: "1xxxxxxxxxx" }` 后提交。微信小程序手机号授权必须使用微信真实返回的 `countryCode + purePhoneNumber`,不得套用普通请求的缺省国家码。后端分别验证国家码和纯号码后再生成 E.164 存储;前端校验只提供即时反馈,`inputMode="numeric"` 也只提示软键盘布局。 ## 账户与充值 diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 5bb17107b..a49ffde03 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -27,8 +27,12 @@ export type PublicUserSearchResponse = { user: PublicUserSummary; }; -export type AuthEntryRequest = { - phone: string; +export type AuthPhoneNumberInput = { + countryCode?: string; + purePhoneNumber: string; +}; + +export type AuthEntryRequest = AuthPhoneNumberInput & { password: string; }; @@ -55,8 +59,7 @@ export type AuthProfileUpdateResponse = { user: AuthUser; }; -export type AuthPasswordResetRequest = { - phone: string; +export type AuthPasswordResetRequest = AuthPhoneNumberInput & { code: string; newPassword: string; }; @@ -66,8 +69,7 @@ export type AuthPasswordResetResponse = { user: AuthUser; }; -export type AuthPhoneSendCodeRequest = { - phone: string; +export type AuthPhoneSendCodeRequest = AuthPhoneNumberInput & { scene?: 'login' | 'bind_phone' | 'change_phone' | 'reset_password'; captchaChallengeId?: string; captchaAnswer?: string; @@ -80,8 +82,7 @@ export type AuthPhoneSendCodeResponse = { providerRequestId: string | null; }; -export type AuthPhoneLoginRequest = { - phone: string; +export type AuthPhoneLoginRequest = AuthPhoneNumberInput & { code: string; inviteCode?: string; }; @@ -116,7 +117,8 @@ export type AuthWechatStartResponse = { }; export type AuthWechatBindPhoneRequest = { - phone?: string; + countryCode?: string; + purePhoneNumber?: string; code?: string; wechatPhoneCode?: string; displayName?: string; @@ -139,8 +141,7 @@ export type AuthWechatMiniProgramLoginResponse = { created: boolean; }; -export type AuthPhoneChangeRequest = { - phone: string; +export type AuthPhoneChangeRequest = AuthPhoneNumberInput & { code: string; }; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 5ae7f642d..ef49e1123 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -377,7 +377,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": phone_number, + "purePhoneNumber": phone_number, "password": password }) .to_string(), @@ -408,7 +408,7 @@ mod tests { .header("x-forwarded-for", forwarded_for) .body(Body::from( serde_json::json!({ - "phone": phone_number, + "purePhoneNumber": phone_number, "password": password }) .to_string(), @@ -2111,7 +2111,8 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "countryCode": "86", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -2147,6 +2148,52 @@ mod tests { ); } + #[tokio::test] + async fn send_phone_code_rejects_foreign_country_code() { + let config = AppConfig { + sms_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "countryCode": "1", + "purePhoneNumber": "12025550123", + "scene": "login" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = serde_json::from_slice(&body).expect("body should be valid json"); + + assert_eq!( + payload["error"]["code"], + Value::String("BAD_REQUEST".to_string()) + ); + assert_eq!( + payload["error"]["message"], + Value::String("仅支持中国大陆手机号(+86)".to_string()) + ); + } + #[tokio::test] async fn send_phone_code_rejects_same_scene_during_cooldown() { let config = AppConfig { @@ -2164,7 +2211,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -2183,7 +2230,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -2243,7 +2290,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -2266,7 +2313,7 @@ mod tests { ) .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "code": "123456" }) .to_string(), @@ -2329,7 +2376,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13900139000", + "purePhoneNumber": "13900139000", "scene": "login" }) .to_string(), @@ -2349,7 +2396,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13900139000", + "purePhoneNumber": "13900139000", "code": "123456" }) .to_string(), @@ -2377,7 +2424,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13900139000", + "purePhoneNumber": "13900139000", "scene": "login" }) .to_string(), @@ -2396,7 +2443,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13900139000", + "purePhoneNumber": "13900139000", "code": "123456" }) .to_string(), @@ -2438,7 +2485,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13600136000", + "purePhoneNumber": "13600136000", "scene": "login" }) .to_string(), @@ -2457,7 +2504,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13600136000", + "purePhoneNumber": "13600136000", "code": "123456", "inviteCode": "SPRING2026" }) @@ -2503,7 +2550,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13500135000", + "purePhoneNumber": "13500135000", "scene": "login" }) .to_string(), @@ -2523,7 +2570,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13500135000", + "purePhoneNumber": "13500135000", "code": "123456" }) .to_string(), @@ -2543,7 +2590,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13500135000", + "purePhoneNumber": "13500135000", "scene": "login" }) .to_string(), @@ -2562,7 +2609,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13500135000", + "purePhoneNumber": "13500135000", "code": "123456", "inviteCode": "SPRING2026" }) @@ -2604,7 +2651,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "scene": "login" }) .to_string(), @@ -2625,7 +2672,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "code": "000000" }) .to_string(), @@ -2646,7 +2693,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "code": "000000" }) .to_string(), @@ -2679,7 +2726,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "code": "123456" }) .to_string(), @@ -2699,7 +2746,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "scene": "login" }) .to_string(), @@ -2718,7 +2765,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "code": "123456" }) .to_string(), @@ -3213,7 +3260,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -3233,7 +3280,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "code": "123456" }) .to_string(), @@ -3327,7 +3374,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "bind_phone" }) .to_string(), @@ -3348,7 +3395,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "code": "123456" }) .to_string(), @@ -3409,7 +3456,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -3429,7 +3476,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "code": "123456" }) .to_string(), @@ -3575,7 +3622,7 @@ mod tests { .header("x-client-instance-id", "chrome-instance-001") .body(Body::from( serde_json::json!({ - "phone": "13800138013", + "purePhoneNumber": "13800138013", "password": TEST_PASSWORD }) .to_string(), @@ -3619,7 +3666,7 @@ mod tests { .header("user-agent", "Mozilla/5.0 Chrome/123.0 MicroMessenger") .body(Body::from( serde_json::json!({ - "phone": "13800138013", + "purePhoneNumber": "13800138013", "password": TEST_PASSWORD }) .to_string(), @@ -3679,7 +3726,7 @@ mod tests { seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await; let app = build_router(state); let login_body = serde_json::json!({ - "phone": "13800138028", + "purePhoneNumber": "13800138028", "password": TEST_PASSWORD }) .to_string(); @@ -3836,7 +3883,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138026", + "purePhoneNumber": "13800138026", "scene": "reset_password" }) .to_string(), @@ -3856,7 +3903,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138026", + "purePhoneNumber": "13800138026", "code": "123456", "newPassword": "secret456" }) @@ -3979,7 +4026,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "user@example.com", + "purePhoneNumber": "user@example.com", "password": TEST_PASSWORD }) .to_string(), @@ -4571,7 +4618,7 @@ mod tests { ) .body(Body::from( serde_json::json!({ - "phone": "13800138020", + "purePhoneNumber": "13800138020", "password": TEST_PASSWORD }) .to_string(), @@ -4610,7 +4657,7 @@ mod tests { .header("x-client-instance-id", "logout-all-instance-002") .body(Body::from( serde_json::json!({ - "phone": "13800138020", + "purePhoneNumber": "13800138020", "password": TEST_PASSWORD }) .to_string(), diff --git a/server-rs/crates/api-server/src/auth_public_user.rs b/server-rs/crates/api-server/src/auth_public_user.rs index d1a641adc..e07b4f6ab 100644 --- a/server-rs/crates/api-server/src/auth_public_user.rs +++ b/server-rs/crates/api-server/src/auth_public_user.rs @@ -65,6 +65,7 @@ fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppEr module_auth::PasswordEntryError::Store(_) | module_auth::PasswordEntryError::PasswordHash(_) | module_auth::PasswordEntryError::InvalidPhoneNumber + | module_auth::PasswordEntryError::UnsupportedPhoneCountryCode | module_auth::PasswordEntryError::InvalidPasswordLength | module_auth::PasswordEntryError::InvalidDisplayName | module_auth::PasswordEntryError::InvalidAvatarDataUrl diff --git a/server-rs/crates/api-server/src/creation_agent_document_input.rs b/server-rs/crates/api-server/src/creation_agent_document_input.rs index cb0bd80bd..10d82001c 100644 --- a/server-rs/crates/api-server/src/creation_agent_document_input.rs +++ b/server-rs/crates/api-server/src/creation_agent_document_input.rs @@ -352,7 +352,8 @@ mod tests { .phone_auth_service() .send_code( SendPhoneCodeInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), scene: PhoneAuthScene::Login, }, now, @@ -363,7 +364,8 @@ mod tests { .phone_auth_service() .login( PhoneLoginInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), verify_code: "123456".to_string(), }, now + time::Duration::seconds(1), diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index eaa05fa86..f2e82660e 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -28,7 +28,8 @@ pub async fn password_entry( Json(payload): Json, ) -> Result { let input = PasswordEntryInput { - phone_number: payload.phone, + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, password: payload.password, }; let result = if state.config.dev_password_entry_auto_register_enabled { @@ -88,8 +89,15 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError { PasswordEntryError::InvalidPhoneNumber => AppError::from_status(StatusCode::BAD_REQUEST) .with_message("手机号格式不正确") .with_details(json!({ - "field": "phone", + "field": "purePhoneNumber", })), + PasswordEntryError::UnsupportedPhoneCountryCode => { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(error.to_string()) + .with_details(json!({ + "field": "countryCode", + })) + } PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST) .with_message("密码长度需要在 6 到 128 位之间") .with_details(json!({ @@ -98,7 +106,7 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError { PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST) .with_message("陶泥号格式不正确") .with_details(json!({ - "field": "phone", + "field": "purePhoneNumber", })), PasswordEntryError::InvalidDisplayName | PasswordEntryError::InvalidAvatarDataUrl diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 596140a2a..70f620791 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -85,7 +85,8 @@ pub async fn reset_password( .phone_auth_service() .reset_password( ResetPasswordInput { - phone_number: payload.phone, + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, verify_code: payload.code, new_password: payload.new_password, }, @@ -135,7 +136,9 @@ pub async fn reset_password( fn map_password_management_error(error: PasswordEntryError) -> AppError { match error { - PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => { + PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::UnsupportedPhoneCountryCode + | PasswordEntryError::InvalidPublicUserCode => { AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) } PasswordEntryError::InvalidDisplayName diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 55b2cce3e..a0e9cd753 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -41,7 +41,7 @@ pub async fn send_phone_code( ); } let scene = map_phone_auth_scene(payload.scene.as_deref())?; - let phone_input_masked = mask_phone_input(payload.phone.as_str()); + let phone_input_masked = mask_phone_input(payload.pure_phone_number.as_str()); info!( request_id = request_context.request_id(), operation = request_context.operation(), @@ -54,7 +54,8 @@ pub async fn send_phone_code( .phone_auth_service() .send_code( SendPhoneCodeInput { - phone_number: payload.phone, + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, scene: scene.clone(), }, OffsetDateTime::now_utc(), @@ -118,7 +119,8 @@ pub async fn phone_login( .phone_auth_service() .login( PhoneLoginInput { - phone_number: payload.phone, + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, verify_code: payload.code, }, OffsetDateTime::now_utc(), @@ -300,6 +302,7 @@ fn mask_phone_digits(value: &str) -> String { pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError { match error { PhoneAuthError::InvalidPhoneNumber + | PhoneAuthError::UnsupportedPhoneCountryCode | PhoneAuthError::InvalidVerifyCode | PhoneAuthError::VerifyCodeNotFound | PhoneAuthError::VerifyCodeExpired diff --git a/server-rs/crates/api-server/src/profile_identity.rs b/server-rs/crates/api-server/src/profile_identity.rs index 4a00cd1a7..fd91b8eb6 100644 --- a/server-rs/crates/api-server/src/profile_identity.rs +++ b/server-rs/crates/api-server/src/profile_identity.rs @@ -96,6 +96,7 @@ fn map_profile_update_error(error: PasswordEntryError) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) } PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::UnsupportedPhoneCountryCode | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials => { diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 4e263c3f3..0edcdf386 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1537,7 +1537,8 @@ impl AppState { self.phone_auth_service() .send_code( module_auth::SendPhoneCodeInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), scene: module_auth::PhoneAuthScene::Login, }, now, @@ -1548,7 +1549,8 @@ impl AppState { .phone_auth_service() .login( module_auth::PhoneLoginInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), verify_code: "123456".to_string(), }, now + time::Duration::seconds(1), diff --git a/server-rs/crates/api-server/src/volcengine_speech.rs b/server-rs/crates/api-server/src/volcengine_speech.rs index e6a5f7464..5b30638cd 100644 --- a/server-rs/crates/api-server/src/volcengine_speech.rs +++ b/server-rs/crates/api-server/src/volcengine_speech.rs @@ -506,7 +506,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( json!({ - "phone": "13800138088", + "purePhoneNumber": "13800138088", "password": "Password123" }) .to_string(), diff --git a/server-rs/crates/api-server/src/wechat/auth.rs b/server-rs/crates/api-server/src/wechat/auth.rs index 7496ad4e1..420438146 100644 --- a/server-rs/crates/api-server/src/wechat/auth.rs +++ b/server-rs/crates/api-server/src/wechat/auth.rs @@ -270,14 +270,15 @@ pub async fn bind_wechat_phone( .phone_auth_service() .bind_wechat_verified_phone(BindWechatVerifiedPhoneInput { user_id: authenticated.claims().user_id().to_string(), - phone_number: phone_profile.phone_number, + country_code: phone_profile.country_code, + pure_phone_number: phone_profile.pure_phone_number, wechat_display_name: payload.display_name.clone(), }) .await .map_err(map_wechat_bind_phone_error)? } else { - let phone = payload - .phone + let pure_phone_number = payload + .pure_phone_number .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) @@ -297,7 +298,8 @@ pub async fn bind_wechat_phone( .bind_wechat_phone( BindWechatPhoneInput { user_id: authenticated.claims().user_id().to_string(), - phone_number: phone.to_string(), + country_code: payload.country_code.clone(), + pure_phone_number: pure_phone_number.to_string(), verify_code: code.to_string(), wechat_display_name: payload.display_name.clone(), }, @@ -556,6 +558,7 @@ fn map_wechat_auth_error(error: WechatAuthError) -> AppError { fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError { match error { module_auth::PhoneAuthError::InvalidPhoneNumber + | module_auth::PhoneAuthError::UnsupportedPhoneCountryCode | module_auth::PhoneAuthError::InvalidVerifyCode | module_auth::PhoneAuthError::VerifyCodeNotFound | module_auth::PhoneAuthError::VerifyCodeExpired diff --git a/server-rs/crates/module-auth/src/commands.rs b/server-rs/crates/module-auth/src/commands.rs index 6706afe65..b014cb0a6 100644 --- a/server-rs/crates/module-auth/src/commands.rs +++ b/server-rs/crates/module-auth/src/commands.rs @@ -9,7 +9,8 @@ use crate::domain::{ #[derive(Clone, Debug, PartialEq, Eq)] pub struct PasswordEntryInput { - pub phone_number: String, + pub country_code: Option, + pub pure_phone_number: String, pub password: String, } @@ -22,7 +23,8 @@ pub struct ChangePasswordInput { #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResetPasswordInput { - pub phone_number: String, + pub country_code: Option, + pub pure_phone_number: String, pub verify_code: String, pub new_password: String, } @@ -36,13 +38,15 @@ pub struct UpdateProfileInput { #[derive(Clone, Debug, PartialEq, Eq)] pub struct SendPhoneCodeInput { - pub phone_number: String, + pub country_code: Option, + pub pure_phone_number: String, pub scene: PhoneAuthScene, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PhoneLoginInput { - pub phone_number: String, + pub country_code: Option, + pub pure_phone_number: String, pub verify_code: String, } @@ -68,7 +72,8 @@ pub struct CreateWechatAuthStateInput { #[derive(Clone, Debug, PartialEq, Eq)] pub struct BindWechatPhoneInput { pub user_id: String, - pub phone_number: String, + pub country_code: Option, + pub pure_phone_number: String, pub verify_code: String, pub wechat_display_name: Option, } @@ -76,7 +81,8 @@ pub struct BindWechatPhoneInput { #[derive(Clone, Debug, PartialEq, Eq)] pub struct BindWechatVerifiedPhoneInput { pub user_id: String, - pub phone_number: String, + pub country_code: String, + pub pure_phone_number: String, pub wechat_display_name: Option, } diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs index 1321b9824..e602d5dd5 100644 --- a/server-rs/crates/module-auth/src/domain.rs +++ b/server-rs/crates/module-auth/src/domain.rs @@ -13,6 +13,7 @@ pub const SMS_CODE_LENGTH: usize = 6; pub const SMS_CODE_TTL_MINUTES: i64 = 5; pub const SMS_CODE_COOLDOWN_SECONDS: u64 = 60; pub const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5; +pub const MAINLAND_CHINA_COUNTRY_CODE: &str = "86"; /// 用户最近一次完成认证的入口类型。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -252,9 +253,9 @@ pub fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> { } pub fn normalize_mainland_china_phone_number( - raw_phone_number: &str, + pure_phone_number: &str, ) -> Result { - let digits = raw_phone_number + let digits = pure_phone_number .trim() .chars() .filter(|character| character.is_ascii_digit()) @@ -269,6 +270,16 @@ pub fn normalize_mainland_china_phone_number( }) } +pub fn validate_mainland_china_country_code( + country_code: Option<&str>, +) -> Result<(), PhoneAuthError> { + match country_code { + None => Ok(()), + Some(country_code) if country_code.trim() == MAINLAND_CHINA_COUNTRY_CODE => Ok(()), + Some(_) => Err(PhoneAuthError::UnsupportedPhoneCountryCode), + } +} + pub fn mask_phone_number(phone_number: &str) -> String { format!("{}****{}", &phone_number[..3], &phone_number[7..11]) } diff --git a/server-rs/crates/module-auth/src/errors.rs b/server-rs/crates/module-auth/src/errors.rs index 1757606ca..75c4040e7 100644 --- a/server-rs/crates/module-auth/src/errors.rs +++ b/server-rs/crates/module-auth/src/errors.rs @@ -7,6 +7,7 @@ use std::{error::Error, fmt}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum PasswordEntryError { InvalidPhoneNumber, + UnsupportedPhoneCountryCode, InvalidPasswordLength, InvalidDisplayName, InvalidAvatarDataUrl, @@ -21,6 +22,7 @@ pub enum PasswordEntryError { #[derive(Clone, Debug, PartialEq, Eq)] pub enum PhoneAuthError { InvalidPhoneNumber, + UnsupportedPhoneCountryCode, InvalidVerifyCode, VerifyCodeNotFound, VerifyCodeExpired, @@ -66,6 +68,7 @@ impl fmt::Display for PasswordEntryError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), + Self::UnsupportedPhoneCountryCode => f.write_str("仅支持中国大陆手机号(+86)"), Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"), Self::InvalidDisplayName => f.write_str("昵称格式不正确"), Self::InvalidAvatarDataUrl => f.write_str("头像图片格式不正确"), @@ -84,6 +87,7 @@ impl fmt::Display for PhoneAuthError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), + Self::UnsupportedPhoneCountryCode => f.write_str("仅支持中国大陆手机号(+86)"), Self::InvalidVerifyCode => f.write_str("验证码错误"), Self::VerifyCodeNotFound => f.write_str("验证码不存在或已失效"), Self::VerifyCodeExpired => f.write_str("验证码已过期"), @@ -147,6 +151,7 @@ pub(crate) fn map_password_store_error(error: PasswordEntryError) -> RefreshSess match error { PasswordEntryError::Store(message) => RefreshSessionError::Store(message), PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::UnsupportedPhoneCountryCode | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidDisplayName | PasswordEntryError::InvalidAvatarDataUrl @@ -165,6 +170,7 @@ pub(crate) fn map_password_error_to_phone_error(error: PasswordEntryError) -> Ph PasswordEntryError::Store(message) => PhoneAuthError::Store(message), PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message), PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::UnsupportedPhoneCountryCode | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidDisplayName | PasswordEntryError::InvalidAvatarDataUrl @@ -179,6 +185,7 @@ pub(crate) fn map_password_error_to_logout_error(error: PasswordEntryError) -> L match error { PasswordEntryError::Store(message) => LogoutError::Store(message), PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::UnsupportedPhoneCountryCode | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidDisplayName | PasswordEntryError::InvalidAvatarDataUrl diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 6682c8406..6336495d0 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -207,8 +207,10 @@ impl PasswordEntryService { input: PasswordEntryInput, ) -> Result { validate_password(&input.password)?; - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number) - .map_err(|_| PasswordEntryError::InvalidPhoneNumber)?; + validate_mainland_china_country_code(input.country_code.as_deref()) + .map_err(map_phone_number_error_to_password_error)?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number) + .map_err(map_phone_number_error_to_password_error)?; let Some(existing_user) = self .store .find_by_phone_number_for_password(&normalized_phone.e164)? @@ -224,8 +226,10 @@ impl PasswordEntryService { input: PasswordEntryInput, ) -> Result { validate_password(&input.password)?; - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number) - .map_err(|_| PasswordEntryError::InvalidPhoneNumber)?; + validate_mainland_china_country_code(input.country_code.as_deref()) + .map_err(map_phone_number_error_to_password_error)?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number) + .map_err(map_phone_number_error_to_password_error)?; if let Some(existing_user) = self .store .find_by_phone_number_for_password(&normalized_phone.e164)? @@ -508,7 +512,8 @@ impl PhoneAuthService { now: OffsetDateTime, ) -> Result { let scene = input.scene.clone(); - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; let national_phone_number = build_national_phone_number(&normalized_phone.e164)?; let verify_code = self.generate_phone_verify_code(); info!( @@ -591,7 +596,8 @@ impl PhoneAuthService { input: PhoneLoginInput, now: OffsetDateTime, ) -> Result { - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; verify_sms_code_format(&input.verify_code)?; let provider_out_id = self.verify_phone_code( &normalized_phone.e164, @@ -640,7 +646,8 @@ impl PhoneAuthService { input: ResetPasswordInput, now: OffsetDateTime, ) -> Result { - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; verify_sms_code_format(&input.verify_code)?; validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?; let provider_out_id = self.verify_phone_code( @@ -673,7 +680,8 @@ impl PhoneAuthService { input: BindWechatPhoneInput, now: OffsetDateTime, ) -> Result { - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; verify_sms_code_format(&input.verify_code)?; self.verify_phone_code( &normalized_phone.e164, @@ -739,7 +747,8 @@ impl PhoneAuthService { &self, input: BindWechatVerifiedPhoneInput, ) -> Result { - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + validate_mainland_china_country_code(Some(&input.country_code))?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; let current_user = self .store .find_by_user_id(&input.user_id) @@ -2569,6 +2578,15 @@ fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthEr } } +fn map_phone_number_error_to_password_error(error: PhoneAuthError) -> PasswordEntryError { + match error { + PhoneAuthError::UnsupportedPhoneCountryCode => { + PasswordEntryError::UnsupportedPhoneCountryCode + } + _ => PasswordEntryError::InvalidPhoneNumber, + } +} + async fn verify_stored_password_user( existing_user: StoredPasswordUser, password: &str, @@ -2733,6 +2751,32 @@ mod tests { ); } + #[test] + fn mainland_china_phone_normalization_accepts_pure_phone_number() { + let national = normalize_mainland_china_phone_number("13800138000") + .expect("national phone should normalize"); + + assert_eq!(national.e164, "+8613800138000"); + } + + #[test] + fn mainland_china_country_code_defaults_to_china_and_accepts_explicit_86() { + validate_mainland_china_country_code(None).expect("missing country code should default"); + validate_mainland_china_country_code(Some(" 86 ")) + .expect("explicit mainland China country code should pass"); + } + + #[test] + fn mainland_china_country_code_rejects_foreign_or_non_wechat_format() { + for country_code in ["1", "+86", ""] { + let error = validate_mainland_china_country_code(Some(country_code)) + .expect_err("unsupported country code should fail"); + + assert_eq!(error, PhoneAuthError::UnsupportedPhoneCountryCode); + assert_eq!(error.to_string(), "仅支持中国大陆手机号(+86)"); + } + } + fn build_store() -> InMemoryAuthStore { InMemoryAuthStore::default() } @@ -2830,7 +2874,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), scene: PhoneAuthScene::Login, }, now, @@ -2840,7 +2885,8 @@ mod tests { phone_service .login( PhoneLoginInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -2856,7 +2902,8 @@ mod tests { let error = service .execute(PasswordEntryInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), password: "secret123".to_string(), }) .await @@ -2871,21 +2918,24 @@ mod tests { let created = service .execute_with_dev_registration(PasswordEntryInput { - phone_number: "13800138009".to_string(), + country_code: None, + pure_phone_number: "13800138009".to_string(), password: "secret123".to_string(), }) .await .expect("dev registration should create user"); let reused = service .execute_with_dev_registration(PasswordEntryInput { - phone_number: "13800138009".to_string(), + country_code: None, + pure_phone_number: "13800138009".to_string(), password: "secret123".to_string(), }) .await .expect("same password should reuse created user"); let wrong_password = service .execute_with_dev_registration(PasswordEntryInput { - phone_number: "13800138009".to_string(), + country_code: None, + pure_phone_number: "13800138009".to_string(), password: "secret999".to_string(), }) .await @@ -2914,7 +2964,8 @@ mod tests { .expect("phone user should set first password"); let result = service .execute(PasswordEntryInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), password: "secret123".to_string(), }) .await @@ -3020,7 +3071,8 @@ mod tests { assert_eq!( password_service .execute(PasswordEntryInput { - phone_number: "13800138030".to_string(), + country_code: None, + pure_phone_number: "13800138030".to_string(), password: "secret123".to_string(), }) .await @@ -3029,7 +3081,8 @@ mod tests { ); let login = password_service .execute(PasswordEntryInput { - phone_number: "13800138030".to_string(), + country_code: None, + pure_phone_number: "13800138030".to_string(), password: "secret456".to_string(), }) .await @@ -3053,7 +3106,8 @@ mod tests { let error = service .execute(PasswordEntryInput { - phone_number: "13800138001".to_string(), + country_code: None, + pure_phone_number: "13800138001".to_string(), password: "secret999".to_string(), }) .await @@ -3070,7 +3124,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138002".to_string(), + country_code: None, + pure_phone_number: "13800138002".to_string(), scene: PhoneAuthScene::ResetPassword, }, now, @@ -3081,7 +3136,8 @@ mod tests { let error = phone_service .reset_password( ResetPasswordInput { - phone_number: "13800138002".to_string(), + country_code: None, + pure_phone_number: "13800138002".to_string(), verify_code: "123456".to_string(), new_password: "secret123".to_string(), }, @@ -3099,7 +3155,8 @@ mod tests { let created = service .execute_with_dev_registration(PasswordEntryInput { - phone_number: "13800138004".to_string(), + country_code: None, + pure_phone_number: "13800138004".to_string(), password: "secret123".to_string(), }) .await @@ -3119,7 +3176,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138005".to_string(), + country_code: None, + pure_phone_number: "13800138005".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3130,7 +3188,8 @@ mod tests { let created = phone_service .login( PhoneLoginInput { - phone_number: "13800138005".to_string(), + country_code: None, + pure_phone_number: "13800138005".to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -3164,7 +3223,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138009".to_string(), + country_code: None, + pure_phone_number: "13800138009".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3175,7 +3235,8 @@ mod tests { let reused = phone_service .login( PhoneLoginInput { - phone_number: "13800138009".to_string(), + country_code: None, + pure_phone_number: "13800138009".to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -3263,7 +3324,8 @@ mod tests { local_phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138034".to_string(), + country_code: None, + pure_phone_number: "13800138034".to_string(), scene: PhoneAuthScene::Login, }, local_now, @@ -3286,7 +3348,8 @@ mod tests { local_phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138034".to_string(), + country_code: None, + pure_phone_number: "13800138034".to_string(), scene: PhoneAuthScene::Login, }, local_now + Duration::seconds(5), @@ -3304,7 +3367,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138032".to_string(), + country_code: None, + pure_phone_number: "13800138032".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3314,7 +3378,8 @@ mod tests { let result = phone_service .login( PhoneLoginInput { - phone_number: "13800138032".to_string(), + country_code: None, + pure_phone_number: "13800138032".to_string(), verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), }, now + Duration::seconds(1), @@ -3335,7 +3400,8 @@ mod tests { let error = service .execute(PasswordEntryInput { - phone_number: "user@example.com".to_string(), + country_code: None, + pure_phone_number: "user@example.com".to_string(), password: "secret123".to_string(), }) .await @@ -3344,6 +3410,22 @@ mod tests { assert_eq!(error, PasswordEntryError::InvalidPhoneNumber); } + #[tokio::test] + async fn password_entry_rejects_foreign_country_code() { + let service = build_password_service(build_store()); + + let error = service + .execute(PasswordEntryInput { + country_code: Some("1".to_string()), + pure_phone_number: "12025550123".to_string(), + password: "secret123".to_string(), + }) + .await + .expect_err("foreign phone should fail"); + + assert_eq!(error, PasswordEntryError::UnsupportedPhoneCountryCode); + } + #[tokio::test] async fn phone_send_code_rejects_same_scene_during_cooldown() { let service = build_phone_service(build_store()); @@ -3352,7 +3434,8 @@ mod tests { service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3363,7 +3446,8 @@ mod tests { let error = service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now + Duration::seconds(10), @@ -3387,7 +3471,8 @@ mod tests { service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3396,7 +3481,8 @@ mod tests { .expect("login scene code should send"); let bind_result = service.send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::BindPhone, }, now + Duration::seconds(1), @@ -3421,7 +3507,8 @@ mod tests { service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3433,7 +3520,8 @@ mod tests { let error = service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: "000000".to_string(), }, now + Duration::seconds(i64::from(attempt)), @@ -3446,7 +3534,8 @@ mod tests { let exhausted_error = service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: "000000".to_string(), }, now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS)), @@ -3458,7 +3547,8 @@ mod tests { let missing_error = service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), }, now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 1)), @@ -3470,7 +3560,8 @@ mod tests { service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 2)), @@ -3480,7 +3571,8 @@ mod tests { let login = service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), }, now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 3)), @@ -3930,7 +4022,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3940,7 +4033,8 @@ mod tests { let phone_user = phone_service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -4009,7 +4103,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -4019,7 +4114,8 @@ mod tests { let phone_user = phone_service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -4055,7 +4151,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::BindPhone, }, now + Duration::seconds(2), @@ -4066,7 +4163,8 @@ mod tests { .bind_wechat_phone( BindWechatPhoneInput { user_id: wechat_user.id.clone(), - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: "123456".to_string(), wechat_display_name: None, }, @@ -4107,6 +4205,46 @@ mod tests { assert_eq!(reused_wechat_user.user.display_name, "已归并微信用户"); } + #[tokio::test] + async fn bind_wechat_verified_phone_rejects_foreign_country_before_account_merge() { + let store = build_store(); + let phone_service = build_phone_service(store.clone()); + let wechat_user = WechatAuthService::new(store.clone()) + .resolve_login(ResolveWechatLoginInput { + profile: WechatIdentityProfile { + provider_uid: "wx-openid-foreign-phone".to_string(), + provider_union_id: None, + display_name: Some("境外手机号用户".to_string()), + avatar_url: None, + session_key: None, + }, + }) + .await + .expect("wechat login should succeed") + .user; + + let error = phone_service + .bind_wechat_verified_phone(BindWechatVerifiedPhoneInput { + user_id: wechat_user.id.clone(), + country_code: "1".to_string(), + pure_phone_number: "12025550123".to_string(), + wechat_display_name: None, + }) + .await + .expect_err("foreign country code must fail before account merge"); + + assert_eq!(error, PhoneAuthError::UnsupportedPhoneCountryCode); + let unchanged_user = store + .find_by_user_id(&wechat_user.id) + .expect("user lookup should succeed") + .expect("pending wechat user should remain"); + assert_eq!( + unchanged_user.user.binding_status, + AuthBindingStatus::PendingBindPhone + ); + assert!(unchanged_user.user.phone_number.is_none()); + } + #[tokio::test] async fn bind_wechat_phone_merges_when_existing_phone_restored_from_projection() { let store = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { @@ -4140,7 +4278,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138032".to_string(), + country_code: None, + pure_phone_number: "13800138032".to_string(), scene: PhoneAuthScene::BindPhone, }, now, @@ -4151,7 +4290,8 @@ mod tests { .bind_wechat_phone( BindWechatPhoneInput { user_id: wechat_user.id, - phone_number: "13800138032".to_string(), + country_code: None, + pure_phone_number: "13800138032".to_string(), verify_code: "123456".to_string(), wechat_display_name: None, }, @@ -4191,7 +4331,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138031".to_string(), + country_code: None, + pure_phone_number: "13800138031".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -4201,7 +4342,8 @@ mod tests { let phone_user = phone_service .login( PhoneLoginInput { - phone_number: "13800138031".to_string(), + country_code: None, + pure_phone_number: "13800138031".to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -4234,7 +4376,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138031".to_string(), + country_code: None, + pure_phone_number: "13800138031".to_string(), scene: PhoneAuthScene::BindPhone, }, now + Duration::seconds(2), @@ -4245,7 +4388,8 @@ mod tests { .bind_wechat_phone( BindWechatPhoneInput { user_id: wechat_user.id.clone(), - phone_number: "13800138031".to_string(), + country_code: None, + pure_phone_number: "13800138031".to_string(), verify_code: "123456".to_string(), wechat_display_name: Some("补填微信昵称".to_string()), }, diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index 03898a03a..177e1df28 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -242,9 +242,8 @@ pub struct RealWechatProvider { #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatPhoneNumberProfile { - pub phone_number: String, - pub pure_phone_number: Option, - pub country_code: Option, + pub pure_phone_number: String, + pub country_code: String, } #[derive(Clone, Debug)] @@ -362,17 +361,44 @@ struct WechatPhoneNumberResponse { phone_info: Option, } +// 微信成功响应按官方文档必须包含这三个字段: +// https://developers.weixin.qq.com/miniprogram/dev/server/API/user-info/phone-number/api_getphonenumber.html#Res-phone-info-Object-Payload #[derive(Debug, Deserialize)] struct WechatPhoneNumberInfo { - #[serde(default)] - #[serde(alias = "phoneNumber")] - phone_number: Option, - #[serde(default)] - #[serde(alias = "purePhoneNumber")] - pure_phone_number: Option, - #[serde(default)] - #[serde(alias = "countryCode")] - country_code: Option, + #[serde(rename = "phoneNumber")] + phone_number: String, + #[serde(rename = "purePhoneNumber")] + pure_phone_number: String, + #[serde(rename = "countryCode")] + country_code: String, +} + +fn normalize_wechat_phone_number_info( + phone_info: WechatPhoneNumberInfo, +) -> Result { + let pure_phone_number = phone_info.pure_phone_number.trim(); + if pure_phone_number.is_empty() { + return Err(WechatProviderError::MissingProfile( + "微信手机号授权失败:缺少纯手机号".to_string(), + )); + } + let country_code = phone_info.country_code.trim(); + if country_code.is_empty() { + return Err(WechatProviderError::MissingProfile( + "微信手机号授权失败:缺少国家码".to_string(), + )); + } + let phone_number = phone_info.phone_number.trim(); + if phone_number.is_empty() { + return Err(WechatProviderError::MissingProfile( + "微信手机号授权失败:缺少完整手机号".to_string(), + )); + } + + Ok(WechatPhoneNumberProfile { + pure_phone_number: pure_phone_number.to_string(), + country_code: country_code.to_string(), + }) } #[derive(Debug, Deserialize)] @@ -792,9 +818,8 @@ impl WechatProvider { .unwrap_or("13800138000") .to_string(); Ok(WechatPhoneNumberProfile { - phone_number: phone_number.clone(), - pure_phone_number: Some(phone_number), - country_code: Some("86".to_string()), + pure_phone_number: phone_number, + country_code: "86".to_string(), }) } Self::Real(provider) => provider.resolve_mini_program_phone_number(code).await, @@ -1118,20 +1143,7 @@ impl RealWechatProvider { let phone_info = payload.phone_info.ok_or_else(|| { WechatProviderError::MissingProfile("微信手机号授权失败:缺少手机号信息".to_string()) })?; - let phone_number = phone_info - .pure_phone_number - .clone() - .or(phone_info.phone_number.clone()) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - WechatProviderError::MissingProfile("微信手机号授权失败:缺少手机号".to_string()) - })?; - - Ok(WechatPhoneNumberProfile { - phone_number, - pure_phone_number: phone_info.pure_phone_number, - country_code: phone_info.country_code, - }) + normalize_wechat_phone_number_info(phone_info) } async fn request_mini_program_access_token( @@ -2074,7 +2086,7 @@ mod tests { "errcode": 0, "errmsg": "ok", "phone_info": { - "phoneNumber": "+8613800138000", + "phoneNumber": "13800138000", "purePhoneNumber": "13800138000", "countryCode": "86" } @@ -2083,9 +2095,54 @@ mod tests { .expect("wechat phone number response should parse"); let phone_info = payload.phone_info.expect("phone info should exist"); - assert_eq!(phone_info.phone_number.as_deref(), Some("+8613800138000")); - assert_eq!(phone_info.pure_phone_number.as_deref(), Some("13800138000")); - assert_eq!(phone_info.country_code.as_deref(), Some("86")); + assert_eq!(phone_info.phone_number, "13800138000"); + assert_eq!(phone_info.pure_phone_number, "13800138000"); + assert_eq!(phone_info.country_code, "86"); + let profile = normalize_wechat_phone_number_info(phone_info) + .expect("wechat phone profile should normalize"); + assert_eq!(profile.pure_phone_number, "13800138000"); + assert_eq!(profile.country_code, "86"); + } + + #[test] + fn wechat_phone_number_success_response_requires_country_code() { + let error = serde_json::from_str::( + r#"{ + "errcode": 0, + "phone_info": { + "phoneNumber": "+8613800138000", + "purePhoneNumber": "13800138000" + } + }"#, + ) + .expect_err("missing provider country code should fail deserialization"); + + assert!(error.to_string().contains("countryCode")); + } + + #[test] + fn wechat_phone_number_error_response_may_omit_phone_info() { + let payload = serde_json::from_str::( + r#"{ + "errcode": 40029, + "errmsg": "invalid code" + }"#, + ) + .expect("wechat error response should remain parseable"); + + assert!(payload.phone_info.is_none()); + } + + #[test] + fn wechat_phone_number_profile_requires_non_empty_phone_number() { + let error = normalize_wechat_phone_number_info(WechatPhoneNumberInfo { + phone_number: " ".to_string(), + pure_phone_number: "13800138000".to_string(), + country_code: "86".to_string(), + }) + .expect_err("empty provider phone number should fail closed"); + + assert!(matches!(error, WechatProviderError::MissingProfile(_))); } #[test] diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index a654414ec..8555480e4 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -47,7 +47,9 @@ pub struct PublicUserSearchResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PasswordEntryRequest { - pub phone: String, + #[serde(default)] + pub country_code: Option, + pub pure_phone_number: String, pub password: String, } @@ -87,7 +89,9 @@ pub struct ProfileUpdateResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PasswordResetRequest { - pub phone: String, + #[serde(default)] + pub country_code: Option, + pub pure_phone_number: String, pub code: String, pub new_password: String, } @@ -149,7 +153,9 @@ pub struct RevokeAuthSessionResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PhoneSendCodeRequest { - pub phone: String, + #[serde(default)] + pub country_code: Option, + pub pure_phone_number: String, pub scene: Option, } @@ -165,7 +171,9 @@ pub struct PhoneSendCodeResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PhoneLoginRequest { - pub phone: String, + #[serde(default)] + pub country_code: Option, + pub pure_phone_number: String, pub code: String, #[serde(default)] pub invite_code: Option, @@ -214,7 +222,9 @@ pub struct WechatCallbackQuery { #[serde(rename_all = "camelCase")] pub struct WechatBindPhoneRequest { #[serde(default)] - pub phone: Option, + pub country_code: Option, + #[serde(default)] + pub pure_phone_number: Option, #[serde(default)] pub code: Option, #[serde(default)] @@ -294,7 +304,8 @@ mod tests { #[test] fn password_entry_request_uses_camel_case_fields() { let payload = serde_json::to_value(PasswordEntryRequest { - phone: "13800138000".to_string(), + country_code: Some("86".to_string()), + pure_phone_number: "13800138000".to_string(), password: "secret123".to_string(), }) .expect("payload should serialize"); @@ -302,12 +313,32 @@ mod tests { assert_eq!( payload, json!({ - "phone": "13800138000", + "countryCode": "86", + "purePhoneNumber": "13800138000", "password": "secret123" }) ); } + #[test] + fn password_entry_request_defaults_country_code_and_rejects_legacy_phone_field() { + let request = serde_json::from_value::(json!({ + "purePhoneNumber": "13800138000", + "password": "secret123" + })) + .expect("country code should be optional"); + assert_eq!(request.country_code, None); + + let legacy = serde_json::from_value::(json!({ + "phone": "13800138000", + "password": "secret123" + })); + assert!( + legacy.is_err(), + "legacy phone field must not satisfy request" + ); + } + #[test] fn profile_update_request_uses_camel_case_fields() { let payload = serde_json::to_value(ProfileUpdateRequest { @@ -347,7 +378,8 @@ mod tests { #[test] fn wechat_bind_phone_request_accepts_mini_program_phone_code() { let payload = serde_json::to_value(WechatBindPhoneRequest { - phone: None, + country_code: None, + pure_phone_number: None, code: None, wechat_phone_code: Some("wx-phone-code-001".to_string()), display_name: Some("陶泥儿玩家".to_string()), @@ -357,7 +389,8 @@ mod tests { assert_eq!( payload, json!({ - "phone": null, + "countryCode": null, + "purePhoneNumber": null, "code": null, "wechatPhoneCode": "wx-phone-code-001", "displayName": "陶泥儿玩家" diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 9715780b5..11cdae721 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -39,10 +39,9 @@ const authMocks = vi.hoisted(() => ({ })); vi.mock('../../services/apiClient', async () => { - const actual = - await vi.importActual( - '../../services/apiClient', - ); + const actual = await vi.importActual< + typeof import('../../services/apiClient') + >('../../services/apiClient'); return { ...actual, @@ -480,9 +479,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi const phoneInput = within(dialog).getByLabelText( '手机号', ) as HTMLInputElement; - const codeInput = within(dialog).getByLabelText( - '验证码', - ) as HTMLInputElement; + const codeInput = within(dialog).getByLabelText('验证码') as HTMLInputElement; expect(phoneInput.className).toContain('platform-text-field'); expect(codeInput.className).toContain('platform-text-field'); @@ -937,6 +934,28 @@ test('auth gate shows sms send feedback in the login modal', async () => { expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy(); }); +test('auth gate shows mainland China phone validation errors', async () => { + const user = userEvent.setup(); + authMocks.sendPhoneLoginCode.mockRejectedValueOnce( + new Error('仅支持中国大陆手机号(+86)'), + ); + + render( + + + , + ); + + await user.click(await screen.findByRole('button', { name: '进入作品' })); + const dialog = screen.getByRole('dialog', { name: '账号入口' }); + await user.type(within(dialog).getByLabelText('手机号'), '+12025550123'); + await user.click(within(dialog).getByRole('button', { name: '获取验证码' })); + + expect( + await within(dialog).findByText('仅支持中国大陆手机号(+86)'), + ).toBeTruthy(); +}); + test('login modal resets draft state every time it is reopened', async () => { const user = userEvent.setup(); diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 669704f3d..15a94e055 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -530,7 +530,9 @@ function PhoneCodeForm({ tone="secondary" size="lg" className="shrink-0 text-sm" - onClick={() => void onSendCode()} + onClick={() => { + void onSendCode().catch(() => undefined); + }} > {sendingCode ? '发送中' @@ -624,7 +626,9 @@ function PasswordResetPanel({ tone="secondary" size="lg" className="shrink-0 text-sm" - onClick={() => void onSendCode()} + onClick={() => { + void onSendCode().catch(() => undefined); + }} > {sendingCode ? '发送中' diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index d275cf9f4..d01f5e814 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -20,10 +20,9 @@ vi.mock('./apiClient', async () => { }); vi.mock('./host-bridge/hostBridge', async () => { - const actual = - await vi.importActual( - './host-bridge/hostBridge', - ); + const actual = await vi.importActual< + typeof import('./host-bridge/hostBridge') + >('./host-bridge/hostBridge'); return { ...actual, openHostExternalUrl: hostBridgeMocks.openHostExternalUrl, @@ -49,6 +48,7 @@ import { liftAuthRiskBlock, loginWithPhoneCode, logoutAllAuthSessions, + normalizePhoneInput, redeemRegistrationInviteCode, requestWechatMiniProgramPhoneLogin, revokeAuthSession, @@ -57,6 +57,7 @@ import { startWechatBind, startWechatLogin, updateAuthProfile, + validateAndNormalizeMainlandChinaPhoneInput, } from './authService'; function createLocalStorageMock() { @@ -104,6 +105,26 @@ describe('authService', () => { clearStoredAccessToken({ emit: false }); }); + it('normalizes mainland China browser autofill phone numbers to national format', () => { + expect(normalizePhoneInput('+86 198 7654 3210')).toBe('19876543210'); + expect(normalizePhoneInput('86-198-7654-3210')).toBe('19876543210'); + expect(normalizePhoneInput('198 7654 3210')).toBe('19876543210'); + }); + + it('validates mainland China phone numbers before calling auth APIs', async () => { + expect( + validateAndNormalizeMainlandChinaPhoneInput('+86 198 7654 3210'), + ).toBe('19876543210'); + expect(validateAndNormalizeMainlandChinaPhoneInput('198 7654 3210')).toBe( + '19876543210', + ); + + await expect(sendPhoneLoginCode('+1 202 555 0123')).rejects.toThrow( + '仅支持中国大陆手机号(+86)', + ); + expect(apiClientMocks.requestJson).not.toHaveBeenCalled(); + }); + it('auth entry posts phone password credentials and 写入 access token', async () => { apiClientMocks.requestJson.mockResolvedValue({ token: 'jwt-entry-token', @@ -126,7 +147,8 @@ describe('authService', () => { '/api/auth/entry', expect.objectContaining({ body: JSON.stringify({ - phone: '13800138000', + countryCode: '86', + purePhoneNumber: '13800138000', password: 'secret123', }), }), @@ -217,14 +239,15 @@ describe('authService', () => { providerRequestId: 'mock-request-id', }); - const result = await sendPhoneLoginCode(' 138 0013 8000 '); + const result = await sendPhoneLoginCode('+86 138 0013 8000'); expect(result.cooldownSeconds).toBe(60); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/phone/send-code', expect.objectContaining({ body: JSON.stringify({ - phone: '13800138000', + countryCode: '86', + purePhoneNumber: '13800138000', scene: 'login', }), }), @@ -277,7 +300,7 @@ describe('authService', () => { }); const response = await loginWithPhoneCode( - '13800138000', + '+86 138 0013 8000', '123456', 'spring-2026', ); @@ -287,7 +310,8 @@ describe('authService', () => { '/api/auth/phone/login', expect.objectContaining({ body: JSON.stringify({ - phone: '13800138000', + countryCode: '86', + purePhoneNumber: '13800138000', code: '123456', inviteCode: 'SPRING2026', }), @@ -356,6 +380,17 @@ describe('authService', () => { const user = await bindWechatPhone('13800138000', '123456'); expect(user.wechatBound).toBe(true); + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/auth/wechat/bind-phone', + expect.objectContaining({ + body: JSON.stringify({ + countryCode: '86', + purePhoneNumber: '13800138000', + code: '123456', + }), + }), + '绑定手机号失败', + ); expect(getStoredAccessToken()).toBe('jwt-wechat-bind-token'); expect(window.dispatchEvent).not.toHaveBeenCalled(); }); @@ -377,6 +412,17 @@ describe('authService', () => { const user = await changePhoneNumber('13900139000', '123456'); expect(user.phoneNumberMasked).toBe('139****9000'); + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/auth/phone/change', + expect.objectContaining({ + body: JSON.stringify({ + countryCode: '86', + purePhoneNumber: '13900139000', + code: '123456', + }), + }), + '更换手机号失败', + ); expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled(); }); @@ -504,9 +550,11 @@ describe('authService', () => { }); it('requests mini program phone login by opening the native auth page', async () => { - const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { - options.success?.(); - }); + const navigateTo = vi.fn( + (options: { url: string; success?: () => void }) => { + options.success?.(); + }, + ); vi.stubGlobal( 'window', createWindowMock({ @@ -555,16 +603,16 @@ describe('authService', () => { }); it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => { - const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { - options.success?.(); - }); + const navigateTo = vi.fn( + (options: { url: string; success?: () => void }) => { + options.success?.(); + }, + ); const scriptListeners = new Map(); const existingScript = { - addEventListener: vi.fn( - (type: string, listener: EventListener) => { - scriptListeners.set(type, listener); - }, - ), + addEventListener: vi.fn((type: string, listener: EventListener) => { + scriptListeners.set(type, listener); + }), }; vi.stubGlobal( 'window', diff --git a/src/services/authService.ts b/src/services/authService.ts index 8cb86f967..98936f574 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -67,9 +67,38 @@ const PUBLIC_AUTH_REQUEST_OPTIONS = { } satisfies ApiRequestOptions; const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone'; +const INVALID_MAINLAND_CHINA_PHONE_MESSAGE = '手机号格式不正确'; +const UNSUPPORTED_PHONE_COUNTRY_CODE_MESSAGE = '仅支持中国大陆手机号(+86)'; export function normalizePhoneInput(phoneInput: string) { - return phoneInput.replace(/[^\d+]/gu, '').trim(); + const compactPhone = phoneInput.trim().replace(/[^\d+]/gu, ''); + const mainlandChinaInternationalPhone = + compactPhone.match(/^\+?86(1\d{10})$/u); + + return mainlandChinaInternationalPhone?.[1] ?? compactPhone; +} + +export function validateAndNormalizeMainlandChinaPhoneInput( + phoneInput: string, +) { + const compactPhone = phoneInput.trim().replace(/[^\d+]/gu, ''); + if (compactPhone.startsWith('+') && !compactPhone.startsWith('+86')) { + throw new Error(UNSUPPORTED_PHONE_COUNTRY_CODE_MESSAGE); + } + + const normalizedPhone = normalizePhoneInput(phoneInput); + if (!/^1\d{10}$/u.test(normalizedPhone)) { + throw new Error(INVALID_MAINLAND_CHINA_PHONE_MESSAGE); + } + + return normalizedPhone; +} + +function buildMainlandChinaPhoneInput(phoneInput: string) { + return { + countryCode: '86', + purePhoneNumber: validateAndNormalizeMainlandChinaPhoneInput(phoneInput), + } as const; } export function normalizeInviteCodeInput(inviteCode: string | undefined) { @@ -92,10 +121,7 @@ export function setStoredLastLoginPhone(phone: string) { return; } - const normalizedPhone = normalizePhoneInput(phone); - if (!normalizedPhone) { - return; - } + const normalizedPhone = validateAndNormalizeMainlandChinaPhoneInput(phone); window.localStorage.setItem(LAST_LOGIN_PHONE_STORAGE_KEY, normalizedPhone); } @@ -146,7 +172,7 @@ export async function sendPhoneLoginCode( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), scene, captchaChallengeId: captcha?.challengeId?.trim() || undefined, captchaAnswer: captcha?.answer?.trim() || undefined, @@ -171,7 +197,7 @@ export async function loginWithPhoneCode( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), code: code.trim(), ...(normalizedInviteCode ? { inviteCode: normalizedInviteCode } : {}), }), @@ -200,7 +226,7 @@ export async function redeemRegistrationInviteCode(inviteCode: string) { export async function bindWechatPhone(phone: string, code: string) { const payload: AuthWechatBindPhoneRequest = { - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), code: code.trim(), }; const response = await requestJson( @@ -224,7 +250,7 @@ export async function changePhoneNumber(phone: string, code: string) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), code: code.trim(), }), }, @@ -289,7 +315,7 @@ export async function authEntry(phone: string, password: string) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), password: password.trim(), }), }, @@ -350,7 +376,7 @@ export async function resetPassword( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), code: code.trim(), newPassword: newPassword.trim(), }), diff --git a/vitest.config.ts b/vitest.config.ts index f3be028ad..3cea4602d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,7 +57,6 @@ export default defineConfig({ ], exclude: [ 'apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx', - 'apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx', 'scripts/loadtest/**', ], },