359 lines
19 KiB
Markdown
359 lines
19 KiB
Markdown
---
|
||
phase: 01-credential-slot-api
|
||
plan: 01
|
||
type: execute
|
||
wave: 1
|
||
depends_on: []
|
||
files_modified:
|
||
- lib/api/credential-slot.ts
|
||
- lib/api/index.ts
|
||
autonomous: true
|
||
requirements:
|
||
- CRED-FE-01
|
||
|
||
must_haves:
|
||
truths:
|
||
- "新文件 lib/api/credential-slot.ts 存在并导出两个 API 函数 + 两个公开类型"
|
||
- "getCredentialSlot() 走 apiClient.get 命中 /v1/admin/credential-slot/(无 /api 前缀)"
|
||
- "updateCredentialSlot() 走 apiClient.put 命中 /v1/admin/credential-slot/,body 仅包含 { app_id, access_token } 不含 updated_at"
|
||
- "mapBackendCredentialSlot 把 { app_id, access_token, updated_at } 映射为 { appId, accessTokenMasked, updatedAt }"
|
||
- "lib/api/index.ts 末尾通过具名 re-export 导出新模块的 2 函数 + 2 类型"
|
||
artifacts:
|
||
- path: "lib/api/credential-slot.ts"
|
||
provides: "凭据槽位 API client 模块(类型 + 适配器 + GET/PUT 函数)"
|
||
exports:
|
||
- "getCredentialSlot"
|
||
- "updateCredentialSlot"
|
||
- "type CredentialSlot"
|
||
- "type CredentialSlotUpdatePayload"
|
||
contains:
|
||
- "interface BackendCredentialSlot"
|
||
- "function mapBackendCredentialSlot"
|
||
- "apiClient.get('/v1/admin/credential-slot/'"
|
||
- "apiClient.put('/v1/admin/credential-slot/'"
|
||
- "response.data?.data || response.data"
|
||
- path: "lib/api/index.ts"
|
||
provides: "barrel re-export 入口,新增凭据槽位模块导出"
|
||
contains:
|
||
- "from './credential-slot'"
|
||
- "getCredentialSlot"
|
||
- "updateCredentialSlot"
|
||
- "type CredentialSlot"
|
||
- "type CredentialSlotUpdatePayload"
|
||
key_links:
|
||
- from: "lib/api/credential-slot.ts"
|
||
to: "lib/api/client.ts"
|
||
via: "import { apiClient } from './client'"
|
||
pattern: "import.*apiClient.*from.*[\"']\\./client[\"']"
|
||
- from: "lib/api/index.ts"
|
||
to: "lib/api/credential-slot.ts"
|
||
via: "具名 re-export"
|
||
pattern: "from\\s+['\"]\\./credential-slot['\"]"
|
||
- from: "getCredentialSlot / updateCredentialSlot"
|
||
to: "mapBackendCredentialSlot"
|
||
via: "返回值前先经 adapter 转换 snake -> camel"
|
||
pattern: "return\\s+mapBackendCredentialSlot\\("
|
||
---
|
||
|
||
<objective>
|
||
新建 `lib/api/credential-slot.ts`,按 1:1 模板(`lib/api/ai-models.ts`)封装凭据槽位 GET / PUT 两个调用 + camelCase 类型 + `mapBackendCredentialSlot` 适配器;并在 `lib/api/index.ts` 末尾追加具名 re-export,让 UI 层(Phase 2/3)可以 `import { getCredentialSlot, type CredentialSlot } from '@/lib/api'`。
|
||
|
||
Purpose:为 Milestone v1.0「通用凭据槽位前端集成」搭建纯逻辑层调用基础。本 plan 是 Phase 1 的代码落地,无 UI 依赖;TS 类型层面通过 `accessTokenMasked` vs `accessToken` 命名差异在编译期切断「把脱敏字符串当真值回写 PUT」这条 bug 路径。
|
||
|
||
Output:`lib/api/credential-slot.ts`(新建)+ `lib/api/index.ts`(末尾追加 7 行)。
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.planning/PROJECT.md
|
||
@.planning/ROADMAP.md
|
||
@.planning/STATE.md
|
||
@.planning/REQUIREMENTS.md
|
||
@.planning/phases/01-credential-slot-api/01-CONTEXT.md
|
||
@.planning/phases/01-credential-slot-api/01-RESEARCH.md
|
||
|
||
@CLAUDE.md
|
||
@lib/api/client.ts
|
||
@lib/api/ai-models.ts
|
||
@lib/api/index.ts
|
||
|
||
<interfaces>
|
||
<!-- 关键接口契约 — 执行者直接照抄,不需要再去探索代码库 -->
|
||
|
||
From lib/api/client.ts (已存在,本 plan 不修改):
|
||
```typescript
|
||
// L9:baseURL 已包含 /api,业务路径不要重复写 /api
|
||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000/api"
|
||
|
||
// L12-17:单例 axios,请求拦截器自动注入 Authorization: Bearer,响应拦截器只透传不解包
|
||
export const apiClient = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json' } })
|
||
|
||
// L91-96:标准响应壳层(仅类型,本 plan 不需 import)
|
||
export interface ApiResponse<T> { success: boolean; code: number; message: string; data: T }
|
||
```
|
||
|
||
From lib/api/ai-models.ts (1:1 模板,本 plan 完全照抄结构):
|
||
```typescript
|
||
// L1-3:import 风格
|
||
import type { AiModel } from "./types"
|
||
import { apiClient } from "./client"
|
||
|
||
// L7-20:mapBackend* 函数风格(snake -> camel + 字段默认值)
|
||
function mapBackendBot(b: any): AiModel { return { id: String(b.id), name: b.name, /* ... */ } }
|
||
|
||
// L46-50:单资源 GET 形态(与本 plan getCredentialSlot 完全一致)
|
||
export const getAiModel = async (id: string): Promise<AiModel> => {
|
||
const response = await apiClient.get(`/ai/bots/${id}/`)
|
||
const data = response.data?.data || response.data // ← 双保险解包
|
||
return mapBackendBot(data)
|
||
}
|
||
|
||
// L65-73:写入 body 仅含业务字段,不带 updated_at
|
||
export const updateAiModel = async (id: string, modelData: Partial<AiModel>): Promise<AiModel> => {
|
||
const payload: any = {}
|
||
if (modelData.name !== undefined) payload.name = modelData.name
|
||
// 注意:updated_at 不在 payload 中
|
||
const response = await apiClient.patch(`/ai/bots/${id}/`, payload)
|
||
const data = response.data?.data || response.data
|
||
return mapBackendBot(data)
|
||
}
|
||
```
|
||
|
||
From lib/api/index.ts (本 plan 在末尾追加导出):
|
||
```typescript
|
||
// L1-6:当前文件头部 — 现有 export * 风格,与新增的具名 re-export 在同文件混用合法
|
||
import * as client from "./client"
|
||
export * from "./card"
|
||
export * from "./upload"
|
||
export * from "./food"
|
||
|
||
// L190-196:当前文件末尾 — 在 handleApiError 之前或之后追加新模块导出(推荐文件末尾)
|
||
export const handleApiError = (error: any) => { /* ... */ }
|
||
```
|
||
</interfaces>
|
||
</context>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 1:新建 lib/api/credential-slot.ts(类型 + 适配器 + GET/PUT)</name>
|
||
|
||
<read_first>
|
||
- `lib/api/ai-models.ts`(L1-50 + L65-73,1:1 模板)— 确认 import 风格、mapBackend* 函数形态、`response.data?.data || response.data` 双保险解包、PUT body 不带 `updated_at`
|
||
- `lib/api/client.ts` L9-17 — 确认 `apiClient` 由该文件导出,`baseURL` 已含 `/api`,业务路径不要重复写
|
||
- `.planning/phases/01-credential-slot-api/01-CONTEXT.md` <decisions> 节 — 锁定的类型命名(`accessTokenMasked` 故意带 Masked 后缀;`accessToken` 用于明文提交载荷)
|
||
- `.planning/phases/01-credential-slot-api/01-RESEARCH.md` 「Code Examples」节 — 完整可粘贴骨架
|
||
</read_first>
|
||
|
||
<files>lib/api/credential-slot.ts</files>
|
||
|
||
<action>
|
||
在 `lib/api/credential-slot.ts` 创建以下**完整**文件内容(直接写入,不要省略任何行;中文 JSDoc 必须保留):
|
||
|
||
```typescript
|
||
import { apiClient } from "./client"
|
||
|
||
// ───── 后端响应原始 dict(snake_case,仅 adapter 内部用,不导出)────────────
|
||
interface BackendCredentialSlot {
|
||
app_id: string
|
||
access_token: string
|
||
updated_at: string
|
||
}
|
||
|
||
// ───── 前端响应类型(camelCase,导出给 UI 层 import)──────────────────────
|
||
/**
|
||
* 凭据槽位(后端响应)。
|
||
* 注意:access_token 已是脱敏掩码(末 4 位明文),不要把它当明文回写。
|
||
*/
|
||
export interface CredentialSlot {
|
||
appId: string
|
||
accessTokenMasked: string // 后端返回的脱敏字符串
|
||
updatedAt: string // ISO 8601
|
||
}
|
||
|
||
// ───── 提交载荷类型(camelCase 明文)──────────────────────────────────────
|
||
/**
|
||
* 凭据槽位更新载荷。
|
||
* 注意:accessToken 是明文,提交后将完整覆写后端记录。
|
||
*/
|
||
export interface CredentialSlotUpdatePayload {
|
||
appId: string
|
||
accessToken: string // 明文
|
||
}
|
||
|
||
// ───── adapter(后端 → 前端)─────────────────────────────────────────────
|
||
function mapBackendCredentialSlot(raw: BackendCredentialSlot): CredentialSlot {
|
||
return {
|
||
appId: raw.app_id,
|
||
accessTokenMasked: raw.access_token,
|
||
updatedAt: raw.updated_at,
|
||
}
|
||
}
|
||
|
||
// ───── API 函数 ──────────────────────────────────────────────────────────
|
||
/**
|
||
* 读取当前凭据槽位(access_token 字段为脱敏掩码)。
|
||
*/
|
||
export const getCredentialSlot = async (): Promise<CredentialSlot> => {
|
||
const response = await apiClient.get('/v1/admin/credential-slot/')
|
||
const data = response.data?.data || response.data // 仓库统一双保险解包
|
||
return mapBackendCredentialSlot(data)
|
||
}
|
||
|
||
/**
|
||
* 全字段覆写凭据槽位(access_token 必须为明文;响应里返回的同样是脱敏掩码)。
|
||
*/
|
||
export const updateCredentialSlot = async (
|
||
payload: CredentialSlotUpdatePayload
|
||
): Promise<CredentialSlot> => {
|
||
const body = {
|
||
app_id: payload.appId,
|
||
access_token: payload.accessToken,
|
||
// 不带 updated_at —— 后端 auto_now 维护,与 updateOutfit / updateAiModel 风格一致
|
||
}
|
||
const response = await apiClient.put('/v1/admin/credential-slot/', body)
|
||
const data = response.data?.data || response.data
|
||
return mapBackendCredentialSlot(data)
|
||
}
|
||
```
|
||
|
||
**关键约束(不可偏离,均来自 RESEARCH.md 实证)**:
|
||
1. 路径**必须**写 `/v1/admin/credential-slot/`,**不要**写 `/api/v1/admin/credential-slot/`(baseURL 已含 `/api`,重复会变 `/api/api/v1/...` 导致 404)
|
||
2. 解包行必须写 `const data = response.data?.data || response.data`(per RESEARCH Pitfall 1,拦截器**不**自动解包;不能直接 `mapBackendCredentialSlot(response.data)`)
|
||
3. PUT body **不带** `updated_at`(per RESEARCH 问题 5,与现有 `updateAiModel` / `updateOutfit` 一致)
|
||
4. `BackendCredentialSlot` 是**内部接口**(无 `export`),仅 adapter 入参类型用;不要把它 export
|
||
5. `mapBackendCredentialSlot` 是**模块级私有函数**(无 `export`),与现有 `mapBackendBot` / `mapBackendOutfit` 约定一致
|
||
6. 函数风格选 `export const fn = async () => {}`(与 `ai-models.ts` 一致;不要写 `export async function`)
|
||
7. **不要** import `lib/api/types.ts`(业务专属类型在本文件内定义,per RESEARCH 问题 4)
|
||
8. **不要**修改 `lib/api/types.ts` / `lib/api/adapters.ts` / `lib/api/client.ts`(CONTEXT.md 锁定)
|
||
</action>
|
||
|
||
<acceptance_criteria>
|
||
- 文件 `lib/api/credential-slot.ts` 存在且非空
|
||
- `grep -E "^import \{ apiClient \} from \"\./client\"" lib/api/credential-slot.ts` 命中 1 次
|
||
- `grep -E "^export interface CredentialSlot[^U]" lib/api/credential-slot.ts` 命中 1 次(公共响应类型,名字后**不**接 U,避免误命中 `CredentialSlotUpdatePayload`)
|
||
- `grep -E "^export interface CredentialSlotUpdatePayload" lib/api/credential-slot.ts` 命中 1 次
|
||
- `grep -E "^export const getCredentialSlot = async" lib/api/credential-slot.ts` 命中 1 次
|
||
- `grep -E "^export const updateCredentialSlot = async" lib/api/credential-slot.ts` 命中 1 次
|
||
- `grep -E "function mapBackendCredentialSlot" lib/api/credential-slot.ts` 命中 1 次
|
||
- `grep -E "interface BackendCredentialSlot" lib/api/credential-slot.ts` 命中 1 次(不带 export)
|
||
- `grep -F "apiClient.get('/v1/admin/credential-slot/')" lib/api/credential-slot.ts` 命中 1 次
|
||
- `grep -F "apiClient.put('/v1/admin/credential-slot/'" lib/api/credential-slot.ts` 命中 1 次
|
||
- `grep -F "response.data?.data || response.data" lib/api/credential-slot.ts` 命中 2 次(GET / PUT 各一次)
|
||
- `grep -E "/api/v1/admin/credential-slot" lib/api/credential-slot.ts` **不**命中(确保路径没多写 `/api`)
|
||
- `grep -E "updated_at" lib/api/credential-slot.ts` 仅命中 `BackendCredentialSlot.updated_at` 与 `mapBackendCredentialSlot` 内 `raw.updated_at` 与一行注释,**不**出现在 PUT body 字面量里
|
||
</acceptance_criteria>
|
||
|
||
<verify>
|
||
<automated>node -e "const c = require('fs').readFileSync('lib/api/credential-slot.ts','utf8'); const checks = [/^import \{ apiClient \} from \"\.\/client\"/m, /^export interface CredentialSlot\b/m, /^export interface CredentialSlotUpdatePayload\b/m, /^export const getCredentialSlot = async/m, /^export const updateCredentialSlot = async/m, /function mapBackendCredentialSlot/, /interface BackendCredentialSlot/, /apiClient\.get\('\/v1\/admin\/credential-slot\/'\)/, /apiClient\.put\('\/v1\/admin\/credential-slot\/'/]; const fails = checks.filter(r => !r.test(c)); if (fails.length) { console.error('FAIL:', fails.map(r=>r.toString()).join('\n')); process.exit(1); } if (/\/api\/v1\/admin\/credential-slot/.test(c)) { console.error('FAIL: 路径错误,含 /api 前缀'); process.exit(1); } const occCount = (c.match(/response\.data\?\.data \|\| response\.data/g) || []).length; if (occCount !== 2) { console.error('FAIL: 双保险解包行命中次数 =', occCount, '应为 2'); process.exit(1); } console.log('OK'); "</automated>
|
||
</verify>
|
||
|
||
<done>
|
||
- `lib/api/credential-slot.ts` 文件存在
|
||
- 上述 acceptance_criteria 中所有 grep 检查全部满足
|
||
- `verify.automated` 命令退出码为 0 且打印 `OK`
|
||
- 文件不含 `/api/v1/admin/credential-slot` 这种重复 `/api` 前缀
|
||
- PUT body 字面量内不含 `updated_at` 键
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto" tdd="false">
|
||
<name>Task 2:在 lib/api/index.ts 末尾追加具名 re-export</name>
|
||
|
||
<read_first>
|
||
- `lib/api/index.ts`(全文 197 行)— 确认当前导出风格混合(`export *` + `usersApi` / `rolesApi` 对象 + `handleApiError` 函数),定位末尾追加位置
|
||
- `.planning/phases/01-credential-slot-api/01-CONTEXT.md` <decisions> 节「`lib/api/index.ts` 导出」段(L130-141)— 锁定的具名 re-export 写法
|
||
- `.planning/phases/01-credential-slot-api/01-RESEARCH.md` 「6. lib/api/index.ts 导出风格」节 — 解释为何用具名 re-export 而非 `export *`
|
||
</read_first>
|
||
|
||
<files>lib/api/index.ts</files>
|
||
|
||
<action>
|
||
在 `lib/api/index.ts` **文件末尾**(最后一行 `}` 之后,紧贴 `handleApiError` 定义的下方)追加以下 7 行(不要修改文件中任何已有内容;不要插在文件中部):
|
||
|
||
```typescript
|
||
|
||
// 凭据槽位(Milestone v1.0 通用凭据槽位前端集成 — Phase 1 / CRED-FE-01)
|
||
export {
|
||
getCredentialSlot,
|
||
updateCredentialSlot,
|
||
type CredentialSlot,
|
||
type CredentialSlotUpdatePayload,
|
||
} from './credential-slot'
|
||
```
|
||
|
||
**关键约束**:
|
||
1. 必须用**具名 re-export**(`export { fn1, fn2, type T } from './module'`),**不**要用 `export * from './credential-slot'`(per RESEARCH 问题 6:具名导出更可控、可读性最高)
|
||
2. **必须**包含 `type CredentialSlot` 与 `type CredentialSlotUpdatePayload` 两个类型导出(前置 `type` 关键字使其在 isolatedModules 模式下安全)
|
||
3. 追加位置选**文件末尾**(`handleApiError` 之后),不要插在 `usersApi` / `rolesApi` 之间
|
||
4. 该模块导入路径用 `'./credential-slot'`(相对路径,与现有 `export * from './card'` 一致),不要写 `'@/lib/api/credential-slot'`
|
||
5. **不要**触碰文件中其他任何行(包括 `import * as client`、`export * from './card'`、`usersApi`、`rolesApi`、`handleApiError`)
|
||
|
||
追加之前最后一行示意(确认 anchor):
|
||
```typescript
|
||
// 导出错误处理函数
|
||
export const handleApiError = (error: any) => {
|
||
if (error instanceof Error) {
|
||
return error.message
|
||
}
|
||
return "发生未知错误,请重试"
|
||
}
|
||
// ← 在这行 } 之后追加上述 7 行
|
||
```
|
||
</action>
|
||
|
||
<acceptance_criteria>
|
||
- `grep -F "from './credential-slot'" lib/api/index.ts` 命中 1 次
|
||
- `grep -F "getCredentialSlot," lib/api/index.ts` 命中 1 次
|
||
- `grep -F "updateCredentialSlot," lib/api/index.ts` 命中 1 次
|
||
- `grep -F "type CredentialSlot," lib/api/index.ts` 命中 1 次
|
||
- `grep -F "type CredentialSlotUpdatePayload," lib/api/index.ts` 命中 1 次
|
||
- `grep -F "export * from './credential-slot'" lib/api/index.ts` **不**命中(确保用具名而非 barrel)
|
||
- 文件原有 197 行业务代码(usersApi / rolesApi / handleApiError / 顶部 4 行 export * /import)保留不变
|
||
- 追加后整个文件经 `node -e "require('fs').readFileSync(...)"` 不报错(合法 UTF-8)
|
||
</acceptance_criteria>
|
||
|
||
<verify>
|
||
<automated>node -e "const c = require('fs').readFileSync('lib/api/index.ts','utf8'); const checks = [[/from\s+'\.\/credential-slot'/, 1], [/getCredentialSlot,/, 1], [/updateCredentialSlot,/, 1], [/type CredentialSlot,/, 1], [/type CredentialSlotUpdatePayload,/, 1]]; for (const [r, expected] of checks) { const n = (c.match(new RegExp(r.source, 'g')) || []).length; if (n !== expected) { console.error('FAIL:', r.toString(), '命中', n, '期望', expected); process.exit(1); } } if (/export \* from '\.\/credential-slot'/.test(c)) { console.error('FAIL: 错误使用 export * 风格'); process.exit(1); } if (!/export \* from \"\.\/card\"/.test(c) && !/export \* from '\.\/card'/.test(c)) { console.error('FAIL: 现有 card 导出被破坏'); process.exit(1); } if (!/export const handleApiError/.test(c)) { console.error('FAIL: 现有 handleApiError 被破坏'); process.exit(1); } console.log('OK'); "</automated>
|
||
</verify>
|
||
|
||
<done>
|
||
- `lib/api/index.ts` 末尾包含具名 re-export 块
|
||
- 4 个符号(2 函数 + 2 类型)全部导出
|
||
- 现有 `export * from "./card"` / `usersApi` / `rolesApi` / `handleApiError` 不受破坏
|
||
- `verify.automated` 命令退出码为 0 且打印 `OK`
|
||
</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<verification>
|
||
本 plan 完成后,**仅**做 grep / 文件存在性级别的结构性验证(acceptance_criteria)。完整的工具链验证(`npm run lint` + `npx tsc --noEmit`)放在 plan 02,因为 plan 02 也要编辑 `docs/修改记录.md`,把所有 lint/type-check 集中在 plan 02 末尾一次跑完,避免重复运行类型检查。
|
||
|
||
唯一例外:每个 task 的 `verify.automated` 已嵌入文本级正则检查,确保关键串(路径、解包行、类型名、re-export 路径)就位。
|
||
</verification>
|
||
|
||
<success_criteria>
|
||
- [ ] `lib/api/credential-slot.ts` 文件存在
|
||
- [ ] 文件导出 `CredentialSlot` 类型 + `CredentialSlotUpdatePayload` 类型 + `getCredentialSlot` 函数 + `updateCredentialSlot` 函数共 4 个公共符号
|
||
- [ ] `mapBackendCredentialSlot` 函数已定义(私有,未导出)
|
||
- [ ] GET / PUT 路径**精确**为 `/v1/admin/credential-slot/`(不含重复 `/api`)
|
||
- [ ] GET 与 PUT 函数体内各含 1 次 `response.data?.data || response.data` 双保险解包
|
||
- [ ] PUT body 字面量**不含** `updated_at`
|
||
- [ ] `lib/api/index.ts` 末尾通过具名 re-export 暴露 4 个符号,路径 `./credential-slot`
|
||
- [ ] `lib/api/index.ts` 中现有 `export * from "./card" / "./upload" / "./food"` 与 `usersApi` / `rolesApi` / `handleApiError` 完全不变
|
||
- [ ] 两个文件均为合法 UTF-8(无 BOM 干扰、无残缺字符)
|
||
</success_criteria>
|
||
|
||
<output>
|
||
完成后创建 `.planning/phases/01-credential-slot-api/01-01-SUMMARY.md`,记录:
|
||
- 创建的 `credential-slot.ts` 完整字节大小、行数
|
||
- `index.ts` 追加前后的行数对比
|
||
- 实际命中的关键串清单(GET 路径、PUT 路径、4 个导出符号、双保险解包行计数)
|
||
- 任何与 PLAN action 锁定写法的偏差及理由(理论上应为 0 偏差)
|
||
</output>
|