diff --git a/README.md b/README.md index 70730363..3905bfb8 100644 --- a/README.md +++ b/README.md @@ -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 待接。 - 题库导出目前完成服务端结构化 payload;PDF/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/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/auth/index.ts b/apps/api/src/features/auth/index.ts index 2dabe08b..485b645c 100644 --- a/apps/api/src/features/auth/index.ts +++ b/apps/api/src/features/auth/index.ts @@ -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], ]; diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts index 40ebb0de..56685d5f 100644 --- a/apps/api/src/features/auth/routes.ts +++ b/apps/api/src/features/auth/routes.ts @@ -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, 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, 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 = {}; + for (const [key, child] of Object.entries(value as Record)) { + 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, + }, + }; + }); } diff --git a/docs/refactor/auth-payment-provider-plan.md b/docs/refactor/auth-payment-provider-plan.md index 0d3096c4..205160b3 100644 --- a/docs/refactor/auth-payment-provider-plan.md +++ b/docs/refactor/auth-payment-provider-plan.md @@ -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": "", "lang": "zh_CN" } + +POST /api/auth/oauth/qq +body: { "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/ diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 05db9ced..0c220e2b 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -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 继续补 | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 2bb8d243..908dc863 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -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 状态轮询和复检结果面板。 - 视频深度防盗链、动态水印和播放统计。 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index acc5b814..ddf6f9d9 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -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` 抽到跨端包或适配层。 diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index 91e00f2a..d653e458 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -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 基础已落地,继续补分配策略和告警。 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index f23be073..4af29027 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -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 择校、主题模板市场等仍是后续商用增强项。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 7ee4d40a..61e7df32 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -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。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 0153dac9..98111685 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -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` | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 9acb851b..c8a81a3c 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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` diff --git a/docs/refactor/project-structure.md b/docs/refactor/project-structure.md index d1e4f4d9..592a4b4b 100644 --- a/docs/refactor/project-structure.md +++ b/docs/refactor/project-structure.md @@ -20,7 +20,7 @@ F:\project src/ core/ 配置、HTTP、路由、数据库访问 features/ 业务模块 - auth/ 迁移期登录、短信、微信小程序登录、OAuth 占位 + auth/ 迁移期登录、短信、微信小程序/网页登录、QQ 登录 catalog/ 学生端目录、题库、资料、商品只读接口 commerce/ 订单、支付确认、激活码、权益 health/ 健康检查 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 953c6e17..c4b9ddb0 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -146,7 +146,7 @@ tenant::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": "", + "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": "", + "redirectUri": "https://h5.example.com/auth/qq/callback" +} +``` + +后端会完成 `code -> access_token -> openid -> userinfo`,并签发本项目 session。 + +前端注意: + +- `redirectUri` 必须与 QQ 互联后台登记地址一致;也可以由租户后台 provider 配置固定,前端不传。 +- 前端不要接触 QQ `clientSecret/AppKey` 或 `access_token`。 +- 登录后如果没有手机号,同样进入“绑定或更换手机号”流程。 + ## 支付对接 支付流程必须以后端订单金额和后端回调为准,前端只负责拉起支付。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 7e79d197..50ce4cf1 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -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',