chore(sso-portal): 整合近期重构、UX 改进、部署脚本与文档

项目改名 sso-mock → sso-portal:
- 重命名仓库目录 sso-mock/ → sso-portal/
- package.json name 改 sso-portal
- app/layout.tsx title/description 改 SSO Portal
- 源码 [sso-mock] console.log 前缀、注释全部 → [sso-portal]
- 文档(README、ADAPTATION_GUIDE)、env 同步
- 生产 svc 名 sso-mock-svc → sso-portal-svc(external-app 引用同步)

SSO tenant 改环境变量驱动:
- 新增 SSO_TENANT env(默认 cowarobot)
- login/route.ts 顶部 const SSO_TENANT,ssoBody 显式使用
- callSsoApi 改回纯透传(不再硬编码覆盖 tenant)
- external-app 同步用 SSO_TENANT,命名对齐

external-app 登录态改为基于 token:
- page.tsx 读 token cookie 判定 authenticated(不再依赖 user cookie)
- DashboardPage 用 authenticated 决定跳转,user 为 null 时优雅兜底展示
- 解决「200 后又跳回 SSO」bug(access_token 接口不返回 user)

sso-portal 缺 SSO 参数体验优化:
- 缺 app_id/app_url 时 Alert 提示 + 禁用所有输入和登录按钮
- components/login/index.tsx 加 missingSsoParams 检测
- Alert 样式两排完整显示

部署脚本(参考 uirefbase):
- 新增 build.sh(git → pnpm install → build → docker build → tag → push)
- 新增 .gitlab-ci.yml(main 分支触发 build.sh prod)
- 新增 Dockerfile(standalone 模式,端口 5501)
- package.json 加 env-cmd 依赖 + build:stage/build 脚本
- next.config.ts 保持 output: 'standalone'

代码清理:
- 删除 utils/code-store.ts
- 删除 app/api/auth/verify/(连同目录)
- 移除旧的 codeStore / generateCode 逻辑

文档:
- ADAPTATION_GUIDE.md:完整重写对齐真实后端三个 SSO 接口(/sso/account、/sso/phone/smscode、/sso/access_token)
- 新增 INTEGRATION_GUIDE.md(从 external-app 迁移过来作为通用接入指南)
- README.md 同步更新(sso-portal 仓库门面 + 文档索引)
- external-app/README.md 加交叉链接指向 sso-portal 的接入文档
This commit is contained in:
zhangheng
2026-07-08 20:53:51 +08:00
parent 00d3de91f9
commit 775b4f6044
19 changed files with 1086 additions and 511 deletions

View File

@@ -1,54 +0,0 @@
import { NextResponse } from 'next/server'
import { codeStore } from '@/utils/code-store'
/**
* SSO Code 验证接口
*
* 外部应用拿到 sso_code 后,调用此接口换取用户信息。
* POST /api/auth/verify
* Body: { code: string }
*/
export async function POST(req: Request) {
try {
const { code } = await req.json()
if (!code) {
return NextResponse.json({ error: 'code 不能为空' }, { status: 400 })
}
const entry = codeStore.get(code)
if (!entry) {
return NextResponse.json({ error: 'code 无效或已过期' }, { status: 401 })
}
if (entry.expires < Date.now()) {
codeStore.delete(code)
return NextResponse.json({ error: 'code 已过期' }, { status: 401 })
}
// 验证通过,删除已使用的 code一次性
codeStore.delete(code)
// 返回用户信息和 token
const token =
'mock_token_' +
Math.random().toString(36).substring(2) +
'_' +
Date.now()
return NextResponse.json({
token,
refresh_token: 'mock_refresh_' + Date.now(),
user: entry.user,
permissions: [
{ object: '/dashboard', action: 'view' },
{ object: '/settings', action: 'view' },
],
})
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || '验证失败' },
{ status: 500 }
)
}
}

View File

@@ -6,7 +6,7 @@ const SHARED_API = (
export async function GET() {
const fetchUrl = `${SHARED_API}/api/v1/basis/tenant/base/listabbr`
console.log('[sso-mock] getAbbrList:', fetchUrl)
console.log('[sso-portal] getAbbrList:', fetchUrl)
try {
const res = await fetch(fetchUrl, {
@@ -24,7 +24,7 @@ export async function GET() {
const data = await res.json()
return NextResponse.json(data, { status: 200 })
} catch (err: any) {
console.error('[sso-mock] getAbbrList error:', err.message)
console.error('[sso-portal] getAbbrList error:', err.message)
return NextResponse.json(
{ message: err?.message || '获取租户信息失败' },
{ status: 500 }

View File

@@ -13,7 +13,7 @@ const SHARED_API = (
export async function POST(req: Request) {
const body = await req.json()
const fetchUrl = `${SHARED_API}/api/v1/basis/user/phone/code`
console.log('[sso-mock] getVerifyCode →', fetchUrl, '| phone:', body?.phone)
console.log('[sso-portal] getVerifyCode →', fetchUrl, '| phone:', body?.phone)
try {
const res = await fetch(fetchUrl, {
@@ -41,7 +41,7 @@ export async function POST(req: Request) {
return NextResponse.json(data, { status: 200 })
} catch (err: any) {
console.error('[sso-mock] getVerifyCode error:', err.message)
console.error('[sso-portal] getVerifyCode error:', err.message)
return NextResponse.json(
{ code: 500, message: err?.message || '验证码发送失败' },
{ status: 500 }

View File

@@ -1,45 +1,47 @@
import { NextResponse } from 'next/server'
import CryptoJS from 'crypto-js'
import { codeStore } from '@/utils/code-store'
import CryptoJS from "crypto-js";
import { NextResponse } from "next/server";
const CODE_CRYPTO_KEY = 'k#8Mp$2Qr&9Tz@4W'
const CODE_CRYPTO_KEY = "k#8Mp$2Qr&9Tz@4W";
const SHARED_API = (
process.env.NEXT_PUBLIC_SHARED_API_PATH || 'http://172.16.115.31:6610'
).replace(/\/+$/, '')
process.env.NEXT_PUBLIC_SHARED_API_PATH || "http://172.16.115.31:6610"
).replace(/\/+$/, "");
// SSO 接口的租户标识account / phone-smscode / access_token 共用,由环境变量驱动)
const SSO_TENANT = process.env.SSO_TENANT || "cowarobot";
function decrypt(encrypted: string): string {
try {
const bytes = CryptoJS.AES.decrypt(encrypted, CODE_CRYPTO_KEY)
return bytes.toString(CryptoJS.enc.Utf8)
const bytes = CryptoJS.AES.decrypt(encrypted, CODE_CRYPTO_KEY);
return bytes.toString(CryptoJS.enc.Utf8);
} catch {
return encrypted
return encrypted;
}
}
function generateCode(): string {
return Math.random().toString(36).substring(2, 10) + Date.now().toString(36)
}
async function callBasisApi(path: string, body: object) {
const url = `${SHARED_API}${path}`
console.log('[sso-mock] →', url)
/**
* 调用真实后端 SSO 端点。
* 透传响应(含后端签发的一次性授权 code不再自造 code。
*/
async function callSsoApi(path: string, body: object) {
const url = `${SHARED_API}${path}`;
console.log("[sso-portal] →", url, body);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
});
const text = await res.text()
let data: any
const text = await res.text();
let data: any;
try {
data = JSON.parse(text)
data = JSON.parse(text);
} catch {
throw new Error(`后端返回非 JSON [${res.status}]: ${text.slice(0, 200)}`)
throw new Error(`后端返回非 JSON [${res.status}]: ${text.slice(0, 200)}`);
}
return data
return { data, status: res.status };
}
// ─── POST /api/login ───
@@ -47,79 +49,82 @@ async function callBasisApi(path: string, body: object) {
// req.json() 去掉外层引号得到加密字符串decrypt 解密得到原始 JSON
export async function POST(req: Request) {
try {
const decryptData = await req.json() // JSON 字符串 → 去掉引号 → 加密原文
const body = JSON.parse(decrypt(decryptData))
const decryptData = await req.json(); // JSON 字符串 → 去掉引号 → 加密原文
const body = JSON.parse(decrypt(decryptData));
const type = body?.type
console.log('[sso-mock] login | type:', type, '| body keys:', Object.keys(body || {}))
const type = body?.type;
console.log(
"[sso-portal] login | type:",
type,
"| body keys:",
Object.keys(body || {}),
);
if (!type) {
return NextResponse.json({ error: '缺少 type 字段', debug_type: typeof type })
return NextResponse.json({
error: "缺少 type 字段",
debug_type: typeof type,
});
}
let res: any
const client_id = body.client_id;
const client_redirect_uri = body.client_redirect_uri;
if (type === 'account') {
res = await callBasisApi('/api/v1/basis/login/account', {
// OAuth2 授权码流程client_id / client_redirect_uri 为必填(授权 code 必须绑定应用)
if (!client_id || !client_redirect_uri) {
return NextResponse.json({
error: "缺少 client_id 或 client_redirect_uri无法签发授权 code",
});
}
let path: string;
let ssoBody: Record<string, unknown>;
if (type === "account") {
path = "/api/v1/basis/sso/account";
ssoBody = {
account: body.account,
password: body.password,
abbr: body.abbr,
})
} else if (type === 'phone') {
res = await callBasisApi('/api/v1/basis/user/phone/login', {
tenant: SSO_TENANT,
client_id,
client_redirect_uri,
};
} else if (type === "phone") {
path = "/api/v1/basis/sso/phone/smscode";
ssoBody = {
phone: body.phone,
verify_code: body.verify_code,
tntkey: body.tntkey,
})
} else if (type === 'feishu') {
res = await callBasisApi('/api/v1/basis/login/feishu', {
code: body.code,
redirect_uri: body.redirect_uri,
tenant_abbr: body.tenant,
})
smscode: body.verify_code,
tenant: SSO_TENANT,
client_id,
client_redirect_uri,
};
} else {
console.error('[sso-mock] 未知 type:', type)
return NextResponse.json({ error: `不支持的登录方式: ${type}` })
console.error("[sso-portal] 未知 type:", type);
return NextResponse.json({ error: `不支持的登录方式: ${type}` });
}
console.log('[sso-mock] backend res.code:', res?.code)
const { data: ssoRes, status } = await callSsoApi(path, ssoBody);
console.log(
"[sso-portal] backend status:",
status,
"| res:",
JSON.stringify(ssoRes).slice(0, 300),
);
// 后端业务码非 200 视为失败,直接返回错误,不生成 code
if (res?.code !== 200) {
const message = res?.message || res?.msg || '登录失败'
console.log('[sso-mock] backend error:', message)
return NextResponse.json({ error: message })
// ⚠️ code 在新流程里是「授权码字符串」,不再是业务状态码。
// 兼容平铺 { code } 与外壳 { code:200, data:{ code } } 两种响应。
const authCode = ssoRes?.data?.code ?? ssoRes?.code;
if (!authCode || typeof authCode !== "string") {
const message =
ssoRes?.message || ssoRes?.msg || "登录失败,后端未返回授权 code";
console.log("[sso-portal] no auth code:", message);
return NextResponse.json({ error: message });
}
const user = res.data?.user
if (!user || !user.account) {
console.log('[sso-mock] backend returned 200 but no valid user')
return NextResponse.json({ error: '后端未返回有效用户信息' })
}
const tenant = user?.tntkey || body.abbr || 'coowa'
const user_info = user
const rbac = res?.data?.rbac || { data: [] }
const token = res.data?.access_token || ''
const responseData: any = { tenant, user_info, rbac, token }
// SSO 模式:仅当 client_id + client_redirect_uri 都存在时生成 code
const client_id = body.client_id
const client_redirect_uri = body.client_redirect_uri
if (client_id && client_redirect_uri) {
const code = generateCode()
codeStore.set(code, {
user: user_info,
expires: Date.now() + 5 * 60 * 1000,
})
responseData.code = code
console.log('[sso-mock] code generated →', code)
}
return NextResponse.json(responseData)
console.log("[sso-portal] auth code →", authCode);
return NextResponse.json({ code: authCode });
} catch (err: any) {
console.error('[sso-mock] login error:', err.message)
return NextResponse.json({ error: err?.message || '登录失败' })
console.error("[sso-portal] login error:", err.message);
return NextResponse.json({ error: err?.message || "登录失败" });
}
}