@clawos-dev/clawd 0.2.143-beta.302.5df5cc1 → 0.2.143-beta.303.ab28945

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.
Files changed (26) hide show
  1. package/dist/persona-defaults/persona-dataclaw-builder/CLAUDE.md +29 -9
  2. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/README.md +10 -18
  3. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/config.env +2 -5
  4. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/contract/s.yaml.tmpl +1 -6
  5. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/ext.conf +3 -2
  6. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/.env.example +5 -14
  7. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/package.json +1 -3
  8. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/app.module.ts +2 -2
  9. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.client.ts +18 -8
  10. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.config.ts +9 -12
  11. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/dataclaw/dataclaw.controller.ts +12 -8
  12. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/main.ts +3 -16
  13. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/App.jsx +1 -4
  14. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/api.js +4 -2
  15. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/ResultTable.jsx +4 -2
  16. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/enums.js +3 -18
  17. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/pages/PerformanceQueryPage.jsx +35 -57
  18. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/hooks/pre-deploy.sh +16 -20
  19. package/package.json +1 -1
  20. package/dist/persona-defaults/persona-dataclaw-builder/.secrets/dataclaw.env.example +0 -3
  21. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/gate.ts +0 -70
  22. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/session.ts +0 -52
  23. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/server/src/auth/ttc-user-info.ts +0 -23
  24. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/auth.js +0 -7
  25. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/FilterRow.jsx +0 -79
  26. package/dist/persona-defaults/persona-dataclaw-builder/extension-kit/examples/nestjs-react/web/src/components/SchemaFilters.jsx +0 -93
@@ -13,7 +13,7 @@ daemon 启动你时已注入两个 env:
13
13
  所有调用都是 `POST $CLAWD_DAEMON_URL/api/rpc/<method>` + JSON body + `X-Clawd-Session-Id` header。
14
14
 
15
15
  **状态 A:未绑 project(fresh session)** —— 起手三步:
16
- 1. 主动问「老板想看什么数据的页?」(一句话,别长 prompt
16
+ 1. 主动问「老板想看什么数据的页?」(一句话,别长 prompt)。顺带提醒:稍后需要老板给一把 dataclaw apikey(向刘浩索取;只要冒号后的 secret 段)。
17
17
  2. 老板描述需求 → **反问「项目叫什么名字」**(不擅自命名)
18
18
  3. 拿到名字后建 project:
19
19
 
@@ -24,7 +24,7 @@ curl -sS -X POST "$CLAWD_DAEMON_URL/api/rpc/appBuilder.createProject" \
24
24
  ```
25
25
  返回 `result.projectDir` 就是项目目录绝对路径,**记住它**,后续 cd / 写文件都用它。重名报 `VALIDATION_ERROR` → 让老板换名。createProject 后 daemon 自动 scaffold 模板(dataclaw 起手 app)+ 设 stage='install-pending'。
26
26
 
27
- **状态 B:已绑 project** —— UI 双栏(左 chat + 右实时预览)。接着按顺序做 3 步:
27
+ **状态 B:已绑 project** —— UI 双栏(左 chat + 右实时预览)。接着按顺序做:
28
28
 
29
29
  1. 上报安装 + 装依赖:
30
30
  ```bash
@@ -34,13 +34,23 @@ curl -sS -X POST "$CLAWD_DAEMON_URL/api/rpc/appBuilder.reportStage" \
34
34
  ```
35
35
  然后 `cd <projectDir>` → `pnpm install`(模板有 server/ 和 web/ 两套 deps)。
36
36
 
37
- 2. dev server(双栏预览):
37
+ 2. 写入 apikey(**起 dev server 之前**,否则预览查不到数):向老板要 dataclaw apikey(只要冒号后的 secret 段),写进 `<projectDir>/server/.env`:
38
+ ```bash
39
+ cat > "<projectDir>/server/.env" <<EOF
40
+ DATACLAW_SERVICE_URL=https://120.48.21.36
41
+ DATACLAW_API_KEY=<老板给的 secret>
42
+ PORT=3000
43
+ EOF
44
+ ```
45
+ key 只存这里:dev 由 dotenv 加载、发布 FC 时 pre-deploy 自动从这读出注入 FC(`.env` 被 `.fcignore` 排除、不进部署包)。老板要换 key → 重写本文件后重启 dev server。
46
+
47
+ 3. 起 dev server(双栏预览):
38
48
  ```bash
39
49
  curl -sS -X POST "$CLAWD_DAEMON_URL/api/rpc/appBuilder.startDevServer" \
40
50
  -H "Content-Type: application/json" -H "X-Clawd-Session-Id: $CLAWD_SESSION_ID" -d '{}'
41
51
  ```
42
52
 
43
- 3. 任一步失败上报 failed:
53
+ 4. 任一步失败上报 failed:
44
54
  ```bash
45
55
  curl -sS -X POST "$CLAWD_DAEMON_URL/api/rpc/appBuilder.reportStage" \
46
56
  -H "Content-Type: application/json" -H "X-Clawd-Session-Id: $CLAWD_SESSION_ID" \
@@ -51,16 +61,26 @@ curl -sS -X POST "$CLAWD_DAEMON_URL/api/rpc/appBuilder.reportStage" \
51
61
 
52
62
  **发布上线**:老板点 PreviewPane「发布上线」按钮走 daemon RPC,happy path 完全跳过你。只有失败时 daemon 才发来一条以 `发布失败:[` 开头的消息(含 `.publish.log` 路径),那时你读日志、修问题、重跑发布。改完代码即时 HMR 刷新,不重新部署。
53
63
 
54
- ## 数据来源:dataclaw-service(只读,通路 A
55
- 生成的 app 后端直调 dataclaw-service HTTP(`/api/flexible-query`、`/api/intent-execute`、`/api/schema`),用**建页人(builder)的身份**取数、纯展示、不写数据。具体查询能力见 §「dataclaw 查询能力」。
64
+ ## 数据来源:dataclaw 公网查询 API(只读,Bearer
65
+ 生成的 app 后端用**老板提供的 apikey** 以 `Authorization: Bearer <secret>` 调 dataclaw 公网 service(`https://120.48.21.36`,自签证书,后端已放开 TLS 校验),纯展示、不写数据。前端只调本 app 的 `/api/*`,apikey 由后端注入、绝不下发浏览器。有 key 即全量只读查询(无身份裁剪)。具体端点见 §「dataclaw 查询能力」。
66
+
67
+ ## dataclaw 查询能力(公网端点速查)
68
+ 模板后端 BFF(`server/src/dataclaw`)已封好以下端点,前端经本 app `/api/*` 调用:
69
+
70
+ | 端点 | 方法 | 入参 | 返回要点 |
71
+ |---|---|---|---|
72
+ | `/api/schema` | GET | `dataset?` | 数据集字段定义(metadata_user / consultant_progress / performance),字段带 aliases + operators |
73
+ | `/api/performance` | POST | `{startDate,endDate}` 必填 | `total` / `queryRange` / `byDateField`(billing/invoice/payment 各 total+amount) / `items[]` 明细 |
74
+ | `/api/ppl` | POST | `{startDate,endDate}` 必填 | `total` / `byStatus` / `byCustomer` 聚合(**只聚合无明细**) |
75
+ | `/api/consultants` | GET | `keyword` | `matches[]`(顾问基本信息) |
76
+ | `/api/intent` | POST | 意图 JSON(domain+intent+过滤,字段见 schema) | 结构化查询结果 |
56
77
 
57
- ## dataclaw 查询能力
58
- 本章节在后续模板/知识充实阶段填全(来源:dataclaw-src 仓库 workspace/docs/dataclaw-builder-architecture.md)。当前仅建骨架。
78
+ 约定:所有返回都带 `_provenance` 溯源字段,前端用 `INTERNAL_FIELDS` 过滤不展示;业绩 / 进展广查询慢,务必带较短时间窗或收窄条件。起手页(业绩查询)是范例,按老板需求改数据集 / 端点 / 展示即可。
59
79
 
60
80
  ## 双客户
61
81
  业务用户自助建简单页(语气业务化、屏蔽技术细节);管理员代建复杂页(可技术化)。
62
82
 
63
83
  ## 红线
64
84
  - 只读:绝不让生成的 app 往 dataclaw 写数据 / 不接 Supabase 写库。
65
- - 数据范围是隐式 AND(按建页人身份兜),**绝不把 scope(self/team/all)做成用户可选下拉**——等于越权。
85
+ - apikey 只存 `server/.env`、只在后端用,**绝不下发到前端 / 不硬编码进代码 / 不写进会被打包的文件**。
66
86
  - persona 目录只读;项目路径以 createProject 返回的 projectDir 为准。
@@ -1,13 +1,13 @@
1
1
  # extension-kit —— 从零到发布的 extension 流水线
2
2
 
3
- 把「前端 + 后端 + Supabase 的 extension 发布到阿里云 FC(serverless,免 Docker)」固化成可复用流水线。开发靠人/agent,发布全自动。
3
+ 把「前端 + 后端的 extension 发布到阿里云 FC(serverless,免 Docker)」固化成可复用流水线。开发靠人/agent,发布全自动。
4
4
 
5
5
  ## 设计理念:契约层 + 可插拔技术栈
6
6
 
7
7
  FC 对应用只有一条要求:**一个监听 `$PORT` 的 HTTP server**。所以固化的是「发布管道」,不是技术栈。
8
8
 
9
9
  - **共享 deploy-kit(凭证 + 脚本骨架 + bootstrap,全 FC persona 一份)**:`$HOME/.clawd/deploy-kit/`(`scripts/` + `contract/bootstrap` + `.secrets/aliyun.env`)
10
- - **persona 特化(本目录)**:`config.env`(dataclaw/TTC 注入值) + `contract/s.yaml.tmpl`(占位符清单) + `hooks/pre-deploy.sh`(发布前置) + `examples/`(默认 `nestjs-react`)
10
+ - **persona 特化(本目录)**:`config.env`(dataclaw 注入值) + `contract/s.yaml.tmpl`(占位符清单) + `hooks/pre-deploy.sh`(发布前置) + `examples/`(默认 `nestjs-react`)
11
11
 
12
12
  ## 目录
13
13
 
@@ -24,9 +24,9 @@ deploy-kit/
24
24
 
25
25
  # 本 persona 的 extension-kit(只放特化资产)
26
26
  extension-kit/
27
- ├── config.env dataclaw/TTC 注入值(region / Node层 / DATACLAW_SERVICE_URL / TTC_*)
27
+ ├── config.env dataclaw 注入值(region / Node层 / DATACLAW_SERVICE_URL)
28
28
  ├── contract/s.yaml.tmpl FC 部署模板(占位符,publish.sh 通用扫描替换)
29
- ├── hooks/pre-deploy.sh 发布前置(校验 dataclaw.env、生成 SESSION_SECRET、推导 APP_BASE_URL)
29
+ ├── hooks/pre-deploy.sh 发布前置(从生成 app 的 server/.env 读出 DATACLAW_API_KEY、注入 FC)
30
30
  └── examples/nestjs-react/ 默认示例(含模板自带 ext.conf)
31
31
  ```
32
32
 
@@ -38,8 +38,8 @@ extension-kit/
38
38
  PR="$HOME/.clawd/personas/persona-dataclaw-builder"; DK="$HOME/.clawd/deploy-kit"
39
39
 
40
40
  # 1) scaffold(daemon 已自动跑;手动签名: new-extension.sh <name> <模板源目录> <目标目录>)
41
- # 2) 改 web/ 和 server/ 业务逻辑(只读查 dataclaw-service,不接 Supabase 写库)
42
- # 3) 一键发布到 FC,输出公网地址(发布前需在 $PR/.secrets/dataclaw.env 填 DATACLAW_INTERNAL_SECRET)
41
+ # 2) 改 web/ 和 server/ 业务逻辑(只读查 dataclaw 公网 API,不接 Supabase 写库)
42
+ # 3) 一键发布到 FC,输出公网地址(apikey 在建页时已写进生成 app 的 server/.env,无需额外配置)
43
43
  "$DK/scripts/publish.sh" <projDir> "$PR"
44
44
  # 4) 不要了就一键清理 FC 资源
45
45
  "$DK/scripts/remove-extension.sh" <projDir> "$PR"
@@ -50,10 +50,9 @@ PR="$HOME/.clawd/personas/persona-dataclaw-builder"; DK="$HOME/.clawd/deploy-kit
50
50
  | 步骤 | 谁做 |
51
51
  |------|------|
52
52
  | new-extension 脚手架 | 🟢 脚本 |
53
- | 设计 Supabase schema | 🔴 人/agent(每个 extension 不同) |
54
53
  | 写前后端业务 | 🔴 人/agent |
55
54
  | publish(build/部署/域名/验证) | 🟢 脚本 |
56
- | remove(清 FC + 表) | 🟢 脚本 |
55
+ | remove(清 FC) | 🟢 脚本 |
57
56
 
58
57
  ## 清理一个 extension
59
58
 
@@ -61,13 +60,7 @@ PR="$HOME/.clawd/personas/persona-dataclaw-builder"; DK="$HOME/.clawd/deploy-kit
61
60
  "$HOME/.clawd/deploy-kit/scripts/remove-extension.sh" <projDir> "$HOME/.clawd/personas/persona-dataclaw-builder"
62
61
  ```
63
62
 
64
- `s remove` 删 FC 函数+触发器+自定义域名,再按 `ext.conf` 的 `SUPABASE_TABLES` 删它的表。
65
-
66
- **脚本怎么知道删哪个表**:靠 `ext.conf` 里显式声明的 `SUPABASE_TABLES="myapp_xxx"`(建表时填进去,extension 的"自描述")。三道安全锁:
67
-
68
- 1. 每个表必须 `<app>_` 前缀,否则**拒绝删**(物理上防误伤 clawos 共享库)
69
- 2. 删前列出所有要删的表
70
- 3. 要手输 `yes` 确认(数据不可逆,不用 `-y` 静默删)
63
+ `s remove` 删 FC 函数+触发器+自定义域名。
71
64
 
72
65
  > SLS 日志 project / OSS 代码桶是**账号级共享**(多 extension 共用),不随单个 extension 删。
73
66
 
@@ -89,11 +82,10 @@ PR="$HOME/.clawd/personas/persona-dataclaw-builder"; DK="$HOME/.clawd/deploy-kit
89
82
 
90
83
  ## 红线(都是踩坑换来的,见记忆 fc-nodejs-deploy-recipe)
91
84
 
92
- - **Supabase 是 clawos 共享生产库**:新表必须 `<app>_` 前缀,**绝不 drop/alter clawos 已有表**
93
85
  - **必须绑自定义域名**(fc3-domain auto),否则 `fcapp.run` 默认 URL 强制下载网页、浏览器不渲染
94
86
  - **不用 SAE**(Node 无代码包,只能镜像=要 Docker);不用 `custom.debian10`/裸 command(node 找不到 / CAExited)
95
- - **长连接 / WebSocket 推送不适合 FC** → 用 Supabase Realtime(FC 不维持长连接)
87
+ - **长连接 / WebSocket 推送不适合 FC**(FC 不维持长连接)
96
88
 
97
89
  ## 不固化的(留给人/agent)
98
90
 
99
- 数据模型设计、业务逻辑、建表前的归属判断、发布失败时的异常排查。
91
+ 业务逻辑、发布失败时的异常排查。
@@ -8,11 +8,8 @@ REGION=cn-hangzhou
8
8
  FC_RUNTIME=custom.debian12
9
9
  NODE_LAYER=acs:fc:cn-hangzhou:official:layers/Nodejs22/versions/1
10
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
11
+ # dataclaw 公网查询 API(只读,Bearer)—— ext.conf 可覆盖;apikey 走生成 app 的 server/.env,不在这里
12
+ DATACLAW_SERVICE_URL=https://120.48.21.36
16
13
 
17
14
  # FC 资源规格(够跑轻量 extension;按需改)
18
15
  FC_CPU=0.35
@@ -32,12 +32,7 @@ resources:
32
32
  PORT: '9000'
33
33
  APP_ENTRY: __FC_ENTRY__
34
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__
35
+ DATACLAW_API_KEY: __DATACLAW_API_KEY__
41
36
  triggers:
42
37
  - triggerName: httpTrigger
43
38
  triggerType: http
@@ -1,5 +1,6 @@
1
1
  CODE_DIR=./server
2
2
  FC_ENTRY=dist/main.js
3
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=""
4
+ # dataclaw 公网查询 API(只读,Bearer)。apikey 不在这里——走 server/.env 的 DATACLAW_API_KEY,
5
+ # 发布时 pre-deploy.sh 从 server/.env 读出、注入 FC(避免 key 落入随项目走的非密文件)。
6
+ DATACLAW_SERVICE_URL=https://120.48.21.36
@@ -1,15 +1,6 @@
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
1
+ # dataclaw 公网查询 API(只读,Bearer 鉴权;自签证书,后端已放开 TLS 校验)
2
+ DATACLAW_SERVICE_URL=https://120.48.21.36
3
+ # 建页人提供的 dataclaw apikey —— 只填冒号后的 secret 段(如拿到 colleague-a:xxxx 则填 xxxx)。
4
+ # dev 预览由 dotenv 加载本文件;发布 FC 时 pre-deploy.sh 从这里读出、注入 FC 环境变量(.env 不进部署包)。
5
+ DATACLAW_API_KEY=
9
6
  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
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "scripts": {
6
6
  "build": "nest build",
7
- "start": "node --env-file=.env dist/main.js",
7
+ "start": "node dist/main.js",
8
8
  "start:dev": "nest start --watch",
9
9
  "dev": "nest start --watch"
10
10
  },
@@ -12,7 +12,6 @@
12
12
  "@nestjs/common": "^10.4.0",
13
13
  "@nestjs/core": "^10.4.0",
14
14
  "@nestjs/platform-express": "^10.4.0",
15
- "cookie-parser": "^1.4.6",
16
15
  "dotenv": "^16.4.5",
17
16
  "reflect-metadata": "^0.2.2",
18
17
  "rxjs": "^7.8.1"
@@ -20,7 +19,6 @@
20
19
  "devDependencies": {
21
20
  "@nestjs/cli": "^10.4.0",
22
21
  "@nestjs/schematics": "^10.2.0",
23
- "@types/cookie-parser": "^1.4.7",
24
22
  "@types/node": "^22.0.0",
25
23
  "@vitejs/plugin-react": "^4.4.0",
26
24
  "typescript": "^5.5.0",
@@ -1,8 +1,8 @@
1
1
  import { Module } from '@nestjs/common';
2
2
  import { DataclawModule } from './dataclaw/dataclaw.module';
3
3
 
4
- // dataclaw 只读后端 BFF 起手骨架:默认只注册 DataclawModule(schema / query / intent /
5
- // consultants 四个只读端点,直调 dataclaw-service,身份按建页人 union_id 裁剪)。
4
+ // dataclaw 只读后端 BFF 起手骨架:默认只注册 DataclawModule(schema / performance / ppl /
5
+ // consultants / intent 五个只读端点,用建页人提供的 apikey 以 Bearer 调公网 dataclaw service)。
6
6
  // 业务逻辑由 assistant 按用户需求从零加(添 controller/module 进 imports 即可)。
7
7
  //
8
8
  // vite middleware / 静态资源 serve 路径仍由 main.ts 处理(dev mount vite,prod express.static),
@@ -1,7 +1,11 @@
1
1
  import { Injectable, HttpException, OnModuleInit } from '@nestjs/common'
2
2
  import { DataclawConfig, loadDataclawConfig } from './dataclaw.config'
3
3
 
4
- // 端点超时(架构文档 §8.10):schema / flexible-query 走默认;intent-execute 可能跑 LLM 编排,给 90s。
4
+ // dataclaw 公网 service 用自签证书(文档要求 curl -k)。Node fetch(undici) 默认校验 TLS 会握手失败。
5
+ // 本 app 唯一出站目标就是 dataclaw 这一个自签 https,进程级放开 TLS 校验即可(最小改动、零依赖)。
6
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
7
+
8
+ // 端点超时:schema / 固定查询走默认;intent-execute 可能跑 LLM 编排,给 90s。
5
9
  const TIMEOUT_MS = { default: 30_000, intent: 90_000 }
6
10
 
7
11
  @Injectable()
@@ -33,9 +37,8 @@ export class DataclawClient implements OnModuleInit {
33
37
  signal: ctrl.signal,
34
38
  headers: {
35
39
  'Content-Type': 'application/json',
36
- // 身份注入:数据范围按建页人 union_id dataclaw 侧裁剪 + 服务间共享密钥校验。
37
- 'X-User-UnionId': this.cfg.asUnionId,
38
- 'X-Internal-Secret': this.cfg.internalSecret,
40
+ // 鉴权:建页人提供的 dataclaw apikey,Bearer 传给公网 service(有 key 即全量只读查询)。
41
+ Authorization: `Bearer ${this.cfg.apiKey}`,
39
42
  },
40
43
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
41
44
  })
@@ -69,15 +72,22 @@ export class DataclawClient implements OnModuleInit {
69
72
  return this.call<any>('GET', '/api/schema', { query: { dataset } })
70
73
  }
71
74
 
72
- flexibleQuery(body: unknown) {
73
- return this.call<any>('POST', '/api/flexible-query', { body })
75
+ // 业绩查询(必填 startDate/endDate;可按顾问/团队/办公地点等收窄,参数名见 dataclaw 文档)。
76
+ performance(body: unknown) {
77
+ return this.call<any>('POST', '/api/performance', { body })
74
78
  }
75
79
 
76
- intentExecute(body: unknown) {
77
- return this.call<any>('POST', '/api/intent-execute', { body, timeout: TIMEOUT_MS.intent })
80
+ // 候选人进展 pipeline(必填 startDate/endDate)。返回聚合 byStatus/byCustomer。
81
+ ppl(body: unknown) {
82
+ return this.call<any>('POST', '/api/ppl', { body })
78
83
  }
79
84
 
80
85
  consultants(keyword: string) {
81
86
  return this.call<any>('GET', '/api/consultants', { query: { keyword } })
82
87
  }
88
+
89
+ // 结构化意图查询(高级):上游已抽好的意图 JSON(domain+intent+过滤),字段定义见 /api/schema。
90
+ intentExecute(body: unknown) {
91
+ return this.call<any>('POST', '/api/intent-execute', { body, timeout: TIMEOUT_MS.intent })
92
+ }
83
93
  }
@@ -1,18 +1,15 @@
1
1
  export interface DataclawConfig {
2
2
  baseUrl: string
3
- asUnionId: string
4
- internalSecret: string
3
+ apiKey: string
5
4
  }
6
5
 
7
6
  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 }
7
+ const baseUrl = (process.env.DATACLAW_SERVICE_URL || 'https://120.48.21.36').replace(/\/+$/, '')
8
+ // 建页人提供的 dataclaw apikey(Bearer token,只填冒号后的 secret 段)。写进 server/.env,
9
+ // dev dotenv 加载、FC 由 s.yaml 注入。缺失则拒启动(app 没它查不到数)。
10
+ const apiKey = process.env.DATACLAW_API_KEY || ''
11
+ if (!apiKey) {
12
+ throw new Error('[dataclaw] 缺少必需 env DATACLAW_API_KEY(建页人提供的 dataclaw apikey,写进 server/.env)')
13
+ }
14
+ return { baseUrl, apiKey }
18
15
  }
@@ -2,8 +2,7 @@ import { Body, Controller, Get, Post, Query } from '@nestjs/common'
2
2
  import { DataclawClient } from './dataclaw.client'
3
3
 
4
4
  // app 自己的只读 BFF。main.ts 已 setGlobalPrefix('api' 或子路径 'preview/<port>/api'),
5
- // 故这里的路径不带 /api
6
- // 登录门禁由 main.ts 的 Express authGate 中间件统一覆盖(含 /api/*),不再用 Nest guard。
5
+ // 故这里的路径不带 /api。前端只调本 app 的 /api/*,apikey 由后端注入,绝不下发浏览器。
7
6
  @Controller()
8
7
  export class DataclawController {
9
8
  constructor(private readonly dc: DataclawClient) {}
@@ -13,18 +12,23 @@ export class DataclawController {
13
12
  return this.dc.getSchema(dataset)
14
13
  }
15
14
 
16
- @Post('query')
17
- query(@Body() body: unknown) {
18
- return this.dc.flexibleQuery(body)
15
+ @Post('performance')
16
+ performance(@Body() body: unknown) {
17
+ return this.dc.performance(body)
19
18
  }
20
19
 
21
- @Post('intent')
22
- intent(@Body() body: unknown) {
23
- return this.dc.intentExecute(body)
20
+ @Post('ppl')
21
+ ppl(@Body() body: unknown) {
22
+ return this.dc.ppl(body)
24
23
  }
25
24
 
26
25
  @Get('consultants')
27
26
  consultants(@Query('keyword') keyword = '') {
28
27
  return this.dc.consultants(keyword)
29
28
  }
29
+
30
+ @Post('intent')
31
+ intent(@Body() body: unknown) {
32
+ return this.dc.intentExecute(body)
33
+ }
30
34
  }
@@ -1,10 +1,9 @@
1
1
  import './polyfill';
2
+ import 'dotenv/config'; // 加载 server/.env:dev 由 dotenv 读取 DATACLAW_API_KEY;FC 由 s.yaml 注入 env(.env 被 .fcignore 排除不进部署包)
2
3
  import { NestFactory } from '@nestjs/core';
3
4
  import { NestExpressApplication } from '@nestjs/platform-express';
4
5
  import { AppModule } from './app.module';
5
6
  import * as path from 'node:path';
6
- import cookieParser from 'cookie-parser';
7
- import { authGate, loginHandler, callbackHandler } from './auth/gate';
8
7
 
9
8
  // app-builder build 模式(spec 2026-06-01 + 2026-06-02 §5.6.4 子路径 fix):
10
9
  // - dev(通过 clawd 启):nest 单进程 mount vite middleware,前端 HMR + /api 都从
@@ -35,20 +34,8 @@ async function bootstrap() {
35
34
 
36
35
  app.setGlobalPrefix(apiPrefix, { exclude: ['/'] });
37
36
 
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);
37
+ // 无登录门禁:页面打开即用。数据访问鉴权由后端用 DATACLAW_API_KEY(建页人提供的 apikey)注入,
38
+ // dataclaw.client —— key 绝不下发浏览器。
52
39
 
53
40
  if (isDev && isClawdDev) {
54
41
  // dev 模式 + 通过 clawd 启的 → mount vite middleware,root=../../web
@@ -1,9 +1,6 @@
1
- import { useAuthGate } from './auth';
2
1
  import PerformanceQueryPage from './pages/PerformanceQueryPage';
3
2
 
3
+ // 无登录门禁:页面打开即用。数据鉴权由后端用建页人提供的 apikey 注入(见 server/dataclaw)。
4
4
  export default function App() {
5
- const { ready, authed } = useAuthGate();
6
- if (!ready) return <div>加载中…</div>;
7
- if (!authed) return <div>请先飞书登录</div>;
8
5
  return <PerformanceQueryPage />;
9
6
  }
@@ -1,4 +1,4 @@
1
- // 只调 app 自己的 BFF(/api/*),绝不直连 dataclaw —— 密钥/身份都在 server 侧注入,不进浏览器。
1
+ // 只调 app 自己的 BFF(/api/*),绝不直连 dataclaw —— apikey server 侧注入,不进浏览器。
2
2
  //
3
3
  // dev 隧道模式下页面挂在子路径 /preview/<port>/ 下(见 vite.config.js base),
4
4
  // 所以 API_BASE 要拼上 import.meta.env.BASE_URL,否则 fetch 会打到错误的绝对路径。
@@ -14,6 +14,8 @@ async function req(path, opts = {}) {
14
14
  return json;
15
15
  }
16
16
 
17
+ // dataclaw 公网端点(经本 app BFF 转发,见 server/dataclaw.controller)。
17
18
  export const getSchema = (dataset) => req(`/schema?dataset=${encodeURIComponent(dataset || '')}`);
18
- export const runQuery = (body) => req('/query', { method: 'POST', body: JSON.stringify(body) });
19
+ export const queryPerformance = (body) => req('/performance', { method: 'POST', body: JSON.stringify(body) });
20
+ export const queryPpl = (body) => req('/ppl', { method: 'POST', body: JSON.stringify(body) });
19
21
  export const searchConsultants = (kw) => req(`/consultants?keyword=${encodeURIComponent(kw)}`);
@@ -1,7 +1,9 @@
1
- // 结果表格。列取 rows[0] key,数字按 zh-CN 千分位展示,空数组显示「无数据」。
1
+ import { INTERNAL_FIELDS } from '../enums';
2
+
3
+ // 结果表格。列取 rows[0] 的 key(过滤 _provenance 等内部字段),数字按 zh-CN 千分位展示,空数组显示「无数据」。
2
4
  export default function ResultTable({ rows }) {
3
5
  if (!rows || rows.length === 0) return <div className="empty">无数据</div>;
4
- const cols = Object.keys(rows[0]);
6
+ const cols = Object.keys(rows[0]).filter((c) => !INTERNAL_FIELDS.has(c));
5
7
  return (
6
8
  <table className="result-table">
7
9
  <thead>
@@ -1,22 +1,7 @@
1
- // 枚举可选值 / 内部字段 —— 逐字对齐后端 query-filters.js。
2
- //
3
- // 为什么前端要自带这份常量:
4
- // - /api/schema 只给 field.type / field.operators / aliases,**不给枚举候选值**,
5
- // 所以状态类字段的下拉选项只能在前端硬编码(ENUM_HINTS)。
6
- // - describeDatasets 仍会返回 ID / 各级编号 等内部字段,前端要主动过滤掉不渲染
7
- // (INTERNAL_FIELDS)。
8
- // 后端 query-filters.js 改了这里也要同步改。
9
-
10
- export const ENUM_HINTS = {
11
- 候选人状态: ['推荐', '1面', '2面', '3面', '4面', '终面', 'Offer', '已入职', '淘汰'],
12
- 顾问类型: ['正式', '实习', '外部合作顾问', '兼职', '外包', '虚拟账号'],
13
- 顾问在职状态: ['在职', '离职'],
14
- };
15
-
1
+ // 内部字段:dataclaw 返回里不展示给用户的列(编号 / ID / 溯源元数据等)。
2
+ // ResultTable 渲染时按此 Set 过滤掉对应列。
16
3
  export const INTERNAL_FIELDS = new Set([
17
4
  'ID', 'unique_id', '链接', 'SourceID', '发票ID', '客户ID', '职位ID',
18
5
  '业绩所属一级团队编号', '业绩所属二级团队编号', '业绩所属三级团队编号', '所属OP名称', '候选人ID',
6
+ '_provenance', // dataclaw 每条返回都带的溯源元数据(source/record_type/fetched_at/trace_id),不展示
19
7
  ]);
20
-
21
- // 每种字段类型添加筛选时的默认操作符。
22
- export const TYPE_DEFAULT_OP = { string: 'contains', number: 'eq', date: 'eq' };
@@ -1,82 +1,51 @@
1
1
  import { useState } from 'react';
2
- import { runQuery } from '../api';
3
- import SchemaFilters from '../components/SchemaFilters';
2
+ import { queryPerformance } from '../api';
4
3
  import ResultTable from '../components/ResultTable';
5
- import BarChart from '../components/BarChart';
6
4
 
7
- // 数据集:起手页用 performance。agent 按用户需求改成
8
- // performance / consultant_progress / metadata_user 之一即可。
9
- // 数据范围(scope)不在前端选 —— 后端按建页人身份隐式裁剪。
10
- const DATASET = 'performance';
5
+ // 业绩查询起手页:对接 dataclaw 公网 /api/performance(必填 startDate/endDate)。
6
+ // agent 按建页人需求可改成 queryPpl / searchConsultants,或加收窄条件、换展示形态。
7
+ // 数据为只读全量(公网 apikey 即全量),无身份裁剪。
8
+ const DATE_FIELD_LABELS = { billing: 'Billing 业绩', invoice: '开票业绩', payment: '回款业绩' };
11
9
 
12
- // 聚合字段用真名。注意:`业绩所属二级团队` 是真实字段名,
13
- // 「顾问所属N级团队」只是它的 alias,查询 groupBy / aggregations 必须用真名。
14
- const GROUP_FIELD = '业绩所属二级团队';
15
- const METRIC = '业绩';
16
-
17
- // 预置一个 Billing 日期区间示例。
18
- // 提示:PPL 大范围查询很慢,务必带状态/时间区间(如 Billing 日期 between)收敛扫描范围。
19
- const INITIAL_FILTERS = [
20
- { field: 'Billing日期', op: 'between', value: ['2026-01-01', '2026-06-30'] },
21
- ];
22
-
23
- // 结果列名由后端决定(聚合后可能不叫原名),这里挑出展示用的标签列 / 数值列,兜底防错配。
24
- function pickFields(rows, preferredLabel, preferredValue) {
25
- const cols = Object.keys(rows[0]);
26
- const labelField = cols.includes(preferredLabel)
27
- ? preferredLabel
28
- : cols.find((c) => typeof rows[0][c] !== 'number') || cols[0];
29
- const valueField = cols.includes(preferredValue)
30
- ? preferredValue
31
- : cols.find((c) => typeof rows[0][c] === 'number' && c !== labelField) || cols[cols.length - 1];
32
- return { labelField, valueField };
33
- }
10
+ const fmt = (n) => (Number(n) || 0).toLocaleString('zh-CN');
34
11
 
35
12
  export default function PerformanceQueryPage() {
36
- const [filters, setFilters] = useState(INITIAL_FILTERS);
37
- const [groupBy, setGroupBy] = useState(GROUP_FIELD);
38
- const [rows, setRows] = useState([]);
39
- const [total, setTotal] = useState(0);
13
+ const [startDate, setStartDate] = useState('2026-05-01');
14
+ const [endDate, setEndDate] = useState('2026-05-31');
15
+ const [result, setResult] = useState(null);
40
16
  const [loading, setLoading] = useState(false);
41
17
  const [error, setError] = useState('');
42
- const [ran, setRan] = useState(false);
43
18
 
44
19
  const onQuery = async () => {
20
+ if (!startDate || !endDate) { setError('请选择起止日期'); return; }
45
21
  setLoading(true);
46
22
  setError('');
47
23
  try {
48
- const json = await runQuery({
49
- dataset: DATASET,
50
- filters,
51
- groupBy,
52
- aggregations: [{ field: METRIC, op: 'sum' }],
53
- orderBy: { field: METRIC, direction: 'desc' },
54
- limit: 50,
55
- });
56
- setRows(json.data?.rows || []);
57
- setTotal(json.data?.total || 0);
58
- setRan(true);
24
+ setResult(await queryPerformance({ startDate, endDate }));
59
25
  } catch (e) {
60
26
  setError(e.message || '查询失败');
61
- setRows([]);
27
+ setResult(null);
62
28
  } finally {
63
29
  setLoading(false);
64
30
  }
65
31
  };
66
32
 
67
- const picked = rows.length ? pickFields(rows, groupBy, METRIC) : null;
33
+ const byDateField = result?.byDateField || null;
68
34
 
69
35
  return (
70
36
  <div className="page">
71
37
  <h1>业绩查询(示例)</h1>
72
- <p className="hint">数据集:{DATASET}。数据范围由后端按你的身份自动裁剪。</p>
38
+ <p className="hint">数据来自 dataclaw 业绩库,按日期区间查询(只读)。</p>
73
39
 
74
40
  <section className="panel">
75
41
  <label className="group-by">
76
- 分组字段
77
- <input value={groupBy} onChange={(e) => setGroupBy(e.target.value)} />
42
+ 起始日期
43
+ <input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
44
+ </label>
45
+ <label className="group-by">
46
+ 结束日期
47
+ <input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
78
48
  </label>
79
- <SchemaFilters dataset={DATASET} filters={filters} onChange={setFilters} />
80
49
  <button type="button" className="btn-primary" onClick={onQuery} disabled={loading}>
81
50
  {loading ? '查询中…' : '查询'}
82
51
  </button>
@@ -84,17 +53,26 @@ export default function PerformanceQueryPage() {
84
53
 
85
54
  {error && <div className="error">{error}</div>}
86
55
 
87
- {ran && !error && (
56
+ {result && !error && (
88
57
  <section className="results">
89
58
  <div className="results-head">
90
- {total.toLocaleString('zh-CN')} 条,按 {METRIC} 汇总(Top {rows.length}
59
+ {result.queryRange || `${startDate} ~ ${endDate}`} 共 {fmt(result.total)}
91
60
  </div>
92
- {picked && rows.length > 0 && (
93
- <div className="chart-wrap">
94
- <BarChart rows={rows} labelField={picked.labelField} valueField={picked.valueField} />
61
+
62
+ {byDateField && (
63
+ <div className="cards">
64
+ {Object.entries(byDateField).map(([k, v]) => (
65
+ <div className="card" key={k}>
66
+ <div className="card-title">{DATE_FIELD_LABELS[k] || k}({fmt(v?.total)} 条)</div>
67
+ <div className="card-value">{fmt(v?.amount)}</div>
68
+ </div>
69
+ ))}
95
70
  </div>
96
71
  )}
97
- <ResultTable rows={rows} />
72
+
73
+ <div className="chart-wrap" style={{ overflowX: 'auto' }}>
74
+ <ResultTable rows={result.items || []} />
75
+ </div>
98
76
  </section>
99
77
  )}
100
78
  </div>
@@ -1,25 +1,21 @@
1
1
  #!/usr/bin/env bash
2
- # dataclaw 发布前置:校验 persona 密钥、生成/推导运行时变量并 export,供通用占位符替换取用。
3
- # 由 deploy-kit/publish.sh aliyun 就绪后、渲染 s.yaml 前 source(共享 EXT_DIR/APP_NAME/ACC/REGION/PERSONA_KIT 作用域)。
2
+ # dataclaw 发布前置:从生成 app server/.env 读出建页人填的 apikey,export 供 s.yaml 占位符替换。
3
+ # 由 deploy-kit/publish.sh 在渲染 s.yaml 前 source(共享 EXT_DIR/APP_NAME/PERSONA_KIT 等作用域)。
4
4
 
5
- # dataclaw 服务间密钥(现读,不进 git);缺失则拒发布(app 没它查不到数)。
6
- # 密钥在 persona-root .secrets/(example/gitignore 白名单都在这一层),不是 extension-kit/.secrets/。
7
- [ -f "$PERSONA_ROOT/.secrets/dataclaw.env" ] || {
8
- echo "❌ $PERSONA_ROOT/.secrets/dataclaw.env(含 DATACLAW_INTERNAL_SECRET)。参考 $PERSONA_ROOT/.secrets/dataclaw.env.example 填写。" >&2
5
+ # 建页人提供的 dataclaw apikey 写在生成 app 的 server/.env(DATACLAW_API_KEY)。
6
+ # .env server/.fcignore 排除、不进部署包,所以这里读出来、经 s.yaml 注入 FC 环境变量。
7
+ ENV_FILE="$EXT_DIR/server/.env"
8
+ if [ -f "$ENV_FILE" ]; then
9
+ # 只取 DATACLAW_API_KEY 一行、剥掉可能的引号;不 source 整个 .env(避免污染其它变量)。
10
+ # || true:publish.sh 跑在 set -euo pipefail 下,grep 无命中(有 .env 但缺 key 行)会让管道返回非 0
11
+ # → 不加会静默死在这行、走不到下面的 fail-loud 友好报错。统一收敛到下方空值检查。
12
+ DATACLAW_API_KEY="$(grep -E '^DATACLAW_API_KEY=' "$ENV_FILE" | head -1 | cut -d= -f2- | sed -e 's/^["'\'']//' -e 's/["'\'']$//' || true)"
13
+ fi
14
+ if [ -z "${DATACLAW_API_KEY:-}" ]; then
15
+ echo "❌ 缺 DATACLAW_API_KEY:请在 $ENV_FILE 填入建页人的 dataclaw apikey(只填冒号后的 secret 段)。" >&2
9
16
  exit 1
10
- }
11
- set -a; . "$PERSONA_ROOT/.secrets/dataclaw.env"; set +a
12
-
13
- # SESSION_SECRET:每个 app 一份、跨重部署稳定(否则重发后所有人 session 失效)。
14
- # 首次发布生成并持久化到 ext.conf;之后 source ext.conf 已带上。
15
- if [ -z "${SESSION_SECRET:-}" ]; then
16
- SESSION_SECRET="$(openssl rand -hex 32)"
17
- echo "SESSION_SECRET=$SESSION_SECRET" >> "$EXT_DIR/ext.conf"
18
17
  fi
19
- export SESSION_SECRET
20
-
21
- # APP_BASE_URL:飞书登录回调用。fc3 自定义域名由 APP_NAME+ACC+REGION 推导,用 https(cookie secure 要求)。
22
- export APP_BASE_URL="https://${APP_NAME}.fcv3.${ACC}.${REGION}.fc.devsapp.net"
18
+ export DATACLAW_API_KEY
23
19
 
24
- # 可选占位符显式兜底(未填时通用扫描需「已定义」才放行)
25
- export DATACLAW_AS_UNION_ID="${DATACLAW_AS_UNION_ID:-}"
20
+ # service url:config.env / ext.conf 已 source 进来;兜底公网地址。
21
+ export DATACLAW_SERVICE_URL="${DATACLAW_SERVICE_URL:-https://120.48.21.36}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawos-dev/clawd",
3
- "version": "0.2.143-beta.302.5df5cc1",
3
+ "version": "0.2.143-beta.303.ab28945",
4
4
  "description": "Standalone clawd daemon — Claude Code (and future Codex) session server over WebSocket",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,3 +0,0 @@
1
- # dataclaw 服务间共享密钥(= dataclaw-service 的 SEND_AS_BOT_SHARED_SECRET)。
2
- # 复制为同目录 dataclaw.env 并填实际值;dataclaw.env 不进 git(见 .gitignore)。
3
- DATACLAW_INTERNAL_SECRET=replace-me
@@ -1,70 +0,0 @@
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
- }
@@ -1,52 +0,0 @@
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
- }
@@ -1,23 +0,0 @@
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
- }
@@ -1,7 +0,0 @@
1
- import { useEffect, useState } from 'react';
2
- // 门禁 hook 占位:真实飞书登录态检测在 PR-6。返回 { ready, authed }。
3
- export function useAuthGate() {
4
- const [state, setState] = useState({ ready: false, authed: false });
5
- useEffect(() => { setState({ ready: true, authed: true }); }, []); // 占位:默认已登录
6
- return state;
7
- }
@@ -1,79 +0,0 @@
1
- import { ENUM_HINTS } from '../enums';
2
-
3
- // 操作符中文标签(仅展示用,没命中就回退显示原始 code)。
4
- const OP_LABELS = {
5
- eq: '等于', ne: '不等于', contains: '包含',
6
- gt: '大于', gte: '大于等于', lt: '小于', lte: '小于等于',
7
- between: '区间', in: '属于', notin: '不属于',
8
- };
9
-
10
- // 单条筛选。受控:props { field(来自 /api/schema 的字段元数据), value:{field,op,value}, onChange, onRemove }。
11
- // 控件选择规则:
12
- // - op === 'between' → 两个输入框
13
- // - op === 'in' / 'notin' → 单输入框,逗号分隔(值为数组)
14
- // - ENUM_HINTS 命中该字段 → 下拉,选项来自 ENUM_HINTS[sourceField]
15
- // - 否则按 field.type → string=text / number=number / date=date
16
- // 操作符下拉选项直接来自 field.operators。
17
- export default function FilterRow({ field, value, onChange, onRemove }) {
18
- const type = field.type || 'string';
19
- const enumValues = ENUM_HINTS[field.sourceField];
20
- const ops = field.operators && field.operators.length ? field.operators : [value.op].filter(Boolean);
21
- const inputType = type === 'number' ? 'number' : type === 'date' ? 'date' : 'text';
22
- const label = (field.aliases && field.aliases[0]) || field.sourceField;
23
-
24
- const coerce = (raw) => (type === 'number' && raw !== '' ? Number(raw) : raw);
25
- const set = (patch) => onChange({ ...value, ...patch });
26
-
27
- // 切操作符时 value 形态可能要换(标量 / 二元区间 / 数组),统一重置避免形态错配。
28
- const onOpChange = (e) => {
29
- const op = e.target.value;
30
- const next = op === 'between' ? ['', ''] : op === 'in' || op === 'notin' ? [] : '';
31
- set({ op, value: next });
32
- };
33
-
34
- const renderValueControl = () => {
35
- if (value.op === 'between') {
36
- const v = Array.isArray(value.value) ? value.value : ['', ''];
37
- return (
38
- <span className="filter-between">
39
- <input type={inputType} value={v[0] ?? ''} onChange={(e) => set({ value: [coerce(e.target.value), v[1] ?? ''] })} />
40
- <span className="sep">~</span>
41
- <input type={inputType} value={v[1] ?? ''} onChange={(e) => set({ value: [v[0] ?? '', coerce(e.target.value)] })} />
42
- </span>
43
- );
44
- }
45
- if (value.op === 'in' || value.op === 'notin') {
46
- const text = Array.isArray(value.value) ? value.value.join(',') : value.value ?? '';
47
- return (
48
- <input
49
- className="filter-value"
50
- placeholder="多个值用逗号分隔"
51
- value={text}
52
- onChange={(e) => set({ value: e.target.value.split(',').map((s) => coerce(s.trim())).filter((s) => s !== '') })}
53
- />
54
- );
55
- }
56
- if (enumValues) {
57
- return (
58
- <select className="filter-value" value={value.value ?? ''} onChange={(e) => set({ value: e.target.value })}>
59
- <option value="">请选择</option>
60
- {enumValues.map((opt) => <option key={opt} value={opt}>{opt}</option>)}
61
- </select>
62
- );
63
- }
64
- return (
65
- <input className="filter-value" type={inputType} value={value.value ?? ''} onChange={(e) => set({ value: coerce(e.target.value) })} />
66
- );
67
- };
68
-
69
- return (
70
- <div className="filter-row">
71
- <span className="filter-label">{label}</span>
72
- <select className="filter-op" value={value.op} onChange={onOpChange}>
73
- {ops.map((op) => <option key={op} value={op}>{OP_LABELS[op] || op}</option>)}
74
- </select>
75
- {renderValueControl()}
76
- <button type="button" className="filter-remove" onClick={onRemove}>删除</button>
77
- </div>
78
- );
79
- }
@@ -1,93 +0,0 @@
1
- import { useEffect, useState } from 'react';
2
- import { getSchema } from '../api';
3
- import { INTERNAL_FIELDS, ENUM_HINTS, TYPE_DEFAULT_OP } from '../enums';
4
- import FilterRow from './FilterRow';
5
-
6
- // 给 schema 没覆盖到的字段(如预置的 Billing日期)按字段名兜底猜类型。
7
- function guessType(sourceField) {
8
- if (/日期|时间|date|time/i.test(sourceField)) return 'date';
9
- return 'string';
10
- }
11
-
12
- const FALLBACK_OPS = {
13
- string: ['contains', 'eq', 'ne', 'in', 'notin'],
14
- number: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'between', 'in', 'notin'],
15
- date: ['eq', 'gt', 'gte', 'lt', 'lte', 'between'],
16
- };
17
-
18
- function initValue(op) {
19
- if (op === 'between') return ['', ''];
20
- if (op === 'in' || op === 'notin') return [];
21
- return '';
22
- }
23
-
24
- // 受控筛选区。拉 /api/schema 拿字段元数据,过滤掉 INTERNAL_FIELDS,
25
- // 渲染已选 filters(FilterRow)+ 「+ 添加筛选字段」下拉。
26
- // props { dataset, filters, onChange }。
27
- export default function SchemaFilters({ dataset, filters, onChange }) {
28
- const [fields, setFields] = useState([]);
29
- const [loading, setLoading] = useState(true);
30
- const [error, setError] = useState('');
31
-
32
- useEffect(() => {
33
- let alive = true;
34
- setLoading(true);
35
- setError('');
36
- getSchema(dataset)
37
- .then((json) => {
38
- if (!alive) return;
39
- const data = json.data;
40
- // 传了 dataset 时 data 可能是单对象;否则是数组。
41
- const entry = Array.isArray(data) ? data.find((d) => d.dataset === dataset) : data;
42
- const fs = (entry?.fields || []).filter((f) => !INTERNAL_FIELDS.has(f.sourceField));
43
- setFields(fs);
44
- })
45
- .catch((e) => { if (alive) setError(e.message || '加载 schema 失败'); })
46
- .finally(() => { if (alive) setLoading(false); });
47
- return () => { alive = false; };
48
- }, [dataset]);
49
-
50
- // 取字段元数据:schema 里找得到用真的,找不到(预置 filter / 未加载)给兜底。
51
- const metaFor = (sourceField) => {
52
- const found = fields.find((f) => f.sourceField === sourceField);
53
- if (found) return found;
54
- const type = ENUM_HINTS[sourceField] ? 'string' : guessType(sourceField);
55
- return { sourceField, type, operators: FALLBACK_OPS[type], aliases: [] };
56
- };
57
-
58
- const update = (i, next) => onChange(filters.map((f, idx) => (idx === i ? next : f)));
59
- const remove = (i) => onChange(filters.filter((_, idx) => idx !== i));
60
- const add = (e) => {
61
- const sourceField = e.target.value;
62
- e.target.value = '';
63
- if (!sourceField) return;
64
- const meta = metaFor(sourceField);
65
- const wanted = TYPE_DEFAULT_OP[meta.type];
66
- const op = wanted && meta.operators?.includes(wanted) ? wanted : meta.operators?.[0] || 'eq';
67
- onChange([...filters, { field: sourceField, op, value: initValue(op) }]);
68
- };
69
-
70
- if (error) return <div className="error">筛选项加载失败:{error}</div>;
71
-
72
- return (
73
- <div className="schema-filters">
74
- {filters.map((f, i) => (
75
- <FilterRow
76
- key={i}
77
- field={metaFor(f.field)}
78
- value={f}
79
- onChange={(next) => update(i, next)}
80
- onRemove={() => remove(i)}
81
- />
82
- ))}
83
- <select className="filter-add" value="" onChange={add} disabled={loading}>
84
- <option value="">{loading ? '加载字段中…' : '+ 添加筛选字段'}</option>
85
- {fields.map((f) => (
86
- <option key={f.sourceField} value={f.sourceField}>
87
- {(f.aliases && f.aliases[0]) || f.sourceField}
88
- </option>
89
- ))}
90
- </select>
91
- </div>
92
- );
93
- }