Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a118ad407 | ||
|
|
ea5d28304e | ||
|
|
57767c77a1 | ||
|
|
9ac3b46430 | ||
|
|
2dbe4cf675 | ||
|
|
7c84ff3b8d | ||
|
|
ffae7e380f |
@@ -517,8 +517,12 @@ Query:
|
|||||||
|
|
||||||
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
|
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
|
||||||
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
|
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
|
||||||
|
附件可挂在 `kind` 为 `task / ticket / knowledge` 的事项上(`ALLOWED_KINDS`),知识与任务、工单共用同一套接口;
|
||||||
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
|
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
|
||||||
|
|
||||||
|
新建知识时条目还不存在,前端用「懒建草稿」:点上传(或往正文里贴图)时先 `POST /knowledge` 存一条草稿拿到 `item_id`,
|
||||||
|
再上传到 `/items/{item_id}/attachments`,之后的保存一律走 `PATCH /knowledge/{item_id}`。没传过附件就取消的人,库里不会留下空条目。
|
||||||
|
|
||||||
### 6.2 图片直链:短期签名,而不是把令牌写进正文
|
### 6.2 图片直链:短期签名,而不是把令牌写进正文
|
||||||
|
|
||||||
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
|
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# 部署(生产 · openclaw-01)
|
||||||
|
|
||||||
|
> 适用于当前唯一的线上环境:`openclaw-01`(192.168.8.188),访问入口 `http://192.168.8.188:8200`。
|
||||||
|
> 本地开发见 [`DEVELOPMENT.md`](DEVELOPMENT.md)。部署铁律见仓库根 [`AGENTS.md`](../AGENTS.md) 第 2 节。
|
||||||
|
|
||||||
|
## 1. 生产拓扑
|
||||||
|
|
||||||
|
```
|
||||||
|
浏览器 ──HTTP──▶ openclaw-01:8200
|
||||||
|
└ gunicorn(2 × UvicornWorker) ── app.main:app
|
||||||
|
├ /api/v1/** 业务 API
|
||||||
|
└ / 前端产物 web/dist(单页应用,404 回退 index.html)
|
||||||
|
│
|
||||||
|
┌────────────────────────┼────────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
fnsvr-01:8432 PG 17.6 fnsvr-01:6379 Redis 本机 backend/var/uploads
|
||||||
|
库 dadaotask_prod db 6(独立库号) 附件文件
|
||||||
|
角色 dadao_prod_app(RLS)
|
||||||
|
角色 dadao_prod_owner(迁移)
|
||||||
|
```
|
||||||
|
|
||||||
|
为什么是这个形状:
|
||||||
|
|
||||||
|
- **生产库和开发库在同一个 PG 实例**(fnsvr-01),所以生产用**独立的库 + 独立的角色**。复用 `dadao_app` 等于拿到开发口令就能连生产。迁移里的 `GRANT`/`DEFAULT PRIVILEGES` 按 `APP_DB_ROLE` / `OWNER_DB_ROLE` 生成,靠 `backend/app/roles.py` 注入。
|
||||||
|
- **Redis 用 6 号库**隔离:共享实例没有密码,靠库号避免和别的项目撞键(验证码、幂等键)。
|
||||||
|
- **没有 nginx**:openclaw 账号无 sudo 密码,改不了系统 nginx,所以后端单端口同时托管 API 与前端产物(同源,免 CORS)。root 愿意配合时按 `deploy/prod/nginx.dadaotask.conf.example` 加反代,不用改应用。
|
||||||
|
- **用户级 systemd**:openclaw-01 上所有服务(easymemo、portal-site…)都是 `systemctl --user` 起的,本方案沿用,全程不需要 root。
|
||||||
|
|
||||||
|
## 2. 首次部署(一次性)
|
||||||
|
|
||||||
|
前置:能 SSH 到 openclaw-01;fnsvr-01 的 root 权限(只在建库这一步需要)。
|
||||||
|
|
||||||
|
### 2.1 建生产库与角色(在 fnsvr-01 上,root)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 口令自己生成,别用示例里的
|
||||||
|
OWNER_PW='<dadao_prod_owner 的口令>' APP_PW='<dadao_prod_app 的口令>' \
|
||||||
|
bash deploy/prod/provision-db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
会创建:库 `dadaotask_prod`(owner `dadao_prod_owner`)、角色 `dadao_prod_owner` / `dadao_prod_app`(`NOSUPERUSER NOBYPASSRLS`,否则 RLS 会被跳过)、`public` schema 归 owner、`REVOKE CREATE ... FROM PUBLIC`。建表与 RLS 策略由 alembic 负责(2.4)。
|
||||||
|
|
||||||
|
### 2.2 代码落地(在 openclaw-01 上,openclaw 身份)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --branch main --single-branch http://gitea.tatta.cn/Tatta/DadaoTask.git ~/app/dadaotask
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 配置(不进仓库)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/dadaotask && chmod 700 ~/.config/dadaotask
|
||||||
|
cp ~/app/dadaotask/deploy/prod/env.production.example ~/.config/dadaotask/env.production
|
||||||
|
chmod 600 ~/.config/dadaotask/env.production
|
||||||
|
vim ~/.config/dadaotask/env.production # 替换全部 __CHANGE_ME__
|
||||||
|
```
|
||||||
|
|
||||||
|
必填项:`DATABASE_URL` / `MIGRATION_DATABASE_URL`(2.1 的口令)、`JWT_SECRET`(≥32 位随机串)、`REDIS_URL`、`WEB_DIST_DIR`、`UPLOAD_DIR`。
|
||||||
|
|
||||||
|
**当前状态是内测模式**:短信通道还没接(非 dev provider 直接 501),所以 `SMS_PROVIDER=dev` + `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`。启动日志会打 WARNING。**只允许内网访问**;短信通道接好后删掉 `INTERNAL_TEST_LOGIN` 并把 `SMS_PROVIDER` 换成真实供应商。
|
||||||
|
|
||||||
|
### 2.4 装服务单元
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
cp ~/app/dadaotask/deploy/prod/dadaotask-api.service ~/.config/systemd/user/
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 首次上线
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/app/dadaotask/deploy/prod/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 日常发布(每次上线)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本地:提交并 push 到 main(生产只跑仓库 HEAD)
|
||||||
|
# 服务器:
|
||||||
|
bash ~/app/dadaotask/deploy/prod/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本做六件事,任何一步失败即中止:代码对齐 `origin/main`(`git reset --hard`,禁止服务器手工改)→ 装 Python 依赖 → `alembic upgrade head`(用迁移角色)→ **在服务器上** `npm ci && npm run build` → 写 `env.build` 并重启 → 校验 `/health` 与 `/version` 的 commit 等于 HEAD。
|
||||||
|
|
||||||
|
前端产物只在服务器上构建,**不允许**本地上传 dist(AGENTS.md 铁律 3)。
|
||||||
|
|
||||||
|
## 4. 部署后校验
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://192.168.8.188:8200/health # {"status":"ok","db":true,...}
|
||||||
|
curl -s http://192.168.8.188:8200/version # commit 必须等于 git rev-parse --short HEAD
|
||||||
|
curl -sI http://192.168.8.188:8200/ | head -1 # 200,text/html(前端产物)
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器验收:打开 `http://192.168.8.188:8200` → 手机号任意 + 验证码 `123456` 登录 → 任务/工单/知识各点一遍。
|
||||||
|
|
||||||
|
## 5. 运维
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user status dadaotask-api # 状态
|
||||||
|
systemctl --user restart dadaotask-api # 重启
|
||||||
|
journalctl --user -u dadaotask-api -f # 日志(gunicorn 输出到 stdout)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 附件落盘:`backend/var/uploads`(按工作区分子目录),删库不删文件,备份时一起打包
|
||||||
|
- 回滚:`cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构
|
||||||
|
- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致)
|
||||||
|
|
||||||
|
## 6. 已知限制(当前阶段)
|
||||||
|
|
||||||
|
| 限制 | 影响 | 解除方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输 | 域名 + nginx + 证书(需 root) |
|
||||||
|
| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 `INTERNAL_TEST_LOGIN` |
|
||||||
|
| 无 pgvector | 语义检索走 `real[]` 全表扫描,数据量大了会慢 | 上独立 pgvector 实例(`deploy/docker-compose.vector.yml`) |
|
||||||
|
| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 `backend/var/uploads`,或将来到对象存储 |
|
||||||
|
| 共享 PG / Redis | 别人占满连接或内存会波及本站 | 独立实例 |
|
||||||
@@ -292,7 +292,8 @@
|
|||||||
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
|
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
|
||||||
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 |
|
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 |
|
||||||
| 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force` |
|
| 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force` |
|
||||||
| 3.12 | 备忘录形态:新建知识一次写全(标题/类型/分类/标签/正文),不再是"填个标题就完成";抽屉加宽 + 可整屏 | 1.0 | [x] Web `KnowledgeComposer` + 安卓新建后直进编辑器;正文就是详情同款富文本编辑器(Vditor / Compose 编辑器),未保存前不能贴图;`Sheet` 增 `size="md/lg/xl"` 与展开按钮;截图 `2.Design/screens/knowledge-composer.png`、`knowledge-categories.png` |
|
| 3.12 | 备忘录形态:新建知识一次写全(标题/类型/分类/标签/正文),不再是"填个标题就完成";抽屉加宽 + 可整屏 | 1.0 | [x] Web `KnowledgeComposer` + 安卓新建后直进编辑器;正文就是详情同款富文本编辑器(Vditor / Compose 编辑器);`Sheet` 增 `size="md/lg/xl"` 与展开按钮;截图 `2.Design/screens/knowledge-composer.png`、`knowledge-categories.png` |
|
||||||
|
| 3.13 | 知识能挂附件:新建面板里直接上传(懒建草稿拿到条目 id 再传),详情里能看、能下载/打开 | 1.0 | [x] Web `AttachmentPanel` + `RichTextEditor.ensureItemId`(贴图/传附件前自动存草稿);安卓详情页附件列表 + `OpenDocument` 上传 + 浏览器打开;附件仍落在 `item` 上(`ALLOWED_KINDS` 含 knowledge) |
|
||||||
|
|
||||||
### M3 完成定义(DOD)
|
### M3 完成定义(DOD)
|
||||||
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
|
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -6,6 +6,7 @@
|
|||||||
- M0–M1 实施清单(含真实进度):[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md)
|
- M0–M1 实施清单(含真实进度):[`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md)
|
||||||
- 接口约定(已实现部分):[`2.Design/API.md`](2.Design/API.md)
|
- 接口约定(已实现部分):[`2.Design/API.md`](2.Design/API.md)
|
||||||
- 本地开发:[`2.Design/DEVELOPMENT.md`](2.Design/DEVELOPMENT.md)
|
- 本地开发:[`2.Design/DEVELOPMENT.md`](2.Design/DEVELOPMENT.md)
|
||||||
|
- 生产部署(openclaw-01):[`2.Design/DEPLOY.md`](2.Design/DEPLOY.md)
|
||||||
- 中文命名方案:[`2.Design/NAMING.md`](2.Design/NAMING.md)
|
- 中文命名方案:[`2.Design/NAMING.md`](2.Design/NAMING.md)
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package com.dadao.task.ui
|
package com.dadao.task.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -34,16 +40,20 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import com.dadao.task.data.Api
|
import com.dadao.task.data.Api
|
||||||
import com.dadao.task.data.ApiException
|
import com.dadao.task.data.ApiException
|
||||||
|
import com.dadao.task.data.Attachment
|
||||||
import com.dadao.task.data.KnowledgeCategory
|
import com.dadao.task.data.KnowledgeCategory
|
||||||
import com.dadao.task.data.KnowledgeDoc
|
import com.dadao.task.data.KnowledgeDoc
|
||||||
import com.dadao.task.data.Workspace
|
import com.dadao.task.data.Workspace
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private val knowledgeTabs = listOf(
|
private val knowledgeTabs = listOf(
|
||||||
"draft" to "待核对",
|
"draft" to "待核对",
|
||||||
@@ -459,12 +469,47 @@ private fun KnowledgeDetailDialog(
|
|||||||
onEdit: (EditRequest) -> Unit,
|
onEdit: (EditRequest) -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
var action by remember { mutableStateOf(doc.status) }
|
var action by remember { mutableStateOf(doc.status) }
|
||||||
var version by remember { mutableStateOf(doc.version) }
|
var version by remember { mutableStateOf(doc.version) }
|
||||||
var categoryId by remember { mutableStateOf(doc.category?.id) }
|
var categoryId by remember { mutableStateOf(doc.category?.id) }
|
||||||
var tags by remember { mutableStateOf(doc.tags.joinToString(" ") { it.name }) }
|
var tags by remember { mutableStateOf(doc.tags.joinToString(" ") { it.name }) }
|
||||||
var savingMeta by remember { mutableStateOf(false) }
|
var savingMeta by remember { mutableStateOf(false) }
|
||||||
var metaNotice by remember { mutableStateOf<String?>(null) }
|
var metaNotice by remember { mutableStateOf<String?>(null) }
|
||||||
|
/**
|
||||||
|
* 附件:备忘录不只文字,手册、日志、表格都要能挂上来。
|
||||||
|
* 附件必须落在条目上,所以这里只在已有的知识详情里传(新建时由正文里的编辑器负责)。
|
||||||
|
*/
|
||||||
|
var attachments by remember(doc.id) { mutableStateOf(doc.attachments) }
|
||||||
|
var uploading by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(doc.id) {
|
||||||
|
runCatching { api.attachments(workspace.id, doc.id) }.onSuccess { attachments = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri == null) return@rememberLauncherForActivityResult
|
||||||
|
scope.launch {
|
||||||
|
uploading = true
|
||||||
|
try {
|
||||||
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
}
|
||||||
|
if (bytes == null) {
|
||||||
|
onFail(IllegalStateException("读不到这个文件,换个来源试试"))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val name = attachmentName(context, uri) ?: "file-${System.currentTimeMillis()}"
|
||||||
|
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||||
|
val created = api.uploadAttachment(workspace.id, doc.id, name, mime, bytes)
|
||||||
|
attachments = attachments + created
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onFail(e)
|
||||||
|
} finally {
|
||||||
|
uploading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val actions = when (action) {
|
val actions = when (action) {
|
||||||
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
|
"draft" -> listOf("submit_review" to "提交审议", "publish" to "发布", "archive" to "归档")
|
||||||
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
|
"in_review" -> listOf("withdraw" to "撤回审议", "publish" to "发布", "archive" to "归档")
|
||||||
@@ -498,6 +543,50 @@ private fun KnowledgeDetailDialog(
|
|||||||
color = InkSoft,
|
color = InkSoft,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
SectionLabel("附件")
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
if (attachments.isEmpty()) "还没有附件" else "${attachments.size} 个",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
TextButton(
|
||||||
|
enabled = !uploading,
|
||||||
|
onClick = { picker.launch(arrayOf("*/*")) },
|
||||||
|
) { Text(if (uploading) "上传中…" else "上传附件") }
|
||||||
|
}
|
||||||
|
attachments.forEach { row ->
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
row.filename,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = Ink,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
humanSize(row.size),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = InkMuted,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
// raw_url 是带短时签名的相对路径,拼上当前后端地址才能在浏览器里打开
|
||||||
|
val url = row.rawUrl?.let { api.currentOrigin() + it } ?: return@TextButton
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||||
|
}.onFailure { onFail(it) }
|
||||||
|
},
|
||||||
|
) { Text("打开") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
|
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
|
||||||
if (action != "published") {
|
if (action != "published") {
|
||||||
Spacer(Modifier.height(14.dp))
|
Spacer(Modifier.height(14.dp))
|
||||||
@@ -613,3 +702,20 @@ private fun KnowledgeDetailDialog(
|
|||||||
containerColor = Paper,
|
containerColor = Paper,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 附件列表里显示的大小:人与手机都不需要精确到字节。 */
|
||||||
|
private fun humanSize(bytes: Long): String = when {
|
||||||
|
bytes < 1024 -> "$bytes B"
|
||||||
|
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||||
|
else -> String.format("%.1f MB", bytes / 1024.0 / 1024.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 content:// 里问出文件名:微信、文件管理器给的 uri 形态各不相同,能问就问。 */
|
||||||
|
private fun attachmentName(context: android.content.Context, uri: Uri): String? =
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||||
|
?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: uri.lastPathSegment?.substringAfterLast('/')
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,20 @@ JWT_SECRET=dev-only-change-me
|
|||||||
ACCESS_TOKEN_MINUTES=30
|
ACCESS_TOKEN_MINUTES=30
|
||||||
REFRESH_TOKEN_DAYS=14
|
REFRESH_TOKEN_DAYS=14
|
||||||
|
|
||||||
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败
|
# 生产环境禁止 SMS_PROVIDER=dev(否则任意验证码可登录),启动即失败;
|
||||||
|
# 内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true(启动会打 WARNING)
|
||||||
SMS_PROVIDER=dev
|
SMS_PROVIDER=dev
|
||||||
SMS_DEV_CODE=123456
|
SMS_DEV_CODE=123456
|
||||||
|
INTERNAL_TEST_LOGIN=false
|
||||||
# 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭
|
# 同一手机号两次发码的最小间隔(秒),测试里置 0 可关闭
|
||||||
SMS_THROTTLE_SECONDS=60
|
SMS_THROTTLE_SECONDS=60
|
||||||
|
|
||||||
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
|
CORS_ORIGINS=http://localhost:5180,http://127.0.0.1:5180
|
||||||
|
|
||||||
|
# 生产单端口部署:后端直接托管前端构建产物(同源,可留空 CORS)
|
||||||
|
# 留空则只提供 API;部署方式见 2.Design/DEPLOY.md
|
||||||
|
# WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist
|
||||||
|
|
||||||
# ── 知识库向量库(独立的 PG + pgvector,M3 起用)──────────────────────
|
# ── 知识库向量库(独立的 PG + pgvector,M3 起用)──────────────────────
|
||||||
# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。
|
# 单独实例,不动 fnsvr-01:那台没有 pgvector,且向量检索吃内存,不该和业务库挤。
|
||||||
# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。
|
# 起法见 deploy/docker-compose.vector.yml。不配则 M3 的语义检索能力直接不可用。
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import sqlalchemy as sa
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE, OWNER_ROLE
|
||||||
|
|
||||||
revision: str = "0001"
|
revision: str = "0001"
|
||||||
down_revision: str | None = None
|
down_revision: str | None = None
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
APP_ROLE = "dadao_app"
|
|
||||||
|
|
||||||
# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
|
# 说明:asyncpg 走"预备语句"执行,**一条 execute 里只能有一条 SQL**,
|
||||||
# 多条语句会报 `cannot insert multiple commands into a prepared statement`。
|
# 多条语句会报 `cannot insert multiple commands into a prepared statement`。
|
||||||
# 所以这里按语句拆成元组,逐条执行。
|
# 所以这里按语句拆成元组,逐条执行。
|
||||||
@@ -76,7 +76,7 @@ POLICY_STATEMENTS: tuple[str, ...] = (
|
|||||||
f"workspace, app_user, member, invitation, audit_log TO {APP_ROLE};"
|
f"workspace, app_user, member, invitation, audit_log TO {APP_ROLE};"
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"ALTER DEFAULT PRIVILEGES FOR ROLE dadao_owner IN SCHEMA public "
|
f"ALTER DEFAULT PRIVILEGES FOR ROLE {OWNER_ROLE} IN SCHEMA public "
|
||||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};"
|
f"GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {APP_ROLE};"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ import sqlalchemy as sa
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
revision: str = "0002"
|
revision: str = "0002"
|
||||||
down_revision: str | None = "0001"
|
down_revision: str | None = "0001"
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
APP_ROLE = "dadao_app"
|
|
||||||
|
|
||||||
TENANT_TABLES_0002: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
|
TENANT_TABLES_0002: tuple[str, ...] = ("item", "tag", "item_tag", "comment", "activity")
|
||||||
|
|
||||||
_TIMESTAMP_COLUMNS = (
|
_TIMESTAMP_COLUMNS = (
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ import sqlalchemy as sa
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
revision: str = "0003"
|
revision: str = "0003"
|
||||||
down_revision: str | None = "0002"
|
down_revision: str | None = "0002"
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
APP_ROLE = "dadao_app"
|
|
||||||
|
|
||||||
TENANT_TABLES_0003: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
|
TENANT_TABLES_0003: tuple[str, ...] = ("ticket_ext", "item_link", "notification")
|
||||||
|
|
||||||
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ import sqlalchemy as sa
|
|||||||
from alembic import op
|
from alembic import op
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
revision: str = "0004"
|
revision: str = "0004"
|
||||||
down_revision: str | None = "0003"
|
down_revision: str | None = "0003"
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
APP_ROLE = "dadao_app"
|
|
||||||
|
|
||||||
TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",)
|
TENANT_TABLES_0004: tuple[str, ...] = ("knowledge_doc",)
|
||||||
|
|
||||||
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
TASK_STATUSES = ("inbox", "todo", "doing", "done", "blocked", "canceled")
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
revision = "0005"
|
revision = "0005"
|
||||||
down_revision = "0004"
|
down_revision = "0004"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
@@ -55,7 +57,7 @@ def upgrade() -> None:
|
|||||||
"ALTER TABLE attachment ENABLE ROW LEVEL SECURITY",
|
"ALTER TABLE attachment ENABLE ROW LEVEL SECURITY",
|
||||||
"CREATE POLICY attachment_isolation ON attachment "
|
"CREATE POLICY attachment_isolation ON attachment "
|
||||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||||
"GRANT SELECT, INSERT, UPDATE, DELETE ON attachment TO dadao_app",
|
f"GRANT SELECT, INSERT, UPDATE, DELETE ON attachment TO {APP_ROLE}",
|
||||||
):
|
):
|
||||||
op.execute(statement)
|
op.execute(statement)
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from collections.abc import Sequence
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
revision: str = "0006"
|
revision: str = "0006"
|
||||||
down_revision: str = "0005"
|
down_revision: str = "0005"
|
||||||
branch_labels: Sequence[str] | None = None
|
branch_labels: Sequence[str] | None = None
|
||||||
@@ -82,7 +84,7 @@ POLICY_SQL = (
|
|||||||
"ALTER TABLE chunk ENABLE ROW LEVEL SECURITY",
|
"ALTER TABLE chunk ENABLE ROW LEVEL SECURITY",
|
||||||
"CREATE POLICY chunk_isolation ON chunk "
|
"CREATE POLICY chunk_isolation ON chunk "
|
||||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||||
"GRANT SELECT, INSERT, UPDATE, DELETE ON chunk TO dadao_app",
|
f"GRANT SELECT, INSERT, UPDATE, DELETE ON chunk TO {APP_ROLE}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from collections.abc import Sequence
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
from app.roles import APP_ROLE
|
||||||
|
|
||||||
revision: str = "0007"
|
revision: str = "0007"
|
||||||
down_revision: str = "0006"
|
down_revision: str = "0006"
|
||||||
branch_labels: Sequence[str] | None = None
|
branch_labels: Sequence[str] | None = None
|
||||||
@@ -60,7 +62,7 @@ POLICY_SQL = (
|
|||||||
"ALTER TABLE knowledge_category ENABLE ROW LEVEL SECURITY",
|
"ALTER TABLE knowledge_category ENABLE ROW LEVEL SECURITY",
|
||||||
"CREATE POLICY knowledge_category_isolation ON knowledge_category "
|
"CREATE POLICY knowledge_category_isolation ON knowledge_category "
|
||||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||||
"GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_category TO dadao_app",
|
f"GRANT SELECT, INSERT, UPDATE, DELETE ON knowledge_category TO {APP_ROLE}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -645,20 +645,19 @@ async def patch_knowledge(
|
|||||||
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
|
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
|
||||||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
||||||
|
|
||||||
|
# 这里只放 Item 表自己的列:doc_type / summary 属于 knowledge_doc,
|
||||||
|
# 混进来会直接 CompileError: Unconsumed column names
|
||||||
values: dict[str, Any] = {}
|
values: dict[str, Any] = {}
|
||||||
if body.title is not None:
|
if body.title is not None:
|
||||||
values["title"] = body.title.strip()
|
values["title"] = body.title.strip()
|
||||||
if body.body_md is not None:
|
if body.body_md is not None:
|
||||||
values["body_md"] = body.body_md
|
values["body_md"] = body.body_md
|
||||||
if body.summary is not None:
|
|
||||||
values["summary"] = body.summary
|
|
||||||
if body.doc_type is not None:
|
|
||||||
values["doc_type"] = body.doc_type
|
|
||||||
|
|
||||||
# 分类/标签也改的是这条知识的"样子",所以同样要推进 version:
|
# 分类/标签/类型也改的是这条知识的"样子",所以同样要推进 version:
|
||||||
# 否则只改了标签的知识,下一次保存还会拿着旧 version 撞 4009。
|
# 否则只改了标签的知识,下一次保存还会拿着旧 version 撞 4009。
|
||||||
|
doc_changed = body.doc_type is not None or body.summary is not None
|
||||||
meta_changed = body.clear_category or body.category_id is not None or body.tag_names is not None
|
meta_changed = body.clear_category or body.category_id is not None or body.tag_names is not None
|
||||||
if values or meta_changed:
|
if values or doc_changed or meta_changed:
|
||||||
values["updated_at"] = datetime.now(timezone.utc)
|
values["updated_at"] = datetime.now(timezone.utc)
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
update(Item)
|
update(Item)
|
||||||
|
|||||||
+13
-2
@@ -57,6 +57,9 @@ class Settings(BaseSettings):
|
|||||||
sms_throttle_seconds: int = 60
|
sms_throttle_seconds: int = 60
|
||||||
sms_dev_code: str = "123456"
|
sms_dev_code: str = "123456"
|
||||||
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
|
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
|
||||||
|
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
|
||||||
|
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
|
||||||
|
internal_test_login: bool = False
|
||||||
|
|
||||||
# ── 幂等 ──────────────────────────────────────────────
|
# ── 幂等 ──────────────────────────────────────────────
|
||||||
idempotency_enabled: bool = True
|
idempotency_enabled: bool = True
|
||||||
@@ -100,6 +103,11 @@ class Settings(BaseSettings):
|
|||||||
embedding_dim: int = 1024
|
embedding_dim: int = 1024
|
||||||
embedding_model: str = "BAAI/bge-m3"
|
embedding_model: str = "BAAI/bge-m3"
|
||||||
|
|
||||||
|
# ── 前端产物 ──────────────────────────────────────────
|
||||||
|
# 生产没有 nginx 时由后端直接托管前端构建产物(同源,/api 无跨域)。
|
||||||
|
# 指向 web/dist 的绝对路径;留空则只提供 API。
|
||||||
|
web_dist_dir: str | None = None
|
||||||
|
|
||||||
# ── 构建标记(部署校验用)──────────────────────────────
|
# ── 构建标记(部署校验用)──────────────────────────────
|
||||||
build_commit: str = "unknown"
|
build_commit: str = "unknown"
|
||||||
build_time: str = "unknown"
|
build_time: str = "unknown"
|
||||||
@@ -121,8 +129,11 @@ class Settings(BaseSettings):
|
|||||||
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
||||||
if "dadao_dev_pw" in self.database_url:
|
if "dadao_dev_pw" in self.database_url:
|
||||||
problems.append("DATABASE_URL 仍在用开发默认口令")
|
problems.append("DATABASE_URL 仍在用开发默认口令")
|
||||||
if self.sms_provider == "dev":
|
if self.sms_provider == "dev" and not self.internal_test_login:
|
||||||
problems.append("生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录)")
|
problems.append(
|
||||||
|
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
|
||||||
|
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
|
||||||
|
)
|
||||||
if problems:
|
if problems:
|
||||||
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
@@ -11,6 +12,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
from starlette.responses import Response
|
||||||
|
from starlette.staticfiles import StaticFiles
|
||||||
|
from starlette.types import Scope
|
||||||
|
|
||||||
from .api import attachments, auth, items, knowledge, tickets, workspaces
|
from .api import attachments, auth, items, knowledge, tickets, workspaces
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
@@ -27,6 +31,11 @@ logger = get_logger("app")
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings.validate_runtime()
|
settings.validate_runtime()
|
||||||
|
if settings.env == "prod" and settings.internal_test_login:
|
||||||
|
logger.warning(
|
||||||
|
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
|
||||||
|
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"服务启动",
|
"服务启动",
|
||||||
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
|
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
|
||||||
@@ -138,3 +147,22 @@ async def version() -> dict:
|
|||||||
"api_prefix": settings.api_prefix,
|
"api_prefix": settings.api_prefix,
|
||||||
"db_role": role,
|
"db_role": role,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 前端产物:单端口部署(没有 nginx 时也让一个端口提供完整站点)────────
|
||||||
|
# 顺序很重要:必须挂在所有 API 路由之后,否则挂载 "/" 会吞掉 /api、/health、/version。
|
||||||
|
class _SPAStaticFiles(StaticFiles):
|
||||||
|
"""静态命中就返回,未命中回退 index.html——前端路由(如 /w/<id>)交给浏览器处理。"""
|
||||||
|
|
||||||
|
async def get_response(self, path: str, scope: Scope) -> Response:
|
||||||
|
try:
|
||||||
|
return await super().get_response(path, scope)
|
||||||
|
except StarletteHTTPException as exc:
|
||||||
|
if exc.status_code == 404:
|
||||||
|
return await super().get_response("index.html", scope)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
_web_dist = Path(settings.web_dist_dir) if settings.web_dist_dir else None
|
||||||
|
if _web_dist is not None and (_web_dist / "index.html").is_file():
|
||||||
|
app.mount("/", _SPAStaticFiles(directory=str(_web_dist), html=True), name="web")
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""迁移里用到的数据库角色名。
|
||||||
|
|
||||||
|
为什么要有这个模块:RLS 的隔离强度取决于「应用角色不是表 owner、没有 BYPASSRLS」。
|
||||||
|
开发和生产的库在同一个 PG 实例上(fnsvr-01:8432),如果两边共用 `dadao_app` 角色,
|
||||||
|
拿到开发口令的人就能直连生产库——所以生产用独立角色,由迁移时的环境变量注入。
|
||||||
|
|
||||||
|
默认值就是开发/测试的角色名,所以本地和 CI 行为不变。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
# PostgreSQL 标识符:小写字母/数字/下划线,且不能以数字开头
|
||||||
|
_ROLE_RE = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _role_from_env(env_name: str, default: str) -> str:
|
||||||
|
value = os.getenv(env_name, default)
|
||||||
|
if not _ROLE_RE.match(value):
|
||||||
|
raise RuntimeError(f"{env_name} 不是合法的 PostgreSQL 角色名:{value!r}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# 迁移角色(owner,可建表,绕过 RLS)与应用角色(只能读写数据,受 RLS 约束)
|
||||||
|
OWNER_ROLE = _role_from_env("OWNER_DB_ROLE", "dadao_owner")
|
||||||
|
APP_ROLE = _role_from_env("APP_DB_ROLE", "dadao_app")
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
|
# 生产进程管理:gunicorn + UvicornWorker(开发用 `uvicorn --reload` 即可)
|
||||||
|
gunicorn==23.0.0
|
||||||
sqlalchemy[asyncio]==2.0.36
|
sqlalchemy[asyncio]==2.0.36
|
||||||
asyncpg==0.30.0
|
asyncpg==0.30.0
|
||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""生产配置校验:固定验证码必须显式开关,否则启动即失败。
|
||||||
|
|
||||||
|
这两条是「部署纪律」的守门员:生产一旦误用 dev 验证码,等于任意手机号可登录。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
_STRONG_SECRET = "x" * 48
|
||||||
|
_PROD_DB = "postgresql+asyncpg://dadao_prod_app:pw@192.168.8.160:8432/dadaotask_prod"
|
||||||
|
|
||||||
|
|
||||||
|
def _prod(**overrides: object) -> Settings:
|
||||||
|
values: dict[str, object] = {
|
||||||
|
"env": "prod",
|
||||||
|
"jwt_secret": _STRONG_SECRET,
|
||||||
|
"database_url": _PROD_DB,
|
||||||
|
"sms_provider": "aliyun",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return Settings(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prod_rejects_dev_sms_without_switch() -> None:
|
||||||
|
with pytest.raises(RuntimeError, match="SMS_PROVIDER=dev"):
|
||||||
|
_prod(sms_provider="dev").validate_runtime()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prod_allows_dev_sms_with_internal_test_login() -> None:
|
||||||
|
# 内测模式:显式开关后放行,启动时 main.lifespan 会打 WARNING
|
||||||
|
_prod(sms_provider="dev", internal_test_login=True).validate_runtime()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prod_still_rejects_weak_jwt_secret() -> None:
|
||||||
|
with pytest.raises(RuntimeError, match="JWT_SECRET"):
|
||||||
|
_prod(jwt_secret="short").validate_runtime()
|
||||||
@@ -278,3 +278,28 @@ async def test_patch_knowledge_moves_and_clears_category(client: AsyncClient) ->
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert cleared["category"] is None
|
assert cleared["category"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_patch_knowledge_accepts_doc_type_and_bumps_version(client: AsyncClient) -> None:
|
||||||
|
"""PATCH 只该写 items 自己的列:doc_type/summary 混进去会 CompileError(线上 500)。"""
|
||||||
|
user, ws_id = await _ws(client)
|
||||||
|
doc = await _note(client, user, ws_id, "先写结论再写过程", body="原始正文")
|
||||||
|
|
||||||
|
patched = unwrap(
|
||||||
|
await client.patch(
|
||||||
|
f"{V1}/workspaces/{ws_id}/knowledge/{doc['id']}",
|
||||||
|
json={
|
||||||
|
"version": doc["version"],
|
||||||
|
"title": "先写结论再写过程",
|
||||||
|
"body_md": "改过的正文",
|
||||||
|
"doc_type": "howto",
|
||||||
|
"tag_names": ["写法"],
|
||||||
|
},
|
||||||
|
headers=user["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert patched["knowledge"]["doc_type"] == "howto"
|
||||||
|
assert patched["body_md"] == "改过的正文"
|
||||||
|
assert patched["version"] == doc["version"] + 1
|
||||||
|
assert {t["name"] for t in patched["tags"]} == {"写法"}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 达道Task 生产服务(用户级 systemd,openclaw-01 上不需要 root)
|
||||||
|
#
|
||||||
|
# 安装:
|
||||||
|
# cp deploy/prod/dadaotask-api.service ~/.config/systemd/user/
|
||||||
|
# systemctl --user daemon-reload
|
||||||
|
# systemctl --user enable --now dadaotask-api
|
||||||
|
#
|
||||||
|
# 两份 EnvironmentFile:
|
||||||
|
# env.production —— 真实配置与口令(chmod 600,不进仓库)
|
||||||
|
# env.build —— 由 deploy.sh 每次部署重写(BUILD_COMMIT / BUILD_TIME),供 /version 校验
|
||||||
|
[Unit]
|
||||||
|
Description=DadaoTask API (prod, gunicorn + uvicorn workers)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/home/openclaw/app/dadaotask/backend
|
||||||
|
EnvironmentFile=/home/openclaw/.config/dadaotask/env.production
|
||||||
|
EnvironmentFile=/home/openclaw/.config/dadaotask/env.build
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/home/openclaw/app/dadaotask/.venv/bin/gunicorn app.main:app \
|
||||||
|
-k uvicorn.workers.UvicornWorker -w 2 \
|
||||||
|
-b 0.0.0.0:8200 \
|
||||||
|
--timeout 60 --graceful-timeout 30 --keep-alive 5 \
|
||||||
|
--access-logfile - --error-logfile -
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
KillSignal=SIGTERM
|
||||||
|
TimeoutStopSec=40
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 生产部署脚本 —— openclaw-01(用户级 systemd,全程不需要 root)
|
||||||
|
#
|
||||||
|
# 遵守 AGENTS.md 的部署铁律:
|
||||||
|
# 1. 生产只跑仓库 HEAD(git reset --hard origin/main,禁止服务器手工改)
|
||||||
|
# 2. 前端产物只在服务器上由 HEAD 构建(npm ci && npm run build)
|
||||||
|
# 3. 部署后校验 /version 的 commit == git rev-parse --short HEAD
|
||||||
|
#
|
||||||
|
# 用法(在 openclaw-01 上,openclaw 身份):
|
||||||
|
# bash ~/app/dadaotask/deploy/prod/deploy.sh
|
||||||
|
# 首次部署前准备好 ~/.config/dadaotask/env.production(见 env.production.example)。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_DIR=${APP_DIR:-/home/openclaw/app/dadaotask}
|
||||||
|
CONF_DIR=${CONF_DIR:-$HOME/.config/dadaotask}
|
||||||
|
ENV_FILE=$CONF_DIR/env.production
|
||||||
|
SERVICE=${SERVICE:-dadaotask-api}
|
||||||
|
PORT=${PORT:-8200}
|
||||||
|
REPO_URL=${REPO_URL:-https://gitea.tatta.cn/Tatta/DadaoTask.git}
|
||||||
|
# 服务器直连 pypi/npm 官方源很慢(实测 npm 包几百 KB 就能卡住),默认走国内镜像
|
||||||
|
NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmmirror.com}
|
||||||
|
|
||||||
|
step() { printf '\n== %s ==\n' "$*"; }
|
||||||
|
die() { printf '!! %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
step "0. 前置检查"
|
||||||
|
test -f "$ENV_FILE" || die "缺少 $ENV_FILE(参考 deploy/prod/env.production.example)"
|
||||||
|
|
||||||
|
if ! command -v node >/dev/null; then die "node 未安装(前端必须在服务器上构建)"; fi
|
||||||
|
node_major=$(node -v | sed 's/^v\([0-9]*\).*/\1/')
|
||||||
|
[ "$node_major" -ge 20 ] || die "node 版本过低:$(node -v),需要 20+"
|
||||||
|
|
||||||
|
step "1. 代码对齐仓库 HEAD"
|
||||||
|
if [ ! -d "$APP_DIR/.git" ]; then
|
||||||
|
git clone --branch main --single-branch "$REPO_URL" "$APP_DIR"
|
||||||
|
fi
|
||||||
|
cd "$APP_DIR"
|
||||||
|
git fetch --prune origin
|
||||||
|
git checkout -q main
|
||||||
|
git reset --hard -q origin/main # 生产不允许领先/落后仓库,也不允许脏改动
|
||||||
|
COMMIT=$(git rev-parse HEAD)
|
||||||
|
SHORT=$(git rev-parse --short HEAD)
|
||||||
|
echo " HEAD=$COMMIT ($SHORT)"
|
||||||
|
|
||||||
|
step "2. Python 依赖"
|
||||||
|
# openclaw-01 是 Debian/Ubuntu 系但没装 python3-venv(ensurepip 缺失),也没有 sudo;
|
||||||
|
# 所以先建 --without-pip 的 venv,再用系统 pip 把 pip 装进去。镜像可用 env 覆盖。
|
||||||
|
PIP_INDEX=${PIP_INDEX:-https://mirrors.aliyun.com/pypi/simple/}
|
||||||
|
if ! [ -x .venv/bin/python ] || ! .venv/bin/python -m pip --version >/dev/null 2>&1; then
|
||||||
|
rm -rf .venv
|
||||||
|
python3 -m venv --without-pip .venv
|
||||||
|
python3 -m pip --python .venv/bin/python install -q --index-url "$PIP_INDEX" pip setuptools wheel
|
||||||
|
fi
|
||||||
|
.venv/bin/python -m pip install -q --index-url "$PIP_INDEX" --upgrade -r backend/requirements.txt
|
||||||
|
|
||||||
|
step "3. 数据库迁移(迁移角色,非应用角色)"
|
||||||
|
set -a; . "$ENV_FILE"; set +a
|
||||||
|
( cd backend && ../.venv/bin/alembic upgrade head )
|
||||||
|
|
||||||
|
step "4. 前端构建(只在服务器上、只用 HEAD 的源码)"
|
||||||
|
( cd web && npm ci --no-audit --no-fund --registry="$NPM_REGISTRY" && npm run build )
|
||||||
|
test -f web/dist/index.html || die "前端构建产物缺失:web/dist/index.html"
|
||||||
|
|
||||||
|
step "5. 写入构建标记并重启服务"
|
||||||
|
mkdir -p "$CONF_DIR" backend/var/uploads
|
||||||
|
chmod 700 "$CONF_DIR"
|
||||||
|
printf 'BUILD_COMMIT=%s\nBUILD_TIME=%s\n' "$SHORT" "$(date -Iseconds)" >"$CONF_DIR/env.build"
|
||||||
|
chmod 600 "$CONF_DIR/env.build" "$ENV_FILE"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable "$SERVICE" >/dev/null 2>&1 || true
|
||||||
|
systemctl --user restart "$SERVICE"
|
||||||
|
|
||||||
|
step "6. 校验(/health + /version 的 commit)"
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
HEALTH=$(curl -fsS "http://127.0.0.1:$PORT/health") || die "健康检查失败:服务没起来(journalctl --user -u $SERVICE -n 100)"
|
||||||
|
COMMIT_HTTP=$(curl -fsS "http://127.0.0.1:$PORT/version" | .venv/bin/python -c 'import json,sys; print(json.load(sys.stdin)["commit"])') \
|
||||||
|
|| die "/version 读取失败"
|
||||||
|
echo " health=$HEALTH"
|
||||||
|
echo " version.commit=$COMMIT_HTTP"
|
||||||
|
[ "$COMMIT_HTTP" = "$SHORT" ] || die "/version 的 commit($COMMIT_HTTP) 与 HEAD($SHORT) 不一致"
|
||||||
|
|
||||||
|
printf '\n部署完成:http://%s:%s (commit %s)\n' "$(hostname -I | awk '{print $1}')" "$PORT" "$SHORT"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 生产环境变量模板(部署到 openclaw-01)
|
||||||
|
# 用法:复制到 ~/.config/dadaotask/env.production(chmod 600),把 __CHANGE_ME__ 换成真实口令。
|
||||||
|
# 真实口令不进仓库;本文件只描述字段。
|
||||||
|
ENV=prod
|
||||||
|
APP_NAME=达道Task
|
||||||
|
|
||||||
|
# ── 数据库:fnsvr-01 共享 PG 上**独立的库 + 独立的角色** ──────────────
|
||||||
|
# 生产不复用 dev 的 dadao_app/dadao_owner 角色:同集群同角色 = 拿到开发口令即可连生产。
|
||||||
|
# 角色名必须在迁移前注入(迁移里的 GRANT 按它生成)。
|
||||||
|
OWNER_DB_ROLE=dadao_prod_owner
|
||||||
|
APP_DB_ROLE=dadao_prod_app
|
||||||
|
DATABASE_URL=postgresql+asyncpg://dadao_prod_app:__APP_PW__@192.168.8.160:8432/dadaotask_prod
|
||||||
|
MIGRATION_DATABASE_URL=postgresql+asyncpg://dadao_prod_owner:__OWNER_PW__@192.168.8.160:8432/dadaotask_prod
|
||||||
|
|
||||||
|
# ── Redis:fnsvr-01 共享实例,独立 db 号隔离(不使用默认 0 号库)──────
|
||||||
|
REDIS_URL=redis://192.168.8.160:6379/6
|
||||||
|
|
||||||
|
# ── 认证 ─────────────────────────────────────────────────────────────
|
||||||
|
# 生产校验:长度 ≥ 32 且不是开发默认值
|
||||||
|
JWT_SECRET=__CHANGE_ME_32_CHARS_OR_MORE__
|
||||||
|
ACCESS_TOKEN_MINUTES=30
|
||||||
|
REFRESH_TOKEN_DAYS=14
|
||||||
|
|
||||||
|
# 内测登录:短信通道还没接(非 dev provider 直接 501),先把固定验证码显式打开。
|
||||||
|
# 只允许内网访问;短信通道接好后把这两行改成 SMS_PROVIDER=<供应商> 并删掉开关。
|
||||||
|
INTERNAL_TEST_LOGIN=true
|
||||||
|
SMS_PROVIDER=dev
|
||||||
|
SMS_DEV_CODE=123456
|
||||||
|
SMS_THROTTLE_SECONDS=60
|
||||||
|
|
||||||
|
# 同源部署(后端直接托管 web/dist),CORS 只留本机调试用
|
||||||
|
CORS_ORIGINS=http://127.0.0.1:8200,http://192.168.8.188:8200
|
||||||
|
|
||||||
|
# ── 前端产物与上传目录(绝对路径)─────────────────────────────────────
|
||||||
|
WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist
|
||||||
|
UPLOAD_DIR=/home/openclaw/app/dadaotask/backend/var/uploads
|
||||||
|
MAX_UPLOAD_MB=20
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 给 root 用的 nginx 反代(当前没启用:openclaw 账号无 sudo,先跑 8200 单端口)
|
||||||
|
#
|
||||||
|
# 装法(root):
|
||||||
|
# cp deploy/prod/nginx.dadaotask.conf.example /etc/nginx/sites-available/dadaotask
|
||||||
|
# ln -s /etc/nginx/sites-available/dadaotask /etc/nginx/sites-enabled/dadaotask
|
||||||
|
# nginx -t && systemctl reload nginx
|
||||||
|
#
|
||||||
|
# 说明:后端已直接在 8200 上同时托管 API 和前端产物(web/dist),
|
||||||
|
# 所以这里只做一层透传,不需要单独的静态站点 root。
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name _; # 有域名后改成真实域名
|
||||||
|
|
||||||
|
client_max_body_size 25m; # 附件上限 20MB,留余量
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8200;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 120s; # 知识检索/上传偶发慢请求
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 在 fnsvr-01 的共享 PostgreSQL 上给「生产」建独立的库与角色。
|
||||||
|
#
|
||||||
|
# 为什么独立角色:生产库和开发库在同一个实例(fnsvr-01:8432)。复用 dadao_app
|
||||||
|
# 等于拿到开发口令就能连生产;所以生产用 dadao_prod_owner / dadao_prod_app,
|
||||||
|
# 迁移里的 GRANT 与 DEFAULT PRIVILEGES 会按 APP_DB_ROLE / OWNER_DB_ROLE 生成。
|
||||||
|
#
|
||||||
|
# 在 fnsvr-01 上以 root 执行(需要 docker 权限):
|
||||||
|
# OWNER_PW='...' APP_PW='...' bash deploy/prod/provision-db.sh
|
||||||
|
#
|
||||||
|
# 幂等:角色或库已存在时跳过创建(不会重置已有口令)。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CONTAINER=${CONTAINER:-1Panel-postgresql-wIKz}
|
||||||
|
DB=${DB:-dadaotask_prod}
|
||||||
|
OWNER_ROLE=${OWNER_ROLE:-dadao_prod_owner}
|
||||||
|
APP_ROLE=${APP_ROLE:-dadao_prod_app}
|
||||||
|
: "${OWNER_PW:?需要 OWNER_PW 环境变量(迁移角色口令)}"
|
||||||
|
: "${APP_PW:?需要 APP_PW 环境变量(应用角色口令)}"
|
||||||
|
|
||||||
|
echo "== 建角色与库:$DB(owner=$OWNER_ROLE app=$APP_ROLE)=="
|
||||||
|
docker exec -i "$CONTAINER" psql -U dbadmin -d postgres -v ON_ERROR_STOP=1 -q \
|
||||||
|
-v owner_role="$OWNER_ROLE" -v owner_pw="$OWNER_PW" \
|
||||||
|
-v app_role="$APP_ROLE" -v app_pw="$APP_PW" -v db="$DB" <<'SQL'
|
||||||
|
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'owner_role', :'owner_pw')
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'owner_role')\gexec
|
||||||
|
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'app_role', :'app_pw')
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'app_role')\gexec
|
||||||
|
-- 应用角色绝不能是 superuser / bypassrls,否则 RLS 直接被跳过
|
||||||
|
SELECT format('ALTER ROLE %I NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE', :'app_role')\gexec
|
||||||
|
SELECT format('ALTER ROLE %I NOSUPERUSER NOBYPASSRLS', :'owner_role')\gexec
|
||||||
|
SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'owner_role')
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db')\gexec
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "== 库内权限(schema 归 owner,app 只能读写数据)=="
|
||||||
|
docker exec -i "$CONTAINER" psql -U dbadmin -d "$DB" -v ON_ERROR_STOP=1 -q \
|
||||||
|
-v owner_role="$OWNER_ROLE" -v app_role="$APP_ROLE" <<'SQL'
|
||||||
|
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||||
|
SELECT format('ALTER SCHEMA public OWNER TO %I', :'owner_role')\gexec
|
||||||
|
SELECT format('GRANT CONNECT ON DATABASE %I TO %I', current_database(), :'app_role')\gexec
|
||||||
|
SELECT format('GRANT USAGE ON SCHEMA public TO %I', :'app_role')\gexec
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "== 自检 =="
|
||||||
|
docker exec -i "$CONTAINER" psql -U dbadmin -d "$DB" -tAc \
|
||||||
|
"select current_database(), (select rolname from pg_roles where rolname='$APP_ROLE'),
|
||||||
|
(select rolsuper from pg_roles where rolname='$APP_ROLE'),
|
||||||
|
(select rolbypassrls from pg_roles where rolname='$APP_ROLE')"
|
||||||
|
echo "完成。注意:建表与 RLS 由 alembic(deploy.sh 第 3 步)负责。"
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
* 前面造数据走接口,界面部分点真实按钮。
|
* 前面造数据走接口,界面部分点真实按钮。
|
||||||
*/
|
*/
|
||||||
import { chromium } from "playwright-core";
|
import { chromium } from "playwright-core";
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
|
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
|
||||||
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||||
@@ -98,6 +99,28 @@ try {
|
|||||||
(await composerBody.getAttribute("aria-label")) === "正文",
|
(await composerBody.getAttribute("aria-label")) === "正文",
|
||||||
);
|
);
|
||||||
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-editor.png" });
|
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-editor.png" });
|
||||||
|
|
||||||
|
// ②½ 附件:新建面板里就能传——点「添加附件」先把这条存成草稿,再上传
|
||||||
|
mkdirSync("e2e/artifacts", { recursive: true });
|
||||||
|
const samplePath = "e2e/artifacts/附件样例.txt";
|
||||||
|
writeFileSync(samplePath, "命令行回滚手册:kubectl rollout undo deployment/api\n");
|
||||||
|
await page.getByRole("button", { name: "添加附件", exact: true }).click();
|
||||||
|
// 面板出现后再走真实的「上传」按钮 + filechooser:页面上不止一个 file input(编辑器也有)
|
||||||
|
await page.getByRole("button", { name: "上传", exact: true }).waitFor({ timeout: 15_000 });
|
||||||
|
const [chooser] = await Promise.all([
|
||||||
|
page.waitForEvent("filechooser", { timeout: 15_000 }),
|
||||||
|
page.getByRole("button", { name: "上传", exact: true }).click(),
|
||||||
|
]);
|
||||||
|
const [uploadResp] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(r) => r.url().includes("/attachments") && r.request().method() === "POST",
|
||||||
|
{ timeout: 20_000 },
|
||||||
|
),
|
||||||
|
chooser.setFiles(samplePath),
|
||||||
|
]);
|
||||||
|
await page.getByText("附件样例.txt", { exact: false }).first().waitFor({ timeout: 10_000 });
|
||||||
|
check("新建面板里能上传附件(先懒建草稿)", uploadResp.ok(), `HTTP ${uploadResp.status()}`);
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-attachment.png" });
|
||||||
await page.getByRole("button", { name: "保存并继续写", exact: true }).click();
|
await page.getByRole("button", { name: "保存并继续写", exact: true }).click();
|
||||||
|
|
||||||
// 保存后直接打开详情:标题、分类路径、标签都在
|
// 保存后直接打开详情:标题、分类路径、标签都在
|
||||||
@@ -107,6 +130,7 @@ try {
|
|||||||
"新建面板能把分类与标签一起存下来",
|
"新建面板能把分类与标签一起存下来",
|
||||||
drawerText.includes("研发 / 部署") && drawerText.includes("发布"),
|
drawerText.includes("研发 / 部署") && drawerText.includes("发布"),
|
||||||
);
|
);
|
||||||
|
check("详情抽屉里能看到刚传的附件", drawerText.includes("附件样例.txt"));
|
||||||
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false });
|
await page.screenshot({ path: "e2e/artifacts/knowledge-composer-saved.png", fullPage: false });
|
||||||
|
|
||||||
// ③ 列表卡片上能看到分类路径与标签(不用点开)
|
// ③ 列表卡片上能看到分类路径与标签(不用点开)
|
||||||
@@ -188,7 +212,17 @@ try {
|
|||||||
doc?.category?.full_path === "研发 / 部署",
|
doc?.category?.full_path === "研发 / 部署",
|
||||||
`tags=${JSON.stringify((doc?.tags ?? []).map((t) => t.name))} category=${doc?.category?.full_path}`,
|
`tags=${JSON.stringify((doc?.tags ?? []).map((t) => t.name))} category=${doc?.category?.full_path}`,
|
||||||
);
|
);
|
||||||
check("正文按 Markdown 存下来了", String(doc?.body_md ?? "").includes("停掉灰度流量"));
|
check(
|
||||||
|
"正文按 Markdown 存下来了",
|
||||||
|
String(doc?.body_md ?? "").includes("停掉灰度流量"),
|
||||||
|
);
|
||||||
|
// 列表只带 attachment_count(省一次 N+1),文件清单要问详情
|
||||||
|
const detail = await call("GET", `/workspaces/${ws.id}/knowledge/${doc.id}`, undefined, token);
|
||||||
|
check(
|
||||||
|
"附件挂在这条知识上(详情回读得到)",
|
||||||
|
(detail?.attachments ?? []).some((a) => a.filename === "附件样例.txt"),
|
||||||
|
`attachments=${JSON.stringify((detail?.attachments ?? []).map((a) => a.filename))}`,
|
||||||
|
);
|
||||||
|
|
||||||
// ⑥ 分类重名与三级上限:接口层面确认约束还在
|
// ⑥ 分类重名与三级上限:接口层面确认约束还在
|
||||||
const dup = await fetch(
|
const dup = await fetch(
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ interface Props {
|
|||||||
wsId: string;
|
wsId: string;
|
||||||
/** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */
|
/** 新建场景还没有条目 id:正文照样能写,只是暂时不能贴图(贴图前必须先存草稿) */
|
||||||
itemId?: string;
|
itemId?: string;
|
||||||
|
/**
|
||||||
|
* 新建场景的兜底:要贴图时先老老实实把草稿存下来,拿到条目 id 再传。
|
||||||
|
* 返回 null 表示没存成(由调用方自己提示),编辑器不会继续上传。
|
||||||
|
*/
|
||||||
|
ensureItemId?: () => Promise<string | null>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** 编辑器初始高度(px) */
|
/** 编辑器初始高度(px) */
|
||||||
@@ -46,6 +51,7 @@ export function RichTextEditor({
|
|||||||
onChange,
|
onChange,
|
||||||
wsId,
|
wsId,
|
||||||
itemId,
|
itemId,
|
||||||
|
ensureItemId,
|
||||||
placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版",
|
placeholder = "写点什么…支持粘贴/拖入图片,也可以用工具栏排版",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
minHeight = 240,
|
minHeight = 240,
|
||||||
@@ -62,6 +68,7 @@ export function RichTextEditor({
|
|||||||
const onErrorRef = useRef(onError);
|
const onErrorRef = useRef(onError);
|
||||||
const resourcesRef = useRef(resources);
|
const resourcesRef = useRef(resources);
|
||||||
const onUploadedRef = useRef(onUploaded);
|
const onUploadedRef = useRef(onUploaded);
|
||||||
|
const ensureItemIdRef = useRef(ensureItemId);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
@@ -70,6 +77,7 @@ export function RichTextEditor({
|
|||||||
onErrorRef.current = onError;
|
onErrorRef.current = onError;
|
||||||
resourcesRef.current = resources;
|
resourcesRef.current = resources;
|
||||||
onUploadedRef.current = onUploaded;
|
onUploadedRef.current = onUploaded;
|
||||||
|
ensureItemIdRef.current = ensureItemId;
|
||||||
const disabledRef = useRef(disabled);
|
const disabledRef = useRef(disabled);
|
||||||
disabledRef.current = disabled;
|
disabledRef.current = disabled;
|
||||||
|
|
||||||
@@ -113,8 +121,8 @@ export function RichTextEditor({
|
|||||||
"line",
|
"line",
|
||||||
"code",
|
"code",
|
||||||
"inline-code",
|
"inline-code",
|
||||||
// 没有条目 id 就没有上传落点,索性不摆这个按钮,免得点了才报错
|
// 没有落点就不摆这个按钮,免得点了才报错;新建时靠 ensureItemId 先存草稿
|
||||||
...(itemId ? ["upload"] : []),
|
...(itemId || ensureItemId ? ["upload"] : []),
|
||||||
"table",
|
"table",
|
||||||
"|",
|
"|",
|
||||||
"undo",
|
"undo",
|
||||||
@@ -131,9 +139,18 @@ export function RichTextEditor({
|
|||||||
accept: "image/*",
|
accept: "image/*",
|
||||||
multiple: true,
|
multiple: true,
|
||||||
handler: async (files: File[]) => {
|
handler: async (files: File[]) => {
|
||||||
if (!itemId) {
|
// 新建时还没有条目 id:先让调用方把草稿存下来,拿到落点再传
|
||||||
onErrorRef.current?.("先保存这条内容,再回来贴图或传附件");
|
let targetId = itemId;
|
||||||
return null;
|
if (!targetId) {
|
||||||
|
const ensured = await ensureItemIdRef.current?.();
|
||||||
|
if (!ensured) {
|
||||||
|
// 存草稿失败的原因由 ensureItemId 自己报,这里不叠一层噪音
|
||||||
|
if (!ensureItemIdRef.current) {
|
||||||
|
onErrorRef.current?.("先保存这条内容,再回来贴图或传附件");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
targetId = ensured;
|
||||||
}
|
}
|
||||||
const images = files.filter((file) => file.type.startsWith("image/"));
|
const images = files.filter((file) => file.type.startsWith("image/"));
|
||||||
if (images.length === 0) {
|
if (images.length === 0) {
|
||||||
@@ -145,7 +162,7 @@ export function RichTextEditor({
|
|||||||
try {
|
try {
|
||||||
for (const file of images) {
|
for (const file of images) {
|
||||||
const created = await uploadFile<Attachment>(
|
const created = await uploadFile<Attachment>(
|
||||||
`/workspaces/${wsId}/items/${itemId}/attachments`,
|
`/workspaces/${wsId}/items/${targetId}/attachments`,
|
||||||
file,
|
file,
|
||||||
);
|
);
|
||||||
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
|
// 自己插入,不用 Vditor 代劳:它对 handler 的返回值只会
|
||||||
@@ -219,7 +236,9 @@ export function RichTextEditor({
|
|||||||
? "正在上传图片…"
|
? "正在上传图片…"
|
||||||
: itemId
|
: itemId
|
||||||
? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"
|
? "支持直接粘贴或拖入图片;内容按 Markdown 保存,模型和导出都读得懂"
|
||||||
: "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
|
: ensureItemId
|
||||||
|
? "支持直接粘贴或拖入图片:会先自动存成草稿,再传上去"
|
||||||
|
: "内容按 Markdown 保存;存下来之后就能直接贴图、传附件"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useParams } from "react-router-dom";
|
|||||||
import { AppShell } from "../components/AppShell";
|
import { AppShell } from "../components/AppShell";
|
||||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||||
import { RichTextEditor } from "../components/RichTextEditor";
|
import { RichTextEditor } from "../components/RichTextEditor";
|
||||||
import { Link2, Plus, Search, Sparkle } from "../components/icons";
|
import { Link2, Paperclip, Plus, Search, Sparkle } from "../components/icons";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
@@ -566,6 +566,16 @@ function KnowledgeComposer({
|
|||||||
const [newCat, setNewCat] = useState("");
|
const [newCat, setNewCat] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
/**
|
||||||
|
* 附件与图片都要落在具体条目上,可新建时条目还不存在。
|
||||||
|
* 解法是懒建草稿:第一次传附件/贴图时才 POST 一条草稿,之后一律 PATCH。
|
||||||
|
* 没传过附件的人,取消后什么都不会留在库里。
|
||||||
|
*/
|
||||||
|
const [draftId, setDraftId] = useState<string | null>(null);
|
||||||
|
const [draftVersion, setDraftVersion] = useState(0);
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
const draftingRef = useRef<Promise<string | null> | null>(null);
|
||||||
|
|
||||||
const parentDepth = categories.find((c) => c.id === categoryId)?.depth ?? 0;
|
const parentDepth = categories.find((c) => c.id === categoryId)?.depth ?? 0;
|
||||||
const canAddCategory = parentDepth < 3 && Boolean(newCat.trim());
|
const canAddCategory = parentDepth < 3 && Boolean(newCat.trim());
|
||||||
@@ -581,17 +591,50 @@ function KnowledgeComposer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 拿条目 id:已有就直接返回,没有就现存一条草稿;并发调用共用同一个请求。 */
|
||||||
|
async function ensureDraft(): Promise<string | null> {
|
||||||
|
if (draftId) return draftId;
|
||||||
|
if (draftingRef.current) return draftingRef.current;
|
||||||
|
const pending = (async () => {
|
||||||
|
try {
|
||||||
|
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
|
||||||
|
title: title.trim() || "未命名知识",
|
||||||
|
body_md: body,
|
||||||
|
doc_type: docType,
|
||||||
|
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
|
||||||
|
category_id: categoryId || null,
|
||||||
|
});
|
||||||
|
setDraftId(doc.id);
|
||||||
|
setDraftVersion(doc.version);
|
||||||
|
setAttachments(doc.attachments ?? []);
|
||||||
|
setTitle(doc.title);
|
||||||
|
setNotice("已存成草稿,现在可以继续写、贴图、传附件");
|
||||||
|
return doc.id;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "保存草稿失败");
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
draftingRef.current = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
draftingRef.current = pending;
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
|
const itemId = await ensureDraft();
|
||||||
|
if (!itemId) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const doc = await api.post<Knowledge>(`/workspaces/${wsId}/knowledge`, {
|
const doc = await api.patch<Knowledge>(`/workspaces/${wsId}/knowledge/${itemId}`, {
|
||||||
|
version: draftVersion,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
body_md: body,
|
body_md: body,
|
||||||
doc_type: docType,
|
doc_type: docType,
|
||||||
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
|
tag_names: tags.split(/[\s,,]+/).filter(Boolean),
|
||||||
category_id: categoryId || null,
|
...(categoryId ? { category_id: categoryId } : { clear_category: true }),
|
||||||
});
|
});
|
||||||
onCreated(doc);
|
onCreated(doc);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -607,7 +650,7 @@ function KnowledgeComposer({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="xl"
|
size="xl"
|
||||||
title="记一条知识"
|
title="记一条知识"
|
||||||
subtitle="标题、正文、标签、分类一次填完;保存后可以继续贴图、传附件。"
|
subtitle="标题、正文、标签、分类一次填完;附件和贴图会先把这条存成草稿。"
|
||||||
footer={
|
footer={
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button variant="primary" disabled={!canSave} onClick={() => void save()}>
|
<Button variant="primary" disabled={!canSave} onClick={() => void save()}>
|
||||||
@@ -617,7 +660,9 @@ function KnowledgeComposer({
|
|||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<span className="ml-auto text-2xs text-ink-400">
|
<span className="ml-auto text-2xs text-ink-400">
|
||||||
先存成草稿,核对发布后才进团队检索
|
{draftId
|
||||||
|
? "已经是草稿了:直接关掉会留在「待核对」里"
|
||||||
|
: "先存成草稿,核对发布后才进团队检索"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -717,12 +762,39 @@ function KnowledgeComposer({
|
|||||||
value={body}
|
value={body}
|
||||||
onChange={setBody}
|
onChange={setBody}
|
||||||
minHeight={320}
|
minHeight={320}
|
||||||
|
ensureItemId={ensureDraft}
|
||||||
onError={setError}
|
onError={setError}
|
||||||
placeholder={"写下要点、步骤、结论…\n\n标题、类型、分类、标签都在上面,正文就是这张备忘的主体。"}
|
placeholder={"写下要点、步骤、结论…\n\n标题、类型、分类、标签都在上面,正文就是这张备忘的主体。"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
|
||||||
|
{draftId ? (
|
||||||
|
<AttachmentPanel
|
||||||
|
wsId={wsId}
|
||||||
|
itemId={draftId}
|
||||||
|
attachments={attachments}
|
||||||
|
onChange={setAttachments}
|
||||||
|
onError={setError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-2xs font-medium text-ink-500">
|
||||||
|
<Paperclip size={13} />
|
||||||
|
附件
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" disabled={busy} onClick={() => void ensureDraft()}>
|
||||||
|
添加附件
|
||||||
|
</Button>
|
||||||
|
<span className="text-2xs text-ink-400">
|
||||||
|
附件要挂在条目上:点一下先把这条存成草稿,然后就能上传、下载
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notice && <p className="text-2xs text-primary-700">{notice}</p>}
|
||||||
{error && <InlineAlert>{error}</InlineAlert>}
|
{error && <InlineAlert>{error}</InlineAlert>}
|
||||||
</div>
|
</div>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
Reference in New Issue
Block a user