feat: add authenticated phone binding

This commit is contained in:
Codex
2026-06-29 09:53:44 +08:00
parent 6f0083bd13
commit 64d58d895e
12 changed files with 441 additions and 105 deletions

View File

@@ -28,7 +28,7 @@
还没有达到生产交付的部分:
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配微信网页登录、QQ 登录、手机号换绑、完整资金流水对账和真实生产账号联调还没接完。
- 阿里云/腾讯云短信、微信小程序登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配微信网页登录、QQ 登录、完整资金流水对账和真实生产账号联调还没接完。
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF/图片预览和资源复检 worker 已完成CDN 防盗链、杀毒扫描和视频动态水印还没完成。
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
- 题库导出目前完成服务端结构化 payloadPDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。

View File

@@ -1,5 +1,6 @@
import type { RouteDefinition } from '../../core/router.js';
import {
bindPhoneRoute,
logoutRoute,
meRoute,
oauthProviderPlaceholderRoute,
@@ -13,6 +14,7 @@ export const authRoutes: RouteDefinition[] = [
['POST', '/api/auth/sms/verify', verifySmsCodeRoute],
['GET', '/api/auth/me', meRoute],
['POST', '/api/auth/logout', logoutRoute],
['POST', '/api/auth/phone/bind', bindPhoneRoute],
['POST', '/api/auth/oauth/wechat', oauthProviderPlaceholderRoute],
['POST', '/api/auth/oauth/wechat-miniapp', wechatMiniappLoginRoute],
['POST', '/api/auth/oauth/qq', oauthProviderPlaceholderRoute],

View File

@@ -23,6 +23,7 @@ import {
upsertPhoneUser,
userAgentFrom,
writeLoginEvent,
type PlatformUserSummary,
} from './service.js';
interface SmsCodeRow {
@@ -54,6 +55,15 @@ type SmsVerifyResult =
session?: unknown;
};
type PhoneBindResult =
| Extract<SmsVerifyResult, { ok: false }>
| {
ok: true;
user: PlatformUserSummary;
phoneChanged: boolean;
revokedOtherSessions: number;
};
function hashEquals(left: string, right: string) {
const leftBuffer = Buffer.from(left, 'hex');
const rightBuffer = Buffer.from(right, 'hex');
@@ -64,6 +74,114 @@ function jsonObject(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
async function consumeSmsCode(
client: import('pg').PoolClient,
input: {
tenantId: string;
phone: string;
purpose: string;
code: string;
ipAddress: string;
userAgent: string;
},
): Promise<{ ok: true } | Extract<SmsVerifyResult, { ok: false }>> {
const expectedHash = hashSmsCode(input.tenantId, input.phone, input.purpose, input.code);
const codeResult = await client.query<SmsCodeRow>(
`
select id, code_hash as "codeHash", attempts, expires_at as "expiresAt", status
from public.sms_verification_codes
where tenant_id = $1
and phone = $2
and purpose = $3
and consumed_at is null
and status in ('pending', 'sent')
order by created_at desc
limit 1
for update
`,
[input.tenantId, input.phone, input.purpose],
);
const smsCode = codeResult.rows[0];
if (!smsCode) {
await writeLoginEvent(client, {
tenantId: input.tenantId,
provider: 'sms',
identifier: input.phone,
result: 'failed',
failureCode: 'SMS_CODE_NOT_FOUND',
ipAddress: input.ipAddress,
userAgent: input.userAgent,
metadata: { purpose: input.purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code not found or already used', code: 'SMS_CODE_NOT_FOUND' };
}
if (new Date(smsCode.expiresAt).getTime() <= Date.now()) {
await client.query(
`
update public.sms_verification_codes
set status = 'expired'
where tenant_id = $1 and id = $2
`,
[input.tenantId, smsCode.id],
);
await writeLoginEvent(client, {
tenantId: input.tenantId,
provider: 'sms',
identifier: input.phone,
result: 'failed',
failureCode: 'SMS_CODE_EXPIRED',
ipAddress: input.ipAddress,
userAgent: input.userAgent,
metadata: { purpose: input.purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code expired', code: 'SMS_CODE_EXPIRED' };
}
const matched = hashEquals(smsCode.codeHash, expectedHash);
if (!matched) {
const nextAttempts = smsCode.attempts + 1;
const blocked = nextAttempts >= 5;
await client.query(
`
update public.sms_verification_codes
set attempts = attempts + 1,
status = case when $3::boolean then 'blocked' else status end
where tenant_id = $1 and id = $2
`,
[input.tenantId, smsCode.id, blocked],
);
await writeLoginEvent(client, {
tenantId: input.tenantId,
provider: 'sms',
identifier: input.phone,
result: blocked ? 'blocked' : 'failed',
failureCode: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
ipAddress: input.ipAddress,
userAgent: input.userAgent,
metadata: { purpose: input.purpose, attempts: nextAttempts },
});
return {
ok: false,
statusCode: blocked ? 429 : 400,
message: blocked ? 'SMS code attempts exceeded' : 'Invalid SMS code',
code: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
};
}
await client.query(
`
update public.sms_verification_codes
set status = 'verified', consumed_at = now()
where tenant_id = $1 and id = $2
`,
[input.tenantId, smsCode.id],
);
return { ok: true };
}
function normalizeSmsProviderName(value: string) {
const normalized = value.toLowerCase().replace(/_/g, '-');
if (normalized === 'aliyun' || normalized === 'aliyun-sms') return 'aliyun';
@@ -210,101 +328,10 @@ export async function verifySmsCodeRoute(ctx: RequestContext) {
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const expectedHash = hashSmsCode(tenantId, phone, purpose, code);
const result = await transaction<SmsVerifyResult>(async client => {
const codeResult = await client.query<SmsCodeRow>(
`
select id, code_hash as "codeHash", attempts, expires_at as "expiresAt", status
from public.sms_verification_codes
where tenant_id = $1
and phone = $2
and purpose = $3
and consumed_at is null
and status in ('pending', 'sent')
order by created_at desc
limit 1
for update
`,
[tenantId, phone, purpose],
);
const smsCode = codeResult.rows[0];
if (!smsCode) {
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'SMS_CODE_NOT_FOUND',
ipAddress,
userAgent,
metadata: { purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code not found or already used', code: 'SMS_CODE_NOT_FOUND' };
}
if (new Date(smsCode.expiresAt).getTime() <= Date.now()) {
await client.query(
`
update public.sms_verification_codes
set status = 'expired'
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id],
);
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'SMS_CODE_EXPIRED',
ipAddress,
userAgent,
metadata: { purpose },
});
return { ok: false, statusCode: 400, message: 'SMS code expired', code: 'SMS_CODE_EXPIRED' };
}
const matched = hashEquals(smsCode.codeHash, expectedHash);
if (!matched) {
const nextAttempts = smsCode.attempts + 1;
const blocked = nextAttempts >= 5;
await client.query(
`
update public.sms_verification_codes
set attempts = attempts + 1,
status = case when $3::boolean then 'blocked' else status end
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id, blocked],
);
await writeLoginEvent(client, {
tenantId,
provider: 'sms',
identifier: phone,
result: blocked ? 'blocked' : 'failed',
failureCode: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
ipAddress,
userAgent,
metadata: { purpose, attempts: nextAttempts },
});
return {
ok: false,
statusCode: blocked ? 429 : 400,
message: blocked ? 'SMS code attempts exceeded' : 'Invalid SMS code',
code: blocked ? 'SMS_CODE_BLOCKED' : 'SMS_CODE_INVALID',
};
}
await client.query(
`
update public.sms_verification_codes
set status = 'verified', consumed_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, smsCode.id],
);
const consumed = await consumeSmsCode(client, { tenantId, phone, purpose, code, ipAddress, userAgent });
if (!consumed.ok) return consumed;
if (purpose !== 'login') {
await writeLoginEvent(client, {
@@ -398,6 +425,214 @@ export async function logoutRoute(ctx: RequestContext) {
return { ok: true };
}
export async function bindPhoneRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
await hydrateRequestAuth(ctx);
const session = currentSessionFromContext(ctx);
if (!session) {
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
}
if (session.tenantId !== tenantId) {
throw new HttpError(403, 'Session does not belong to this tenant', 'AUTH_TENANT_MISMATCH');
}
const body = await readJsonBody(ctx);
const phone = assertChinaPhone(requiredString(body, 'phone'));
const code = requiredString(body, 'code');
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'bind_phone');
if (purpose !== 'bind_phone') {
throw new HttpError(400, 'Phone binding requires a bind_phone SMS code', 'PHONE_BIND_PURPOSE_REQUIRED');
}
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const result = await transaction<PhoneBindResult>(async client => {
const consumed = await consumeSmsCode(client, { tenantId, phone, purpose, code, ipAddress, userAgent });
if (!consumed.ok) return consumed;
const userResult = await client.query<PlatformUserSummary>(
`
select id, username, phone, name, avatar_url as "avatarUrl",
primary_role as "primaryRole", created_at as "createdAt"
from public.platform_users
where id = $1
limit 1
for update
`,
[session.id],
);
const currentUser = userResult.rows[0];
if (!currentUser) {
return { ok: false, statusCode: 401, message: 'Authenticated user was not found', code: 'AUTH_USER_NOT_FOUND' };
}
const identityConflict = await client.query<{ userId: string }>(
`
select user_id as "userId"
from public.user_identities
where provider = 'phone'
and provider_subject = $1
limit 1
for update
`,
[phone],
);
if (identityConflict.rows[0] && identityConflict.rows[0].userId !== session.id) {
await writeLoginEvent(client, {
tenantId,
userId: session.id,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'PHONE_ALREADY_BOUND',
ipAddress,
userAgent,
metadata: { purpose, bindAttempt: true },
});
return { ok: false, statusCode: 409, message: 'Phone number is already bound to another account', code: 'PHONE_ALREADY_BOUND' };
}
const profileConflict = await client.query<{ id: string }>(
`
select id
from public.platform_users
where phone = $1 and id <> $2
limit 1
`,
[phone, session.id],
);
if (profileConflict.rows[0]) {
await writeLoginEvent(client, {
tenantId,
userId: session.id,
provider: 'sms',
identifier: phone,
result: 'failed',
failureCode: 'PHONE_ALREADY_BOUND',
ipAddress,
userAgent,
metadata: { purpose, bindAttempt: true, conflictSource: 'platform_users' },
});
return { ok: false, statusCode: 409, message: 'Phone number is already bound to another account', code: 'PHONE_ALREADY_BOUND' };
}
await client.query(
`
delete from public.user_identities
where user_id = $1
and provider = 'phone'
and provider_subject <> $2
`,
[session.id, phone],
);
const identityResult = await client.query(
`
insert into public.user_identities (user_id, provider, provider_subject, phone)
values ($1, 'phone', $2, $2)
on conflict (provider, provider_subject)
do update set phone = excluded.phone,
updated_at = now()
where public.user_identities.user_id = excluded.user_id
returning id
`,
[session.id, phone],
);
if (!identityResult.rows[0]) {
return { ok: false, statusCode: 409, message: 'Phone number is already bound to another account', code: 'PHONE_ALREADY_BOUND' };
}
const updatedUser = await client.query<PlatformUserSummary>(
`
update public.platform_users
set phone = $2,
raw_profile = coalesce(raw_profile, '{}'::jsonb) || $3::jsonb,
updated_at = now()
where id = $1
returning id, username, phone, name, avatar_url as "avatarUrl",
primary_role as "primaryRole", created_at as "createdAt"
`,
[
session.id,
phone,
JSON.stringify({
phoneBound: true,
phoneBoundAt: new Date().toISOString(),
phoneBindTenantId: tenantId,
}),
],
);
await client.query(
`
update public.user_identities
set phone = $2,
updated_at = now()
where user_id = $1
and provider <> 'phone'
`,
[session.id, phone],
);
const revoked = await client.query(
`
update app_private.auth_sessions
set revoked_at = now(),
updated_at = now(),
metadata = metadata || $4::jsonb
where tenant_id = $1
and user_id = $2
and revoked_at is null
and ($3::uuid is null or id <> $3::uuid)
returning id
`,
[
tenantId,
session.id,
session.authSource === 'app_session' ? session.sessionId : null,
JSON.stringify({
revokedBy: 'phone_bind',
revokedAt: new Date().toISOString(),
}),
],
);
await writeLoginEvent(client, {
tenantId,
userId: session.id,
provider: 'sms',
identifier: phone,
result: 'success',
ipAddress,
userAgent,
metadata: {
purpose,
phoneChanged: currentUser.phone !== phone,
previousPhoneMasked: currentUser.phone ? `${currentUser.phone.slice(0, 3)}****${currentUser.phone.slice(-4)}` : null,
revokedOtherSessions: revoked.rowCount || 0,
},
});
return {
ok: true,
user: updatedUser.rows[0],
phoneChanged: currentUser.phone !== phone,
revokedOtherSessions: revoked.rowCount || 0,
};
});
if (!result.ok) {
throw new HttpError(result.statusCode, result.message, result.code);
}
return {
ok: true,
user: result.user,
phoneChanged: result.phoneChanged,
revokedOtherSessions: result.revokedOtherSessions,
};
}
function normalizeOAuthProvider(value: string) {
const normalized = value.toLowerCase().replace(/_/g, '-');
if (normalized === 'wechat' || normalized === 'wechat-web') return 'wechat-web';

View File

@@ -127,6 +127,20 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先
微信网页 OAuth 和 QQ OAuth 也必须在后端完成 code 换 token、获取 openid/unionid、验错、账号合并和登录事件审计。旧 PocketBase hooks 中的邀请码/销售归属逻辑后续应拆到 `referral` feature不继续堆在 auth 模块里。
## 手机号绑定/换绑
已实现 `POST /api/auth/phone/bind`。适用场景包括微信/QQ 登录后强制绑定手机号,以及个人中心更换手机号。
流程:
1. 前端调用 `POST /api/auth/sms/send``purpose` 必须是 `bind_phone`
2. 前端在登录态下调用 `POST /api/auth/phone/bind`,提交 `phone` 和验证码。
3. 后端校验当前 session/JWT 属于当前租户,不信任 `x-user-id`
4. 后端校验手机号未被其它账号占用,成功后更新 `platform_users.phone``user_identities(provider='phone')`
5. 换绑成功会删除当前用户旧手机号 identity并撤销其它迁移期 `tk_` session当前 session 保持可用。
前端不能用 `login` 用途验证码绑定手机号;接口会返回 `PHONE_BIND_PURPOSE_REQUIRED`。手机号已被其它账号占用时返回 `PHONE_ALREADY_BOUND`
## 支付 Provider
支付不走 Supabase 内置能力。推荐继续扩展 `commerce`

View File

@@ -39,6 +39,7 @@
| 迁移期 session | 迁移期 | `tk_` token hash 存在 `app_private.auth_sessions`,用户态接口已优先解析 bearer session 并拒绝伪造 userId/tenantId |
| Supabase Auth JWT | 可联调 | API 已用 Bearer JWT 验签并通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份;支持 HS256 JWT secret 或 JWKS测试覆盖学生、租户管理员、平台管理员、错租户、坏签名 |
| 微信小程序登录 | 可联调 | `/api/auth/oauth/wechat-miniapp` 已接 `code2Session`、openid/unionid 身份、session 签发和登录审计 |
| 手机号绑定/换绑 | 可联调 | `/api/auth/phone/bind` 使用 `bind_phone` 短信验证码,后端校验当前登录态、手机号唯一性、移除旧手机号 identity并撤销其它迁移期 session |
| 微信网页/QQ OAuth | 待补齐 | 目前仍是 placeholder需要 code 换 token、回调域名、账号合并和审计 |
| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 |
| 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 |

View File

@@ -30,7 +30,7 @@
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 |
| 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架 | PDF 渲染、CDN 防盗链、杀毒扫描、视频水印 |
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 |
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、OAuth 配置表 | 微信网页登录、QQ 登录、手机号换绑、真实生产账号联调 |
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、手机号绑定/换绑、OAuth 配置表 | 微信网页登录、QQ 登录、真实生产账号联调 |
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载和字段映射 API | 字段映射 UI、真实数据 dry-run 和导入性能压测 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |

View File

@@ -29,7 +29,7 @@
| 销售/代理客资 | 可联调 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、分佣比例、归因、结算单、审核和打款状态 | 真实微信小程序码、真实打款、结算导出、销售团队看板 |
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、OAuth 配置表 | 微信网页登录、QQ 登录、手机号换绑、真实生产账号联调 |
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、手机号绑定/换绑、OAuth 配置表 | 微信网页登录、QQ 登录、真实生产账号联调 |
| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知和补偿 worker | 完整资金流水对账、异常订单运营台、服务商/平台代收模式 |
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI provider、JSON 报告 schema、PDF 报告生成 |
| Taro 跨端 | 未开始 | 旧 Web 新 API 适配开始 | `apps/taro`、共享 API client、H5/小程序统一构建 |

View File

@@ -33,12 +33,12 @@
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 |
| 题目视频讲解 | 已建 `video_explanations``question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放和导入断言 | 播放、权益、后台绑定和批量 JSON 导入链路已实现,深度防盗链、动态水印和播放统计待补 |
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载assets worker 测试 | 资料资源基础闭环可跑CDN 防盗链、杀毒扫描和资料下载前端待补 |
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试 | 学生端基础个人中心已实现,账号绑定/换绑、学习报告可视化和更细任务系统待补 |
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试 | 学生端基础个人中心已实现,学习报告可视化和更细任务系统待补 |
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、复杂活动规则、营销自动化、核销报表待补 |
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、字段映射操作台和导入复检结果面板待补 |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 |
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/微信网页/QQ 登录生产联调待补 |
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信微信网页/QQ 登录生产联调待补 |
| 数据导入 | 已建立 importer、risk report、validate | 已覆盖多类旧集合 | 命令行导入/校验 | `pb:import:validate` | 基础工具可用,需用真实完整数据做多轮 dry-run |
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
@@ -50,6 +50,7 @@ auth:
POST /api/auth/sms/verify
GET /api/auth/me
POST /api/auth/logout
POST /api/auth/phone/bind
POST /api/auth/oauth/wechat
POST /api/auth/oauth/wechat-miniapp
POST /api/auth/oauth/qq
@@ -273,7 +274,7 @@ platform-admin:
1. 正式鉴权API 已支持 Supabase Auth JWT生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id``x-platform-admin-key` 兼容入口。
2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备微信网页登录、QQ 登录、真实生产账号联调、对账和支付补偿仍需实现。
3. 核心缺口 API学生端个人中心、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈和勋章已补基础 API下一步重点是账号绑定、学习报告可视化、后台统计和真实业务验收。
3. 核心缺口 API学生端个人中心、手机号绑定/换绑、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈和勋章已补基础 API下一步重点是学习报告可视化、后台统计和真实业务验收。
4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步批量导入、导入后复检、模板下载/字段映射 API、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API前端操作台待补。
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。

View File

@@ -16,7 +16,7 @@
| 旧功能/页面 | 旧项目参考 | 新后端状态 | 待补齐 |
| --- | --- | --- | --- |
| 登录/注册 | `pages/Login.tsx` | 部分覆盖 | 短信、Supabase JWT、微信小程序登录主链路已有微信网页登录、QQ OAuth、手机号换绑/补绑和生产账号联调待补 |
| 登录/注册 | `pages/Login.tsx` | 部分覆盖 | 短信、Supabase JWT、微信小程序登录主链路、手机号绑定/换绑已有微信网页登录、QQ OAuth、生产账号联调待补 |
| 选地区 | `pages/RegionSelector.tsx` | 已覆盖 | 需要前端按租户套餐和权益展示可选地区 |
| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、FAQ、时间线、考试倒计时、入口、个人统计有基础缺完整运营动态和学习任务聚合 |
| 题库入口 | `pages/SubjectSelector.tsx``RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` |
@@ -31,7 +31,7 @@
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
| 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker 和全额退款权益撤销已有;缺完整资金流水对账、异常订单运营台和前端收银台/售后体验 |
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势和勋章展示 API 已有;缺账号绑定/换绑、学习报告可视化 |
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时、趋势和勋章展示 API 已有;缺学习报告可视化 |
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 |
@@ -96,7 +96,7 @@
这些是旧项目中已经出现过、但新后端还没有完整业务闭环的功能:
1. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
2. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。
2. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。
3. 题库导出:服务端 JSON/试卷 payload 导出、权限审计和答案脱敏已补;仍缺 PDF/Word 二进制生成、水印、资料发布和后台导出操作台。
4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker导入后复检、模板下载和字段映射 API 已补,仍缺前端字段映射 UI 和真实数据 dry-run。
5. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、冲突查询和租户自改冲突保护已完成基础闭环还需版本通知、冲突处理操作台和运营后台 UI。

View File

@@ -72,7 +72,7 @@
2. 国内登录和短信
- 已完成阿里云短信、腾讯云短信 adapter 的后端实现和本地 fake endpoint 测试。
- 已完成微信小程序 `code2Session` 登录主链路。
- 继续补微信网页登录、QQ 登录、手机号绑定/换绑、真实生产账号联调。
- 已完成手机号绑定/换绑基础 API继续补微信网页登录、QQ 登录、真实生产账号联调。
- 旧 PocketBase 用户账号和新身份体系的映射/补绑。
3. 导入体系扩展

View File

@@ -1055,6 +1055,27 @@ Authorization: Bearer <session.token>
x-tenant-id: <tenantId>
```
### 绑定或更换手机号
微信/QQ 登录后强制绑定手机号、个人中心更换手机号,都走同一个后端命令。前端先发送 `bind_phone` 用途验证码,再提交绑定:
```text
POST /api/auth/sms/send
body: { "phone": "13800000000", "purpose": "bind_phone" }
POST /api/auth/phone/bind
Authorization: Bearer <session.token>
body: { "phone": "13800000000", "code": "123456" }
```
前端规则:
- 绑定接口必须带当前登录态,不能用 `x-user-id` 伪造用户。
- 绑定接口只接受 `bind_phone` 验证码,不接受 `login` 验证码。
- 新手机号如果已属于其它账号,后端返回 `PHONE_ALREADY_BOUND`
- 换绑成功后旧手机号登录身份会被移除;迁移期 `tk_` 其它设备 session 会被撤销,当前 session 继续可用。
- 微信手机号授权后也应由后端 adapter 换取手机号,再复用同一类绑定命令;不要在页面里持久化明文手机号授权中间数据。
### 微信小程序登录
微信小程序端调用 `Taro.login()` 获取 code然后交给后端
@@ -1087,7 +1108,7 @@ identity.unionId
- 前端不接触 `appSecret`
- 前端不会拿到微信 `session_key`
- 如果登录前已经解析到推广码,登录成功后再调用 `/api/referral/bind` 完成首绑保护。
- 手机号授权后续应走独立的“绑定手机号”接口,不要把微信手机号解密逻辑写在页面里
- 如果用户没有手机号,跳转到上面的“绑定或更换手机号”流程
## 支付对接

View File

@@ -581,6 +581,16 @@ async function loginBySms(phone = '13800000000') {
return verified;
}
async function sendMockSmsCode(phone, purpose) {
const sent = await request('/api/auth/sms/send', {
userId: false,
method: 'POST',
body: { phone, purpose },
});
assert.ok(sent.debugCode, `mock SMS ${purpose} should expose debugCode in local tests`);
return sent.debugCode;
}
async function testTrustedSessionIdentity() {
const login = await loginBySms();
assert.equal(login.user?.id, USER_ID, 'smoke phone should log in as smoke user');
@@ -650,6 +660,54 @@ async function testTrustedSessionIdentity() {
assert.equal(invalidSession.code, 'AUTH_SESSION_INVALID', 'invalid bearer token must not fall back to legacy user headers');
}
async function testPhoneBinding() {
const phoneSuffix = String(Date.now()).slice(-6);
const oldPhone = `13920${phoneSuffix}`;
const newPhone = `13921${phoneSuffix}`;
const login = await loginBySms(oldPhone);
const authHeaders = { authorization: `Bearer ${login.session.token}` };
const bindCode = await sendMockSmsCode(newPhone, 'bind_phone');
const bound = await request('/api/auth/phone/bind', {
userId: false,
headers: authHeaders,
method: 'POST',
body: { phone: newPhone, code: bindCode },
});
assert.equal(bound.user?.id, login.user.id, 'phone bind should update the authenticated user');
assert.equal(bound.user?.phone, newPhone, 'phone bind should replace platform user phone');
assert.equal(bound.phoneChanged, true, 'phone bind should report phoneChanged for a real change');
const me = await request('/api/auth/me', {
userId: false,
headers: authHeaders,
});
assert.equal(me.user?.phone, newPhone, 'current session should see the newly bound phone');
const oldPhoneLogin = await loginBySms(oldPhone);
assert.notEqual(oldPhoneLogin.user?.id, login.user.id, 'old phone identity should no longer log into the changed account');
const conflictCode = await sendMockSmsCode('13800000000', 'bind_phone');
const conflict = await request('/api/auth/phone/bind', {
userId: false,
headers: authHeaders,
method: 'POST',
body: { phone: '13800000000', code: conflictCode },
expectStatus: 409,
});
assert.equal(conflict.code, 'PHONE_ALREADY_BOUND', 'binding a phone owned by another account should be rejected');
const wrongPurposeCode = await sendMockSmsCode('13800000022', 'login');
const wrongPurpose = await request('/api/auth/phone/bind', {
userId: false,
headers: authHeaders,
method: 'POST',
body: { phone: '13800000022', code: wrongPurposeCode, purpose: 'login' },
expectStatus: 400,
});
assert.equal(wrongPurpose.code, 'PHONE_BIND_PURPOSE_REQUIRED', 'phone bind endpoint must reject login SMS codes');
}
async function testSupabaseJwtIdentity() {
const studentJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' });
const studentHeaders = { authorization: `Bearer ${studentJwt}` };
@@ -904,7 +962,10 @@ async function testCatalogAndLearning() {
},
});
assert.equal(nodeSession.item?.contentNodeId, ids.contentNodeProfessional, 'node session should bind parent content node');
assert.ok(nodeSession.item?.questionIds?.includes(ids.question), 'node session should include descendant questions');
assert.ok(
[ids.question, ids.questionTwo, ids.questionThree].some(questionId => nodeSession.item?.questionIds?.includes(questionId)),
'node session should include descendant questions',
);
const mockSession = await request('/api/learning/practice-sessions', {
userId: TENANT_ADMIN_USER_ID,
@@ -5426,6 +5487,7 @@ async function main() {
await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true)));
await check('trusted session identity', testTrustedSessionIdentity);
await check('phone binding', testPhoneBinding);
await check('Supabase JWT identity', testSupabaseJwtIdentity);
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
await check('catalog and learning', testCatalogAndLearning);