feat: add web oauth login adapters

This commit is contained in:
Codex
2026-06-29 10:20:20 +08:00
parent 64d58d895e
commit d89f77e9af
15 changed files with 766 additions and 41 deletions

View File

@@ -28,7 +28,7 @@
还没有达到生产交付的部分:
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
- 阿里云/腾讯云短信、微信小程序登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;微信网页登录、QQ 登录、完整资金流水对账和真实生产账号联调还没接完。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;完整资金流水对账和真实生产账号联调还没接完。
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF/图片预览和资源复检 worker 已完成CDN 防盗链、杀毒扫描和视频动态水印还没完成。
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
- 题库导出目前完成服务端结构化 payloadPDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。
@@ -172,7 +172,7 @@ npm run test:worker:public-banks
```text
apps/api/src/features/
auth/ 短信登录、迁移期 session、微信小程序登录、OAuth 占位
auth/ 短信登录、迁移期 session、微信小程序登录、微信网页登录、QQ 登录
catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口
commerce/ 订单、支付确认、激活码、优惠券、权益
health/ 健康检查
@@ -233,4 +233,4 @@ git diff --check
2. Taro 前端 scaffold让 H5 和小程序共用同一套 API。
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
4. 题库导出 PDF/Word worker、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
5. 微信网页/QQ 登录、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
5. 真实 OAuth/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。

View File

@@ -3,9 +3,10 @@ import {
bindPhoneRoute,
logoutRoute,
meRoute,
oauthProviderPlaceholderRoute,
qqLoginRoute,
sendSmsCodeRoute,
verifySmsCodeRoute,
wechatWebLoginRoute,
wechatMiniappLoginRoute,
} from './routes.js';
@@ -15,7 +16,7 @@ export const authRoutes: RouteDefinition[] = [
['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', wechatWebLoginRoute],
['POST', '/api/auth/oauth/wechat-miniapp', wechatMiniappLoginRoute],
['POST', '/api/auth/oauth/qq', oauthProviderPlaceholderRoute],
['POST', '/api/auth/oauth/qq', qqLoginRoute],
];

View File

@@ -9,6 +9,7 @@ import {
loadTenantAuthProviderConfig,
optionalPublicString,
providerEndpoint,
providerEndpointForKeys,
requirePublicString,
requireSecretString,
} from '../../core/tenant-provider-config.js';
@@ -649,6 +650,134 @@ function oauthProviderAliases(value: string) {
return [provider];
}
function stringField(source: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const value = source[key];
if (typeof value === 'string' && value.trim()) return value.trim();
}
return '';
}
function intField(source: Record<string, unknown>, keys: string[]) {
const value = stringField(source, keys) || keys.map(key => source[key]).find(item => typeof item === 'number');
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function sanitizeOAuthProfilePayload(value: unknown): unknown {
if (Array.isArray(value)) return value.map(item => sanitizeOAuthProfilePayload(item));
if (!value || typeof value !== 'object') return value;
const sanitized: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
const normalized = key.toLowerCase().replace(/[-_\s]/g, '');
if (
normalized.includes('accesstoken') ||
normalized.includes('refreshtoken') ||
normalized.includes('sessionkey') ||
normalized.includes('secret') ||
normalized.includes('clientsecret')
) {
continue;
}
sanitized[key] = sanitizeOAuthProfilePayload(child);
}
return sanitized;
}
async function parseOAuthProviderResponse(response: Response) {
const text = await response.text();
const trimmed = text.trim();
if (!trimmed) return {};
const tryJson = (candidate: string) => {
try {
return jsonObject(JSON.parse(candidate));
} catch {
return null;
}
};
const directJson = tryJson(trimmed);
if (directJson) return directJson;
const jsonStart = trimmed.indexOf('{');
const jsonEnd = trimmed.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
const jsonpJson = tryJson(trimmed.slice(jsonStart, jsonEnd + 1));
if (jsonpJson) return jsonpJson;
}
const params = new URLSearchParams(trimmed);
const parsed = Object.fromEntries(params.entries());
return jsonObject(parsed);
}
async function callWechatWebAccessToken(input: {
endpoint: string;
appId: string;
appSecret: string;
code: string;
}) {
const url = new URL(input.endpoint);
url.searchParams.set('appid', input.appId);
url.searchParams.set('secret', input.appSecret);
url.searchParams.set('code', input.code);
url.searchParams.set('grant_type', 'authorization_code');
const response = await fetch(url);
const raw = await parseOAuthProviderResponse(response);
const errcode = Number(raw.errcode || 0);
if (!response.ok || errcode) {
const suffix = Number.isFinite(errcode) && errcode ? ` (${errcode})` : '';
throw new HttpError(401, `WeChat web code exchange failed${suffix}`, 'WECHAT_WEB_CODE_EXCHANGE_FAILED');
}
const accessToken = stringField(raw, ['access_token', 'accessToken']);
const openId = stringField(raw, ['openid', 'openId']);
const unionId = stringField(raw, ['unionid', 'unionId']);
const scope = stringField(raw, ['scope']);
const expiresIn = intField(raw, ['expires_in', 'expiresIn']);
if (!accessToken || !openId) {
throw new HttpError(401, 'WeChat web token response is missing access_token or openid', 'WECHAT_WEB_CODE_EXCHANGE_INVALID');
}
return { accessToken, openId, unionId, scope, expiresIn };
}
async function callWechatWebUserInfo(input: {
endpoint: string;
accessToken: string;
openId: string;
lang: string;
}) {
const url = new URL(input.endpoint);
url.searchParams.set('access_token', input.accessToken);
url.searchParams.set('openid', input.openId);
url.searchParams.set('lang', input.lang);
const response = await fetch(url);
const raw = await parseOAuthProviderResponse(response);
const errcode = Number(raw.errcode || 0);
if (!response.ok || errcode) {
const suffix = Number.isFinite(errcode) && errcode ? ` (${errcode})` : '';
throw new HttpError(401, `WeChat web userinfo failed${suffix}`, 'WECHAT_WEB_USERINFO_FAILED');
}
const nickname = stringField(raw, ['nickname', 'name']);
const avatarUrl = stringField(raw, ['headimgurl', 'avatarUrl', 'avatar_url']);
const unionId = stringField(raw, ['unionid', 'unionId']);
return {
nickname,
avatarUrl,
unionId,
profile: {
...jsonObject(sanitizeOAuthProfilePayload(raw)),
nickname,
avatarUrl,
},
};
}
async function callWechatCode2Session(input: {
endpoint: string;
appId: string;
@@ -761,13 +890,302 @@ export async function wechatMiniappLoginRoute(ctx: RequestContext) {
return result;
}
export async function oauthProviderPlaceholderRoute(ctx: RequestContext) {
const provider = ctx.url.pathname.split('/').at(-1) || 'oauth';
const normalized = normalizeOAuthProvider(provider);
const configured = await loadTenantAuthProviderConfig(await tenantIdFrom(ctx), oauthProviderAliases(normalized));
throw new HttpError(
configured ? 501 : 503,
`${provider} OAuth adapter is not implemented yet. Store public config in tenant_auth_providers and secrets in app_private.tenant_secrets.`,
configured ? 'PROVIDER_NOT_IMPLEMENTED' : 'PROVIDER_NOT_CONFIGURED',
export async function wechatWebLoginRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const code = requiredString(body, 'code');
const clientProfile = jsonObject(body.profile);
const lang = optionalString(body, 'lang') || 'zh_CN';
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const providerConfig = await loadTenantAuthProviderConfig(tenantId, oauthProviderAliases('wechat-web'));
if (!providerConfig) {
throw new HttpError(503, 'WeChat web auth provider is not configured', 'PROVIDER_NOT_CONFIGURED');
}
const appId = requirePublicString(providerConfig, ['appId'], 'OAUTH_PUBLIC_CONFIG_REQUIRED');
const appSecret = requireSecretString(providerConfig, ['appSecret', 'clientSecret', 'secret'], 'OAUTH_SECRET_REQUIRED');
const tokenEndpoint = providerEndpointForKeys(
providerConfig,
['tokenEndpoint', 'accessTokenEndpoint', 'endpoint'],
'https://api.weixin.qq.com/sns/oauth2/access_token',
['weixin.qq.com'],
'OAUTH_ENDPOINT_NOT_ALLOWED',
);
const userInfoEndpoint = providerEndpointForKeys(
providerConfig,
['userInfoEndpoint', 'userinfoEndpoint'],
'https://api.weixin.qq.com/sns/userinfo',
['weixin.qq.com'],
'OAUTH_ENDPOINT_NOT_ALLOWED',
);
const token = await callWechatWebAccessToken({ endpoint: tokenEndpoint, appId, appSecret, code });
const userInfo = await callWechatWebUserInfo({
endpoint: userInfoEndpoint,
accessToken: token.accessToken,
openId: token.openId,
lang,
});
const provider = 'wechat_web';
const unionId = userInfo.unionId || token.unionId || null;
const providerSubject = `${appId}:${token.openId}`;
const profile = {
...userInfo.profile,
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
};
return transaction(async client => {
const { user, isNewUser } = await upsertOAuthUser(client, {
tenantId,
provider,
providerSubject,
openId: token.openId,
unionId,
profile,
secretPayload: {
tokenSeenAt: new Date().toISOString(),
scope: token.scope || null,
expiresIn: token.expiresIn,
},
});
const session = await createLoginSession(client, {
tenantId,
userId: user.id,
provider,
ipAddress,
userAgent,
metadata: {
appId,
openId: token.openId,
unionId,
isNewUser,
},
});
await writeLoginEvent(client, {
tenantId,
userId: user.id,
provider,
identifier: token.openId,
result: 'success',
ipAddress,
userAgent,
metadata: {
appId,
unionId,
isNewUser,
},
});
return {
provider,
user,
isNewUser,
session,
identity: {
provider,
openId: token.openId,
unionId,
},
};
});
}
async function callQqAccessToken(input: {
endpoint: string;
appId: string;
appSecret: string;
code: string;
redirectUri: string;
}) {
const url = new URL(input.endpoint);
url.searchParams.set('grant_type', 'authorization_code');
url.searchParams.set('client_id', input.appId);
url.searchParams.set('client_secret', input.appSecret);
url.searchParams.set('code', input.code);
url.searchParams.set('redirect_uri', input.redirectUri);
url.searchParams.set('fmt', 'json');
const response = await fetch(url);
const raw = await parseOAuthProviderResponse(response);
const error = stringField(raw, ['error']);
if (!response.ok || error) {
throw new HttpError(401, 'QQ code exchange failed', 'QQ_CODE_EXCHANGE_FAILED');
}
const accessToken = stringField(raw, ['access_token', 'accessToken']);
const expiresIn = intField(raw, ['expires_in', 'expiresIn']);
if (!accessToken) {
throw new HttpError(401, 'QQ token response is missing access_token', 'QQ_CODE_EXCHANGE_INVALID');
}
return { accessToken, expiresIn };
}
async function callQqOpenId(input: { endpoint: string; appId: string; accessToken: string }) {
const url = new URL(input.endpoint);
url.searchParams.set('access_token', input.accessToken);
url.searchParams.set('fmt', 'json');
const response = await fetch(url);
const raw = await parseOAuthProviderResponse(response);
const error = stringField(raw, ['error']);
if (!response.ok || error) {
throw new HttpError(401, 'QQ openid exchange failed', 'QQ_OPENID_EXCHANGE_FAILED');
}
const openId = stringField(raw, ['openid', 'openId']);
const clientId = stringField(raw, ['client_id', 'clientId']);
if (!openId) {
throw new HttpError(401, 'QQ openid response is missing openid', 'QQ_OPENID_EXCHANGE_INVALID');
}
if (clientId && clientId !== input.appId) {
throw new HttpError(401, 'QQ openid response client_id does not match appId', 'QQ_OPENID_CLIENT_MISMATCH');
}
return { openId, clientId };
}
async function callQqUserInfo(input: {
endpoint: string;
appId: string;
accessToken: string;
openId: string;
}) {
const url = new URL(input.endpoint);
url.searchParams.set('access_token', input.accessToken);
url.searchParams.set('oauth_consumer_key', input.appId);
url.searchParams.set('openid', input.openId);
url.searchParams.set('fmt', 'json');
const response = await fetch(url);
const raw = await parseOAuthProviderResponse(response);
const ret = Number(raw.ret || 0);
if (!response.ok || ret !== 0) {
throw new HttpError(401, 'QQ userinfo failed', 'QQ_USERINFO_FAILED');
}
const nickname = stringField(raw, ['nickname', 'name']);
const avatarUrl = stringField(raw, ['figureurl_qq_2', 'figureurl_qq_1', 'figureurl_2', 'figureurl_1', 'avatarUrl']);
return {
nickname,
avatarUrl,
profile: {
...jsonObject(sanitizeOAuthProfilePayload(raw)),
nickname,
avatarUrl,
},
};
}
export async function qqLoginRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const code = requiredString(body, 'code');
const ipAddress = clientIpFrom(ctx);
const userAgent = userAgentFrom(ctx);
const providerConfig = await loadTenantAuthProviderConfig(tenantId, oauthProviderAliases('qq'));
if (!providerConfig) {
throw new HttpError(503, 'QQ auth provider is not configured', 'PROVIDER_NOT_CONFIGURED');
}
const appId = requirePublicString(providerConfig, ['appId', 'clientId'], 'OAUTH_PUBLIC_CONFIG_REQUIRED');
const appSecret = requireSecretString(providerConfig, ['appSecret', 'clientSecret', 'secret'], 'OAUTH_SECRET_REQUIRED');
const redirectUri = optionalPublicString(providerConfig, ['redirectUri', 'callbackUrl']) || optionalString(body, 'redirectUri');
if (!redirectUri) {
throw new HttpError(400, 'QQ OAuth redirectUri is required for code exchange', 'OAUTH_REDIRECT_URI_REQUIRED');
}
const tokenEndpoint = providerEndpointForKeys(
providerConfig,
['tokenEndpoint', 'accessTokenEndpoint', 'endpoint'],
'https://graph.qq.com/oauth2.0/token',
['graph.qq.com'],
'OAUTH_ENDPOINT_NOT_ALLOWED',
);
const openIdEndpoint = providerEndpointForKeys(
providerConfig,
['openIdEndpoint', 'openidEndpoint'],
'https://graph.qq.com/oauth2.0/me',
['graph.qq.com'],
'OAUTH_ENDPOINT_NOT_ALLOWED',
);
const userInfoEndpoint = providerEndpointForKeys(
providerConfig,
['userInfoEndpoint', 'userinfoEndpoint'],
'https://graph.qq.com/user/get_user_info',
['graph.qq.com'],
'OAUTH_ENDPOINT_NOT_ALLOWED',
);
const token = await callQqAccessToken({ endpoint: tokenEndpoint, appId, appSecret, code, redirectUri });
const openId = await callQqOpenId({ endpoint: openIdEndpoint, appId, accessToken: token.accessToken });
const userInfo = await callQqUserInfo({
endpoint: userInfoEndpoint,
appId,
accessToken: token.accessToken,
openId: openId.openId,
});
const provider = 'qq';
const providerSubject = `${appId}:${openId.openId}`;
const clientProfile = jsonObject(body.profile);
const profile = {
...userInfo.profile,
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
};
return transaction(async client => {
const { user, isNewUser } = await upsertOAuthUser(client, {
tenantId,
provider,
providerSubject,
openId: openId.openId,
profile,
secretPayload: {
tokenSeenAt: new Date().toISOString(),
expiresIn: token.expiresIn,
},
});
const session = await createLoginSession(client, {
tenantId,
userId: user.id,
provider,
ipAddress,
userAgent,
metadata: {
appId,
openId: openId.openId,
isNewUser,
},
});
await writeLoginEvent(client, {
tenantId,
userId: user.id,
provider,
identifier: openId.openId,
result: 'success',
ipAddress,
userAgent,
metadata: {
appId,
isNewUser,
},
});
return {
provider,
user,
isNewUser,
session,
identity: {
provider,
openId: openId.openId,
},
};
});
}

View File

@@ -20,7 +20,8 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先
- `GET /api/auth/me`:通过 Bearer token 获取当前用户。
- `POST /api/auth/logout`:吊销迁移期 session。
- `POST /api/auth/oauth/wechat-miniapp`:微信小程序 `code2Session` 登录,后端换取 openid/unionid签发 `tk_` session。
- `POST /api/auth/oauth/wechat``/qq`provider 占位,已固定 `PROVIDER_NOT_CONFIGURED` / `PROVIDER_NOT_IMPLEMENTED`
- `POST /api/auth/oauth/wechat`:微信网页登录,后端用 code 换 access_token/openid再拉取 userinfo按 unionid 合并微信身份并签发 `tk_` session
- `POST /api/auth/oauth/qq`QQ 登录,后端完成 code 换 token、token 换 openid、拉取 userinfo并签发 `tk_` session。
- `tenant_auth_providers`:租户级公开认证配置。
- `sms_verification_codes`:验证码审计表,不保存明文 code。
- `auth_login_events`:登录事件审计。
@@ -125,7 +126,65 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先
}
```
微信网页 OAuth 和 QQ OAuth 也必须在后端完成 code 换 token、获取 openid/unionid、验错、账号合并和登录事件审计。旧 PocketBase hooks 中的邀请码/销售归属逻辑后续应拆到 `referral` feature不继续堆在 auth 模块里。
微信网页登录和 QQ 登录已按同样原则落地:前端只提交授权 code后端完成 code 换 token、获取 openid/unionid、验错、账号合并和登录事件审计。响应不会返回 `access_token``refresh_token``session_key` 或租户密钥。旧 PocketBase hooks 中的邀请码/销售归属逻辑后续应拆到 `referral` feature不继续堆在 auth 模块里。
微信网页登录配置示例:
```json
{
"provider": "wechat-web",
"displayName": "微信网页登录",
"status": "active",
"configPublic": {
"appId": "wx...",
"endpoint": "https://api.weixin.qq.com/sns/oauth2/access_token",
"userInfoEndpoint": "https://api.weixin.qq.com/sns/userinfo"
},
"secret": {
"secretScope": "oauth",
"secretKey": "wechat-web",
"secretValue": "网站应用 AppSecret"
}
}
```
QQ 登录配置示例:
```json
{
"provider": "qq-oauth",
"displayName": "QQ登录",
"status": "active",
"configPublic": {
"appId": "101xxxx",
"redirectUri": "https://h5.example.com/auth/qq/callback",
"endpoint": "https://graph.qq.com/oauth2.0/token",
"openIdEndpoint": "https://graph.qq.com/oauth2.0/me",
"userInfoEndpoint": "https://graph.qq.com/user/get_user_info"
},
"secret": {
"secretScope": "oauth",
"secretKey": "qq-oauth",
"secretValue": "QQ互联 AppKey"
}
}
```
前端调用:
```text
POST /api/auth/oauth/wechat
body: { "code": "<wechat oauth code>", "lang": "zh_CN" }
POST /api/auth/oauth/qq
body: { "code": "<qq oauth code>", "redirectUri": "https://h5.example.com/auth/qq/callback" }
```
生产注意:
- `endpoint``userInfoEndpoint``openIdEndpoint` 默认使用官方 HTTPS 域名;生产环境不允许本地 HTTP fake endpoint。
- `appSecret/clientSecret/AppKey` 必须存入 `app_private.tenant_secrets(secret_scope='oauth')`,不能进入 `configPublic`
- QQ 的 `redirectUri` 必须与 QQ 互联后台登记的一致;多租户自定义域名上线前需要逐个配置或设计统一授权中转域名。
## 手机号绑定/换绑
@@ -247,6 +306,7 @@ B 端合作商年费、服务费、服务器资源费不走学生端 `orders`
- 阿里云短信 SendSms: https://help.aliyun.com/zh/sms/developer-reference/api-dysmsapi-2017-05-25-sendsms
- 腾讯云短信 SendSms: https://cloud.tencent.com/document/api/382/55981
- 微信小程序登录 code2Session: https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html
- 微信开放平台网站应用微信登录: https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
- QQ 互联 OAuth: https://wiki.connect.qq.com/oauth2-0简介
- 微信支付 API v3: https://pay.weixin.qq.com/doc/v3/merchant/4012791855
- 支付宝开放平台: https://opendocs.alipay.com/

View File

@@ -40,7 +40,7 @@
| 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、回调域名、账号合并和审计 |
| 微信网页/QQ OAuth | 可联调 | `/api/auth/oauth/wechat` 已完成微信网页登录 code 换 token、userinfo、unionid 合并、session 签发和审计;`/api/auth/oauth/qq` 已完成 code/token/openid/userinfo 主链路;生产前需真实开放平台账号和回调域名联调 |
| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 |
| 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 |
| 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`,支持权限、菜单、模块、字段、数据范围配置;前端 UI 继续补 |

View File

@@ -14,7 +14,7 @@
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信生产账号、微信网页登录/QQ 登录、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载和字段映射 API 已可联调,前端操作台待接。
因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信/OAuth 生产账号、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检、模板下载和字段映射 API 已可联调,前端操作台待接。
## 后端模块进度
@@ -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、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载和字段映射 API | 字段映射 UI、真实数据 dry-run 和导入性能压测 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
@@ -85,7 +85,7 @@
- 完整资金流水对账、账单下载比对和异常订单运营台。
- XPay 或其它实际支付网关 adapter。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调
- 公共题库/地区题库自动同步 worker 已具备单批执行能力;继续补版本通知、冲突操作台,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
- 导入模板、字段映射和复检 API 已可用;前端继续补模板下载按钮、字段映射 UI、job 状态轮询和复检结果面板。
- 视频深度防盗链、动态水印和播放统计。

View File

@@ -258,8 +258,8 @@ GET /api/tenant-admin/audit-logs
## 下一步
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、杀毒扫描和视频水印。
2. 接入真实短信 provider阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
3. 接入真实 OAuth provider微信网页、微信小程序、QQ并处理旧 PocketBase 身份映射。
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
3. 完成真实 OAuth provider 联调微信网页、微信小程序、QQ确认回调域名、开放平台账号和旧 PocketBase 身份映射策略
4. 补完整资金流水对账、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认和退款通知主链路已完成。
5. 扩展 `apps/worker`日报统计、CRM 死信告警、公共题库版本通知和冲突处理运营台;公共题库同步 worker 已具备单批执行能力。
6. 开始 Taro scaffold`supabaseApi` 抽到跨端包或适配层。

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、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知和补偿 worker | 完整资金流水对账、异常订单运营台、服务商/平台代收模式 |
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI provider、JSON 报告 schema、PDF 报告生成 |
| Taro 跨端 | 未开始 | 旧 Web 新 API 适配开始 | `apps/taro`、共享 API client、H5/小程序统一构建 |
@@ -41,4 +41,4 @@
3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
4. 视频会员控制:深度防盗链、水印和播放统计。
5. 数据看板预聚合:把实时聚合升级为大租户可承载的日/周/月预聚合。
6. 真实 provider短信、微信/QQ 登录、微信支付/支付宝CRM worker 基础已落地,继续补分配策略和告警。
6. 真实 provider短信、微信/QQ 登录、微信支付/支付宝生产账号联调CRM worker 基础已落地,继续补分配策略和告警。

View File

@@ -43,7 +43,7 @@
- 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;仍要做真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限细化,前端不要继续使用 `x-user-id`
- 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。
- 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览和资源复检 worker但 CDN 防盗链、视频水印和杀毒扫描还要补。
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker模板下载、字段映射 API 和导入后复检已可用,前端还要补上传预览、排队轮询、问题行展示、模板下载按钮、字段映射 UI 和复检结果面板。
- 数据看板、分佣结算和勋章手动发放基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题模板市场等仍是后续商用增强项。

View File

@@ -38,7 +38,7 @@
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、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 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 |
| 数据导入 | 已建立 importer、risk report、validate | 已覆盖多类旧集合 | 命令行导入/校验 | `pb:import:validate` | 基础工具可用,需用真实完整数据做多轮 dry-run |
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
@@ -273,7 +273,7 @@ platform-admin:
上线前至少还需要完成:
1. 正式鉴权API 已支持 Supabase Auth JWT生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id``x-platform-admin-key` 兼容入口。
2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备;微信网页登录、QQ 登录、真实生产账号联调、对账和支付补偿仍需实现
2. 国内能力接入:短信、微信小程序登录、微信网页登录、QQ 登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备;真实生产账号联调、对账和支付补偿仍需继续完善
3. 核心缺口 API学生端个人中心、手机号绑定/换绑、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈和勋章已补基础 API下一步重点是学习报告可视化、后台统计和真实业务验收。
4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步批量导入、导入后复检、模板下载/字段映射 API、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API前端操作台待补。
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。
@@ -289,4 +289,4 @@ platform-admin:
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
4. 补视频商用控制:深度防盗链、动态水印和播放统计。
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。
6. 接真实短信、微信网页/QQ 登录、退款对账和补偿任务,并开始 Taro scaffold。
6. 接真实短信/OAuth 生产账号、退款对账和补偿任务,并开始 Taro scaffold。

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` |

View File

@@ -72,7 +72,7 @@
2. 国内登录和短信
- 已完成阿里云短信、腾讯云短信 adapter 的后端实现和本地 fake endpoint 测试。
- 已完成微信小程序 `code2Session` 登录主链路。
- 已完成手机号绑定/换绑基础 API继续补微信网页登录、QQ 登录、真实生产账号联调。
- 已完成手机号绑定/换绑微信网页登录、QQ 登录基础 API继续补真实生产账号、回调域名和开放平台联调。
- 旧 PocketBase 用户账号和新身份体系的映射/补绑。
3. 导入体系扩展
@@ -172,7 +172,7 @@
2. 登录
- 迁移期可先接短信 mock 或临时登录
- 生产接微信小程序登录、短信登录、QQ/微信网页登录
- 生产接微信小程序登录、短信登录、QQ/微信网页登录真实账号
3. 题库
- `content_entries/content_nodes`

View File

@@ -20,7 +20,7 @@ F:\project
src/
core/ 配置、HTTP、路由、数据库访问
features/ 业务模块
auth/ 迁移期登录、短信、微信小程序登录、OAuth 占位
auth/ 迁移期登录、短信、微信小程序/网页登录、QQ 登录
catalog/ 学生端目录、题库、资料、商品只读接口
commerce/ 订单、支付确认、激活码、权益
health/ 健康检查

View File

@@ -146,7 +146,7 @@ tenant:<tenantId>:theme
| 页面 | 主要接口 |
| --- | --- |
| 启动页 | `GET /api/tenant/resolve` |
| 登录页 | `POST /api/auth/sms/send``POST /api/auth/sms/verify``POST /api/auth/oauth/wechat-miniapp`后续微信网页/QQ provider |
| 登录页 | `POST /api/auth/sms/send``POST /api/auth/sms/verify``POST /api/auth/oauth/wechat-miniapp``POST /api/auth/oauth/wechat``POST /api/auth/oauth/qq` |
| 首页 | `/api/catalog/content-entries``/api/catalog/banners``/api/catalog/announcements``/api/catalog/exam-dates``/api/profile/me` |
| 选地区 | `/api/catalog/regions``/api/commerce/entitlements/check` |
| 题库入口 | `/api/catalog/content-entries` |
@@ -1110,6 +1110,46 @@ identity.unionId
- 如果登录前已经解析到推广码,登录成功后再调用 `/api/referral/bind` 完成首绑保护。
- 如果用户没有手机号,跳转到上面的“绑定或更换手机号”流程。
### 微信网页登录
H5 端在微信开放平台授权回调页拿到 `code` 后,交给后端:
```text
POST /api/auth/oauth/wechat
body: {
"code": "<wechat oauth code>",
"lang": "zh_CN"
}
```
成功响应与小程序登录一致,包含 `provider=user/identity/session`。后端会使用租户 `wechat-web/wechat_web/wechat` provider 配置换取 `access_token``openid`,再拉取用户资料;如果返回 `unionid`,会和同一开放平台下的小程序身份合并。
前端注意:
- H5 回调页只短暂读取 `code/state`,不要持久化微信 `access_token`
- `state` 应在前端本地或服务端中转页校验,避免跨站授权回调混淆。
- 多租户自定义域名下,授权回调域名必须与租户微信开放平台配置一致;如果未来使用统一授权中转域名,需要在回调后再解析目标租户。
### QQ 登录
H5 端在 QQ 互联授权回调页拿到 `code` 后,交给后端:
```text
POST /api/auth/oauth/qq
body: {
"code": "<qq oauth code>",
"redirectUri": "https://h5.example.com/auth/qq/callback"
}
```
后端会完成 `code -> access_token -> openid -> userinfo`,并签发本项目 session。
前端注意:
- `redirectUri` 必须与 QQ 互联后台登记地址一致;也可以由租户后台 provider 配置固定,前端不传。
- 前端不要接触 QQ `clientSecret/AppKey``access_token`
- 登录后如果没有手机号,同样进入“绑定或更换手机号”流程。
## 支付对接
支付流程必须以后端订单金额和后端回调为准,前端只负责拉起支付。

View File

@@ -79,6 +79,7 @@ let serverLogs = '';
let legacyDisabledServer = null;
let legacyDisabledServerLogs = '';
let fakeWechatServer = null;
let fakeQqServer = null;
let fakeWechatPayServer = null;
function buildUrl(path, query = {}) {
@@ -268,22 +269,58 @@ async function startFakeWechatServer() {
query: Object.fromEntries(url.searchParams.entries()),
});
if (url.pathname !== '/sns/jscode2session') {
if (!['/sns/jscode2session', '/sns/oauth2/access_token', '/sns/userinfo'].includes(url.pathname)) {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ errcode: 404, errmsg: 'not found' }));
return;
}
if (
url.searchParams.get('appid') !== 'wx-smoke-appid' ||
url.searchParams.get('secret') !== 'wechat-app-secret-smoke' ||
url.searchParams.get('grant_type') !== 'authorization_code'
) {
if (url.pathname === '/sns/userinfo') {
const accessToken = url.searchParams.get('access_token') || '';
const openid = url.searchParams.get('openid') || '';
if (!accessToken.startsWith('wechat-web-token-') || !openid.startsWith('wechat-web-openid-')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ errcode: 40003, errmsg: 'invalid openid' }));
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
openid,
nickname: '微信网页烟测学生',
headimgurl: 'https://example.test/wechat-web-avatar.png',
unionid: 'unionid-smoke-user',
privilege: [],
}),
);
return;
}
const appId = url.searchParams.get('appid');
const secret = url.searchParams.get('secret');
const grantType = url.searchParams.get('grant_type');
if (appId !== 'wx-smoke-appid' || secret !== 'wechat-app-secret-smoke' || grantType !== 'authorization_code') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ errcode: 40013, errmsg: 'invalid appid or secret' }));
return;
}
if (url.pathname === '/sns/oauth2/access_token') {
const webCode = url.searchParams.get('code') || 'unknown';
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
access_token: `wechat-web-token-${webCode}`,
expires_in: 7200,
refresh_token: `wechat-web-refresh-${webCode}`,
openid: `wechat-web-openid-${webCode}`,
scope: 'snsapi_login',
unionid: 'unionid-smoke-user',
}),
);
return;
}
const jsCode = url.searchParams.get('js_code') || 'unknown';
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
@@ -302,6 +339,91 @@ async function startFakeWechatServer() {
return {
endpoint: `${baseUrl}/sns/jscode2session`,
webTokenEndpoint: `${baseUrl}/sns/oauth2/access_token`,
webUserInfoEndpoint: `${baseUrl}/sns/userinfo`,
requests,
};
}
async function startFakeQqServer() {
const port = await getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;
const requests = [];
const tokenToOpenId = new Map();
fakeQqServer = http.createServer((req, res) => {
const url = new URL(req.url || '/', baseUrl);
requests.push({
method: req.method,
pathname: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
});
if (url.pathname === '/oauth2.0/token') {
if (
url.searchParams.get('client_id') !== 'qq-smoke-appid' ||
url.searchParams.get('client_secret') !== 'qq-client-secret-smoke' ||
url.searchParams.get('grant_type') !== 'authorization_code' ||
url.searchParams.get('redirect_uri') !== 'https://h5.example.test/auth/qq/callback'
) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 100010, error_description: 'invalid client' }));
return;
}
const code = url.searchParams.get('code') || 'unknown';
const accessToken = `qq-token-${code}`;
tokenToOpenId.set(accessToken, `qq-openid-${code}`);
res.writeHead(200, { 'content-type': 'text/plain' });
res.end(`access_token=${encodeURIComponent(accessToken)}&expires_in=7776000&refresh_token=qq-refresh-${encodeURIComponent(code)}`);
return;
}
if (url.pathname === '/oauth2.0/me') {
const accessToken = url.searchParams.get('access_token') || '';
const openid = tokenToOpenId.get(accessToken);
if (!openid) {
res.writeHead(200, { 'content-type': 'application/javascript' });
res.end('callback( {"error":100016,"error_description":"access token check failed"} );');
return;
}
res.writeHead(200, { 'content-type': 'application/javascript' });
res.end(`callback( {"client_id":"qq-smoke-appid","openid":"${openid}"} );`);
return;
}
if (url.pathname === '/user/get_user_info') {
const accessToken = url.searchParams.get('access_token') || '';
const openid = url.searchParams.get('openid') || '';
if (!tokenToOpenId.has(accessToken) || tokenToOpenId.get(accessToken) !== openid) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ret: 100001, msg: 'invalid token' }));
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
ret: 0,
msg: '',
nickname: 'QQ烟测学生',
figureurl_qq_2: 'https://example.test/qq-avatar.png',
}),
);
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ret: 404, msg: 'not found' }));
});
await new Promise((resolve, reject) => {
fakeQqServer.once('error', reject);
fakeQqServer.listen(port, '127.0.0.1', resolve);
});
return {
tokenEndpoint: `${baseUrl}/oauth2.0/token`,
openIdEndpoint: `${baseUrl}/oauth2.0/me`,
userInfoEndpoint: `${baseUrl}/user/get_user_info`,
requests,
};
}
@@ -664,6 +786,7 @@ async function testPhoneBinding() {
const phoneSuffix = String(Date.now()).slice(-6);
const oldPhone = `13920${phoneSuffix}`;
const newPhone = `13921${phoneSuffix}`;
const wrongPurposePhone = `13922${phoneSuffix}`;
const login = await loginBySms(oldPhone);
const authHeaders = { authorization: `Bearer ${login.session.token}` };
@@ -697,12 +820,12 @@ async function testPhoneBinding() {
});
assert.equal(conflict.code, 'PHONE_ALREADY_BOUND', 'binding a phone owned by another account should be rejected');
const wrongPurposeCode = await sendMockSmsCode('13800000022', 'login');
const wrongPurposeCode = await sendMockSmsCode(wrongPurposePhone, 'login');
const wrongPurpose = await request('/api/auth/phone/bind', {
userId: false,
headers: authHeaders,
method: 'POST',
body: { phone: '13800000022', code: wrongPurposeCode, purpose: 'login' },
body: { phone: wrongPurposePhone, code: wrongPurposeCode, purpose: 'login' },
expectStatus: 400,
});
assert.equal(wrongPurpose.code, 'PHONE_BIND_PURPOSE_REQUIRED', 'phone bind endpoint must reject login SMS codes');
@@ -824,6 +947,10 @@ function stopServer() {
fakeWechatServer.close();
fakeWechatServer = null;
}
if (fakeQqServer) {
fakeQqServer.close();
fakeQqServer = null;
}
if (fakeWechatPayServer) {
fakeWechatPayServer.close();
fakeWechatPayServer = null;
@@ -3921,6 +4048,7 @@ async function testPublicQuestionBankAdoption() {
async function testTenantAdminOps() {
const fakeWechat = await startFakeWechatServer();
const fakeQq = await startFakeQqServer();
const denied = await request('/api/tenant-admin/branding', {
method: 'PUT',
body: { brandName: '学生不能改品牌' },
@@ -4046,6 +4174,84 @@ async function testTenantAdminOps() {
});
assert.equal(miniappMe.user?.id, miniappLogin.user.id, 'wechat session should work with auth/me');
const wechatWebProvider = await request('/api/tenant-admin/auth-providers', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
provider: 'wechat-web',
displayName: '微信网页登录',
status: 'testing',
configPublic: {
appId: 'wx-smoke-appid',
endpoint: fakeWechat.webTokenEndpoint,
userInfoEndpoint: fakeWechat.webUserInfoEndpoint,
},
secret: {
secretValue: 'wechat-app-secret-smoke',
},
},
});
assert.equal(wechatWebProvider.item?.provider, 'wechat-web', 'tenant admin should upsert WeChat web provider');
assert.equal(wechatWebProvider.item?.configPublic?.secretRef, 'app_private.tenant_secrets:oauth:wechat-web', 'WeChat web provider should expose only secretRef');
const wechatWebLogin = await request('/api/auth/oauth/wechat', {
userId: false,
method: 'POST',
body: {
code: 'web-code-001',
lang: 'zh_CN',
},
});
assert.equal(wechatWebLogin.provider, 'wechat_web', 'wechat web login should return canonical provider');
assert.equal(wechatWebLogin.user?.id, miniappLogin.user.id, 'wechat web login should merge with miniapp account by unionId');
assert.ok(wechatWebLogin.session?.token?.startsWith('tk_'), 'wechat web login should issue API session token');
assert.equal(wechatWebLogin.identity?.openId, 'wechat-web-openid-web-code-001', 'wechat web login should expose openId');
assert.equal(wechatWebLogin.identity?.unionId, 'unionid-smoke-user', 'wechat web login should expose unionId');
assert.ok(fakeWechat.requests.some(item => item.pathname === '/sns/oauth2/access_token'), 'wechat web login should exchange code server-side');
assert.ok(fakeWechat.requests.some(item => item.pathname === '/sns/userinfo'), 'wechat web login should fetch userinfo server-side');
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-web-token-web-code-001'), 'wechat web response must not leak access_token');
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-app-secret-smoke'), 'wechat web response must not leak app secret');
const qqProvider = await request('/api/tenant-admin/auth-providers', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
provider: 'qq-oauth',
displayName: 'QQ网页登录',
status: 'testing',
configPublic: {
appId: 'qq-smoke-appid',
redirectUri: 'https://h5.example.test/auth/qq/callback',
endpoint: fakeQq.tokenEndpoint,
openIdEndpoint: fakeQq.openIdEndpoint,
userInfoEndpoint: fakeQq.userInfoEndpoint,
},
secret: {
secretValue: 'qq-client-secret-smoke',
},
},
});
assert.equal(qqProvider.item?.provider, 'qq-oauth', 'tenant admin should upsert QQ OAuth provider');
assert.equal(qqProvider.item?.configPublic?.secretRef, 'app_private.tenant_secrets:oauth:qq-oauth', 'QQ provider should expose only secretRef');
const qqLogin = await request('/api/auth/oauth/qq', {
userId: false,
method: 'POST',
body: {
code: 'qq-code-001',
redirectUri: 'https://h5.example.test/auth/qq/callback',
},
});
assert.equal(qqLogin.provider, 'qq', 'qq login should return canonical provider');
assert.ok(qqLogin.user?.id, 'qq login should create or resolve user');
assert.ok(qqLogin.session?.token?.startsWith('tk_'), 'qq login should issue API session token');
assert.equal(qqLogin.identity?.openId, 'qq-openid-qq-code-001', 'qq login should expose openId');
assert.ok(fakeQq.requests.some(item => item.pathname === '/oauth2.0/token'), 'qq login should exchange code server-side');
assert.ok(fakeQq.requests.some(item => item.pathname === '/oauth2.0/me'), 'qq login should fetch openid server-side');
assert.ok(fakeQq.requests.some(item => item.pathname === '/user/get_user_info'), 'qq login should fetch userinfo server-side');
assert.ok(!JSON.stringify(qqLogin).includes('qq-token-qq-code-001'), 'qq response must not leak access_token');
assert.ok(!JSON.stringify(qqLogin).includes('qq-client-secret-smoke'), 'qq response must not leak client secret');
const paymentAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',