feat(api): 添加帮助中心反馈系统和VIP服务功能
- 在ApiClient中新增submitFeedback、myFeedback、helpArticles、helpArticleDetail、 siteArticles、promotions、vipPackages、createVipOrder、vipOrders等方法 - 添加帮助文章和站点资讯的参数验证逻辑 - 更新测试文件添加新的API方法测试用例 - 在HTML页面中添加反馈、帮助和VIP服务相关页面的脚本引用 - 更新加入家谱页面为完整的申请流程界面 - 修改资讯详情页面为站点资讯展示页面 - 更新AxiosRequestUtil中认证处理逻辑 - 添加世系树渲染的HTML生成函数用于页面复用 - 更新文档中的API契约说明和页面规划
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
# 官网资讯 PC 接口对接实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将 `news.html` 与 `article-detail.html` 接入公开 PC 站点文章列表接口,交付可筛选列表与精确详情闭环。
|
||||
|
||||
**Architecture:** `utils/ApiClient.js` 独占 PC path、公开鉴权标记和 Query 校验;`public/js/site-news-pages.js` 独占 `SiteArticleVo` 规范化、渲染和页面初始化。后端没有单条详情接口,详情页使用 URL 中由列表响应生成的字符串 ID 重读列表并精确匹配。
|
||||
|
||||
**Tech Stack:** 原生 HTML/CSS/JavaScript、现有 Axios 请求层、Node `node:test`。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 后端 `D:\WorkSpace\Java\Genealogy` 只读,不得修改。
|
||||
- 只使用 `/genealogy/pc/site/articles`,不得调用 APP、后台管理或家谱谱文 CRUD 接口。
|
||||
- 本批只修改 `news.html` 与 `article-detail.html`,不混改其他静态内容页。
|
||||
- 业务 ID 始终保持字符串,不提供手填 ID 或 OSS ID。
|
||||
- `articleType` 只允许 `news/notice/download`;`limit` 固定为 100 且客户端只允许 1–100 的整数。
|
||||
- 公开请求设置 `auth: false`;其 401/403 不清理既有 token、不跳转登录。
|
||||
- 响应只接受直接 `SiteArticleVo[]`;旧字段、分页对象和任一非法元素均使整批失败。
|
||||
- 所有响应文本 HTML 转义;正文仅保留换行;外链只允许绝对 HTTP/HTTPS。
|
||||
- 当前工作区由用户明确授权直接施工;不得 stage、commit、push 或创建 PR。
|
||||
- 子代理仅用于探索和只读复核;业务代码修改、取舍与最终验证由主代理完成。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 修正公开请求的登录态隔离
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/request-auth-state.test.js`
|
||||
- Modify: `utils/AxiosRequestUtil.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createRequester({ getToken, onUnauthorized, axiosInstance })`
|
||||
- Produces: `request(method, path, { auth: false })` 不发送 Authorization,且 HTTP/业务 401 均不调用 `onUnauthorized`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
在 `tests/request-auth-state.test.js` 引入 `../utils/AxiosRequestUtil.js`,新增两个真实请求层用例:
|
||||
|
||||
```js
|
||||
test('public HTTP 401 neither sends nor clears the stored login token', async () => {
|
||||
let storedToken = 'access-token';
|
||||
let seenRequest;
|
||||
const requester = AxiosRequestUtil.createRequester({
|
||||
baseUrl: 'https://api.example.test',
|
||||
clientId: 'web-pc',
|
||||
getToken() { return storedToken; },
|
||||
onUnauthorized() { storedToken = ''; },
|
||||
axiosInstance: {
|
||||
request(config) {
|
||||
seenRequest = config;
|
||||
return Promise.reject({
|
||||
message: 'Request failed',
|
||||
response: { status: 401, data: { code: 401, msg: '认证失败' } }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await assert.rejects(requester('GET', '/public', { auth: false }));
|
||||
assert.equal(seenRequest.headers.Authorization, undefined);
|
||||
assert.equal(storedToken, 'access-token');
|
||||
});
|
||||
|
||||
test('public business 401 preserves the stored login token', async () => {
|
||||
let storedToken = 'access-token';
|
||||
const requester = AxiosRequestUtil.createRequester({
|
||||
baseUrl: 'https://api.example.test',
|
||||
clientId: 'web-pc',
|
||||
getToken() { return storedToken; },
|
||||
onUnauthorized() { storedToken = ''; },
|
||||
axiosInstance: {
|
||||
request() {
|
||||
return Promise.resolve({
|
||||
data: { code: 401, msg: '认证失败' }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await assert.rejects(requester('GET', '/public', { auth: false }));
|
||||
assert.equal(storedToken, 'access-token');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行 RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node --test tests/request-auth-state.test.js
|
||||
```
|
||||
|
||||
Expected: 新增用例因 `handleUnauthorized` 未区分 `auth: false` 而失败。
|
||||
|
||||
- [ ] **Step 3: 写最小实现**
|
||||
|
||||
在 `utils/AxiosRequestUtil.js` 的单次 `request` 闭包内,使 HTTP catch 与业务解包 catch 只在 `req.auth !== false` 时调用 `handleUnauthorized`:
|
||||
|
||||
```js
|
||||
if (req.auth !== false) handleUnauthorized(status);
|
||||
```
|
||||
|
||||
不得改变默认鉴权请求的现有行为。
|
||||
|
||||
- [ ] **Step 4: 运行 GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node --test tests/request-auth-state.test.js
|
||||
```
|
||||
|
||||
Expected: 现有默认 401 清 token、403 保留 token,以及新增公开请求用例全部通过。
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 增加 PC 站点文章 ApiClient 契约
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/api-client-contract.test.js`
|
||||
- Modify: `utils/ApiClient.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `siteArticles(query?: { articleType?: 'news'|'notice'|'download', limit?: number }): Promise<Array>`
|
||||
- Request: `GET /genealogy/pc/site/articles`, `{ auth: false, query }`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
在公开方法清单中增加 `siteArticles`,并新增契约用例:
|
||||
|
||||
```js
|
||||
await client.siteArticles({
|
||||
articleType: 'notice',
|
||||
limit: 100,
|
||||
keyword: 'must-drop'
|
||||
});
|
||||
|
||||
assert.deepEqual(seen, {
|
||||
method: 'get',
|
||||
url: '/genealogy/pc/site/articles',
|
||||
params: { articleType: 'notice', limit: 100 },
|
||||
authorization: undefined
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => client.siteArticles({ articleType: 'culture', limit: 100 }),
|
||||
/不支持的资讯类型/
|
||||
);
|
||||
assert.throws(
|
||||
() => client.siteArticles({ limit: 101 }),
|
||||
/资讯数量限制/
|
||||
);
|
||||
```
|
||||
|
||||
同时覆盖 `limit` 的 `0`、小数、字符串和负数,确保只接受 1–100 的 Number 整数;省略 `articleType` 时只发送 `{ limit: 100 }`。
|
||||
|
||||
- [ ] **Step 2: 运行 RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node --test tests/api-client-contract.test.js
|
||||
```
|
||||
|
||||
Expected: `siteArticles` 不存在。
|
||||
|
||||
- [ ] **Step 3: 写最小实现**
|
||||
|
||||
在 `utils/ApiClient.js` 增加 `siteArticles`:
|
||||
|
||||
```js
|
||||
siteArticles: function (query) {
|
||||
var source = query || {};
|
||||
var articleType = source.articleType === undefined || source.articleType === null
|
||||
? ''
|
||||
: String(source.articleType).trim();
|
||||
var limit = source.limit;
|
||||
var params = {};
|
||||
|
||||
if (articleType && ['news', 'notice', 'download'].indexOf(articleType) === -1) {
|
||||
throw new Error('不支持的资讯类型:' + articleType);
|
||||
}
|
||||
if (limit !== undefined &&
|
||||
(typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 100)) {
|
||||
throw new Error('资讯数量限制必须是 1 到 100 的整数');
|
||||
}
|
||||
if (articleType) params.articleType = articleType;
|
||||
if (limit !== undefined) params.limit = limit;
|
||||
return request('GET', '/genealogy/pc/site/articles', {
|
||||
auth: false,
|
||||
query: params
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行 GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node --test tests/api-client-contract.test.js tests/request-auth-state.test.js
|
||||
```
|
||||
|
||||
Expected: 全部通过,且公开请求没有 Authorization Header。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 实现站点资讯页面 owner
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/site-news-pages.test.js`
|
||||
- Create: `public/js/site-news-pages.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GenealogyApi.defaultClient.siteArticles({ articleType?, limit: 100 })`
|
||||
- Produces:
|
||||
- `normalizeArticle(item)`
|
||||
- `normalizeArticleList(data)`
|
||||
- `normalizeArticleType(value)`
|
||||
- `normalizeExternalUrl(value)`
|
||||
- `readArticleId(search)`
|
||||
- `renderArticleList(data)`
|
||||
- `renderArticleDetail(item)`
|
||||
- `loadArticles(api, articleType?)`
|
||||
- `loadArticleDetail(api, articleId)`
|
||||
- `init()`
|
||||
|
||||
- [ ] **Step 1: 写完整 RED 测试**
|
||||
|
||||
创建 `tests/site-news-pages.test.js`,使用完整 `SiteArticleVo` fixture:
|
||||
|
||||
```js
|
||||
{
|
||||
articleId: '2062179707935264769',
|
||||
articleType: 'notice',
|
||||
articleTitle: '平台公告',
|
||||
articleSummary: '公告摘要',
|
||||
articleContent: '第一行\n第二行',
|
||||
coverOssId: '2062179707935264701',
|
||||
externalUrl: 'https://example.com/notice',
|
||||
publishTime: '2026-07-30 09:00:00',
|
||||
sortOrder: 1,
|
||||
status: '0',
|
||||
remark: 'internal-only'
|
||||
}
|
||||
```
|
||||
|
||||
必须覆盖:
|
||||
|
||||
- 安全长 ID 字符串保留;不安全 Number、零、空 ID 拒绝。
|
||||
- `articleType` 只接受 `news/notice/download`。
|
||||
- 标题必填、状态必须为 `0`。
|
||||
- 直接数组全有或全无;`{ rows: [...] }` 拒绝。
|
||||
- 规范化结果不包含 `coverOssId/sortOrder/status/remark`。
|
||||
- 列表 HTML 指向 `article-detail.html?articleId=2062179707935264769`。
|
||||
- 标题、摘要、正文转义;正文换行变为 `<br />`。
|
||||
- 危险、相对和 `data:` 外链不渲染;合法 HTTP/HTTPS 使用 `noopener noreferrer`。
|
||||
- `loadArticles(api, 'notice')` 调用 `{ articleType: 'notice', limit: 100 }`。
|
||||
- `loadArticles(api, '')` 调用 `{ limit: 100 }`。
|
||||
- `loadArticleDetail` 只返回同 ID;未匹配抛出“资讯不存在或已下线”。
|
||||
- 非法详情 ID 在调用 API 前抛出“资讯编号无效”。
|
||||
- `readArticleId('?articleId=...')` 只返回安全字符串。
|
||||
|
||||
- [ ] **Step 2: 运行 RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node --test tests/site-news-pages.test.js
|
||||
```
|
||||
|
||||
Expected: 模块不存在。
|
||||
|
||||
- [ ] **Step 3: 写 UMD 模块最小实现**
|
||||
|
||||
创建 UMD 模块:Node 环境导出 `module.exports = factory(root)`;浏览器环境挂到 `root.SiteNewsPages`,并在 `DOMContentLoaded` 调用 `root.SiteNewsPages.init()`。关键行为:
|
||||
|
||||
```js
|
||||
async function loadArticles(api, articleType) {
|
||||
var type = normalizeArticleType(articleType);
|
||||
var query = { limit: 100 };
|
||||
var data;
|
||||
var items;
|
||||
|
||||
if (articleType && !type) throw new Error('资讯分类无效');
|
||||
if (type) query.articleType = type;
|
||||
data = await api.siteArticles(query);
|
||||
items = normalizeArticleList(data);
|
||||
if (!Array.isArray(data) || items.length !== data.length) {
|
||||
throw new Error('资讯列表响应无效');
|
||||
}
|
||||
return items;
|
||||
}
|
||||
```
|
||||
|
||||
`init()`:
|
||||
|
||||
- `[data-site-news-page]`:从 `location.search` 读取 `articleType`,加载并渲染 `[data-site-news-list]`。
|
||||
- `[data-site-article-page]`:读取 `articleId`;非法时不发请求;合法时精确重读并渲染 `[data-site-article-detail]`。
|
||||
- 所有失败只更新对应区域和 `[data-site-*-status]`,不跳转、不清 token。
|
||||
|
||||
- [ ] **Step 4: 运行 GREEN**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node --test tests/site-news-pages.test.js tests/api-client-contract.test.js tests/request-auth-state.test.js
|
||||
```
|
||||
|
||||
Expected: 全部通过。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 接入 HTML、规划并完成验收
|
||||
|
||||
**Files:**
|
||||
- Modify: `news.html`
|
||||
- Modify: `article-detail.html`
|
||||
- Modify: `tests/site-news-pages.test.js`
|
||||
- Modify: `tests/public-static-pages.test.js`
|
||||
- Modify: `tests/pc-scope.test.js`
|
||||
- Modify: `docs/PC接口对接规划.md`
|
||||
|
||||
**Interfaces:**
|
||||
- `news.html`: `[data-site-news-page]`, `[data-site-news-list]`, `[data-site-news-status]`
|
||||
- `article-detail.html`: `[data-site-article-page]`, `[data-site-article-detail]`, `[data-site-article-status]`
|
||||
|
||||
- [ ] **Step 1: 写 HTML 接入 RED 测试**
|
||||
|
||||
断言:
|
||||
|
||||
```js
|
||||
assert.match(newsPage, /data-site-news-page/);
|
||||
assert.match(newsPage, /data-site-news-list/);
|
||||
assert.match(newsPage, /news\.html\?articleType=news/);
|
||||
assert.match(newsPage, /news\.html\?articleType=notice/);
|
||||
assert.match(newsPage, /news\.html\?articleType=download/);
|
||||
assert.match(newsPage, /src="public\/js\/site-news-pages\.js"/);
|
||||
|
||||
assert.match(detailPage, /data-site-article-page/);
|
||||
assert.match(detailPage, /data-site-article-detail/);
|
||||
assert.match(detailPage, /src="public\/js\/site-news-pages\.js"/);
|
||||
assert.doesNotMatch(detailPage, /src="public\/js\/article-pages\.js"/);
|
||||
assert.doesNotMatch(newsPage + detailPage, /name="(?:articleId|coverOssId)"/);
|
||||
```
|
||||
|
||||
更新 `public-static-pages.test.js`,移除旧的固定 `#platform/#culture` 链接断言,改为真实分类入口和详情目标文件断言。
|
||||
|
||||
- [ ] **Step 2: 运行 RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node --test tests/site-news-pages.test.js tests/public-static-pages.test.js tests/pc-scope.test.js
|
||||
```
|
||||
|
||||
Expected: HTML 尚未加载新模块、旧固定链接断言需迁移。
|
||||
|
||||
- [ ] **Step 3: 修改 HTML**
|
||||
|
||||
`news.html`:
|
||||
|
||||
- `<body data-site-news-page>`
|
||||
- 删除“当前为静态展示”文案和两条硬编码文章。
|
||||
- 列表容器初始化为“资讯内容加载中”。
|
||||
- 分类链接使用规格中的四个固定 URL。
|
||||
- 按顺序加载 `config.js`、`StorageUtil.js`、`axios.js`、`AxiosRequestUtil.js`、`ApiClient.js`、`page-effects.js`、`site-news-pages.js`。
|
||||
|
||||
`article-detail.html`:
|
||||
|
||||
- body 标记改为 `data-site-article-page`,避免与家谱谱文语义混用。
|
||||
- 将 hero 和正文合并到 `data-site-article-detail` 可替换容器。
|
||||
- 增加 `data-site-article-status`。
|
||||
- 加载 `site-news-pages.js`,不加载 `article-pages.js`。
|
||||
|
||||
- [ ] **Step 4: 更新规划**
|
||||
|
||||
在 `docs/PC接口对接规划.md` 第 7 节新增“官网资讯契约”,记录:
|
||||
|
||||
- method/path、公开鉴权、Query 枚举和 limit 范围。
|
||||
- `SiteArticleVo` 11 个字段的 R/I/S/A 分类、SQL 长度和默认值。
|
||||
- 列表直接数组、服务端排序、详情重读匹配。
|
||||
- YAML 只给通用响应且没有导出 `SiteArticleVo` Schema 的差异。
|
||||
- `coverOssId` 文件 URL 和站点文章单条详情接口仍阻断。
|
||||
|
||||
在阶段 7 增加第八批完成说明。
|
||||
|
||||
- [ ] **Step 5: 运行自动化验收**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
node --check public/js/site-news-pages.js
|
||||
node --check utils/ApiClient.js
|
||||
node --check utils/AxiosRequestUtil.js
|
||||
node --test tests/site-news-pages.test.js tests/api-client-contract.test.js tests/request-auth-state.test.js tests/public-static-pages.test.js tests/pc-scope.test.js
|
||||
node --test tests/*.test.js
|
||||
git -c safe.directory=D:/WorkSpace/Web/jiapu diff --check
|
||||
```
|
||||
|
||||
Expected: 所有命令退出码 0。
|
||||
|
||||
- [ ] **Step 6: 浏览器验收**
|
||||
|
||||
启动临时本地静态服务器,验证:
|
||||
|
||||
1. 未登录打开 `news.html`,页面不跳登录。
|
||||
2. 真实列表、分类或空状态正确。
|
||||
3. 有数据时点击一个由响应生成的详情链接,详情 ID 精确匹配;无数据时记录真实空状态。
|
||||
4. 控制台没有业务脚本错误。
|
||||
5. 不点击外部链接。
|
||||
6. 停止服务器并删除临时辅助文件。
|
||||
|
||||
- [ ] **Step 7: 阶段报告**
|
||||
|
||||
按以下格式报告并停下:
|
||||
|
||||
```text
|
||||
Changed: 官网资讯列表、详情、公开请求语义和规划补充。
|
||||
Verified: 专项/全量测试数量及真实浏览器覆盖。
|
||||
Conflicts: YAML 通用响应与后端完整 VO/SQL 的差异。
|
||||
Blocked: 单条详情接口、封面文件 URL、仍无 PC Controller 的 pending 页面。
|
||||
```
|
||||
Reference in New Issue
Block a user