docs: add PRD for prompt csrf and blog comment empty state
This commit is contained in:
370
prd-prompt-csrf-and-blog-comment-empty-state.md
Normal file
370
prd-prompt-csrf-and-blog-comment-empty-state.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# Prompt 详情页测试 CSRF 初始化缺失 + 博客详情页评论空态文案优化
|
||||||
|
|
||||||
|
> **版本**: v1.0
|
||||||
|
> **日期**: 2026-05-24
|
||||||
|
> **状态**: 📝 待评审
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、背景与目标
|
||||||
|
|
||||||
|
本次 PRD 合并处理两个独立但都较明确的问题:
|
||||||
|
|
||||||
|
1. **Prompt 详情页测试功能首次使用时报 CSRF token 验证失败**
|
||||||
|
用户在已登录状态下访问公开提示词详情页(如 `/prompts/{key}`),在测试面板中选择模型、填写 user 输入后点击“开始测试”,首次请求可能直接返回 `CSRF token 验证失败`。但如果先进入 `/admin/settings` 页面并执行一次“测试模型连通性”,再回到相同提示词详情页,使用完全相同输入重新点击“开始测试”,则请求又可以正常成功。
|
||||||
|
|
||||||
|
2. **博客详情页无评论时的空态文案不符合期望**
|
||||||
|
当前 `/posts/{slug}` 页面在无评论时会显示:
|
||||||
|
`💬 暂无评论,来发表第一条评论吧!`
|
||||||
|
需求是:**博客详情页不要显示这句话**。
|
||||||
|
|
||||||
|
这两个问题都属于前端展示/交互层的可预期缺陷,适合在同一轮小修中一起纳入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、问题一:Prompt 详情页首次测试触发 CSRF 校验失败
|
||||||
|
|
||||||
|
### 2.1 现象
|
||||||
|
|
||||||
|
以页面:
|
||||||
|
`https://prompt.ephron.ren/prompts/prompt-20260508110413`
|
||||||
|
为例,在已登录状态下:
|
||||||
|
|
||||||
|
1. 打开提示词详情页
|
||||||
|
2. 切换到“测试”标签
|
||||||
|
3. 正确选择模型
|
||||||
|
4. 输入 user 内容
|
||||||
|
5. 点击“开始测试”
|
||||||
|
|
||||||
|
**实际行为**:返回 `CSRF token 验证失败`
|
||||||
|
|
||||||
|
随后:
|
||||||
|
|
||||||
|
1. 访问 `https://prompt.ephron.ren/admin/settings`
|
||||||
|
2. 点击测试模型连通性
|
||||||
|
3. 页面提示 `连接成功!模型: deepseek-v4-flash`
|
||||||
|
4. 再返回刚才的提示词详情页
|
||||||
|
5. 保持完全相同的模型与输入,再次点击“开始测试”
|
||||||
|
|
||||||
|
**实际行为**:此时请求成功,模型可以正常调用。
|
||||||
|
|
||||||
|
### 2.2 影响范围
|
||||||
|
|
||||||
|
- 影响所有带“测试”面板的公开提示词详情页:`/prompts/{key}`
|
||||||
|
- 影响所有已登录但**首次从公开详情页直接发起测试**的用户
|
||||||
|
- 若用户此前访问过某些会设置 CSRF cookie 的后台页面,问题可能被“掩盖”
|
||||||
|
- 不影响仅浏览正文、不使用测试功能的用户
|
||||||
|
|
||||||
|
### 2.3 已定位的源码
|
||||||
|
|
||||||
|
#### 详情页路由
|
||||||
|
文件:`prompt/src/routes/pages.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/prompts/{key}", response_class=HTMLResponse)
|
||||||
|
async def prompt_detail(request: Request, key: str):
|
||||||
|
prompt = get_prompt(key)
|
||||||
|
...
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"public/detail.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"prompt": prompt,
|
||||||
|
"split_csv": _split_csv,
|
||||||
|
"models": models,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
当前实现直接渲染详情页,但**没有为该页面生成并下发 CSRF cookie**。
|
||||||
|
|
||||||
|
#### 详情页测试前端
|
||||||
|
文件:`prompt/static/js/test-prompt.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const csrfToken = document.cookie.match(/ephron_csrf=([^;]+)/)?.[1] || '';
|
||||||
|
const response = await fetch(`/api/prompts/${this.promptKey}/test`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: this.abortController.signal,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
前端完全依赖 `document.cookie` 中存在 `ephron_csrf`。如果 cookie 不存在,就会发送空字符串。
|
||||||
|
|
||||||
|
#### 测试接口后端
|
||||||
|
文件:`prompt/src/routes/api.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
csrf_cookie = request.cookies.get("ephron_csrf")
|
||||||
|
csrf_header = request.headers.get("X-CSRF-Token")
|
||||||
|
if not verify_csrf_token(csrf_header, csrf_cookie):
|
||||||
|
raise HTTPException(status_code=403, detail="CSRF token 验证失败")
|
||||||
|
```
|
||||||
|
|
||||||
|
后端要求:
|
||||||
|
- cookie 中有 `ephron_csrf`
|
||||||
|
- header 中有 `X-CSRF-Token`
|
||||||
|
- 两者一致
|
||||||
|
|
||||||
|
否则直接返回 403。
|
||||||
|
|
||||||
|
#### admin/settings 页面为何会“治好”问题
|
||||||
|
文件:`prompt/src/routes/admin.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_page(...):
|
||||||
|
...
|
||||||
|
csrf_token = generate_csrf_token()
|
||||||
|
response = templates.TemplateResponse(...)
|
||||||
|
set_csrf_cookie(response, csrf_token, is_development=IS_DEVELOPMENT)
|
||||||
|
return response
|
||||||
|
```
|
||||||
|
|
||||||
|
即:访问 `/admin/settings` 时,服务端会显式生成 token 并设置 `ephron_csrf` cookie。
|
||||||
|
因此用户访问过后台设置页后,详情页测试 JS 才终于能从 `document.cookie` 里读到 token,于是测试恢复正常。
|
||||||
|
|
||||||
|
### 2.4 根因分析
|
||||||
|
|
||||||
|
根因不是模型配置问题,也不是测试接口本身偶发失败,而是:
|
||||||
|
|
||||||
|
> **公开提示词详情页的测试功能依赖 CSRF cookie,但详情页自身没有负责初始化这个 cookie。**
|
||||||
|
|
||||||
|
换言之:
|
||||||
|
|
||||||
|
- **依赖方**:`/prompts/{key}` 页面的“开始测试”功能
|
||||||
|
- **提供方**:当前却变成了 `/admin/settings` 这类后台管理页面
|
||||||
|
- **结果**:首次从公开详情页直接测试时,前置条件不满足,导致必现/高概率出现 403
|
||||||
|
|
||||||
|
这是一个**初始化时机错误**问题:
|
||||||
|
- 测试功能属于公开详情页的一部分
|
||||||
|
- 因此其所依赖的 CSRF token 也应由详情页自身在 GET 阶段准备好
|
||||||
|
- 不应要求用户先访问后台页面“预热”环境
|
||||||
|
|
||||||
|
### 2.5 解决方案
|
||||||
|
|
||||||
|
#### 方案 A:在公开详情页 GET 路由中补发 CSRF cookie(推荐)
|
||||||
|
|
||||||
|
在 `prompt/src/routes/pages.py` 的 `prompt_detail()` 中:
|
||||||
|
|
||||||
|
1. 生成新的 CSRF token
|
||||||
|
2. 用 `TemplateResponse` 组装响应对象
|
||||||
|
3. 调用共享的 `set_csrf_cookie(response, csrf_token, is_development=IS_DEVELOPMENT)`
|
||||||
|
4. 返回 response
|
||||||
|
|
||||||
|
参考后台页面已有模式,保持站点内 CSRF 初始化策略一致。
|
||||||
|
|
||||||
|
**优点**:
|
||||||
|
- 根因级修复
|
||||||
|
- 用户首次进入详情页即可直接测试
|
||||||
|
- 行为与后台页面一致,代码路径清晰
|
||||||
|
- 不需要新增 API,不引入额外异步初始化流程
|
||||||
|
|
||||||
|
#### 方案 B:前端在缺 token 时给出明确提示(推荐作为兜底,而不是主修复)
|
||||||
|
|
||||||
|
在 `prompt/static/js/test-prompt.js` 中:
|
||||||
|
|
||||||
|
- 若 `document.cookie` 中取不到 `ephron_csrf`
|
||||||
|
- 不要直接发请求
|
||||||
|
- 改为在界面中提示:
|
||||||
|
- `安全令牌未初始化,请刷新页面后重试`
|
||||||
|
- 或更温和的错误提示文案
|
||||||
|
|
||||||
|
**说明**:这只能改善体验,不能替代 A。因为没有根修复时,刷新也不一定能拿到 token;只有详情页本身负责种 cookie,刷新才有意义。
|
||||||
|
|
||||||
|
### 2.6 实现建议
|
||||||
|
|
||||||
|
#### 后端改动
|
||||||
|
文件:`prompt/src/routes/pages.py`
|
||||||
|
|
||||||
|
- 增加对共享 CSRF 工具的导入:
|
||||||
|
- `generate_csrf_token`
|
||||||
|
- `set_csrf_cookie`
|
||||||
|
- 在 `prompt_detail()` 中:
|
||||||
|
- 先生成 token
|
||||||
|
- 再构造 `TemplateResponse`
|
||||||
|
- 再对 response 调用 `set_csrf_cookie()`
|
||||||
|
|
||||||
|
实现形态建议与 `admin.py` 保持一致,避免两套风格。
|
||||||
|
|
||||||
|
#### 前端改动
|
||||||
|
文件:`prompt/static/js/test-prompt.js`
|
||||||
|
|
||||||
|
在执行 `fetch()` 前增加判断:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const csrfToken = document.cookie.match(/ephron_csrf=([^;]+)/)?.[1] || '';
|
||||||
|
if (!csrfToken) {
|
||||||
|
throw new Error('安全令牌未初始化,请刷新页面后重试');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这样至少避免把“空 token”静默发到后端。
|
||||||
|
|
||||||
|
### 2.7 验收标准
|
||||||
|
|
||||||
|
#### 功能验收
|
||||||
|
1. 已登录用户首次直接访问任意公开提示词详情页
|
||||||
|
2. 不访问任何后台页面
|
||||||
|
3. 直接进入“测试”标签
|
||||||
|
4. 选择模型、输入 user 内容后点击“开始测试”
|
||||||
|
5. 请求成功,页面正常流式返回模型输出
|
||||||
|
|
||||||
|
#### 回归验收
|
||||||
|
1. 后台 `admin/settings` 的连通性测试功能不受影响
|
||||||
|
2. 详情页在未登录状态下仍保持原有鉴权行为(如测试接口要求登录则仍返回 401)
|
||||||
|
3. 详情页正文、标签、示例、模型下拉等渲染不受影响
|
||||||
|
|
||||||
|
#### 失败场景验收
|
||||||
|
1. 若前端在极端情况下仍未读取到 token,应展示可理解错误提示,而不是只有通用“请求失败”
|
||||||
|
2. 不应再出现“访问后台设置页后才恢复”的异常依赖关系
|
||||||
|
|
||||||
|
### 2.8 测试补齐建议
|
||||||
|
|
||||||
|
现有测试中,很多 API case 是直接手动塞入:
|
||||||
|
- `ephron_auth`
|
||||||
|
- `ephron_csrf`
|
||||||
|
- `X-CSRF-Token`
|
||||||
|
|
||||||
|
这验证了 API 校验本身,但掩盖了“公开详情页没有负责初始化 cookie”的集成缺口。
|
||||||
|
|
||||||
|
建议新增测试:
|
||||||
|
|
||||||
|
#### 集成测试 1:详情页 GET 应设置 CSRF cookie
|
||||||
|
文件建议:`prompt/tests/test_prompt_service_api.py` 或更适合的 pages 路由测试文件
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
1. 创建一个可访问的 active prompt
|
||||||
|
2. 使用已登录 client 访问 `/prompts/{key}`
|
||||||
|
3. 断言响应或 client cookies 中出现 `ephron_csrf`
|
||||||
|
|
||||||
|
#### 集成测试 2:首次访问详情页后可直接调用测试接口
|
||||||
|
步骤:
|
||||||
|
1. 已登录 client 先 GET `/prompts/{key}`
|
||||||
|
2. 从 client cookies 中读取 `ephron_csrf`
|
||||||
|
3. 直接 POST `/api/prompts/{key}/test`
|
||||||
|
4. header 中传同一 token
|
||||||
|
5. 断言不返回 403 CSRF 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、问题二:博客详情页无评论时不要显示“暂无评论,来发表第一条评论吧!”
|
||||||
|
|
||||||
|
### 3.1 现象
|
||||||
|
|
||||||
|
当前博客详情页 `/posts/{slug}` 在评论列表为空时,会显示带图标的空态提示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
💬
|
||||||
|
暂无评论,来发表第一条评论吧!
|
||||||
|
```
|
||||||
|
|
||||||
|
需求是:
|
||||||
|
|
||||||
|
> **博客详情页不要显示这句话。**
|
||||||
|
|
||||||
|
### 3.2 已定位的源码
|
||||||
|
|
||||||
|
文件:`blog/templates/post.html`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function renderComments(comments) {
|
||||||
|
if (comments.length === 0) {
|
||||||
|
commentsList.innerHTML = '<div class="no-comments"><div class="no-comments-icon">💬</div><p>暂无评论,来发表第一条评论吧!</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commentsList.innerHTML = comments.map(comment => renderComment(comment)).join('');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 根因分析
|
||||||
|
|
||||||
|
这是一个纯展示层文案问题:
|
||||||
|
- 当前空态提示写死在前端模板 JS 中
|
||||||
|
- 文案过于主动,不符合当前页面的简洁展示要求
|
||||||
|
|
||||||
|
### 3.4 解决方案
|
||||||
|
|
||||||
|
有两种可选实现:
|
||||||
|
|
||||||
|
#### 方案 A:保留空态容器,但移除该句文案(推荐)
|
||||||
|
|
||||||
|
将空评论时的 HTML 改为:
|
||||||
|
- 不显示这句“暂无评论,来发表第一条评论吧!”
|
||||||
|
- 可以仅保留空容器
|
||||||
|
- 或仅保留图标
|
||||||
|
- 或改成空字符串
|
||||||
|
|
||||||
|
推荐最保守方案:
|
||||||
|
- 直接将 `commentsList.innerHTML = ''`
|
||||||
|
- 让评论区在无评论时不额外渲染提示文本
|
||||||
|
|
||||||
|
这样最符合“不要显示这句话”的字面要求,也最干净。
|
||||||
|
|
||||||
|
#### 方案 B:替换为更中性的极简文案
|
||||||
|
|
||||||
|
例如仅显示:
|
||||||
|
- `暂无评论`
|
||||||
|
|
||||||
|
但由于需求明确说“不要显示这句话”,且没有明确要求保留任何替代文案,因此不建议自行引入新文案。
|
||||||
|
|
||||||
|
### 3.5 验收标准
|
||||||
|
|
||||||
|
1. 任意无评论博客详情页打开后
|
||||||
|
2. 评论区域不再显示:`暂无评论,来发表第一条评论吧!`
|
||||||
|
3. 不影响有评论时的正常渲染
|
||||||
|
4. 不影响评论数统计、评论提交、回复渲染等现有逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、实施范围
|
||||||
|
|
||||||
|
### Prompt 侧
|
||||||
|
- `prompt/src/routes/pages.py`
|
||||||
|
- `prompt/static/js/test-prompt.js`
|
||||||
|
- `prompt/tests/...`(补测试)
|
||||||
|
|
||||||
|
### Blog 侧
|
||||||
|
- `blog/templates/post.html`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、风险与注意事项
|
||||||
|
|
||||||
|
### 5.1 Prompt CSRF 修复风险
|
||||||
|
|
||||||
|
- 若详情页每次 GET 都生成并覆盖 CSRF cookie,需要确认不会破坏站内其他表单/接口的当前交互流程
|
||||||
|
- 但现有后台页已经是同样模式,因此风险较低
|
||||||
|
- 需要注意与 `shared/csrf.py` 的有效期和 secure/domain 配置保持一致
|
||||||
|
|
||||||
|
### 5.2 评论空态调整风险
|
||||||
|
|
||||||
|
- 若直接清空评论区,需要确认样式和布局不会出现异常留白
|
||||||
|
- 这是低风险前端调整,但建议实际看一眼无评论页面效果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、上线后预期结果
|
||||||
|
|
||||||
|
### Prompt 详情页
|
||||||
|
用户首次直接进入公开提示词详情页测试时即可成功调用模型,不再依赖先访问 `/admin/settings` 页面“激活”环境。
|
||||||
|
|
||||||
|
### 博客详情页
|
||||||
|
无评论文章页将更干净,不再出现“暂无评论,来发表第一条评论吧!”的提示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、建议提交拆分
|
||||||
|
|
||||||
|
虽然本 PRD 合并记录了两个问题,但实际开发提交建议可按以下粒度:
|
||||||
|
|
||||||
|
1. `fix(prompt): initialize csrf cookie on public prompt detail page`
|
||||||
|
2. `fix(blog): remove no-comments prompt text on post detail page`
|
||||||
|
|
||||||
|
如果实现量很小,也可合并成一次小修提交,但 PR 描述中应明确包含两个修复点。
|
||||||
Reference in New Issue
Block a user