forked from wangziqi/gongxue-base
feat: add authenticated phone binding
This commit is contained in:
@@ -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 待接。
|
||||
- 题库导出目前完成服务端结构化 payload;PDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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`,接口有权限点校验 |
|
||||
|
||||
@@ -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 监控和销售转化看板 |
|
||||
|
||||
@@ -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/小程序统一构建 |
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
2. 国内登录和短信
|
||||
- 已完成阿里云短信、腾讯云短信 adapter 的后端实现和本地 fake endpoint 测试。
|
||||
- 已完成微信小程序 `code2Session` 登录主链路。
|
||||
- 继续补微信网页登录、QQ 登录、手机号绑定/换绑、真实生产账号联调。
|
||||
- 已完成手机号绑定/换绑基础 API;继续补微信网页登录、QQ 登录、真实生产账号联调。
|
||||
- 旧 PocketBase 用户账号和新身份体系的映射/补绑。
|
||||
|
||||
3. 导入体系扩展
|
||||
|
||||
@@ -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` 完成首绑保护。
|
||||
- 手机号授权后续应走独立的“绑定手机号”接口,不要把微信手机号解密逻辑写在页面里。
|
||||
- 如果用户没有手机号,跳转到上面的“绑定或更换手机号”流程。
|
||||
|
||||
## 支付对接
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user