feat(desktop): authorize material accounts and sync private workspaces
This commit is contained in:
parent
c51f567539
commit
971e17fcd8
@ -286,6 +286,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_detail_gen
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_ai_model_registry ./prisma/migrations/20261012_ai_model_registry
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_capabilities ./prisma/migrations/20261013_ai_model_capabilities
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_verifications ./prisma/migrations/20261013_ai_model_verifications
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20261020090000_desktop_client_workspace ./prisma/migrations/20261020090000_desktop_client_workspace
|
||||
COPY --from=builder --chown=root:root /app/lib ./lib
|
||||
COPY --from=builder --chown=root:root /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
|
||||
COPY --from=builder --chown=root:root /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
|
||||
|
||||
61
app/api/desktop/[...path]/route.ts
Normal file
61
app/api/desktop/[...path]/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { desktopAssert, desktopPortal, DesktopClientError, DESKTOP_CLIENT_ID } from '@/lib/desktop-client/contract.mjs'
|
||||
import {isAllowedCorsOrigin} from '@/lib/cors-origin'
|
||||
import { approveDesktop, desktopUser, exchangeDesktop, getDesktopAsset, getDesktopProject, listDesktopDevices, listDesktopProjects, putDesktopAsset, putDesktopProject, requireDesktop, revokeDesktop } from '@/lib/desktop-client/service'
|
||||
export const runtime='nodejs'
|
||||
export const dynamic='force-dynamic'
|
||||
const headers={'Cache-Control':'private, no-store','X-Content-Type-Options':'nosniff'}
|
||||
async function body(request:Request,max=1600000){
|
||||
desktopAssert(Number(request.headers.get('content-length')||0)<=max,'请求内容过大',413)
|
||||
const reader=request.body?.getReader();const chunks:Uint8Array[]=[];let size=0
|
||||
if(reader)while(true){const part=await reader.read();if(part.done)break;size+=part.value.length;if(size>max){await reader.cancel();throw new DesktopClientError('请求内容过大',413)}chunks.push(part.value)}
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
async function browserOwner(request:Request){
|
||||
const origin=request.headers.get('origin')
|
||||
desktopAssert(origin&&isAllowedCorsOrigin(request,origin),'请从素材库授权页面操作',403,'invalid_origin')
|
||||
desktopPortal(origin)
|
||||
const session=await getServerSession(authOptions);return desktopUser((session?.user as {id?:string})?.id||'')
|
||||
}
|
||||
async function route(request:Request,{params}:{params:{path:string[]}}){
|
||||
try{
|
||||
const parts=params.path,url=new URL(request.url),method=request.method
|
||||
let value:unknown
|
||||
if(parts.length===1&&parts[0]==='config'&&method==='GET')return NextResponse.json({clientId:DESKTOP_CLIENT_ID,enabled:process.env.DETAIL_GENERATOR_ENABLED==='true',pkce:'S256'},{headers})
|
||||
if(parts.length===1&&parts[0]==='authorize'&&method==='POST'){const user=await browserOwner(request);value=await approveDesktop(user.id,JSON.parse((await body(request,5000)).toString()))}
|
||||
else if(parts.length===1&&parts[0]==='token'&&method==='POST')value=await exchangeDesktop(JSON.parse((await body(request,5000)).toString()))
|
||||
else if(parts[0]==='devices'){
|
||||
const session=await getServerSession(authOptions);const user=await desktopUser((session?.user as {id?:string})?.id||'')
|
||||
if(method==='GET'&&parts.length===1)value={devices:await listDesktopDevices(user.id)}
|
||||
else if(method==='DELETE'&&parts.length===2){await browserOwner(request);await revokeDesktop(parts[1],user.id);value={revoked:true}}
|
||||
else throw new DesktopClientError('接口不存在',404)
|
||||
}else{
|
||||
const auth=await requireDesktop(request),ownerId=auth.account.id
|
||||
if(parts[0]==='session'&&parts.length===1){if(method==='GET')value={account:auth.account,expiresAt:auth.expiresAt};else if(method==='DELETE'){await revokeDesktop(auth.id,ownerId);value={revoked:true}}else throw new DesktopClientError('接口不存在',404)}
|
||||
else if(parts[0]==='models'&&method==='GET'&&parts.length===1){const {getAiModelRuntimeState}=await import('@/lib/ai-model-service');value=await getAiModelRuntimeState()}
|
||||
else if(parts[0]==='agent-models'&&parts.length===1&&method==='GET'){
|
||||
const {getAiModelRuntimeState,resolveAiModel}=await import('@/lib/ai-model-service');const config=await getAiModelRuntimeState();const models=[]
|
||||
for(const option of config.modelOptions.copy){const resolved=await resolveAiModel('copy',option.value);const base=new URL(resolved.binding.baseUrl);if(base.hostname==='api.vip98k.com'&&base.protocol==='https:'&&['openai-chat','anthropic-messages'].includes(resolved.protocol.id))models.push({key:option.value,id:resolved.binding.modelId,label:option.label,protocol:resolved.protocol.id,path:resolved.protocol.path})}
|
||||
value={models}
|
||||
}
|
||||
else if(parts[0]==='projects'){
|
||||
if(method==='GET'&&parts.length===1)value=await listDesktopProjects(ownerId,url.searchParams.get('cursor'))
|
||||
else if(parts.length===3&&method==='GET')value=await getDesktopProject(ownerId,parts[1],parts[2])
|
||||
else if(parts.length===3&&parts[1]==='desktop'&&method==='PUT')value=await putDesktopProject(ownerId,parts[2],JSON.parse((await body(request)).toString()))
|
||||
else if(parts.length===5&&parts[3]==='assets'){
|
||||
if(method==='PUT'&&parts[1]==='desktop')value=await putDesktopAsset(ownerId,parts[2],parts[4],await body(request,20*1024*1024),request.headers.get('content-type')||'')
|
||||
else if(method==='GET'){const asset=await getDesktopAsset(ownerId,parts[1],parts[2],parts[4]);return new NextResponse(Uint8Array.from(asset.bytes).buffer,{headers:{...headers,'Content-Type':asset.mime,'Content-Disposition':'attachment'}})}
|
||||
else throw new DesktopClientError('接口不存在',404)
|
||||
}else throw new DesktopClientError('接口不存在',404)
|
||||
}else throw new DesktopClientError('接口不存在',404)
|
||||
}
|
||||
return NextResponse.json(value,{headers})
|
||||
}catch(error){
|
||||
const known=error instanceof DesktopClientError||Boolean(error&&typeof error==='object'&&'status'in error)
|
||||
const item=error as {status?:number;code?:string;message?:string}
|
||||
return NextResponse.json({error:known?item.message:'客户端服务暂不可用,请稍后重试',code:known?item.code||'invalid_request':'service_unavailable'},{status:known?item.status||400:503,headers})
|
||||
}
|
||||
}
|
||||
export {route as GET,route as POST,route as PUT,route as DELETE}
|
||||
1
app/client/authorize/authorize.css
Normal file
1
app/client/authorize/authorize.css
Normal file
@ -0,0 +1 @@
|
||||
.client-authorize{max-width:440px;margin:10vh auto;padding:24px;color:#101828;font:14px/1.6 system-ui,sans-serif}.authorize-brand{display:flex;align-items:center;gap:12px;margin-bottom:38px;font-weight:600}.authorize-logo{width:46px;height:46px;object-fit:contain}.client-authorize h1{font-size:26px;letter-spacing:-.025em;line-height:1.35;margin:0 0 10px}.authorize-account{color:#667085}.authorize-account strong{color:#344054}.client-authorize ul{padding:22px 0 22px 22px;display:grid;gap:12px}.authorize-note{font-size:13px;color:#667085;margin-bottom:24px}.authorize-actions{display:flex;justify-content:flex-end;gap:12px}.client-authorize button{min-height:44px;border:1px solid #d0d5dd;border-radius:7px;background:#fff;color:#344054;padding:0 16px;cursor:pointer}.client-authorize button:hover{background:#f2f4f7}.client-authorize button:focus-visible,.client-authorize summary:focus-visible{outline:2px solid #1677ff;outline-offset:3px}.client-authorize button.authorize-primary{background:#0050cb;border-color:#0050cb;color:white;font-weight:600}.client-authorize button:disabled{opacity:.6;cursor:wait}.client-authorize [role=alert]{padding:12px;background:#fff2f0;color:#b42318;margin-bottom:18px}.client-authorize details{border-top:1px solid #e4e7ec;margin-top:30px;padding-top:20px}.client-authorize summary{cursor:pointer;color:#667085}.authorize-device{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:16px 0}.authorize-device small{display:block;color:#667085}@media(max-width:480px){.client-authorize{margin:24px auto;padding:24px}.authorize-actions button{padding:0 12px}}
|
||||
15
app/client/authorize/page.tsx
Normal file
15
app/client/authorize/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import {getServerSession} from 'next-auth'
|
||||
import {redirect} from 'next/navigation'
|
||||
import {authOptions} from '@/lib/auth'
|
||||
import {desktopAuthorization} from '@/lib/desktop-client/contract.mjs'
|
||||
import {desktopUser} from '@/lib/desktop-client/service'
|
||||
import AuthorizeClient from './ui'
|
||||
export const dynamic='force-dynamic'
|
||||
export const metadata={title:'授权详情页工作台',referrer:'no-referrer'}
|
||||
export default async function AuthorizePage({searchParams}:{searchParams:Record<string,string|string[]|undefined>}){
|
||||
const values=Object.fromEntries(Object.entries(searchParams).filter(([,value])=>typeof value==='string')) as Record<string,string>
|
||||
const session=await getServerSession(authOptions)
|
||||
if(!session?.user)redirect('/login?redirect='+encodeURIComponent('/client/authorize?'+new URLSearchParams(values)))
|
||||
try{desktopAuthorization(values);const account=await desktopUser((session.user as {id:string}).id);return <AuthorizeClient account={account} request={values}/>}
|
||||
catch(error){return <main style={{maxWidth:460,margin:'15vh auto',padding:24}}><h1 style={{fontSize:24}}>无法授权客户端</h1><p style={{marginTop:16}}>{error instanceof Error?error.message:'请从客户端重新发起登录'}</p><a href="/assets/material-library">返回素材库</a></main>}
|
||||
}
|
||||
12
app/client/authorize/ui.tsx
Normal file
12
app/client/authorize/ui.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
import {useEffect,useState} from 'react'
|
||||
import BrandLogo from '@/components/BrandLogo'
|
||||
import {DEFAULT_LOGIN_LOGO_URL} from '@/lib/brand-assets'
|
||||
import './authorize.css'
|
||||
export default function AuthorizeClient({account,request}:{account:{id:string;name:string};request:Record<string,string>}){
|
||||
const [busy,setBusy]=useState(false),[error,setError]=useState(''),[devices,setDevices]=useState<{id:string;deviceName:string;createdAt:string}[]>([])
|
||||
useEffect(()=>{void fetch('/api/desktop/devices',{cache:'no-store'}).then(r=>r.json()).then(r=>setDevices(r.devices||[])).catch(()=>{})},[])
|
||||
async function approve(){setBusy(true);setError('');try{const response=await fetch('/api/desktop/authorize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(request)});const value=await response.json();if(!response.ok)throw new Error(value.error);window.location.assign(value.redirect)}catch(error){setError(error instanceof Error?error.message:'授权未完成,请重试');setBusy(false)}}
|
||||
function cancel(){const url=new URL(request.redirect_uri);url.searchParams.set('error','access_denied');url.searchParams.set('state',request.state);window.location.assign(url)}
|
||||
return <main className="client-authorize"><div className="authorize-brand"><BrandLogo src={DEFAULT_LOGIN_LOGO_URL} fallbackSrc={DEFAULT_LOGIN_LOGO_URL} alt="由壹点素材库" width={44} height={44} className="authorize-logo"/><span>由壹点 · 素材库</span></div><h1>授权详情页工作台</h1><p className="authorize-account">使用 <strong>{account.name}</strong> 的账户继续</p><ul><li>查看、继续处理本人项目</li><li>同步草稿、对话和项目文件</li><li>读取管理员开放的模型列表</li></ul><p className="authorize-note">授权只用于当前客户端。你可以随时撤销,客户端不会获取网页登录密码。</p>{error&&<p role="alert">{error}</p>}<div className="authorize-actions"><button disabled={busy} onClick={cancel}>取消</button><button disabled={busy} className="authorize-primary" onClick={()=>void approve()}>{busy?'正在授权…':'授权并返回客户端'}</button></div>{devices.length>0&&<details><summary>已授权的客户端</summary>{devices.map(device=><div className="authorize-device" key={device.id}><span>{device.deviceName}<small>{new Date(device.createdAt).toLocaleDateString('zh-CN')}</small></span><button onClick={async()=>{const r=await fetch('/api/desktop/devices/'+encodeURIComponent(device.id),{method:'DELETE'});if(r.ok)setDevices(v=>v.filter(d=>d.id!==device.id));else setError('撤销未完成,请重试')}}>撤销授权</button></div>)}</details>}</main>
|
||||
}
|
||||
41
docs/detail-desktop-client.md
Normal file
41
docs/detail-desktop-client.md
Normal file
@ -0,0 +1,41 @@
|
||||
# 详情页工作台:客户端授权与项目同步
|
||||
|
||||
客户端在素材库网页完成账户登录和明确授权。网页登录密码、平台模型密钥不进入客户端。正式素材库地址为 `https://sc.jiansun.vip`。
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 打开本机「详情页工作台」,选择「正式素材库」,点击「在素材库登录」。
|
||||
2. 在浏览器登录素材库账户,核对账户后点击「授权并返回客户端」。
|
||||
3. 客户端显示本账户的网页项目、客户端项目和草稿。打开网页项目后,保存为独立的客户端项目,不覆盖原网页版本。
|
||||
4. 「保存项目」同步画布、图片、对话记录和未发送的输入内容;本机自动保存供离线恢复。换电脑前需主动保存同步。
|
||||
5. 退出登录会撤销当前客户端会话;授权页可查看、撤销本账户的其他客户端。
|
||||
|
||||
正式验收前继续仅对当前有效超级管理员开放,普通管理员不等于超级管理员。每次接口调用都重新核对数据库权限、账户状态和授权有效期。
|
||||
|
||||
## 接口与安全边界
|
||||
|
||||
所有接口位于 `/api/desktop/`,响应均禁止缓存。
|
||||
|
||||
| 接口 | 鉴权与作用 |
|
||||
| --- | --- |
|
||||
| `GET config` | 公共发现;返回固定客户端 ID、启用状态、S256 |
|
||||
| `POST authorize` | 素材库网页登录态与同源校验;批准固定用途的授权 |
|
||||
| `POST token` | 60 秒一次性授权码和 PKCE S256 校验,回调固定为本机临时端口的 `/oauth/callback` |
|
||||
| `GET/DELETE session` | Bearer 客户端会话;核对账户/撤销当前会话 |
|
||||
| `GET devices`、`DELETE devices/:id` | 素材库网页登录态;只管理本账户的授权 |
|
||||
| `GET projects` | 本账户项目,游标分页,每页最多 50 项 |
|
||||
| `GET projects/:source/:id` | 读取本人网页或客户端项目及文件清单 |
|
||||
| `PUT projects/desktop/:id` | 保存客户端项目;使用 revision 防止并发覆盖,冲突返回 409 |
|
||||
| `GET projects/:source/:id/assets/:asset` | 鉴权后获取该项目私有文件 |
|
||||
| `PUT projects/desktop/:id/assets/:source` | 上传 PNG/JPG/WEBP,单张 20MiB、每项目 128 张/256MiB |
|
||||
| `GET models`、`GET agent-models` | 只返回管理员已开放且已通过验证的可用模型,不返回平台密钥 |
|
||||
|
||||
客户端会话有效期 7 天,数据库仅保存令牌哈希。授权撤销或超级管理员资格撤销后,旧会话立即失去接口权限。本版本不开放任意生产终端或命令执行,不通过这些接口代理付费模型生成。
|
||||
|
||||
项目最多 12 屏,统一宽度为 750px 或 1440px,总高度最多 30000px。客户端项目单独存储,不迁移或改写现有网页项目。项目图片复用独立的私有持久目录,不进入公开上传目录。
|
||||
|
||||
## 部署与验收
|
||||
|
||||
新增 migration `20261020090000_desktop_client_workspace`,仅创建四张客户端表。生产迁移复用精确提交、真实备份、隔离恢复、数据库身份、迁移校验和与审计门禁,随后发布同一提交的应用镜像。
|
||||
|
||||
本地验收覆盖:真实网页登录/PKCE 回调、授权重放拒绝、跨账户访问拒绝、普通用户与撤权拒绝、私有文件、项目列表分页、并发版本冲突、项目与对话/输入草稿保存恢复、客户端重启恢复、退出后账户隔离,以及 1440px 图片导出。生产验收另记录在对应发布证据目录,不能以本地结果替代。
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 结论
|
||||
|
||||
当前应用发布工具验证三十六份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。
|
||||
当前应用发布工具验证三十七份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。
|
||||
|
||||
目标 migration:
|
||||
|
||||
@ -42,11 +42,14 @@
|
||||
- `20261012_ai_model_registry`
|
||||
- `20261013_ai_model_capabilities`
|
||||
- `20261013_ai_model_verifications`
|
||||
- `20261020090000_desktop_client_workspace`
|
||||
|
||||
`20261012_detail_generator` 仅新增个人详情页项目、私有文件、持久任务和品牌风格草案四张表及其约束/索引,不回填素材库或修改用户权限。正式验收前,启用配置仍只开放给当前有效超级管理员;私有文件目录独立持久挂载。
|
||||
|
||||
`20261012_ai_model_registry` 仅新增模型上游、目录、默认配置、审计四张表及约束/索引;`20261013_ai_model_capabilities` 仅新增目录的可空 `capability_metadata` JSONB 列。`20261013_ai_model_verifications` 新增 `ai_model_verifications`,持久保存按用途的真实测试结果、幂等请求及异步任务检查点;它将 `ai_model_enabled_compatible` 替换为 `ai_model_enabled_stages`,不删除历史 `compatibility` 列,也不将历史人工确认值转成已通过测试。
|
||||
|
||||
`20261020090000_desktop_client_workspace` 仅新增客户端一次性授权码、可撤销会话、个人客户端项目与私有文件四张表,不改写现有网页项目或用户权限。核对凭据哈希唯一性、所属用户/项目外键,以及草稿、对话和输入框草稿的持久化字段。
|
||||
|
||||
受控迁移核对列类型、空值限制、安全默认值、模型唯一性、原连接唯一性、引用限制和凭据完整性约束。验证表还须核对 `stage`、`context_hash`、`status` 检查约束,`task_id` 可空文本及 `evidence` 非空 JSONB,模型外键、幂等唯一索引、未决测试唯一索引和模型用途时间索引;旧人工兼容启用约束必须已移除,新用途启用约束必须存在。切流前复核三份 AI SQL 的精确校验和及五表字段。迁移不写入真实密钥、不自动导入、开放或付费测试模型。
|
||||
|
||||
部署后,尚未迁入的连接由当前有效超级管理员显式执行“迁入当前连接”,然后“同步目录”;已迁入的连接直接核对配置并同步,不能用导入覆盖现有设置。迁入继续引用现有 `DETAIL_GENERATOR_API_KEY` 和精确原地址。新版本不再把旧人工确认或旧默认值视为用途测试证据;需分别实际测试计划开放的分析、文案和生图用途,通过后再由管理员勾选开放并保存三阶段默认。同步目录只读获取目录,不调用付费生成;实际测试需管理员显式发起,已有异步任务只查询结果,不重新生成。应核对迁入/配置/测试审计、动态目录范围、三阶段可用默认和普通用户拒绝状态。现有环境连接不依赖加密主密钥;新增上游或替换密钥才要求已配置至少 32 字节的专用 `API_KEY_ENCRYPTION_KEY`,不要替换已有主密钥或把凭据写进迁移、命令参数、日志。
|
||||
@ -82,7 +85,7 @@
|
||||
1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
|
||||
2. legacy bootstrap `Audit`;
|
||||
3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
|
||||
4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十六条目标 migration 和迁移后审计;
|
||||
4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十七条目标 migration 和迁移后审计;
|
||||
5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
|
||||
|
||||
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
|
||||
@ -408,7 +411,7 @@ docker run --rm \
|
||||
"$MIGRATOR_IMAGE" --apply
|
||||
```
|
||||
|
||||
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对三十六条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;AI 模型五表、能力证据及用途验证约束/索引也须通过前述结构检查,最后原子写审计 JSON。
|
||||
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对三十七条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;AI 模型五表、能力证据及用途验证约束/索引也须通过前述结构检查,最后原子写审计 JSON。
|
||||
|
||||
apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
|
||||
|
||||
@ -424,7 +427,7 @@ test -n "$MIGRATION_AUDIT_SHA256"
|
||||
只有以下全部成立,才进入应用首次安全上线步骤:
|
||||
|
||||
- `applied.json` 的 `verified=true`;
|
||||
- 三十六条 target migration 均 `completed=true`,source/database checksum 一致;
|
||||
- 三十七条 target migration 均 `completed=true`,source/database checksum 一致;
|
||||
- `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空;
|
||||
- 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配;
|
||||
- AI 模型配置及验证五表、用途/上下文/状态约束、幂等与未决测试唯一索引和任务检查点字段精确匹配;旧人工兼容启用约束已由新用途约束替换;
|
||||
|
||||
38
lib/desktop-client/contract.mjs
Normal file
38
lib/desktop-client/contract.mjs
Normal file
@ -0,0 +1,38 @@
|
||||
export const DESKTOP_CLIENT_ID = 'jiansun-detail-desktop'
|
||||
export const DESKTOP_SCOPES = ['projects:read', 'projects:write', 'assets:read', 'assets:write', 'models:read']
|
||||
export class DesktopClientError extends Error {
|
||||
constructor(message, status = 400, code = 'invalid_request') { super(message); this.status = status; this.code = code }
|
||||
}
|
||||
export function desktopAssert(condition, message, status = 400, code = 'invalid_request') { if (!condition) throw new DesktopClientError(message, status, code) }
|
||||
export function desktopId(value) { desktopAssert(typeof value === 'string' && /^[a-zA-Z0-9_-]{1,80}$/.test(value), '项目编号无效'); return value }
|
||||
export function desktopRedirect(value) {
|
||||
let url; try { url = new URL(value) } catch { throw new DesktopClientError('客户端回调地址无效') }
|
||||
desktopAssert(url.protocol === 'http:' && url.hostname === '127.0.0.1' && Number(url.port) >= 1024 && Number(url.port) <= 65535 && url.pathname === '/oauth/callback' && !url.search && !url.hash && !url.username && !url.password, '仅允许当前电脑的客户端回调地址')
|
||||
return url.href
|
||||
}
|
||||
export function desktopAuthorization(input) {
|
||||
desktopAssert(input.client_id === DESKTOP_CLIENT_ID && input.response_type === 'code' && input.code_challenge_method === 'S256', '授权请求不符合客户端协议')
|
||||
desktopAssert(typeof input.state === 'string' && /^[\w-]{32,128}$/.test(input.state), '授权状态无效')
|
||||
desktopAssert(typeof input.code_challenge === 'string' && /^[\w-]{43}$/.test(input.code_challenge), '客户端校验参数无效')
|
||||
return { clientId: DESKTOP_CLIENT_ID, redirectUri: desktopRedirect(input.redirect_uri), state: input.state, challenge: input.code_challenge }
|
||||
}
|
||||
export function desktopPortal(value) {
|
||||
const url = new URL(value)
|
||||
desktopAssert(!url.username && !url.password && url.pathname === '/' && !url.search && !url.hash, '素材库地址应只包含域名和端口')
|
||||
desktopAssert((url.protocol === 'https:' && url.hostname === 'sc.jiansun.vip' && !url.port) || (url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname) && Number(url.port) >= 1024), '只允许正式素材库或本机开发服务')
|
||||
return url.origin
|
||||
}
|
||||
export function desktopProjectBody(input) {
|
||||
desktopAssert(input && typeof input === 'object' && JSON.stringify(input).length <= 1_500_000, '项目内容过大')
|
||||
const doc = input.document
|
||||
desktopAssert(doc?.version === 1 && typeof doc.name === 'string' && doc.name.length <= 120 && Array.isArray(doc.screens) && doc.screens.length > 0 && doc.screens.length <= 12, '客户端项目格式无效')
|
||||
const width = doc.screens[0].width
|
||||
desktopAssert([750, 1440].includes(width) && doc.screens.every(s => s.width === width && Number.isInteger(s.height) && s.height >= 400 && s.height <= 4608 && Array.isArray(s.layers) && s.layers.length <= 100) && doc.screens.reduce((sum,s) => sum + s.height,0) <= 30000, '项目画布超出限制')
|
||||
desktopAssert(['draft', 'active', 'completed'].includes(input.status) && Number.isInteger(input.revision) && input.revision >= 0, '项目状态或版本无效')
|
||||
const conversation = input.conversation || []
|
||||
desktopAssert(Array.isArray(conversation) && conversation.length <= 200 && conversation.every(m => typeof m.id === 'string' && ['user','assistant'].includes(m.role) && typeof m.text === 'string' && m.text.length <= 16000), '对话记录无效')
|
||||
desktopAssert(conversation.reduce((sum,m) => sum + m.text.length,0) <= 180000, '对话记录过大,请新开对话')
|
||||
const composer=input.composer||''
|
||||
desktopAssert(typeof composer==='string'&&composer.length<=6000,'对话草稿过长')
|
||||
return { document: doc, conversation, composer, status: input.status, revision: input.revision, sourceWebId: input.sourceWebId ? desktopId(input.sourceWebId) : null }
|
||||
}
|
||||
107
lib/desktop-client/service.ts
Normal file
107
lib/desktop-client/service.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { assertDetailGeneratorUser } from '@/lib/detail-generator/access'
|
||||
import { findDetailProject, listDetailAssets, findDetailAsset } from '@/lib/detail-generator/repository'
|
||||
import { readDetailFile, saveDetailFile, deleteDetailFile } from '@/lib/detail-generator/storage'
|
||||
import { desktopAssert, desktopAuthorization, desktopId, desktopProjectBody, DesktopClientError, DESKTOP_CLIENT_ID, DESKTOP_SCOPES } from './contract.mjs'
|
||||
|
||||
const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex')
|
||||
const db = prisma
|
||||
type Row = Record<string, any>
|
||||
const columns = 'id,owner_id AS "ownerId",source_web_id AS "sourceWebId",document,conversation,composer,status,revision,updated_at AS "updatedAt"'
|
||||
export async function desktopUser(id: string) {
|
||||
await assertDetailGeneratorUser(id)
|
||||
const user = await prisma.user.findUnique({ where: { id }, select: { id: true, username: true, realName: true } })
|
||||
desktopAssert(user, '账户不存在', 401)
|
||||
return { id, name: user!.realName || user!.username }
|
||||
}
|
||||
export async function approveDesktop(ownerId: string, input: Record<string, unknown>) {
|
||||
await desktopUser(ownerId)
|
||||
const auth = desktopAuthorization(input)
|
||||
const code = 'dca_' + randomBytes(32).toString('base64url')
|
||||
await db.$executeRawUnsafe('INSERT INTO desktop_client_grants(id,code_hash,owner_id,challenge,redirect_uri,expires_at) VALUES($1,$2,$3,$4,$5,$6)', randomUUID(), hash(code), ownerId, auth.challenge, auth.redirectUri, new Date(Date.now() + 60000))
|
||||
const callback = new URL(auth.redirectUri); callback.searchParams.set('code', code); callback.searchParams.set('state', auth.state)
|
||||
return { redirect: callback.href }
|
||||
}
|
||||
export async function exchangeDesktop(input: Record<string, any>) {
|
||||
desktopAssert(input.client_id === DESKTOP_CLIENT_ID && input.grant_type === 'authorization_code' && /^dca_[\w-]{43}$/.test(input.code || '') && /^[a-zA-Z0-9._~-]{43,128}$/.test(input.code_verifier || ''), '授权码或校验参数无效', 400, 'invalid_grant')
|
||||
const rows = await db.$queryRawUnsafe<Row[]>('SELECT id,owner_id AS "ownerId",challenge,redirect_uri AS "redirectUri" FROM desktop_client_grants WHERE code_hash=$1 AND used_at IS NULL AND expires_at>NOW()', hash(input.code))
|
||||
const grant = rows[0]
|
||||
desktopAssert(grant && grant.redirectUri === input.redirect_uri, '授权已失效,请重新登录', 400, 'invalid_grant')
|
||||
const challenge = createHash('sha256').update(input.code_verifier).digest('base64url')
|
||||
desktopAssert(challenge.length === grant.challenge.length && timingSafeEqual(Buffer.from(challenge), Buffer.from(grant.challenge)), '授权校验未通过,请重新登录', 400, 'invalid_grant')
|
||||
const account = await desktopUser(grant.ownerId)
|
||||
const token = 'dcs_' + randomBytes(48).toString('base64url'), id = randomUUID(), expiresAt = new Date(Date.now() + 7 * 86400000)
|
||||
await db.$transaction(async tx => {
|
||||
const consumed = await tx.$executeRawUnsafe('UPDATE desktop_client_grants SET used_at=NOW() WHERE id=$1 AND used_at IS NULL AND expires_at>NOW()', grant.id)
|
||||
desktopAssert(consumed === 1, '授权码已使用,请重新登录', 400, 'invalid_grant')
|
||||
await tx.$executeRawUnsafe('INSERT INTO desktop_client_sessions(id,token_hash,owner_id,device_name,expires_at) VALUES($1,$2,$3,$4,$5)', id, hash(token), account.id, '详情页工作台', expiresAt)
|
||||
})
|
||||
return { access_token: token, token_type: 'Bearer', expires_at: expiresAt.toISOString(), account, scope: DESKTOP_SCOPES.join(' ') }
|
||||
}
|
||||
export async function requireDesktop(request: Request) {
|
||||
const token = request.headers.get('authorization')?.match(/^Bearer (dcs_[\w-]{64})$/)?.[1]
|
||||
desktopAssert(token, '请在网页上登录并授权客户端', 401, 'unauthenticated')
|
||||
const rows = await db.$queryRawUnsafe<Row[]>('SELECT id,owner_id AS "ownerId",expires_at AS "expiresAt" FROM desktop_client_sessions WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>NOW()', hash(token!))
|
||||
desktopAssert(rows[0], '客户端授权已过期或被撤销,请重新登录', 401, 'session_expired')
|
||||
return { id:String(rows[0].id),expiresAt:rows[0].expiresAt,account: await desktopUser(rows[0].ownerId) }
|
||||
}
|
||||
export async function revokeDesktop(id: string, ownerId: string) { await db.$executeRawUnsafe('UPDATE desktop_client_sessions SET revoked_at=NOW() WHERE id=$1 AND owner_id=$2', id, ownerId) }
|
||||
export async function listDesktopDevices(ownerId: string) {
|
||||
return db.$queryRawUnsafe<Row[]>('SELECT id,device_name AS "deviceName",created_at AS "createdAt",expires_at AS "expiresAt" FROM desktop_client_sessions WHERE owner_id=$1 AND revoked_at IS NULL AND expires_at>NOW() ORDER BY created_at DESC', ownerId)
|
||||
}
|
||||
export async function listDesktopProjects(ownerId: string, cursor: string | null) {
|
||||
let point = { date: '9999-01-01T00:00:00.000Z', id: 'zzzzzzzz', source: 'web' }
|
||||
if (cursor) {
|
||||
try { point = JSON.parse(Buffer.from(cursor, 'base64url').toString()) } catch { throw new DesktopClientError('分页参数无效') }
|
||||
desktopAssert(typeof point.date === 'string' && Number.isFinite(Date.parse(point.date)) && typeof point.id === 'string' && /^[\w-]{1,80}$/.test(point.id) && ['web','desktop'].includes(point.source), '分页参数无效')
|
||||
}
|
||||
const rows = await db.$queryRawUnsafe<Row[]>(`SELECT id,source,name,status,revision,updated_at AS "updatedAt",width,screen_count AS "screenCount",has_draft AS "hasDraft" FROM (
|
||||
SELECT p.id,'desktop' AS source,p.document->>'name' AS name,p.status,p.revision,p.updated_at,(p.document->'screens'->0->>'width')::int AS width,jsonb_array_length(p.document->'screens') AS screen_count,(p.status='draft' OR p.composer<>'') AS has_draft FROM desktop_workspace_projects p WHERE p.owner_id=$1
|
||||
UNION ALL SELECT p.id,'web',p.document->>'name',CASE WHEN p.document->>'approvedAt' IS NULL THEN 'draft' ELSE 'active' END,p.revision,p.updated_at,COALESCE((p.document->'screens'->0->>'width')::int,750),jsonb_array_length(p.document->'screens'),p.document->>'approvedAt' IS NULL FROM detail_generator_projects p WHERE p.owner_id=$1 AND p.deleted_at IS NULL AND NOT EXISTS(SELECT 1 FROM desktop_workspace_projects d WHERE d.owner_id=$1 AND d.source_web_id=p.id)
|
||||
) AS projects WHERE (updated_at,id,source)<($2::timestamptz,$3::text,$4::text) ORDER BY updated_at DESC,id DESC,source DESC LIMIT 51`, ownerId, point.date, point.id, point.source)
|
||||
const items = rows.slice(0,50), last = items.at(-1)
|
||||
return { items, nextCursor: rows.length > 50 && last ? Buffer.from(JSON.stringify({date:new Date(last.updatedAt).toISOString(),id:last.id,source:last.source})).toString('base64url') : null }
|
||||
}
|
||||
export async function getDesktopProject(ownerId: string, source: string, id: string) {
|
||||
desktopId(id)
|
||||
if (source === 'web') return { source, project: await findDetailProject(id,ownerId), assets: (await listDetailAssets(id,ownerId)).filter(asset => ['image/png','image/jpeg','image/webp'].includes(asset.mimeType)).map(asset => ({ id:asset.id,source:'web:'+asset.id,mime:asset.mimeType,bytes:asset.byteSize })) }
|
||||
desktopAssert(source === 'desktop', '项目来源无效')
|
||||
const rows = await db.$queryRawUnsafe<Row[]>(`SELECT ${columns} FROM desktop_workspace_projects WHERE id=$1 AND owner_id=$2`, id, ownerId)
|
||||
desktopAssert(rows[0], '项目不存在或无权访问', 404, 'not_found')
|
||||
const assets = await db.$queryRawUnsafe<Row[]>('SELECT id,source,sha256,mime_type AS mime,byte_size AS bytes FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2', id, ownerId)
|
||||
return { source, project:rows[0], assets }
|
||||
}
|
||||
export async function putDesktopProject(ownerId: string, id: string, input: unknown) {
|
||||
desktopId(id); const body = desktopProjectBody(input)
|
||||
desktopAssert(body.document.id === id, '项目编号不一致')
|
||||
if (body.sourceWebId) await findDetailProject(body.sourceWebId,ownerId)
|
||||
const rows = body.revision === 0 ? await db.$queryRawUnsafe<Row[]>(`INSERT INTO desktop_workspace_projects(id,owner_id,source_web_id,document,conversation,status,composer) VALUES($1,$2,$3,$4::jsonb,$5::jsonb,$6,$7) ON CONFLICT DO NOTHING RETURNING ${columns}`,id,ownerId,body.sourceWebId,JSON.stringify(body.document),JSON.stringify(body.conversation),body.status,body.composer)
|
||||
: await db.$queryRawUnsafe<Row[]>(`UPDATE desktop_workspace_projects SET document=$4::jsonb,conversation=$5::jsonb,status=$6,composer=$7,revision=revision+1,updated_at=NOW() WHERE id=$1 AND owner_id=$2 AND revision=$3 RETURNING ${columns}`,id,ownerId,body.revision,JSON.stringify(body.document),JSON.stringify(body.conversation),body.status,body.composer)
|
||||
desktopAssert(rows[0], '项目已有新版本,请先保留本机草稿并刷新项目列表', 409, 'revision_conflict')
|
||||
return { project:rows[0] }
|
||||
}
|
||||
export async function putDesktopAsset(ownerId:string,projectId:string,source:string,bytes:Buffer,mime:string) {
|
||||
await getDesktopProject(ownerId,'desktop',projectId)
|
||||
desktopAssert(/^local:[a-zA-Z0-9-]{1,100}$/.test(source) && bytes.length>0 && bytes.length<=20*1024*1024, '图片编号或体积无效')
|
||||
const extension = mime==='image/png'?'png':mime==='image/jpeg'?'jpg':mime==='image/webp'?'webp':''
|
||||
desktopAssert(extension && (extension==='png'?bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])):extension==='jpg'?bytes[0]===255&&bytes[1]===216:bytes.toString('ascii',0,4)==='RIFF'&&bytes.toString('ascii',8,12)==='WEBP'), '仅支持 PNG、JPG、WEBP 图片')
|
||||
const sha = hash(bytes)
|
||||
return db.$transaction(async tx => {
|
||||
await tx.$queryRawUnsafe('SELECT 1 FROM (SELECT pg_advisory_xact_lock(hashtext($1))) l', 'desktop:'+projectId)
|
||||
const existing = await tx.$queryRawUnsafe<Row[]>('SELECT sha256 FROM desktop_workspace_assets WHERE project_id=$1 AND source=$2 AND owner_id=$3',projectId,source,ownerId)
|
||||
if(existing[0]) { desktopAssert(existing[0].sha256===sha,'图片标识已关联其他内容',409); return {sha256:sha} }
|
||||
const total = await tx.$queryRawUnsafe<Row[]>('SELECT COALESCE(SUM(byte_size),0)::int AS bytes,COUNT(*)::int AS count FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2',projectId,ownerId)
|
||||
desktopAssert(total[0].bytes+bytes.length<=256*1024*1024 && total[0].count<128,'项目文件超过256MiB或128张限制')
|
||||
const key=await saveDetailFile(projectId,bytes,extension)
|
||||
try { await tx.$executeRawUnsafe('INSERT INTO desktop_workspace_assets(id,project_id,owner_id,source,sha256,storage_key,mime_type,byte_size) VALUES($1,$2,$3,$4,$5,$6,$7,$8)',randomUUID(),projectId,ownerId,source,sha,key,mime,bytes.length) }
|
||||
catch(error) {await deleteDetailFile(key);throw error}
|
||||
return {sha256:sha}
|
||||
})
|
||||
}
|
||||
export async function getDesktopAsset(ownerId:string,source:string,projectId:string,assetId:string) {
|
||||
await getDesktopProject(ownerId,source,projectId)
|
||||
if(source==='web') { const asset=await findDetailAsset(desktopId(assetId),ownerId);desktopAssert(asset.projectId===projectId, '文件不属于此项目',404);return {bytes:await readDetailFile(asset.storageKey),mime:asset.mimeType} }
|
||||
const rows=await db.$queryRawUnsafe<Row[]>('SELECT storage_key AS key,mime_type AS mime FROM desktop_workspace_assets WHERE project_id=$1 AND owner_id=$2 AND source=$3',projectId,ownerId,assetId)
|
||||
desktopAssert(rows[0],'文件不存在',404);return {bytes:await readDetailFile(rows[0].key),mime:rows[0].mime}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
-- Additive desktop authorization and workspace storage. No existing tables/data are rewritten.
|
||||
CREATE TABLE IF NOT EXISTS desktop_client_grants (
|
||||
id TEXT PRIMARY KEY, code_hash TEXT NOT NULL UNIQUE, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
challenge TEXT NOT NULL, redirect_uri TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS desktop_client_sessions (
|
||||
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_name TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS desktop_client_sessions_owner_idx ON desktop_client_sessions(owner_id);
|
||||
CREATE TABLE IF NOT EXISTS desktop_workspace_projects (
|
||||
id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, source_web_id TEXT,
|
||||
document JSONB NOT NULL, conversation JSONB NOT NULL DEFAULT '[]', composer TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'draft', revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS desktop_workspace_projects_owner_idx ON desktop_workspace_projects(owner_id,updated_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS desktop_workspace_projects_source_idx ON desktop_workspace_projects(owner_id,source_web_id);
|
||||
CREATE TABLE IF NOT EXISTS desktop_workspace_assets (
|
||||
id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES desktop_workspace_projects(id) ON DELETE CASCADE,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, source TEXT NOT NULL, sha256 TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL, mime_type TEXT NOT NULL, byte_size INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(project_id,source)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS desktop_workspace_assets_owner_idx ON desktop_workspace_assets(owner_id,project_id);
|
||||
@ -64,6 +64,10 @@ model User {
|
||||
detailGeneratorProjects DetailGeneratorProject[]
|
||||
detailGeneratorAssets DetailGeneratorAsset[]
|
||||
detailGeneratorJobs DetailGeneratorJob[]
|
||||
desktopClientGrants DesktopClientGrant[]
|
||||
desktopClientSessions DesktopClientSession[]
|
||||
desktopWorkspaceProjects DesktopWorkspaceProject[]
|
||||
desktopWorkspaceAssets DesktopWorkspaceAsset[]
|
||||
|
||||
@@index([status])
|
||||
@@index([archivedAt])
|
||||
@ -73,6 +77,67 @@ model User {
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model DesktopClientGrant {
|
||||
id String @id
|
||||
codeHash String @unique @map("code_hash")
|
||||
ownerId String @map("owner_id")
|
||||
challenge String
|
||||
redirectUri String @map("redirect_uri")
|
||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(6)
|
||||
usedAt DateTime? @map("used_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
@@map("desktop_client_grants")
|
||||
}
|
||||
|
||||
model DesktopClientSession {
|
||||
id String @id
|
||||
tokenHash String @unique @map("token_hash")
|
||||
ownerId String @map("owner_id")
|
||||
deviceName String @map("device_name")
|
||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(6)
|
||||
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
@@index([ownerId], map: "desktop_client_sessions_owner_idx")
|
||||
@@map("desktop_client_sessions")
|
||||
}
|
||||
|
||||
model DesktopWorkspaceProject {
|
||||
id String @id
|
||||
ownerId String @map("owner_id")
|
||||
sourceWebId String? @map("source_web_id")
|
||||
document Json
|
||||
conversation Json @default("[]")
|
||||
composer String @default("")
|
||||
status String @default("draft")
|
||||
revision Int @default(1)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(6)
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
assets DesktopWorkspaceAsset[]
|
||||
@@index([ownerId,updatedAt], map: "desktop_workspace_projects_owner_idx")
|
||||
@@unique([ownerId,sourceWebId], map: "desktop_workspace_projects_source_idx")
|
||||
@@map("desktop_workspace_projects")
|
||||
}
|
||||
|
||||
model DesktopWorkspaceAsset {
|
||||
id String @id
|
||||
projectId String @map("project_id")
|
||||
ownerId String @map("owner_id")
|
||||
source String
|
||||
sha256 String
|
||||
storageKey String @map("storage_key")
|
||||
mimeType String @map("mime_type")
|
||||
byteSize Int @map("byte_size")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
project DesktopWorkspaceProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
@@unique([projectId,source])
|
||||
@@index([ownerId,projectId], map: "desktop_workspace_assets_owner_idx")
|
||||
@@map("desktop_workspace_assets")
|
||||
}
|
||||
|
||||
model AiModelProvider {
|
||||
id String @id
|
||||
name String
|
||||
|
||||
28
scripts/desktop-client-contract.test.mjs
Normal file
28
scripts/desktop-client-contract.test.mjs
Normal file
@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {test} from 'node:test'
|
||||
import {desktopAuthorization,desktopPortal,desktopProjectBody} from '../lib/desktop-client/contract.mjs'
|
||||
|
||||
test('desktop authorization requires S256 and an exact loopback callback',()=>{
|
||||
const request={client_id:'jiansun-detail-desktop',response_type:'code',code_challenge_method:'S256',state:'a'.repeat(32),code_challenge:'b'.repeat(43),redirect_uri:'http://127.0.0.1:40001/oauth/callback'}
|
||||
assert.equal(desktopAuthorization(request).redirectUri,request.redirect_uri)
|
||||
for(const redirect_uri of ['https://example.com/oauth/callback','http://localhost:40001/oauth/callback','http://127.0.0.1:80/oauth/callback','http://127.0.0.1:40001/oauth/callback?next=elsewhere','http://127.0.0.1:40001/other']){
|
||||
assert.throws(()=>desktopAuthorization({...request,redirect_uri}))
|
||||
}
|
||||
assert.throws(()=>desktopAuthorization({...request,code_challenge_method:'plain'}))
|
||||
assert.throws(()=>desktopAuthorization({...request,state:'short'}))
|
||||
})
|
||||
|
||||
test('desktop portal allowlist excludes unrelated services and credentials in URLs',()=>{
|
||||
assert.equal(desktopPortal('https://sc.jiansun.vip'),'https://sc.jiansun.vip')
|
||||
assert.equal(desktopPortal('http://127.0.0.1:3138'),'http://127.0.0.1:3138')
|
||||
for(const value of ['https://api.vip98k.com','http://sc.jiansun.vip','https://sc.jiansun.vip/other','https://user@sc.jiansun.vip'])assert.throws(()=>desktopPortal(value))
|
||||
})
|
||||
|
||||
test('native draft contract preserves chat and unsent input with bounded 1440px canvases',()=>{
|
||||
const value={document:{version:1,id:'acceptance',name:'验收草稿',screens:[{width:1440,height:1800,layers:[]}]},revision:0,status:'draft',conversation:[{id:'one',role:'user',text:'保留品牌字体'}],composer:'尚未发送的要求'}
|
||||
assert.equal(desktopProjectBody(value).composer,value.composer)
|
||||
assert.deepEqual(desktopProjectBody(value).conversation,value.conversation)
|
||||
assert.throws(()=>desktopProjectBody({...value,document:{...value.document,screens:[{width:4096,height:1800,layers:[]}]}}))
|
||||
assert.throws(()=>desktopProjectBody({...value,revision:-1}))
|
||||
assert.throws(()=>desktopProjectBody({...value,composer:'x'.repeat(6001)}))
|
||||
})
|
||||
13
scripts/desktop-client-migration-fixture.mjs
Normal file
13
scripts/desktop-client-migration-fixture.mjs
Normal file
@ -0,0 +1,13 @@
|
||||
export function desktopEvidence(){
|
||||
const fields={
|
||||
desktop_client_grants:{id:'text',code_hash:'text',owner_id:'text',challenge:'text',redirect_uri:'text',expires_at:'timestamp with time zone',used_at:'timestamp with time zone'},
|
||||
desktop_client_sessions:{id:'text',token_hash:'text',owner_id:'text',device_name:'text',expires_at:'timestamp with time zone',revoked_at:'timestamp with time zone'},
|
||||
desktop_workspace_projects:{id:'text',owner_id:'text',source_web_id:'text',document:'jsonb',conversation:'jsonb',composer:'text',status:'text',revision:'integer'},
|
||||
desktop_workspace_assets:{id:'text',project_id:'text',owner_id:'text',source:'text',sha256:'text',storage_key:'text',mime_type:'text',byte_size:'integer'},
|
||||
}
|
||||
const columns=Object.entries(fields).flatMap(([table,fields])=>Object.entries(fields).map(([column,type])=>({table_name:table,column_name:column,data_type:type,is_nullable:['used_at','revoked_at','source_web_id'].includes(column)?'YES':'NO'})))
|
||||
const indexes=[['desktop_client_grants','code_hash',['code_hash']],['desktop_client_sessions','token_hash',['token_hash']],['desktop_workspace_projects','source_idx',['owner_id','source_web_id']],['desktop_workspace_assets','project_id_source',['project_id','source']]].map(([table,suffix,columns])=>({tablename:table,indexname:table+'_'+suffix+(suffix.endsWith('idx')?'':'_key'),indisunique:true,indisprimary:false,indisvalid:true,indisready:true,access_method:'btree',predicate:'',columns}))
|
||||
const constraints=Object.keys(fields).map(table=>({tablename:table,constraint_name:table+'_owner_id_fkey',contype:'f',convalidated:true,columns:['owner_id'],referenced_table:'users',referenced_columns:['id']}))
|
||||
constraints.push({tablename:'desktop_workspace_assets',constraint_name:'desktop_workspace_assets_project_id_fkey',contype:'f',convalidated:true,columns:['project_id'],referenced_table:'desktop_workspace_projects',referenced_columns:['id']})
|
||||
return {columns,indexes,constraints}
|
||||
}
|
||||
16
scripts/desktop-client-migration.test.mjs
Normal file
16
scripts/desktop-client-migration.test.mjs
Normal file
@ -0,0 +1,16 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {assertDesktopWorkspaceEvidence} from './safe-production-migrate.mjs'
|
||||
import {desktopEvidence} from './desktop-client-migration-fixture.mjs'
|
||||
test('desktop migration requires credential hashes, owner boundaries, and draft persistence columns',()=>{
|
||||
assert.doesNotThrow(()=>assertDesktopWorkspaceEvidence(desktopEvidence()))
|
||||
for(const name of ['code_hash','token_hash','owner_id','expires_at','composer','document','storage_key']){
|
||||
const evidence=desktopEvidence();evidence.columns=evidence.columns.filter(row=>row.column_name!==name)
|
||||
assert.throws(()=>assertDesktopWorkspaceEvidence(evidence),/column mismatch/)
|
||||
}
|
||||
})
|
||||
test('desktop migration rejects missing uniqueness and cross-account or invalid foreign keys',()=>{
|
||||
for(let i=0;i<4;i++){const evidence=desktopEvidence();evidence.indexes[i].indisunique=false;assert.throws(()=>assertDesktopWorkspaceEvidence(evidence),/unique index mismatch/)}
|
||||
for(let i=0;i<5;i++){const evidence=desktopEvidence();evidence.constraints[i].referenced_table='other_accounts';assert.throws(()=>assertDesktopWorkspaceEvidence(evidence),/foreign key mismatch/)}
|
||||
const evidence=desktopEvidence();evidence.columns.find(row=>row.column_name==='token_hash').is_nullable='YES';assert.throws(()=>assertDesktopWorkspaceEvidence(evidence),/column mismatch/)
|
||||
})
|
||||
@ -45,6 +45,7 @@ const TARGET_MIGRATIONS = Object.freeze([
|
||||
'20261012_ai_model_registry',
|
||||
'20261013_ai_model_capabilities',
|
||||
'20261013_ai_model_verifications',
|
||||
'20261020090000_desktop_client_workspace',
|
||||
])
|
||||
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
|
||||
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
|
||||
@ -359,6 +360,7 @@ async function queryEvidence(prisma, localMigrations) {
|
||||
OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private'))
|
||||
OR table_name IN ('material_permission_pack_departments', 'team_function_configs', 'team_function_config_audits', 'material_purge_jobs', 'material_purge_audits')
|
||||
OR table_name IN ('detail_generator_projects', 'detail_generator_assets', 'detail_generator_jobs', 'detail_generator_brand_profiles')
|
||||
OR table_name IN ('desktop_client_grants', 'desktop_client_sessions', 'desktop_workspace_projects', 'desktop_workspace_assets')
|
||||
OR table_name IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_audit', 'ai_model_verifications')
|
||||
OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag'))
|
||||
OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at'))
|
||||
@ -431,6 +433,8 @@ async function queryEvidence(prisma, localMigrations) {
|
||||
'materials_purge_references_idx', 'material_assets_purge_references_idx', 'material_skus_purge_references_idx',
|
||||
'brand_knowledge_purge_references_idx', 'system_settings_purge_references_idx', 'performance_work_items_purge_references_idx',
|
||||
'work_review_versions_purge_references_idx', 'package_upload_jobs_purge_references_idx', 'task_detail_changes_purge_references_idx',
|
||||
'desktop_client_grants_code_hash_key', 'desktop_client_sessions_token_hash_key', 'desktop_workspace_projects_source_idx', 'desktop_workspace_assets_project_id_source_key',
|
||||
'desktop_client_sessions_owner_idx', 'desktop_workspace_projects_owner_idx', 'desktop_workspace_assets_owner_idx',
|
||||
'ai_model_providers_legacy_binding_key', 'ai_model_catalog_provider_model_key', 'ai_model_audit_created_idx',
|
||||
'ai_model_verifications_idempotency_key', 'ai_model_verifications_unresolved_key', 'ai_model_verifications_model_stage_idx'
|
||||
)
|
||||
@ -475,7 +479,8 @@ async function queryEvidence(prisma, localMigrations) {
|
||||
'team_function_configs_organization_unit_id_fkey', 'team_function_configs_lead_employee_profile_id_fkey',
|
||||
'team_function_config_audits_config_id_fkey', 'team_function_configs_revision_check',
|
||||
'material_purge_audits_job_id_fkey'
|
||||
) OR cls.relname IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_verifications')
|
||||
) OR cls.relname IN ('desktop_client_grants', 'desktop_client_sessions', 'desktop_workspace_projects', 'desktop_workspace_assets')
|
||||
OR cls.relname IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_verifications')
|
||||
OR (cls.relname IN ('material_logs', 'material_download_logs') AND ref.relname = 'materials'))
|
||||
ORDER BY con.conname`,
|
||||
)
|
||||
@ -878,6 +883,7 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
|
||||
}
|
||||
assertOrganizationConfigurationEvidence(evidence)
|
||||
assertDetailGeneratorEvidence(evidence)
|
||||
assertDesktopWorkspaceEvidence(evidence)
|
||||
assertAiModelRegistryEvidence(evidence)
|
||||
assertMaterialPurgeEvidence(evidence)
|
||||
assertMaterialPurgeReferenceEvidence(evidence)
|
||||
@ -900,6 +906,30 @@ export function materialPurgeReferenceExpectations() {
|
||||
return { referenceFunctions: functions, indexes }
|
||||
}
|
||||
|
||||
export function assertDesktopWorkspaceEvidence(evidence) {
|
||||
const required={
|
||||
desktop_client_grants:{id:'text',code_hash:'text',owner_id:'text',challenge:'text',redirect_uri:'text',expires_at:'timestamp with time zone',used_at:'timestamp with time zone'},
|
||||
desktop_client_sessions:{id:'text',token_hash:'text',owner_id:'text',device_name:'text',expires_at:'timestamp with time zone',revoked_at:'timestamp with time zone'},
|
||||
desktop_workspace_projects:{id:'text',owner_id:'text',source_web_id:'text',document:'jsonb',conversation:'jsonb',composer:'text',status:'text',revision:'integer'},
|
||||
desktop_workspace_assets:{id:'text',project_id:'text',owner_id:'text',source:'text',sha256:'text',storage_key:'text',mime_type:'text',byte_size:'integer'},
|
||||
}
|
||||
for(const [table,fields]of Object.entries(required))for(const [column,type]of Object.entries(fields)){
|
||||
const item=evidence.columns.find(row=>row.table_name===table&&row.column_name===column)
|
||||
if(!item||item.data_type!==type||item.is_nullable!==(['used_at','revoked_at','source_web_id'].includes(column)?'YES':'NO'))fail(`Desktop database column mismatch: ${table}.${column}`)
|
||||
}
|
||||
for(const [table,name,columns]of [
|
||||
['desktop_client_grants','desktop_client_grants_code_hash_key',['code_hash']],
|
||||
['desktop_client_sessions','desktop_client_sessions_token_hash_key',['token_hash']],
|
||||
['desktop_workspace_projects','desktop_workspace_projects_source_idx',['owner_id','source_web_id']],
|
||||
['desktop_workspace_assets','desktop_workspace_assets_project_id_source_key',['project_id','source']],
|
||||
])assertExactUniqueBtreeIndex(evidence.indexes.find(row=>row.indexname===name),table,name,columns)
|
||||
for(const table of Object.keys(required)){
|
||||
const name=table+'_owner_id_fkey'
|
||||
assertExactForeignKeyConstraint(evidence.constraints.find(row=>row.constraint_name===name),table,name,'owner_id','users','id')
|
||||
}
|
||||
assertExactForeignKeyConstraint(evidence.constraints.find(row=>row.constraint_name==='desktop_workspace_assets_project_id_fkey'),'desktop_workspace_assets','desktop_workspace_assets_project_id_fkey','project_id','desktop_workspace_projects','id')
|
||||
}
|
||||
|
||||
export function assertDetailGeneratorEvidence(evidence) {
|
||||
const columns = new Map(evidence.columns.map(row => [`${row.table_name}.${row.column_name}`, row]))
|
||||
const required = {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import {desktopEvidence} from './desktop-client-migration-fixture.mjs'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
@ -428,6 +429,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
['20261012_ai_model_registry', { checksum: 'ah' }],
|
||||
['20261013_ai_model_capabilities', { checksum: 'ai' }],
|
||||
['20261013_ai_model_verifications', { checksum: 'aj' }],
|
||||
['20261020090000_desktop_client_workspace', { checksum: 'desktop' }],
|
||||
])
|
||||
const evidence = {
|
||||
migrationRows: [
|
||||
@ -703,6 +705,9 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
evidence.migrationRows.push({ migration_name: '20261012_detail_generator', checksum: 'ag', finished_at: 'now', rolled_back_at: null })
|
||||
evidence.migrationRows.push({ migration_name: '20261012_ai_model_registry', checksum: 'ah', finished_at: 'now', rolled_back_at: null }, { migration_name: '20261013_ai_model_capabilities', checksum: 'ai', finished_at: 'now', rolled_back_at: null })
|
||||
evidence.migrationRows.push({ migration_name: '20261013_ai_model_verifications', checksum: 'aj', finished_at: 'now', rolled_back_at: null })
|
||||
evidence.migrationRows.push({ migration_name: '20261020090000_desktop_client_workspace', checksum: 'desktop', finished_at: 'now', rolled_back_at: null })
|
||||
const desktop = desktopEvidence()
|
||||
for (const field of ['columns', 'indexes', 'constraints']) evidence[field].push(...desktop[field])
|
||||
const aiModels = aiModelRegistryEvidence()
|
||||
for (const field of ['columns', 'indexes', 'constraints']) evidence[field].push(...aiModels[field])
|
||||
for (const [table, fields] of Object.entries({
|
||||
@ -1074,12 +1079,12 @@ test('container supply chain and migrator image are immutable while SOP keeps co
|
||||
assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/)
|
||||
assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/)
|
||||
assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/)
|
||||
assert.match(sop, /三十六份目标 migration/)
|
||||
assert.match(sop, /三十六条目标 migration/)
|
||||
assert.match(sop, /三十七份目标 migration/)
|
||||
assert.match(sop, /三十七条目标 migration/)
|
||||
const targetBlock = migratorSource.match(/const TARGET_MIGRATIONS = Object\.freeze\(\[([\s\S]*?)\]\)/)?.[1]
|
||||
assert.ok(targetBlock, 'Migrator target list must be inspectable')
|
||||
const targets = [...targetBlock.matchAll(/'([^']+)'/g)].map(match => match[1])
|
||||
assert.equal(targets.length, 36)
|
||||
assert.equal(targets.length, 37)
|
||||
for (const migration of targets) assert.ok(sop.includes(`\`${migration}\``), `SOP must name ${migration}`)
|
||||
|
||||
const applyMigration = migratorSource.indexOf("['migrate', 'deploy'")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user