From 9bce0c5f683be3797caaa1a5d0296e978c3900b7 Mon Sep 17 00:00:00 2001 From: "z.zhang" Date: Thu, 22 Oct 2026 23:07:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(license):=20=E6=8E=88=E6=9D=83=E7=A0=81?= =?UTF-8?q?=E5=8D=B3=E7=94=A8=E6=88=B7=E8=BA=AB=E4=BB=BD=EF=BC=88codes=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=EF=BC=89=E4=B8=8E=E8=B6=85=E7=AE=A1=E5=8F=91?= =?UTF-8?q?=E7=A0=81=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CodeLicenseProvider:每个授权码恒定派生唯一 user_id,会话/项目/主数据按码隔离, 同一授权码跨浏览器/设备激活找回同一数据;内置超管码 aps@Z@Agent@Admin(永久+全角色) - APS_LICENSE_CLIENTS 控制作用端(web/desktop),JMS 配置存在时 JMS 优先 - 设置面板新增「用户与授权」(仅 admin 可见):登记姓名/手机号/公司,预设时长或 自定义到期时间,自动生成授权码,停用即时生效,可删除并查看激活状态 - Web 端未激活弹「请提供授权码」激活卡片;Web 会话使用独立 aps_license_session Cookie - /api/health 授权配置缺失时兜底返回 invalid 标记,不再 500 - aps_license_codes 表入库管理,SQLite 老库自动补列迁移 - 新增 21 项端到端用例;golden 回归 2398 通过 --- .env.example | 10 + README.md | 19 + apps/web/src/api/client.ts | 64 +++ apps/web/src/auth/AuthEntry.tsx | 12 +- apps/web/src/auth/AuthGate.tsx | 2 +- apps/web/src/auth/AuthProvider.tsx | 28 +- apps/web/src/gov/GovConsole.tsx | 15 +- apps/web/src/gov/LicenseAdminView.tsx | 216 ++++++++++ server/auth/licenses.py | 282 ++++++++++++- server/auth/middleware.py | 4 +- server/db/database.py | 3 + server/db/models.py | 24 ++ server/gateway/app.py | 60 ++- server/gateway/license_admin_api.py | 218 ++++++++++ tests/golden/test_web_license_codes_auth.py | 432 ++++++++++++++++++++ 15 files changed, 1343 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/gov/LicenseAdminView.tsx create mode 100644 server/gateway/license_admin_api.py create mode 100644 tests/golden/test_web_license_codes_auth.py diff --git a/.env.example b/.env.example index f8418ca..efe56b0 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,16 @@ APS_LICENSE_PROVIDER=unconfigured # mock 模式必须显式配置随机签名密钥和租户标识;npm run dev:desktop 会为本地开发自动生成。 # APS_MOCK_LICENSE_SECRET= # APS_MOCK_LICENSE_TENANT_UUID= +# +# ---- 授权码模式(codes:授权码即用户身份,Web/桌面均可激活)---- +# 每个授权码唯一对应一个用户身份,智能体会话、项目、主数据全部绑定到该授权码并互相隔离; +# 同一授权码可在任意浏览器/设备激活并找回同一份数据。 +# 内置超级管理员授权码 aps@Z@Agent@Admin(永久授权 + 全功能角色),其余授权码经 APS_LICENSE_CODES 发放。 +# APS_LICENSE_PROVIDER=codes +# APS_LICENSE_SECRET=至少32个随机字符(HMAC 签名密钥,生产必须独立随机生成) +# APS_LICENSE_TENANT_UUID=platform +# APS_LICENSE_CLIENTS=web,desktop +# APS_LICENSE_CODES={"APS-USER-001":"year","APS-USER-002":{"type":"month","name":"张三"}} # ---- 现场数据目录(可选;未配置时不绑定任何机器目录)---- # KANGNI_DATA_DIR= diff --git a/README.md b/README.md index 3827082..d258756 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,25 @@ Web 端会从 JMS 读取启用企业,用户选择所属企业后使用真实 `APS-HOUR-DEMO`、`APS-DAY-DEMO`、`APS-WEEK-DEMO`、`APS-MONTH-DEMO`、 `APS-YEAR-DEMO`、`APS-PERP-DEMO`。 +### 授权码部署(codes 模式:授权码即用户身份) + +独立 Web 交付(不走 JMS)时可改用授权码认证:用户打开系统即看到"请提供授权码", +激活后智能体会话、项目、主数据全部绑定到该授权码;每个授权码只能看到自己的数据, +同一授权码换浏览器/设备激活可找回同一份数据。配置示例: + +```bash +APS_LICENSE_PROVIDER=codes +APS_LICENSE_SECRET=至少32个随机字符 +APS_LICENSE_CLIENTS=web,desktop +# 发放给用户的一一对应的授权码(类型:hour/day/week/month/year/permanent) +APS_LICENSE_CODES={"APS-USER-001":"year","APS-USER-002":{"type":"month","name":"张三"}} +``` + +内置超级管理员授权码 `aps@Z@Agent@Admin`(永久授权 + 全功能角色),无需配置即可使用; +用管理员码登录后,可在「设置 → 用户与授权」面板直接发放/停用/删除用户授权码 +(登记姓名、手机号、公司、有效时间,系统自动生成授权码),无需重启服务; +`APS_AUTH_PROVIDER=jms` 同时存在时 JMS 优先,授权码模式不生效。 + ```bash # Web(浏览器 http://localhost:5173 ,API :8000) npm run dev:web diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index ae13a0e..ebb9394 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1735,3 +1735,67 @@ export async function postMeshWatchdog(body: { stallTimeoutSec?: number; maxRetr const resp = await apiFetch('/api/mesh/watchdog', { method: 'POST', body: JSON.stringify(body) }); return (await resp.json()) as { error?: string }; } + +// ---------------- 超管 · 用户与授权码管理(codes 模式) ---------------- +export type LicenseCodeActivation = { + status: string; + activatedAt: number; + lastSeenAt: number; + expiresAt: number | null; +}; + +export type LicenseCodeItem = { + id: string; + code: string; + name: string; + mobile: string; + company: string; + licenseType: string; + licenseLabel: string; + expiresAt?: number | null; + status: string; + createdAt: string; + activation: LicenseCodeActivation | null; +}; + +async function readApiError(resp: Response): Promise { + try { + const body = await resp.json() as { detail?: { message?: string } | string; error?: { message?: string } }; + if (typeof body.detail === 'object' && body.detail?.message) return new Error(body.detail.message); + if (typeof body.detail === 'string') return new Error(body.detail); + if (body.error?.message) return new Error(body.error.message); + } catch { /* fall through */ } + return new Error(`请求失败(${resp.status})`); +} + +export async function fetchLicenseCodes(): Promise { + const resp = await apiFetch('/api/admin/license/codes'); + if (!resp.ok) throw await readApiError(resp); + return ((await resp.json()) as { codes: LicenseCodeItem[] }).codes; +} + +export async function createLicenseCode(payload: { + name: string; mobile: string; company: string; licenseType: string; code?: string; expiresAt?: number; +}): Promise { + const resp = await apiFetch('/api/admin/license/codes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) throw await readApiError(resp); + return ((await resp.json()) as { code: LicenseCodeItem }).code; +} + +export async function setLicenseCodeStatus(id: string, status: 'active' | 'disabled'): Promise { + const resp = await apiFetch(`/api/admin/license/codes/${encodeURIComponent(id)}/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }); + if (!resp.ok) throw await readApiError(resp); +} + +export async function deleteLicenseCode(id: string): Promise { + const resp = await apiFetch(`/api/admin/license/codes/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!resp.ok) throw await readApiError(resp); +} diff --git a/apps/web/src/auth/AuthEntry.tsx b/apps/web/src/auth/AuthEntry.tsx index 2492de7..eda060c 100644 --- a/apps/web/src/auth/AuthEntry.tsx +++ b/apps/web/src/auth/AuthEntry.tsx @@ -48,7 +48,7 @@ export default function AuthEntry(props: { settings: 'Settings', result: props.hasVisualResult ? 'Open session result' : 'No session result', logout: 'Sign out', }; const displayName = user?.fullname || user?.username || ''; - const desktop = auth.clientMode === 'desktop'; + const licenseMode = auth.clientMode === 'desktop' || auth.licenseMode; const licenseMeta = auth.license ? (auth.license.permanent ? auth.license.label @@ -65,12 +65,12 @@ export default function AuthEntry(props: { className="account-trigger guest-login-trigger" disabled={checking} onClick={props.onLogin} - title={checking ? '正在验证访问状态' : (desktop ? '客户端授权' : '登录')} + title={checking ? '正在验证访问状态' : (licenseMode ? '授权激活' : '登录')} > - {desktop ? : } + {licenseMode ? : } - {checking ? '正在验证' : (desktop ? '授权进入' : '登录')} - {checking ? '请稍候' : (desktop ? '激活后使用本机项目与会话' : '登录后同步项目与会话')} + {checking ? '正在验证' : (licenseMode ? '授权进入' : '登录')} + {checking ? '请稍候' : (licenseMode ? '激活后使用项目与会话' : '登录后同步项目与会话')} {!checking && } @@ -111,7 +111,7 @@ export default function AuthEntry(props: {
{menuNote &&
{menuNote}
}
diff --git a/apps/web/src/auth/AuthGate.tsx b/apps/web/src/auth/AuthGate.tsx index fed18a4..a6c013d 100644 --- a/apps/web/src/auth/AuthGate.tsx +++ b/apps/web/src/auth/AuthGate.tsx @@ -27,7 +27,7 @@ function GuestSidebar(props: { onToggleCollapsed: () => void; }) { const auth = useAuth(); - const accessWord = auth.clientMode === 'desktop' ? '授权' : '登录'; + const accessWord = (auth.clientMode === 'desktop' || auth.licenseMode) ? '授权' : '登录'; const action = props.checking ? undefined : props.onLogin; return (