@clawos-dev/clawd 0.2.142 → 0.2.143-beta.301.814d293
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +920 -1562
- package/dist/deploy-kit/.secrets/aliyun.env.example +4 -0
- package/dist/deploy-kit/scripts/new-extension.sh +22 -0
- package/dist/deploy-kit/scripts/publish.sh +100 -0
- package/dist/deploy-kit/scripts/publish.spec.ts +69 -0
- package/dist/{persona-defaults/persona-app-builder/extension-kit → deploy-kit}/scripts/remove-extension.sh +15 -26
- package/dist/persona-defaults/persona-app-builder/CLAUDE.md +13 -16
- package/dist/persona-defaults/persona-app-builder/extension-kit/README.md +24 -18
- package/dist/persona-defaults/persona-app-builder/extension-kit/examples/nestjs-react/ext.conf +5 -0
- package/dist/persona-defaults/persona-bug-fixer/CLAUDE.md +0 -2
- package/dist/persona-defaults/persona-dataclaw-builder/.mcp.json +4 -0
- package/dist/persona-defaults/persona-dataclaw-builder/.secrets/dataclaw.env.example +3 -0
- package/dist/persona-defaults/persona-dataclaw-builder/CLAUDE.md +66 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/README.md +99 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/config.env +20 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/contract/s.yaml.tmpl +59 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/ext.conf +5 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/.env.example +15 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/.fcignore +7 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/nest-cli.json +8 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/package.json +29 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/app.module.ts +13 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/gate.ts +70 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/session.ts +52 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/ttc-user-info.ts +23 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.client.ts +83 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.config.ts +18 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.controller.ts +30 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.module.ts +9 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/main.ts +116 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/polyfill.ts +6 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/tsconfig.json +14 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/index.html +12 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/package.json +18 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/App.jsx +9 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/api.js +19 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/auth.js +7 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/BarChart.jsx +38 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/FilterRow.jsx +79 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/ResultCards.jsx +17 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/ResultTable.jsx +21 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/SchemaFilters.jsx +93 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/enums.js +22 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/main.jsx +6 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/pages/PerformanceQueryPage.jsx +102 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/styles.css +83 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/vite.config.js +30 -0
- package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/hooks/pre-deploy.sh +25 -0
- package/dist/persona-defaults/persona-developer/CLAUDE.md +0 -2
- package/package.json +2 -5
- package/dist/dispatch/mcp-server.cjs +0 -39756
- package/dist/persona-defaults/persona-app-builder/extension-kit/scripts/new-extension.sh +0 -66
- package/dist/persona-defaults/persona-app-builder/extension-kit/scripts/publish.sh +0 -110
- /package/dist/{persona-defaults/persona-app-builder → deploy-kit}/.secrets/aliyun.env +0 -0
- /package/dist/{persona-defaults/persona-app-builder/extension-kit → deploy-kit}/contract/bootstrap +0 -0
- /package/dist/{persona-defaults/persona-app-builder/extension-kit → deploy-kit}/scripts/ensure-toolchain.sh +0 -0
- /package/dist/{persona-defaults/persona-app-builder/extension-kit → deploy-kit}/scripts/verify.sh +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# ===== 平台级固定变量(一次配好,所有 extension 共用)=====
|
|
2
|
+
# 阿里云凭证不在这里:从 .secrets/aliyun.env 现读(见 CLAUDE.md)
|
|
3
|
+
|
|
4
|
+
REGION=cn-hangzhou
|
|
5
|
+
|
|
6
|
+
# FC 运行时:custom.debian12(纯净) + 官方 Node22 层(自带 node + 原生 WebSocket)
|
|
7
|
+
# 换语言时改这两行 + 换 contract/bootstrap(见 README「换框架/语言」)
|
|
8
|
+
FC_RUNTIME=custom.debian12
|
|
9
|
+
NODE_LAYER=acs:fc:cn-hangzhou:official:layers/Nodejs22/versions/1
|
|
10
|
+
|
|
11
|
+
# dataclaw-service(通路 A,只读)—— 部署后填实际可达地址
|
|
12
|
+
DATACLAW_SERVICE_URL=
|
|
13
|
+
# TTC 飞书登录门禁(无密钥经纪,只重定向到 TTC)
|
|
14
|
+
TTC_AUTH_BASE=https://app.ttcadvisory.com
|
|
15
|
+
TTC_API_BASE=https://api.ttcadvisory.com
|
|
16
|
+
|
|
17
|
+
# FC 资源规格(够跑轻量 extension;按需改)
|
|
18
|
+
FC_CPU=0.35
|
|
19
|
+
FC_MEMORY=512
|
|
20
|
+
FC_TIMEOUT=60
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# s.yaml 模板 —— publish.sh 用 sed 替换 __占位符__ 后生成实际 s.yaml
|
|
2
|
+
# 框架无关:command 固定走 ./bootstrap,入口由 FC_ENTRY 环境变量决定
|
|
3
|
+
edition: 3.0.0
|
|
4
|
+
name: __APP_NAME__-app
|
|
5
|
+
access: default
|
|
6
|
+
|
|
7
|
+
vars:
|
|
8
|
+
region: __REGION__
|
|
9
|
+
|
|
10
|
+
resources:
|
|
11
|
+
__APP_NAME__:
|
|
12
|
+
component: fc3
|
|
13
|
+
props:
|
|
14
|
+
region: ${vars.region}
|
|
15
|
+
functionName: __APP_NAME__
|
|
16
|
+
description: __APP_NAME__ extension (FC serverless)
|
|
17
|
+
runtime: __FC_RUNTIME__
|
|
18
|
+
logConfig: auto
|
|
19
|
+
layers:
|
|
20
|
+
- __NODE_LAYER__
|
|
21
|
+
cpu: __FC_CPU__
|
|
22
|
+
memorySize: __FC_MEMORY__
|
|
23
|
+
diskSize: 512
|
|
24
|
+
timeout: __FC_TIMEOUT__
|
|
25
|
+
instanceConcurrency: 10
|
|
26
|
+
code: __CODE_DIR__
|
|
27
|
+
customRuntimeConfig:
|
|
28
|
+
command:
|
|
29
|
+
- ./bootstrap
|
|
30
|
+
port: 9000
|
|
31
|
+
environmentVariables:
|
|
32
|
+
PORT: '9000'
|
|
33
|
+
APP_ENTRY: __FC_ENTRY__
|
|
34
|
+
DATACLAW_SERVICE_URL: __DATACLAW_SERVICE_URL__
|
|
35
|
+
DATACLAW_AS_UNION_ID: __DATACLAW_AS_UNION_ID__
|
|
36
|
+
DATACLAW_INTERNAL_SECRET: __DATACLAW_INTERNAL_SECRET__
|
|
37
|
+
TTC_AUTH_BASE: __TTC_AUTH_BASE__
|
|
38
|
+
TTC_API_BASE: __TTC_API_BASE__
|
|
39
|
+
APP_BASE_URL: __APP_BASE_URL__
|
|
40
|
+
SESSION_SECRET: __SESSION_SECRET__
|
|
41
|
+
triggers:
|
|
42
|
+
- triggerName: httpTrigger
|
|
43
|
+
triggerType: http
|
|
44
|
+
triggerConfig:
|
|
45
|
+
authType: anonymous
|
|
46
|
+
methods: [GET, POST, PUT, DELETE, HEAD]
|
|
47
|
+
|
|
48
|
+
# 自定义域名:否则 fcapp.run 默认 URL 强制下载网页(防滥用),浏览器无法渲染
|
|
49
|
+
__APP_NAME__Domain:
|
|
50
|
+
component: fc3-domain
|
|
51
|
+
props:
|
|
52
|
+
region: ${vars.region}
|
|
53
|
+
domainName: auto
|
|
54
|
+
protocol: HTTP
|
|
55
|
+
routeConfig:
|
|
56
|
+
routes:
|
|
57
|
+
- path: /*
|
|
58
|
+
functionName: __APP_NAME__
|
|
59
|
+
qualifier: LATEST
|
package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/ext.conf
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
CODE_DIR=./server
|
|
2
|
+
FC_ENTRY=dist/main.js
|
|
3
|
+
BUILD_CMD="cd web && pnpm install && pnpm build && cd ../server && pnpm install && pnpm build && mkdir -p dist/public && cp -R ../web/dist/. dist/public/"
|
|
4
|
+
# 建页人 union_id(dataclaw 数据范围按它裁剪);persona 建页时调 auth.getIdentity 取到后填入。
|
|
5
|
+
DATACLAW_AS_UNION_ID=""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# dataclaw 通路 A(后端直调 dataclaw-service HTTP,只读)
|
|
2
|
+
DATACLAW_SERVICE_URL=http://dataclaw-service:8080
|
|
3
|
+
# 建页人 builder 的 union_id —— 数据范围按此身份在 dataclaw 侧自动裁剪
|
|
4
|
+
DATACLAW_AS_UNION_ID=on_xxxxxxxxxxxxxxxx
|
|
5
|
+
# 服务间共享密钥(与 dataclaw-service 的 internal secret 对齐);勿提交真实值
|
|
6
|
+
DATACLAW_INTERNAL_SECRET=replace-me
|
|
7
|
+
# 本地起页跳过门禁(仅 dev;上线删或置 0)
|
|
8
|
+
DATACLAW_AUTH_DISABLED=1
|
|
9
|
+
PORT=3000
|
|
10
|
+
|
|
11
|
+
# TTC 飞书登录门禁(无密钥经纪:重定向到 TTC,不持飞书密钥)
|
|
12
|
+
TTC_AUTH_BASE=https://app.ttcadvisory.com
|
|
13
|
+
TTC_API_BASE=https://api.ttcadvisory.com
|
|
14
|
+
APP_BASE_URL=https://your-app.example.com # 发布后的公网地址,回调用;HTTPS(cookie secure)
|
|
15
|
+
SESSION_SECRET=replace-with-openssl-rand-hex-32
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "guestbook-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "nest build",
|
|
7
|
+
"start": "node --env-file=.env dist/main.js",
|
|
8
|
+
"start:dev": "nest start --watch",
|
|
9
|
+
"dev": "nest start --watch"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@nestjs/common": "^10.4.0",
|
|
13
|
+
"@nestjs/core": "^10.4.0",
|
|
14
|
+
"@nestjs/platform-express": "^10.4.0",
|
|
15
|
+
"cookie-parser": "^1.4.6",
|
|
16
|
+
"dotenv": "^16.4.5",
|
|
17
|
+
"reflect-metadata": "^0.2.2",
|
|
18
|
+
"rxjs": "^7.8.1"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@nestjs/cli": "^10.4.0",
|
|
22
|
+
"@nestjs/schematics": "^10.2.0",
|
|
23
|
+
"@types/cookie-parser": "^1.4.7",
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"@vitejs/plugin-react": "^4.4.0",
|
|
26
|
+
"typescript": "^5.5.0",
|
|
27
|
+
"vite": "^6.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { DataclawModule } from './dataclaw/dataclaw.module';
|
|
3
|
+
|
|
4
|
+
// dataclaw 只读后端 BFF 起手骨架:默认只注册 DataclawModule(schema / query / intent /
|
|
5
|
+
// consultants 四个只读端点,直调 dataclaw-service,身份按建页人 union_id 裁剪)。
|
|
6
|
+
// 业务逻辑由 assistant 按用户需求从零加(添 controller/module 进 imports 即可)。
|
|
7
|
+
//
|
|
8
|
+
// vite middleware / 静态资源 serve 路径仍由 main.ts 处理(dev mount vite,prod express.static),
|
|
9
|
+
// 不需要 ServeStaticModule。
|
|
10
|
+
@Module({
|
|
11
|
+
imports: [DataclawModule],
|
|
12
|
+
})
|
|
13
|
+
export class AppModule {}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// TTC 飞书登录门禁(无密钥经纪):Express 中间件 + /auth/login、/auth/callback 处理器。
|
|
2
|
+
// 必须挂在 vite middleware(dev) / express.static(prod) 之前——前端 HTML 不走 Nest 路由,
|
|
3
|
+
// Nest guard 拦不住,所以门禁只能是 Express 层中间件(main.ts 接线在 vite/static 之前)。
|
|
4
|
+
// 门禁只决定"能不能打开页";数据范围仍由 dataclaw client 固定用建页人 union_id(PR-4 已做),
|
|
5
|
+
// 不用登录者身份查数。
|
|
6
|
+
import { fetchTtcUser } from './ttc-user-info'
|
|
7
|
+
import { makeSession, readSession, makeState, verifyState } from './session'
|
|
8
|
+
|
|
9
|
+
// req/res/next 用 any:express 不是本 server 包的直接依赖(pnpm 下从 server 根 require.resolve
|
|
10
|
+
// 'express' 失败,main.ts:67 同样用 any),从 'express' import 类型会编译失败。
|
|
11
|
+
type Req = any
|
|
12
|
+
type Res = any
|
|
13
|
+
type Next = (err?: unknown) => void
|
|
14
|
+
|
|
15
|
+
const SESSION_COOKIE = 'dc_session'
|
|
16
|
+
const STATE_COOKIE = 'dc_oauth_state'
|
|
17
|
+
|
|
18
|
+
// 子路径部署(clawd dev preview/<port>/,见 main.ts subPath 逻辑)下 redirect 要带同样前缀;
|
|
19
|
+
// prod base=/ 时无前缀,返回 '/auth/login'。
|
|
20
|
+
function loginPath(req: Req): string {
|
|
21
|
+
const url = String(req.originalUrl || req.url || '')
|
|
22
|
+
const m = url.match(/^\/(preview\/\d+)\//)
|
|
23
|
+
return m ? `/${m[1]}/auth/login` : '/auth/login'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 门禁中间件:放行 /auth/*;DATACLAW_AUTH_DISABLED=1 全放行;否则无有效 session → 302 /auth/login。
|
|
27
|
+
export function authGate(req: Req, res: Res, next: Next) {
|
|
28
|
+
if (process.env.DATACLAW_AUTH_DISABLED === '1') return next()
|
|
29
|
+
const p = String(req.path || req.url || '')
|
|
30
|
+
if (p.includes('/auth/login') || p.includes('/auth/callback')) return next()
|
|
31
|
+
const sess = readSession(req.cookies?.[SESSION_COOKIE])
|
|
32
|
+
if (sess) return next()
|
|
33
|
+
return res.redirect(302, loginPath(req))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loginHandler(req: Req, res: Res) {
|
|
37
|
+
const authBase = process.env.TTC_AUTH_BASE || 'https://app.ttcadvisory.com'
|
|
38
|
+
const appBase = (process.env.APP_BASE_URL || '').replace(/\/+$/, '')
|
|
39
|
+
const state = makeState()
|
|
40
|
+
res.cookie(STATE_COOKIE, state, { httpOnly: true, sameSite: 'lax', secure: true, maxAge: 5 * 60 * 1000 })
|
|
41
|
+
const url = new URL('/auth/authorize', authBase)
|
|
42
|
+
url.searchParams.set('callback_url', `${appBase}/auth/callback`)
|
|
43
|
+
url.searchParams.set('state', state)
|
|
44
|
+
url.searchParams.set('auto', '1')
|
|
45
|
+
res.redirect(302, url.toString())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function callbackHandler(req: Req, res: Res) {
|
|
49
|
+
const token = String(req.query?.token || '')
|
|
50
|
+
const state = String(req.query?.state || '')
|
|
51
|
+
const cookieState = req.cookies?.[STATE_COOKIE]
|
|
52
|
+
// CSRF:回调 state 必须等于 cookie 里的、且签名有效未过期
|
|
53
|
+
if (!token || !state || state !== cookieState || !verifyState(state)) {
|
|
54
|
+
return res.status(401).send('登录校验失败,请重试')
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const apiBase = process.env.TTC_API_BASE || 'https://api.ttcadvisory.com'
|
|
58
|
+
const user = await fetchTtcUser(apiBase, token) // 只验身份;TTC token 验完即丢,不存不用于查数
|
|
59
|
+
res.clearCookie(STATE_COOKIE)
|
|
60
|
+
res.cookie(SESSION_COOKIE, makeSession(user.uniqueId, user.name), {
|
|
61
|
+
httpOnly: true,
|
|
62
|
+
sameSite: 'lax',
|
|
63
|
+
secure: true,
|
|
64
|
+
maxAge: 12 * 3600 * 1000,
|
|
65
|
+
})
|
|
66
|
+
return res.redirect(302, '/')
|
|
67
|
+
} catch {
|
|
68
|
+
return res.status(401).send('飞书身份校验失败')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// HMAC 签名 session + oauth state cookie,用 SESSION_SECRET 签发/校验。
|
|
2
|
+
// FC 多实例无共享内存,所以 state 也走签名 cookie,不能用进程内 Map。
|
|
3
|
+
import { createHmac, timingSafeEqual, randomBytes } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
const SECRET = () => process.env.SESSION_SECRET || ''
|
|
6
|
+
|
|
7
|
+
function sign(payload: string): string {
|
|
8
|
+
const mac = createHmac('sha256', SECRET()).update(payload).digest('base64url')
|
|
9
|
+
return `${Buffer.from(payload).toString('base64url')}.${mac}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function verify(token: string): string | null {
|
|
13
|
+
const [b64, mac] = (token || '').split('.')
|
|
14
|
+
if (!b64 || !mac) return null
|
|
15
|
+
const payload = Buffer.from(b64, 'base64url').toString()
|
|
16
|
+
const expect = createHmac('sha256', SECRET()).update(payload).digest('base64url')
|
|
17
|
+
const a = Buffer.from(mac)
|
|
18
|
+
const b = Buffer.from(expect)
|
|
19
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) return null
|
|
20
|
+
return payload
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// session:存 {uid,name,exp}(仅展示/审计,不用于查数——数据范围恒用建页人 union_id)。
|
|
24
|
+
export function makeSession(uid: string, name: string, ttlMs = 12 * 3600 * 1000): string {
|
|
25
|
+
return sign(JSON.stringify({ uid, name, exp: Date.now() + ttlMs }))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readSession(token?: string): { uid: string; name: string } | null {
|
|
29
|
+
const p = token && verify(token)
|
|
30
|
+
if (!p) return null
|
|
31
|
+
try {
|
|
32
|
+
const o = JSON.parse(p)
|
|
33
|
+
if (typeof o.exp === 'number' && o.exp > Date.now()) return { uid: o.uid, name: o.name }
|
|
34
|
+
} catch {}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function makeState(): string {
|
|
39
|
+
const nonce = randomBytes(16).toString('base64url')
|
|
40
|
+
return sign(JSON.stringify({ nonce, exp: Date.now() + 5 * 60 * 1000 }))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function verifyState(token?: string): boolean {
|
|
44
|
+
const p = token && verify(token)
|
|
45
|
+
if (!p) return false
|
|
46
|
+
try {
|
|
47
|
+
const o = JSON.parse(p)
|
|
48
|
+
return typeof o.exp === 'number' && o.exp > Date.now()
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// 用 TTC JWT 调 user_info 验身份(无密钥经纪:app 不持飞书/TTC 密钥,只用回调 token 验身份)。
|
|
2
|
+
// 精简自 daemon feishu-auth/ttc-client.ts,独立实现、不 import daemon 代码。
|
|
3
|
+
// 接口响应结构:GET /api/user_service/v1/login/user → { code:0, data:{ unique_id, name, union_id, avatar_url } }
|
|
4
|
+
export interface TtcUser {
|
|
5
|
+
uniqueId: string
|
|
6
|
+
name: string
|
|
7
|
+
unionId?: string
|
|
8
|
+
avatarUrl?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function fetchTtcUser(apiBase: string, token: string, fetchImpl: typeof fetch = fetch): Promise<TtcUser> {
|
|
12
|
+
const res = await fetchImpl(`${apiBase}/api/user_service/v1/login/user`, { headers: { Authorization: `Bearer ${token}` } })
|
|
13
|
+
if (!res.ok) throw new Error(`TTC user_info HTTP ${res.status}`)
|
|
14
|
+
const body = (await res.json()) as { code?: number; data?: { unique_id?: string; name?: string; union_id?: string; avatar_url?: string } }
|
|
15
|
+
if (body.code !== 0 || !body.data?.unique_id || !body.data?.name) throw new Error('TTC user_info invalid')
|
|
16
|
+
const d = body.data
|
|
17
|
+
return {
|
|
18
|
+
uniqueId: d.unique_id,
|
|
19
|
+
name: d.name,
|
|
20
|
+
...(d.union_id ? { unionId: d.union_id } : {}),
|
|
21
|
+
...(d.avatar_url ? { avatarUrl: d.avatar_url } : {}),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Injectable, HttpException, OnModuleInit } from '@nestjs/common'
|
|
2
|
+
import { DataclawConfig, loadDataclawConfig } from './dataclaw.config'
|
|
3
|
+
|
|
4
|
+
// 端点超时(架构文档 §8.10):schema / flexible-query 走默认;intent-execute 可能跑 LLM 编排,给 90s。
|
|
5
|
+
const TIMEOUT_MS = { default: 30_000, intent: 90_000 }
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class DataclawClient implements OnModuleInit {
|
|
9
|
+
private cfg!: DataclawConfig
|
|
10
|
+
|
|
11
|
+
onModuleInit() {
|
|
12
|
+
this.cfg = loadDataclawConfig()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private async call<T>(
|
|
16
|
+
method: 'GET' | 'POST',
|
|
17
|
+
path: string,
|
|
18
|
+
opts: {
|
|
19
|
+
query?: Record<string, string | number | undefined>
|
|
20
|
+
body?: unknown
|
|
21
|
+
timeout?: number
|
|
22
|
+
} = {},
|
|
23
|
+
): Promise<T> {
|
|
24
|
+
const url = new URL(this.cfg.baseUrl + path)
|
|
25
|
+
for (const [k, v] of Object.entries(opts.query ?? {})) {
|
|
26
|
+
if (v !== undefined && v !== '') url.searchParams.set(k, String(v))
|
|
27
|
+
}
|
|
28
|
+
const ctrl = new AbortController()
|
|
29
|
+
const t = setTimeout(() => ctrl.abort(), opts.timeout ?? TIMEOUT_MS.default)
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(url, {
|
|
32
|
+
method,
|
|
33
|
+
signal: ctrl.signal,
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
// 身份注入:数据范围按建页人 union_id 在 dataclaw 侧裁剪 + 服务间共享密钥校验。
|
|
37
|
+
'X-User-UnionId': this.cfg.asUnionId,
|
|
38
|
+
'X-Internal-Secret': this.cfg.internalSecret,
|
|
39
|
+
},
|
|
40
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
41
|
+
})
|
|
42
|
+
const text = await res.text()
|
|
43
|
+
let json: any
|
|
44
|
+
try {
|
|
45
|
+
json = text ? JSON.parse(text) : {}
|
|
46
|
+
} catch {
|
|
47
|
+
throw new HttpException({ ok: false, error: `dataclaw 非 JSON 响应: ${text.slice(0, 200)}` }, 502)
|
|
48
|
+
}
|
|
49
|
+
if (!res.ok || json?.ok === false) {
|
|
50
|
+
// HTTP 非 2xx → 映射上游状态(5xx 归 502);HTTP 2xx 但应用层 {ok:false} → 502
|
|
51
|
+
// (别把 200 当错误码抛——对前端是"成功码带错误体"的矛盾态)。
|
|
52
|
+
const status = res.ok ? 502 : res.status >= 500 ? 502 : res.status || 400
|
|
53
|
+
throw new HttpException(
|
|
54
|
+
{ ok: false, error: json?.error || `dataclaw ${res.status}`, upstreamStatus: res.status },
|
|
55
|
+
status,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
return json as T
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
if (err instanceof HttpException) throw err
|
|
61
|
+
if (err?.name === 'AbortError') throw new HttpException({ ok: false, error: 'dataclaw 请求超时' }, 504)
|
|
62
|
+
throw new HttpException({ ok: false, error: `dataclaw 连接失败: ${err?.message}` }, 502)
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(t)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getSchema(dataset?: string) {
|
|
69
|
+
return this.call<any>('GET', '/api/schema', { query: { dataset } })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
flexibleQuery(body: unknown) {
|
|
73
|
+
return this.call<any>('POST', '/api/flexible-query', { body })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
intentExecute(body: unknown) {
|
|
77
|
+
return this.call<any>('POST', '/api/intent-execute', { body, timeout: TIMEOUT_MS.intent })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
consultants(keyword: string) {
|
|
81
|
+
return this.call<any>('GET', '/api/consultants', { query: { keyword } })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface DataclawConfig {
|
|
2
|
+
baseUrl: string
|
|
3
|
+
asUnionId: string
|
|
4
|
+
internalSecret: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function loadDataclawConfig(): DataclawConfig {
|
|
8
|
+
const baseUrl = (process.env.DATACLAW_SERVICE_URL || '').replace(/\/+$/, '')
|
|
9
|
+
const asUnionId = process.env.DATACLAW_AS_UNION_ID || ''
|
|
10
|
+
const internalSecret = process.env.DATACLAW_INTERNAL_SECRET || ''
|
|
11
|
+
const missing = [
|
|
12
|
+
!baseUrl && 'DATACLAW_SERVICE_URL',
|
|
13
|
+
!asUnionId && 'DATACLAW_AS_UNION_ID',
|
|
14
|
+
!internalSecret && 'DATACLAW_INTERNAL_SECRET',
|
|
15
|
+
].filter(Boolean)
|
|
16
|
+
if (missing.length) throw new Error(`[dataclaw] 缺少必需 env: ${missing.join(', ')}`)
|
|
17
|
+
return { baseUrl, asUnionId, internalSecret }
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Body, Controller, Get, Post, Query } from '@nestjs/common'
|
|
2
|
+
import { DataclawClient } from './dataclaw.client'
|
|
3
|
+
|
|
4
|
+
// app 自己的只读 BFF。main.ts 已 setGlobalPrefix('api' 或子路径 'preview/<port>/api'),
|
|
5
|
+
// 故这里的路径不带 /api。
|
|
6
|
+
// 登录门禁由 main.ts 的 Express authGate 中间件统一覆盖(含 /api/*),不再用 Nest guard。
|
|
7
|
+
@Controller()
|
|
8
|
+
export class DataclawController {
|
|
9
|
+
constructor(private readonly dc: DataclawClient) {}
|
|
10
|
+
|
|
11
|
+
@Get('schema')
|
|
12
|
+
schema(@Query('dataset') dataset?: string) {
|
|
13
|
+
return this.dc.getSchema(dataset)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Post('query')
|
|
17
|
+
query(@Body() body: unknown) {
|
|
18
|
+
return this.dc.flexibleQuery(body)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Post('intent')
|
|
22
|
+
intent(@Body() body: unknown) {
|
|
23
|
+
return this.dc.intentExecute(body)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Get('consultants')
|
|
27
|
+
consultants(@Query('keyword') keyword = '') {
|
|
28
|
+
return this.dc.consultants(keyword)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common'
|
|
2
|
+
import { DataclawClient } from './dataclaw.client'
|
|
3
|
+
import { DataclawController } from './dataclaw.controller'
|
|
4
|
+
|
|
5
|
+
@Module({
|
|
6
|
+
controllers: [DataclawController],
|
|
7
|
+
providers: [DataclawClient],
|
|
8
|
+
})
|
|
9
|
+
export class DataclawModule {}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import './polyfill';
|
|
2
|
+
import { NestFactory } from '@nestjs/core';
|
|
3
|
+
import { NestExpressApplication } from '@nestjs/platform-express';
|
|
4
|
+
import { AppModule } from './app.module';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import cookieParser from 'cookie-parser';
|
|
7
|
+
import { authGate, loginHandler, callbackHandler } from './auth/gate';
|
|
8
|
+
|
|
9
|
+
// app-builder build 模式(spec 2026-06-01 + 2026-06-02 §5.6.4 子路径 fix):
|
|
10
|
+
// - dev(通过 clawd 启):nest 单进程 mount vite middleware,前端 HMR + /api 都从
|
|
11
|
+
// CLAWD_PREVIEW_PORT 出。所有 URL 走子路径 `/preview/<port>/`,含两层 prefix 处理:
|
|
12
|
+
// 1. vite createServer 传 base=/preview/<port>/,让 vite middleware 识别带前缀的
|
|
13
|
+
// 请求并剥前缀做内部路由 + HTML transform 时给 asset URL 加前缀
|
|
14
|
+
// 2. nest setGlobalPrefix=preview/<port>/api,让 /preview/<port>/api/* 也被 nest 路由匹配
|
|
15
|
+
// 不这样配,daemon preview-proxy 反代过来的请求(含 /preview/<port>/ 前缀)会被 nest 和 vite
|
|
16
|
+
// 都不识别 → 全 404 / 'spa' 兜底返 HTML(API 接不通)
|
|
17
|
+
// - prod / 脱离 clawd:nest 起来时 express.static serve web/dist,base=/ globalPrefix=api
|
|
18
|
+
//
|
|
19
|
+
// CLAWD_PREVIEW_PORT 由 daemon supervisor 启动时注入;FC_SERVER_PORT / PORT 是 FC 部署 / 本地脱离 clawd 调试的回退。
|
|
20
|
+
async function bootstrap() {
|
|
21
|
+
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
|
22
|
+
|
|
23
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
24
|
+
const isClawdDev = !!process.env.CLAWD_PREVIEW_PORT;
|
|
25
|
+
const port =
|
|
26
|
+
Number(process.env.CLAWD_PREVIEW_PORT) ||
|
|
27
|
+
Number(process.env.FC_SERVER_PORT) ||
|
|
28
|
+
Number(process.env.PORT) ||
|
|
29
|
+
3000;
|
|
30
|
+
|
|
31
|
+
// 子路径 prefix:clawd dev 模式下挂 /preview/<port>/,其它(prod / 本地 vite 独跑)走根路径。
|
|
32
|
+
const subPath = isClawdDev ? `preview/${port}` : '';
|
|
33
|
+
const apiPrefix = subPath ? `${subPath}/api` : 'api';
|
|
34
|
+
const viteBase = subPath ? `/${subPath}/` : '/';
|
|
35
|
+
|
|
36
|
+
app.setGlobalPrefix(apiPrefix, { exclude: ['/'] });
|
|
37
|
+
|
|
38
|
+
// TTC 飞书登录门禁(PR-6)。前端 HTML 由 vite middleware(dev) / express.static(prod) serve、
|
|
39
|
+
// 不走 Nest 路由,Nest guard 拦不住,所以门禁是 Express 中间件,必须挂在下方 vite middleware
|
|
40
|
+
// (约:75) / useStaticAssets(约:99) 之前。/api/* 也先经过它(gate 在 vite 守卫和 nest router 之前)。
|
|
41
|
+
// /auth/login、/auth/callback 直接在 express 层注册,不带 globalPrefix;子路径部署带同样前缀。
|
|
42
|
+
// fail-loud:门禁启用却没配 SESSION_SECRET,会用空密钥签 cookie(可被伪造)—— 直接拒启动。
|
|
43
|
+
if (process.env.DATACLAW_AUTH_DISABLED !== '1' && !process.env.SESSION_SECRET) {
|
|
44
|
+
throw new Error('[auth] 缺 SESSION_SECRET:登录门禁启用时必填(否则 session cookie 可被伪造)。本地调试可设 DATACLAW_AUTH_DISABLED=1 跳过门禁。');
|
|
45
|
+
}
|
|
46
|
+
app.use(cookieParser());
|
|
47
|
+
const httpAdapter = app.getHttpAdapter();
|
|
48
|
+
const authBasePath = subPath ? `/${subPath}` : '';
|
|
49
|
+
httpAdapter.get(`${authBasePath}/auth/login`, loginHandler);
|
|
50
|
+
httpAdapter.get(`${authBasePath}/auth/callback`, callbackHandler);
|
|
51
|
+
app.use(authGate);
|
|
52
|
+
|
|
53
|
+
if (isDev && isClawdDev) {
|
|
54
|
+
// dev 模式 + 通过 clawd 启的 → mount vite middleware,root=../../web
|
|
55
|
+
const { createServer: createViteServer } = await import('vite');
|
|
56
|
+
const viteRoot = path.resolve(__dirname, '..', '..', 'web');
|
|
57
|
+
// vite 6+ 默认 server.allowedHosts: [](只放 localhost / .localhost / IP)防 DNS rebinding。
|
|
58
|
+
// frpc tunnel 转发过来的 Host header 是 daemon 当前 subdomain(CLAWD_TUNNEL_HOST 由
|
|
59
|
+
// daemon supervisor 注入),不在白名单 → vite middleware 直接拒(老板 tunnel URL 404 根因)。
|
|
60
|
+
// 显式把它加进 allowedHosts。
|
|
61
|
+
const tunnelHost = process.env.CLAWD_TUNNEL_HOST || '';
|
|
62
|
+
const allowedHosts = ['localhost', '127.0.0.1', ...(tunnelHost ? [tunnelHost] : [])];
|
|
63
|
+
const vite = await createViteServer({
|
|
64
|
+
root: viteRoot,
|
|
65
|
+
// **显式 inline base**:不依赖 web/vite.config.js 解析路径(middleware mode + ESM 配置
|
|
66
|
+
// 文件解析有时不可靠)。vite 据此识别带前缀的请求 + transformIndexHtml 注入带前缀的 asset URL。
|
|
67
|
+
base: viteBase,
|
|
68
|
+
server: {
|
|
69
|
+
middlewareMode: true,
|
|
70
|
+
// HMR WS 复用 nest 的 http.Server —— 跟 HTTP 共享同一个 fd / 同一个端口。
|
|
71
|
+
// 不设这个 vite 会 fallback standalone 用 24678,多 project 并发会撞。
|
|
72
|
+
hmr: { server: app.getHttpServer() },
|
|
73
|
+
allowedHosts,
|
|
74
|
+
},
|
|
75
|
+
// appType: 'spa' 让 vite middleware 自己 serve index.html + fallback 路由(base 下任何
|
|
76
|
+
// 非 vite asset 路径都返 index.html)。
|
|
77
|
+
appType: 'spa',
|
|
78
|
+
});
|
|
79
|
+
// /api/* 守卫:实测 vite 'spa' catch-all 会先吃掉 /api/* 返 HTML,导致 API 拿不到 JSON。
|
|
80
|
+
// Express middleware 栈顺序:vite.middlewares 这一行加进去时 nest controllers 还没挂
|
|
81
|
+
// (nest router init 在 app.listen 时),所以 vite 拦截优先于 nest router → 命中 /api 也走 vite。
|
|
82
|
+
// 守卫:req.url 命中 apiPrefix 时直接 next() 跳过 vite,让 nest router 接管返 JSON。
|
|
83
|
+
const apiPathPrefix = `/${apiPrefix}/`; // 如 '/preview/6173/api/' 或 '/api/'
|
|
84
|
+
app.use((req: any, res: any, next: any) => {
|
|
85
|
+
if (typeof req.url === 'string' && req.url.startsWith(apiPathPrefix)) {
|
|
86
|
+
return next(); // skip vite,让 nest router 处理 /api/*
|
|
87
|
+
}
|
|
88
|
+
return vite.middlewares(req, res, next);
|
|
89
|
+
});
|
|
90
|
+
console.log(`[clawd-dev] vite middleware mounted (root=${viteRoot}, base=${viteBase}, apiPrefix=${apiPrefix}, allowedHosts=${allowedHosts.join(',')})`);
|
|
91
|
+
} else {
|
|
92
|
+
// prod / 脱离 clawd 跑:直接 serve 前端静态资源。
|
|
93
|
+
//
|
|
94
|
+
// 路径解析两条路(覆盖 FC 部署与本地 monorepo 启动两种场景):
|
|
95
|
+
// 1. FC(CODE_DIR=./server 只上传 server/,运行时 __dirname=dist/)
|
|
96
|
+
// publish.sh BUILD_CMD 末尾会跑 `mkdir -p dist/public && cp -R ../web/dist/. dist/public/`
|
|
97
|
+
// 把前端打包产物塞进 dist/public,跟着 server 一起上传到 FC。所以 prod 优先用
|
|
98
|
+
// __dirname/public 这条 FC 内可达的绝对路径。
|
|
99
|
+
// 2. 本地 monorepo `node dist/main.js`(极少用,但保留兼容):__dirname/public 不存在
|
|
100
|
+
// 时 fallback 到 ../../web/dist(monorepo 相对路径)。
|
|
101
|
+
//
|
|
102
|
+
// 历史坑:spec 2026-06-03 前只有路径 2,FC 上 404 整个前端 —— 因为 ../../web/dist 在 FC
|
|
103
|
+
// 容器里压根不存在(CODE_DIR 只上传了 server/)。dev 时靠 vite middleware 走内存编译没暴露。
|
|
104
|
+
const fs = await import('node:fs');
|
|
105
|
+
const fcStaticDir = path.resolve(__dirname, 'public');
|
|
106
|
+
const monorepoStaticDir = path.resolve(__dirname, '..', '..', 'web', 'dist');
|
|
107
|
+
const staticDir = fs.existsSync(fcStaticDir) ? fcStaticDir : monorepoStaticDir;
|
|
108
|
+
app.useStaticAssets(staticDir);
|
|
109
|
+
console.log(`[prod] serving static from ${staticDir}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await app.listen(port, '0.0.0.0');
|
|
113
|
+
console.log(`server listening on :${port} (${isDev ? 'dev' : 'prod'}${isClawdDev ? ' / clawd' : ''})`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
bootstrap();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Dev 模式 .env loader:`nest start --watch` 不会经过 `node --env-file=.env`,
|
|
2
|
+
// server 拿不到 DATACLAW_* env → loadDataclawConfig 会 fail-loud。
|
|
3
|
+
// 这里在 nest bootstrap 之前显式 require dotenv 把 .env 填进 process.env。
|
|
4
|
+
// prod 路径(node --env-file=.env dist/main.js)Node 自带 loader 已经填了,dotenv 看到
|
|
5
|
+
// 已存在的 env 不会覆盖(默认行为),行为对齐。
|
|
6
|
+
require('dotenv/config');
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "commonjs",
|
|
4
|
+
"target": "ES2021",
|
|
5
|
+
"outDir": "./dist",
|
|
6
|
+
"rootDir": "./src",
|
|
7
|
+
"experimentalDecorators": true,
|
|
8
|
+
"emitDecoratorMetadata": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"sourceMap": false,
|
|
12
|
+
"declaration": false
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>App</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.jsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "guestbook-web",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"react": "^18.3.1",
|
|
12
|
+
"react-dom": "^18.3.1"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@vitejs/plugin-react": "^4.4.0",
|
|
16
|
+
"vite": "^6.0.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useAuthGate } from './auth';
|
|
2
|
+
import PerformanceQueryPage from './pages/PerformanceQueryPage';
|
|
3
|
+
|
|
4
|
+
export default function App() {
|
|
5
|
+
const { ready, authed } = useAuthGate();
|
|
6
|
+
if (!ready) return <div>加载中…</div>;
|
|
7
|
+
if (!authed) return <div>请先飞书登录</div>;
|
|
8
|
+
return <PerformanceQueryPage />;
|
|
9
|
+
}
|