forked from wangziqi/gongxue-base
feat: add web oauth login adapters
This commit is contained in:
@@ -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/短信/支付生产账号联调、完整资金流水对账、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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 继续补 |
|
||||
|
||||
@@ -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 状态轮询和复检结果面板。
|
||||
- 视频深度防盗链、动态水印和播放统计。
|
||||
|
||||
@@ -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` 抽到跨端包或适配层。
|
||||
|
||||
@@ -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 基础已落地,继续补分配策略和告警。
|
||||
|
||||
@@ -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 择校、主题模板市场等仍是后续商用增强项。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -20,7 +20,7 @@ F:\project
|
||||
src/
|
||||
core/ 配置、HTTP、路由、数据库访问
|
||||
features/ 业务模块
|
||||
auth/ 迁移期登录、短信、微信小程序登录、OAuth 占位
|
||||
auth/ 迁移期登录、短信、微信小程序/网页登录、QQ 登录
|
||||
catalog/ 学生端目录、题库、资料、商品只读接口
|
||||
commerce/ 订单、支付确认、激活码、权益
|
||||
health/ 健康检查
|
||||
|
||||
@@ -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`。
|
||||
- 登录后如果没有手机号,同样进入“绑定或更换手机号”流程。
|
||||
|
||||
## 支付对接
|
||||
|
||||
支付流程必须以后端订单金额和后端回调为准,前端只负责拉起支付。
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user