diff --git a/qy-lty-admin/.planning/phases/01-credential-slot-api/01-RESEARCH.md b/qy-lty-admin/.planning/phases/01-credential-slot-api/01-RESEARCH.md new file mode 100644 index 0000000..16f61ac --- /dev/null +++ b/qy-lty-admin/.planning/phases/01-credential-slot-api/01-RESEARCH.md @@ -0,0 +1,720 @@ +# Phase 1:凭据槽位 API 客户端 - Research + +**Researched**: 2026-05-08 +**Domain**: Next.js 15 + Axios API client(业务模块新增) +**Confidence**: HIGH(所有关键问题均已通过 read_first 直接验证;唯一 LOW 项是包管理器选择,详见包管理器节) + +--- + +## Summary + +本 phase 是纯逻辑层(无 UI):新建 `lib/api/credential-slot.ts`,封装两个调用 + 类型 + 后端→前端适配器,并从 `lib/api/index.ts` 导出。所有 8 个研究问题已 1:1 落地: + +1. **响应拦截器不解包**:`apiClient` 响应拦截器只 `console.log` 后透传 `response`,**不**做 `response.data = data.data` 解包。仓库统一约定是在每个 API 函数里手动 `const data = response.data?.data || response.data` 做"双保险"提取。 +2. **1:1 模板 = `lib/api/ai-models.ts`**:与本 phase 语义最贴近(同一 `/ai-model` 页面、单数据集、CRUD 子集),路径风格、adapter、`response.data?.data || response.data` 模式都可直接照抄。 +3. **路径风格**:`baseURL` 已吃掉 `/api`,调用方写 `/v1/admin/credential-slot/`(不含 `/api`)。 +4. **类型放置**:业务专属类型放各模块文件本身(不放 `types.ts`),仅共享的 `ApiResponse` 等放 `client.ts`。新模块的 `CredentialSlot` / `CredentialSlotUpdatePayload` 应放 `credential-slot.ts` 内部。 +5. **PUT body 不带 `updated_at`**:仓库现有 `outfits.ts`、`ai-models.ts` 的 PATCH/PUT 全部只传业务字段,**不**带 `updated_at` 等服务端字段。沿用此约定。 +6. **`index.ts` 导出风格混合**:现有用 `export *`(card / upload / food)+ 显式对象(`usersApi` / `rolesApi`)。新模块按 CONTEXT.md 锁定方案,使用**具名 re-export**(不与现有任一风格完全一致,但与 CLAUDE.md「barrel 文件」规范一致;CONTEXT.md 已明确写法)。 +7. **`npm run lint` 实际跑 `next lint`**(不含 `tsc --noEmit`)。`next.config.mjs` 的 `typescript.ignoreBuildErrors: true` 仅影响 `next build`;执行 `tsc --noEmit` 仍会真实报错,但**项目脚本未提供该入口**,需单独命令 `npx tsc --noEmit`。 +8. **包管理器**:`yarn.lock` + `package-lock.json` 同时存在且 mtime 完全一致;`pnpm-lock.yaml` 仅有 5 行 settings 是空壳。**推荐用 npm**(CLAUDE.md「开发命令」节示例就是 `npm install` / `npm run lint`,且 `package-lock.json` 体量完整),但本仓库现状本就「混用」——本 phase 内部使用 `npm run lint` 即可,不主动重新生成任何 lockfile。 + +**Primary recommendation**:完全照搬 `lib/api/ai-models.ts` 的骨架结构(mapBackend* + `response.data?.data || response.data` + `apiClient.get/put` 直接调用 + 业务类型在文件内定义);CONTEXT.md 已给出的代码片段不需要修改即可工作。 + +--- + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +- **新建文件**:`lib/api/credential-slot.ts`(单文件,沿用 `lib/api/` 扁平结构) +- **修改文件**:`lib/api/index.ts`(追加导出) +- **不动**:`lib/api/client.ts`、`lib/api/adapters.ts`、`lib/api/types.ts` +- **类型设计**(关键命名约束): + - 前端响应类型 `CredentialSlot { appId, accessTokenMasked, updatedAt }`,`accessTokenMasked` 字段名**故意**带 `Masked` 后缀,明确"已脱敏"语义 + - 提交载荷类型 `CredentialSlotUpdatePayload { appId, accessToken }`(无 Masked 后缀,明文语义) + - 两类型故意命名不同,让 TS 编译期捕捉"把脱敏字符串当真值回写"的 bug +- **接口契约**(已锁定,不质疑): + - GET / PUT 同 URL `/api/v1/admin/credential-slot/` + - GET 响应 `data.access_token` 为脱敏掩码(末 4 位明文) + - PUT 请求体 `{ app_id, access_token }`,明文覆写 + - 标准壳层 `{ success, code, message, data }`,错误为 401(无 token)/ 403(非 admin) +- **API 函数签名**:`getCredentialSlot(): Promise` + `updateCredentialSlot(payload): Promise` +- **沿用 mapBackend\* adapter 约定** +- **不引入新依赖** + +### Claude's Discretion + +- PUT body 是否携带 `updated_at` 字段——本研究确认:**不带**(与仓库现有 PATCH/PUT 一致;详见「PUT body 是否含 updated_at」节) +- 类型放置位置(专属文件 vs `types.ts`)——本研究确认:**放专属文件 `credential-slot.ts` 内**(详见「类型放置位置」节) +- 是否给函数加 JSDoc 中文注释——**推荐加**(与 `auth.ts` 风格一致;规范文档明示「JSDoc 风格注释用于公共 API 函数」) + +### Deferred Ideas (OUT OF SCOPE) + +- `/ai-model` 页面入口 / RBAC 模块声明 — Phase 2 +- 编辑对话框 + Sonner toast 反馈 — Phase 3 +- 跨项目互引修改记录(后端 Phase 2 commit `46d72b8` 已建立,前端 Phase 1 不需要再次互引) +- E2E 测试(项目无 Cypress/Playwright;Phase 1 仅验 tsc + adapter 单元行为) +- 真实后端 dev server 联调(Phase 2/3 再做) + +--- + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| CRED-FE-01 | API 客户端 `lib/api/credential-slot.ts`:导出 `getCredentialSlot()`、`updateCredentialSlot({ app_id, access_token })`;含响应适配器 `mapBackendCredentialSlot()`(snake_case → camelCase);共享类型 `CredentialSlot { appId, accessTokenMasked, updatedAt }`;从 `lib/api/index.ts` 导出 | 「1:1 模板」「响应拦截器解包」「路径风格」「类型放置」「PUT body」「index.ts 导出」六节联合给出可直接落地的写法。 | + +--- + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| HTTP 通信(GET / PUT) | API 客户端层(`lib/api/`) | — | 仓库统一通过 `apiClient` 单例访问后端,token / 401 拦截器复用 | +| snake_case → camelCase 映射 | API 客户端层(模块内 `mapBackend*` 函数) | — | 仓库现有约定:每个业务模块自带 mapBackend\* 函数(不抽到 `adapters.ts`) | +| 类型契约定义 | API 客户端层(业务专属文件) | — | 现有约定:业务专属类型在 `lib/api/[module].ts` 内 export,跨模块共享类型才进 `types.ts` | +| Bearer token 注入 | (已存在,不在本 phase)`lib/api/client.ts` 请求拦截器 | — | 单例拦截器自动注入,新模块零成本继承 | +| 401 未授权统一处理 | (已存在,不在本 phase)`lib/api/client.ts` 响应拦截器 | — | 单例拦截器自动重定向到 `/login`,新模块零成本继承 | +| 业务消费(页面 / 组件) | (Phase 2 / 3)`app/ai-model/page.tsx` + `components/ai-model/` | — | 本 phase 不涉及,纯客户端层 | + +--- + +## Standard Stack + +### Core(已存在;本 phase 不新增) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| axios | ^1.9.0 | HTTP 客户端单例 | [VERIFIED: package.json L42] 全仓库统一通过 `apiClient` 调用后端;拦截器已就位 | +| TypeScript | ^5 | 类型契约 | [VERIFIED: package.json L71] strict 模式开启(`tsconfig.json`) | +| Next.js | 15.2.4 | 框架(运行时无关,仅环境变量 `NEXT_PUBLIC_API_BASE_URL`) | [VERIFIED: package.json L51] | + +### Supporting(本 phase 完全不依赖;列出供 Phase 2/3 参考) + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| react-hook-form | latest | 表单状态 | Phase 3 对话框 | +| zod | latest | 表单 schema 校验 | Phase 3 对话框 | +| sonner | ^1.7.1 | Toast 反馈 | Phase 3 提交反馈 | + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| 在 `credential-slot.ts` 内定义 `BackendCredentialSlot` 类型 | 抽到 `lib/api/types.ts` 共享 | 仓库现有约定:业务专属类型在模块内(参见 outfits.ts、ai-models.ts 都没把后端原始类型放 types.ts)。专属文件更内聚。 | +| `mapBackendCredentialSlot` 放 `lib/api/adapters.ts` | 放业务模块文件内 | 仓库现有约定:所有 `mapBackend*` 都在各业务模块内(adapters.ts 实际只放了 `apiSongToComponentSong` 这种"API 类型 → 组件类型"映射,不放"后端 → API 类型"映射)。沿用业务模块内放法。 | +| PUT body 带 `updated_at` 占位 | 只传 `{ app_id, access_token }` | 仓库现有 `updateOutfit`/`updateAiModel` 全部仅传业务字段。沿用最简方案。 | + +**Installation**:无(不引入新依赖;CONTEXT.md 锁定)。 + +**Version verification**:跳过(不新增依赖)。 + +--- + +## Architecture Patterns + +### System Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Phase 1 范围(虚线内 = 本 phase 唯一新增 / 修改) │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ lib/api/credential-slot.ts(新增) │ │ +│ │ │ │ +│ │ getCredentialSlot() ─┐ │ │ +│ │ ├─→ apiClient.get/put │ │ +│ │ updateCredentialSlot() ─┘ '/v1/admin/credential-slot/' │ │ +│ │ ↓ │ │ +│ │ mapBackendCredentialSlot(raw) │ │ +│ │ {app_id,access_token,updated_at} │ │ +│ │ ↓ snake → camel │ │ +│ │ {appId,accessTokenMasked,updatedAt} │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ lib/api/index.ts(修改) │ │ +│ │ export { getCredentialSlot, updateCredentialSlot, │ │ +│ │ type CredentialSlot, type CredentialSlotUpdatePayload } │ │ +│ │ from './credential-slot' │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + ↓ 复用(不修改) +┌──────────────────────────────────────────────────────────────────┐ +│ lib/api/client.ts(已存在) │ +│ apiClient = axios.create({ baseURL: NEXT_PUBLIC_API_BASE_URL }) │ +│ ├─ 请求拦截器:localStorage.auth_token → Bearer header │ +│ └─ 响应拦截器:401 → 清空 token + 重定向 /login │ +│ │ +│ ⚠️ 响应拦截器不解包 data.data;调用方手动 ?.data || .data │ +└──────────────────────────────────────────────────────────────────┘ + ↓ HTTP +┌──────────────────────────────────────────────────────────────────┐ +│ qy_lty 后端(已存在;后端 Phase 2 commit 46d72b8 落地) │ +│ /api/v1/admin/credential-slot/ │ +│ GET → { success, code, message, data: { │ +│ app_id, access_token (掩码), updated_at } } │ +│ PUT → 同 GET 响应(access_token 也是掩码返回) │ +│ PUT body { app_id, access_token (明文) } │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Component Responsibilities + +| 文件 | 角色 | 本 phase 操作 | +|------|------|---------------| +| `lib/api/credential-slot.ts` | 凭据槽位 API client(GET / PUT + adapter + 类型) | **新增** | +| `lib/api/index.ts` | barrel re-export | **追加 4 行导出** | +| `lib/api/client.ts` | Axios 单例 + 拦截器 | **不动**(仅复用) | +| `lib/api/types.ts` | 跨模块共享类型 | **不动** | +| `lib/api/adapters.ts` | 跨模块"API 类型 ↔ 组件类型"适配(**不**放 mapBackend\*) | **不动** | +| `docs/修改记录.md` | 变更日志 | 顶部追加一条 Phase 1 条目 | + +### Recommended Project Structure + +``` +lib/api/ +├── client.ts # ← 已存在,单例 + 拦截器 +├── types.ts # ← 已存在,跨模块共享类型 +├── adapters.ts # ← 已存在,仅"API 类型 → 组件类型"映射 +├── ai-models.ts # ← 1:1 模板 +├── outfits.ts # ← 备选模板(CRUD 范式更完整) +├── credential-slot.ts # ← 本 phase 新增 +└── index.ts # ← 本 phase 追加导出 +``` + +### Pattern 1: 业务 API 模块骨架(来自 `ai-models.ts`) + +**What**:每个业务接口一个文件;文件结构 = (类型 import) + `mapBackend*` 函数 + 一组 `apiClient.get/post/put/patch/delete` 命令。 +**When to use**:本 phase 完全照搬。 + +**Example**: + +```typescript +// Source: lib/api/ai-models.ts L1-50(已 read_first 验证) +import type { AiModel } from "./types" +import { apiClient } from "./client" +import type { PaginatedResponse, PaginationParams } from "./client" + +function mapBackendBot(b: any): AiModel { + return { + id: String(b.id), + name: b.name, + // ... snake → camel 字段映射 + } +} + +export const getAiModels = async (params?: PaginationParams): Promise> => { + const response = await apiClient.get(`/ai/bots/?page=${page}&page_size=${pageSize}${searchParam}`) + const data = response.data?.data || response.data // ← 关键:手动解包 + // ... +} + +export const getAiModel = async (id: string): Promise => { + const response = await apiClient.get(`/ai/bots/${id}/`) + const data = response.data?.data || response.data + return mapBackendBot(data) +} + +export const updateAiModel = async (id: string, modelData: Partial): Promise => { + const payload: any = {} + if (modelData.name !== undefined) payload.name = modelData.name + if (modelData.description !== undefined) payload.description = modelData.description + // ⚠️ 注意:updated_at 不在 payload 中 + + const response = await apiClient.patch(`/ai/bots/${id}/`, payload) + const data = response.data?.data || response.data + return mapBackendBot(data) +} +``` + +**关键点**: +- 函数风格:仓库现有 6 个模块(`auth.ts`、`outfits.ts`、`ai-models.ts`、`food.ts`...)混用「`export const fn = async`」与「`export async function fn`」两种写法。**推荐选 `export const fn = async (...) => {}` 形式**(与 `ai-models.ts` 一致)。 +- 类型来源:`AiModel` 从 `./types` 引;本 phase 业务专属类型直接在 `credential-slot.ts` 内定义即可(详见「类型放置位置」节)。 +- adapter 名字:`mapBackend`(`mapBackendBot`、`mapBackendOutfit`...)。本 phase 沿用 `mapBackendCredentialSlot`。 + +### Pattern 2: 响应数据解包(最关键) + +**What**:响应拦截器**不**自动提取 `data.data`,调用方必须手动 `response.data?.data || response.data`。 +**Why**:仓库统一兼容两种后端响应形态—— +- 经过 Django `StandardResponseMiddleware` 包裹的 `{ success, code, message, data: {...} }`(绝大多数 admin 接口) +- 没有包壳层的 DRF 原生响应(少量历史接口) + +**Example(本 phase 应用)**: + +```typescript +// CONTEXT.md 给出的写法是正确的;解包行为已验证 +export const getCredentialSlot = async (): Promise => { + const response = await apiClient.get('/v1/admin/credential-slot/') + const data = response.data?.data || response.data // ← 必须有这行 + return mapBackendCredentialSlot(data) +} +``` + +**注意**:CONTEXT.md 第 113 行提到「拦截器已解包 success/code/message/data,到这里 response.data 就是 BackendCredentialSlot」**这段是 CONTEXT.md 内的"待 researcher 确认"假设**,本研究已确认:**拦截器并未解包**;必须手动 `?.data || .data`。 + +### Anti-Patterns to Avoid + +- **直接 `response.data` 当 BackendCredentialSlot 用**:会得到 `{ success, code, message, data: { app_id, ... } }` 整个壳层,导致 `mapBackendCredentialSlot` 拿到错误字段。 +- **直接 `response.data.data` 不带 `?.`**:若后端中间件因某种原因没包壳,`response.data.data` 是 `undefined`,会运行时 NPE。 +- **抽 `mapBackendCredentialSlot` 到 `adapters.ts`**:违反仓库现有约定(见 `outfits.ts` L5、`ai-models.ts` L7、`food.ts` 等都把 mapBackend\* 放业务文件内)。 +- **PUT body 带 `updated_at: ''` 占位**:现有 `updateOutfit` / `updateAiModel` 都没带服务端字段。后端 `auto_now=True` 自动维护,前端不需要传。 +- **路径写 `/api/v1/admin/credential-slot/`**:`API_BASE_URL` 默认 `http://localhost:8000/api`,会重复。正确写法去掉 `/api` 前缀。 +- **使用 `apiClient.put` 时直接 `apiClient.put(url, payload)` 不指定泛型**:现有模块也没指定泛型(直接 `any`),保持一致即可;类型安全靠 `mapBackendCredentialSlot` 入参收口。 + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Bearer token 注入 | 任何 `headers.Authorization = ...` 手动设置 | `apiClient` 请求拦截器(已存在) | 拦截器读 `localStorage.auth_token` 自动注入(client.ts L20-30) | +| 401 错误处理 | `try/catch` 中手动判 status | `apiClient` 响应拦截器(已存在) | 拦截器统一清 token + 重定向(client.ts L45-65) | +| `ApiResponse` 类型 | 重新定义壳层类型 | `import { ApiResponse } from './client'` 或 `'./types'` | client.ts L91-96 + types.ts L1-7 已两处导出(重复但兼容) | +| HTTP 重试 / 错误映射 toast | 手撸重试 / toast | (Phase 3)`lib/api/error-handler.ts`(已存在) | 本 phase 不需要,Phase 3 再消费 | +| snake → camel 通用 helper | 反射式 `mapKeys(snakeCase → camelCase)` | 手写每个字段(仓库统一约定) | 仓库 6 个模块全是手写映射,不引入通用 helper | + +**Key insight**:本 phase 几乎所有"看起来应该有"的基础设施都已存在;新模块只做"业务字段映射 + 两个 axios 调用"两件事。 + +--- + +## Runtime State Inventory + +> 本 phase 是**纯新增**(无 rename/refactor/migration),但 CRED-FE-01 要求新模块从 `lib/api/index.ts` 导出,需扫一遍是否有"已经在某处声明 / 占位"的同名残留。 + +| Category | Items Found | Action Required | +|----------|-------------|------------------| +| Stored data | None — 本 phase 无任何数据存储变更(新增纯客户端代码,不接触 localStorage / Cookie / IndexedDB) | 无 | +| Live service config | None — 不影响后端配置;后端 Phase 2 已落地,本 phase 仅消费已存在的接口 | 无 | +| OS-registered state | None — Web 前端无 OS 注册项 | 无 | +| Secrets/env vars | `NEXT_PUBLIC_API_BASE_URL` 已存在(`.env.local`),本 phase 复用;不新增任何环境变量 | 无 | +| Build artifacts | None — `.next/`、`node_modules/` 不受新增源文件影响(重新 `npm run build` 即可) | 无 | + +--- + +## Common Pitfalls + +### Pitfall 1: 把"假设拦截器已解包"当真 + +**What goes wrong**:CONTEXT.md 第 113 行的注释自承认这是 read_first 待验证项;如果不验证就写成 `return mapBackendCredentialSlot(response.data)`,运行时拿到的是整个壳层,`raw.app_id` / `raw.access_token` 都是 `undefined`。 +**Why it happens**:训练数据里很多 axios 教程会示范"在响应拦截器里 `return response.data`"提取一层;本仓库**没**这么做。 +**How to avoid**:照抄 `ai-models.ts` L31-32 / L48-49 的 `const data = response.data?.data || response.data`,**所有调用都用这一行**。 +**Warning signs**:调试时 `console.log(data)` 看到 `{ success: true, code: 0, message: '...', data: {...} }` 而不是 `{ app_id, ... }`。 + +### Pitfall 2: 路径多写 `/api` 前缀 + +**What goes wrong**:写成 `apiClient.get('/api/v1/admin/credential-slot/')`,最终 URL 变成 `http://localhost:8000/api/api/v1/admin/credential-slot/`,404。 +**Why it happens**:CLAUDE.md 第 60 行说「所有请求走 `NEXT_PUBLIC_API_BASE_URL + /api/v1/admin/...`」容易让人误以为 `apiClient.get` 的相对路径要含 `/api`,实际不需要。 +**How to avoid**:检查 `client.ts` L9:`API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000/api"` —— `/api` 已在 baseURL 里。`auth.ts` L27 / `ai-models.ts` L29 都用 `/v1/...` / `/ai/...` 不带 `/api`,照抄。 +**Warning signs**:浏览器 Network 面板看到 `/api/api/v1/...` 双重 `/api`。 + +### Pitfall 3: 把脱敏 `accessTokenMasked` 当明文回写 + +**What goes wrong**:Phase 3 表单组件如果直接把 `slot.accessTokenMasked` 当 `defaultValue`,用户不改提交时就把 `********xyz9` 这串脱敏字符当真值传给后端 PUT,覆盖真正的明文。 +**Why it happens**:字段名相近、运营不察觉。 +**How to avoid**:本 phase 锁定的命名 `accessTokenMasked` vs `accessToken` 已经在 TS 编译期切断这条路(`CredentialSlot.accessTokenMasked` 与 `CredentialSlotUpdatePayload.accessToken` 字段名不同,无法互赋);**Phase 1 的责任是把这个屏障建起来**,Phase 3 表单提交时只能拼装 `CredentialSlotUpdatePayload`(必须从空字符串 / 用户新输入起步)。 +**Warning signs**:Phase 3 出现 `accessToken: slot.accessTokenMasked` 这样的赋值——直接是 bug。 + +### Pitfall 4: `npm run lint` 漏掉类型错误 + +**What goes wrong**:`package.json` L9 `"lint": "next lint"` 只跑 ESLint(且 `next.config.mjs` L17 `eslint.ignoreDuringBuilds: true`,构建时也不报);TS 错误不会被它捕获。 +**Why it happens**:CLAUDE.md L23-24「类型检查 → `npm run lint`」措辞误导;CONTEXT.md L13/L143 也按此假设写。实际 `npm run lint` ≠ `tsc --noEmit`。 +**How to avoid**:本 phase 验证除了 `npm run lint`,**还要单独跑** `npx tsc --noEmit`。两者退出码都 0 才算通过。 +**Warning signs**:`npm run lint` 0 退出但跑 `npx tsc --noEmit` 时报 `Cannot find name 'CredentialSlot'` 等错误。 + +### Pitfall 5: 包管理器混用导致 lockfile 漂移 + +**What goes wrong**:CLAUDE.md L99 警告「不要混用」。本仓库 `package-lock.json`、`yarn.lock` mtime 完全相同(2026/3/17 13:52:16),`pnpm-lock.yaml` 是 5 行空壳——说明历史上有人混用过。如果本 phase 验证时用 `pnpm install` 会重新生成完整 `pnpm-lock.yaml`,与现状漂移。 +**How to avoid**:本 phase **不动任何依赖**(CONTEXT.md 锁定);`npm install`(如有需要)应避免——纯代码变更不需要 install。仅运行 `npm run lint` + `npx tsc --noEmit` 验证。 +**Warning signs**:git status 出现 `pnpm-lock.yaml` 或 `yarn.lock` 改动——立刻 `git checkout` 回去。 + +--- + +## Code Examples + +### 完整 `lib/api/credential-slot.ts` 骨架(基于 ai-models.ts 模板,可直接落地) + +```typescript +// Source: 综合 lib/api/ai-models.ts + CONTEXT.md 锁定的类型与签名 +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 => { + 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 => { + 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) +} +``` + +### `lib/api/index.ts` 追加导出(CONTEXT.md 锁定的写法) + +```typescript +// 在文件末尾追加(与"具名 re-export"风格一致;index.ts 当前已有 export * + 显式对象混用,新模块用具名 re-export 可读性最高) +export { + getCredentialSlot, + updateCredentialSlot, + type CredentialSlot, + type CredentialSlotUpdatePayload, +} from './credential-slot' +``` + +### `docs/修改记录.md` 顶部追加条目(基于现有「2026-05-07 Phase 2」条目格式) + +```markdown +### [2026-05-08] Phase 1(前端)凭据槽位 API 客户端 + +配套服务端 Phase:[../qy_lty/.planning/phases/02-admin-rest/](../../qy_lty/.planning/phases/02-admin-rest/)(已落地,commit 46d72b8) +覆盖前端需求:CRED-FE-01 + +- **文件路径**: + - `lib/api/credential-slot.ts`(新增) + - `lib/api/index.ts`(修改) +- **修改类型**: 新增(API 客户端层;纯逻辑,无 UI 改动) +- **修改内容**: + - 新建 `lib/api/credential-slot.ts`,封装: + - 类型 `CredentialSlot { appId, accessTokenMasked, updatedAt }`(脱敏掩码语义命名) + - 类型 `CredentialSlotUpdatePayload { appId, accessToken }`(明文语义命名) + - adapter `mapBackendCredentialSlot()`(snake_case → camelCase) + - API 函数 `getCredentialSlot()`、`updateCredentialSlot(payload)`,分别走 `apiClient.get/put` 命中 `/v1/admin/credential-slot/` + - `lib/api/index.ts` 追加具名 re-export,让组件层可 `import { getCredentialSlot, type CredentialSlot } from '@/lib/api'` +- **修改原因**: + - 启动 Milestone v1.0「通用凭据槽位前端集成」,本 phase 为后续 Phase 2(RBAC + 入口控件)、Phase 3(编辑对话框 + Sonner 反馈)提供调用层基础 + - `accessTokenMasked` vs `accessToken` 故意命名不同,让 TS 编译期捕捉「把脱敏字符串当真值回写 PUT」的 bug +- **服务端联动**: 无 — Phase 1 是纯 API client 层落地,调用的后端接口由 qy_lty 后端 Milestone v1.0 Phase 2 提供(commit `46d72b8` 已建立前后端互引);Phase 1 不引入新代码契约,无需再次互引。前端 UI 集成(Phase 2 + 3)完成前不会真正调到后端。 +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| 在响应拦截器里 `return response.data`(一些 axios 教程示例) | 拦截器不解包,调用方手动 `?.data \|\| .data`(本仓库约定) | 历史遗留 | 必须遵守,否则与所有现有模块行为不一致 | +| 通用 snake → camel helper(如 `humps`、`change-case`) | 手写 `mapBackend*` 函数 | 仓库历史选择 | 字段级控制力强,但需要手写每个映射 | +| `mapBackend*` 放统一 `adapters.ts` | 放各业务模块文件内 | 仓库历史选择 | `adapters.ts` 实际只放"API 类型 → 组件类型"映射(见 `apiSongToComponentSong`),不放后端→前端映射 | + +**Deprecated/outdated**: +- 无 — 本 phase 不涉及任何已废弃模式。 + +--- + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | 包管理器选 `npm`(CLAUDE.md「开发命令」节示例如此) | 包管理器节 | 低 — 本 phase **不动依赖**,不会触发 lockfile 重新生成;只跑 `npm run lint` 与 `npx tsc --noEmit`。若开发者本地装的是 yarn/pnpm,命令字面替换即可 | +| A2 | `next lint` 只跑 ESLint,**不**跑 `tsc --noEmit` | npm run lint 实际行为节 | 低 — 这是 Next.js 15.x 的 [VERIFIED: Next.js 官方文档行为];`next lint` 即 ESLint CLI 包装,与 TS 类型检查完全分离 | +| A3 | `pnpm-lock.yaml` 是空壳(5 行 settings,无包记录),不被任何命令真正使用 | 包管理器节 | 低 — 已 `Get-Content` 验证内容只有 `lockfileVersion: '9.0'` + `settings: { autoInstallPeers: true, excludeLinksFromLockfile: false }` 共 5 行;不是有效 pnpm lockfile | + +> **CONTEXT.md 中所有"已锁定"的决策(接口契约、类型命名、文件路径、不动既有代码)本研究均不重新论证,按"locked"接受。** + +--- + +## Open Questions + +无 — 所有 8 个核心研究问题均已通过 read_first 直接验证,可直接进入 plan。 + +--- + +## Environment Availability + +> 本 phase 是纯代码变更,依赖项已全部存在;下表仅示意。 + +| Dependency | Required By | Available | Version | Fallback | +|------------|-------------|-----------|---------|----------| +| Node.js | `npm run lint` / `npx tsc --noEmit` | ✓ | (仓库要求 22.10.0) | 无 | +| `next` CLI | `npm run lint`(即 `next lint`) | ✓(已在 dependencies) | 15.2.4 | 无 | +| `typescript` | `npx tsc --noEmit` 类型检查 | ✓(已在 devDependencies) | ^5 | 无 | +| `axios` | 运行期 | ✓(已在 dependencies) | ^1.9.0 | 无 | +| qy_lty 后端 dev server | **不要求**(Phase 1 仅静态类型 + 编译验证;联调留给 Phase 2/3) | — | — | 无需联调 | + +**Missing dependencies with no fallback**:无。 +**Missing dependencies with fallback**:无。 + +--- + +## Project Constraints (from CLAUDE.md) + +CLAUDE.md 直接相关的硬性约束(按本 phase 适用范围摘录): + +1. **语言约束**([VERIFIED: CLAUDE.md L98]):注释、commit message 用中文;面向用户的输出一律中文。本研究的 RESEARCH.md / 后续 PLAN.md / 修改记录条目均用中文。 +2. **修改记录强制规则**([VERIFIED: CLAUDE.md L70-95]):每次代码改动**必须**在同一会话内追加到 `docs/修改记录.md` **顶部**(最新在前);条目按头部「修改格式说明」格式(文件路径 / 修改类型 / 修改内容 / 修改原因 4 段)。 +3. **跨项目互引规则**([VERIFIED: CLAUDE.md L83-88]):`qy-lty-admin/docs/修改记录.md` 仅记录前端改动;跨项目联动需两端各写一条相互引用。本 Phase 1 **不需要**新建跨项目互引(后端 Phase 2 commit `46d72b8` 已建立)。 +4. **包管理器警告**([VERIFIED: CLAUDE.md L99]):「项目同时存在 `package-lock.json` 和 `pnpm-lock.yaml`;本地开发任选其一即可,但**不要混用**导致 lock 文件冲突。」本 phase 不动依赖,规避此风险。 +5. **shadcn 组件约定**([VERIFIED: CLAUDE.md L100]):`components/ui/` 是复制粘贴源码,可直接修改。本 Phase 1 不涉及 UI 组件。 +6. **API 路径约定**([VERIFIED: CLAUDE.md L60]):「所有请求走 `NEXT_PUBLIC_API_BASE_URL + /api/v1/admin/...`」——但实际 `apiClient.get` 的相对路径**不**包含 `/api`(已在 `baseURL` 里)。本研究确认正确写法是 `/v1/admin/credential-slot/`。 + +--- + +## 8 个核心研究问题的直接答案 + +### 1. apiClient 拦截器实际行为 + +**请求拦截器**(`client.ts` L19-42): +- ✅ 自动注入 `Authorization: Bearer ` +- token 来源:**`localStorage.getItem('auth_token')`**(**不是** cookie) +- SSR 安全(`typeof window !== 'undefined'` 判断) +- 副作用:5 条 `console.log`(带 emoji)—— REQUIREMENTS.md 已标记为「极高」需移除(PERM-06 段落附近的候选优先级 #2),**但不在本 phase 范围** + +**响应拦截器**(`client.ts` L44-65): +- ✅ 处理 401:清空 `localStorage.auth_token` + `window.location.href = '/login'`(除非已在 /login) +- ❌ **不解包** `data.data`:`(response) => { console.log(...); return response; }` 完整透传 `AxiosResponse` +- 因此调用方 `response.data` 拿到的是**完整的** `{ success, code, message, data: {...} }` 壳层 +- 仓库统一约定:在每个 API 函数里写 `const data = response.data?.data || response.data`(兼容两种壳层形态) + +**关键结论**:`getCredentialSlot()` 中必须写 `const data = response.data?.data || response.data; return mapBackendCredentialSlot(data)`,**不**能直接 `return mapBackendCredentialSlot(response.data)`。 + +[VERIFIED: lib/api/client.ts L19-65 + lib/api/ai-models.ts L31-32, L48-49 + lib/api/outfits.ts L33, L49 + lib/api/auth.ts L32(auth.ts 例外,因为 login 接口直接消费整个 response.data)] + +### 2. 1:1 模板模块 + +**首选**:`lib/api/ai-models.ts` L1-85(85 行整文件可读) +- 与本 phase 同在 `/ai-model` 页面(Phase 2 入口控件即 `/ai-model`) +- 单数据集(不是嵌套资源) +- 包含完整 CRUD(GET list / GET single / POST / PATCH / DELETE)—— 本 phase 只用其中 GET single + PATCH 模式(升级为 PUT) +- 关键参考行: + - L7-20:`mapBackendBot` adapter(snake → camel + 字段默认值) + - L46-50:`getAiModel(id)` 单资源 GET 模式(**与本 phase `getCredentialSlot()` 形态完全一致**,差别仅在 URL 是否带 ID) + - L65-73:`updateAiModel(id, modelData)` 部分更新(PATCH 而非 PUT;本 phase 用 PUT 全字段覆写,但 body 构造方式可借鉴) + +**次选**:`lib/api/outfits.ts` L1-103 +- CRUD 范式更完整(含 publish / archive 等动作) +- 但本 phase 不需要 list / publish,多余信息会增加照抄难度 + +**不推荐**: +- `lib/api/auth.ts`:处理 token 持久化、Cookie 同步等副作用,与本 phase 形态差异大 +- `lib/api/index.ts` 内的 `usersApi` / `rolesApi`:是 mock 数据 fixture,不是真正调后端 + +[VERIFIED: 直接 read 三文件 + REQUIREMENTS.md L38 标记 ai-models 为 AI-01] + +### 3. 路径风格 + +**结论**:`apiClient.get/put` 的相对路径写 **`/v1/admin/credential-slot/`**(不含 `/api`)。 + +**证据**: +- `client.ts` L9:`API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000/api"` — `/api` 已在 baseURL +- `auth.ts` L27:`apiClient.post('/v1/admin/login/', ...)` — 不含 `/api` +- `ai-models.ts` L29:`apiClient.get(\`/ai/bots/?...\`)` — 不含 `/api`,也不含 `/v1/admin`(DRF 接口前缀差异) +- `outfits.ts` L29:`apiClient.get(\`/card/category/clothing/?...\`)` — 不含 `/api` +- 拼接结果:`http://localhost:8000/api` + `/v1/admin/credential-slot/` = `http://localhost:8000/api/v1/admin/credential-slot/` ✓ + +**注意**:CLAUDE.md L60「所有请求走 `NEXT_PUBLIC_API_BASE_URL + /api/v1/admin/...`」是**完整 URL**视角描述;调用方代码不需要重复写 `/api`。 + +[VERIFIED: lib/api/client.ts L9 + auth.ts L27, L115 + ai-models.ts L29 + outfits.ts L29] + +### 4. 类型放置位置 + +**结论**:业务专属类型放 `credential-slot.ts` 内,**不**放 `types.ts`。 + +**证据**: +- `types.ts` L1-318:包含 `ApiResponse`(共享)、`User`、`Role`、`Permission`、`Outfit`、`Prop`、`Song`(含 `rawData` 子结构)、`Dance`、`Food`、`HomeDecor`、`AffinityLevel`、`AffinityRule`、`AiModel`、`Achievement`——是**已被多处消费**的"前端类型"集合 +- 所有"后端原始 dict 类型"都**不**在 `types.ts` 里: + - `outfits.ts` L5 直接用 `mapBackendOutfit(item: any)` —— 不定义 `BackendOutfit` 类型 + - `ai-models.ts` L7 同样 `mapBackendBot(b: any)` —— 不定义 `BackendBot` 类型 +- CONTEXT.md L67-73 给出的 `BackendCredentialSlot interface` 是**显式类型**,比仓库现有约定**更严格**——这个改进无害,建议保留(用 `: BackendCredentialSlot` 替代 `: any` 提升类型安全) + +**结论的应用方式**: +- `CredentialSlot`、`CredentialSlotUpdatePayload`、`BackendCredentialSlot` 三个类型**全部在 `credential-slot.ts` 内** export / 内部 declare +- 不修改 `types.ts`(CONTEXT.md 已锁定「不动 types.ts」) + +[VERIFIED: lib/api/types.ts L1-318 + lib/api/outfits.ts L5 + lib/api/ai-models.ts L7] + +### 5. PUT body 是否含 updated_at + +**结论**:**不含**。 + +**证据**: +- `updateOutfit` (`outfits.ts` L75-86):payload 仅包含 `name` / `description` / `clothing_attributes` / `rarity` / `image_url`,**无** `updated_at`、`created_at` +- `updateAiModel` (`ai-models.ts` L65-73):payload 仅 `name` / `description`,**无** `updated_at` +- `createOutfit` (`outfits.ts` L52-72):同样无服务端字段 +- 后端 `auto_now=True` 自动维护 `updated_at`,前端传也会被忽略(甚至可能被 DRF serializer 当成"未知字段"忽略或报错) + +**结论的应用方式**:CONTEXT.md L94-103 提到的"备选:直接 `{ app_id, access_token }` 不带 `updated_at`"——选这个方案(不传 `updated_at`)。 + +[VERIFIED: lib/api/outfits.ts L52-86 + lib/api/ai-models.ts L52-73] + +### 6. lib/api/index.ts 导出风格 + +**现状**:仓库 `index.ts` 风格混合: +- L3-5:`export * from "./card" / "./upload" / "./food"`(barrel 风格) +- L8-142:`export const usersApi = { ... }` / `export const rolesApi = { ... }`(mock 数据对象,**不是**真 API) +- 没有任何模块用「具名 re-export」(`export { fn1, fn2, type T } from './module'`) 风格 + +**问题**:`export *` 会把模块所有顶层符号都暴露出来;`credential-slot.ts` 内部的 `mapBackendCredentialSlot` 没标 export 也不会泄漏,但**`BackendCredentialSlot` 内部接口**也不会泄漏(因为没 export 它)。所以理论上 `export * from './credential-slot'` 也能工作。 + +**推荐**:CONTEXT.md L132-139 给出的**具名 re-export** 写法。原因: +- 显式列出导出符号,可读性最高 +- `import { getCredentialSlot, type CredentialSlot } from '@/lib/api'` 路径短,开发体验好 +- `export *` 在重名冲突时(未来若引入另一个 `mapBackend*` 同名 helper)会编译错;具名导出更可控 +- 与现有 `export *` 风格不冲突(在同一文件混用合法) + +**结论**:照抄 CONTEXT.md 锁定的具名 re-export 写法,追加在 `index.ts` 末尾即可。 + +[VERIFIED: lib/api/index.ts L1-197 + CLAUDE.md / CONVENTIONS.md L138-140] + +### 7. npm run lint 实际行为 + +**结论**:`npm run lint` = `next lint`,**只跑 ESLint,不跑 tsc**。 + +**证据**: +- `package.json` L9:`"lint": "next lint"` +- `next lint` 是 Next.js 内置 ESLint 包装,[CITED: Next.js 官方文档默认行为] +- `next.config.mjs` L17:`eslint.ignoreDuringBuilds: true` —— 仅影响 `next build`,不影响显式 `next lint` 命令 +- `next.config.mjs` L20:`typescript.ignoreBuildErrors: true` —— 仅影响 `next build` 时的类型检查,**不**影响 `npx tsc --noEmit` + +**关键结论**:CONTEXT.md L13、L143-146 的成功标准「`tsc --noEmit` 通过(项目用 `npm run lint` 跑)」是**误判**——`npm run lint` 不会跑 tsc。 +**正确做法**:本 phase 验证步骤应明确两条命令: +1. `npm run lint`(ESLint 检查) +2. `npx tsc --noEmit`(类型检查) + +两者退出码都为 0 才算通过。Plan 必须显式列出这两条。 + +[VERIFIED: package.json L9 + next.config.mjs L16-21 + Next.js 文档 next-lint 行为] + +### 8. docs/修改记录.md 状态 + +**头部「修改格式说明」**(`docs/修改记录.md` L9-20): +``` +### [日期] 修改简述 + +- **文件路径**: 相对于项目根目录的文件路径 +- **修改类型**: 新增 / 修改 / 删除 / 重构 / 修复Bug +- **修改内容**: 具体修改了什么 +- **修改原因**: 为什么要做这个修改 +``` + +**已存在 Phase 2 互引条目**(L28-47,2026-05-07): +- 标题格式:`### [日期] Phase X — 简述` +- 包含「配套服务端 Phase」「覆盖服务端需求」前置元数据行 +- 文件路径段:用反引号 + 相对路径 +- 修改原因段:可多 bullet +- 「服务端联动」段:可选,与「修改原因」并列 + +**Phase 1 条目模板**:本研究已在「Code Examples」节给出完整可粘贴文本。 + +[VERIFIED: docs/修改记录.md L1-50] + +### 包管理器选定 + +**lockfile 现状**(`Get-Item` 验证): + +| 文件 | mtime | 大小 | 内容判断 | +|------|-------|------|---------| +| `package-lock.json` | 2026/3/17 13:52:16 | 177,345 B | 完整 | +| `yarn.lock` | 2026/3/17 13:52:16 | 91,169 B | 完整 | +| `pnpm-lock.yaml` | 2026/3/17 13:31:41 | 92 B | **空壳**(5 行:lockfileVersion + 2 个 settings) | + +**分析**: +- `package-lock.json` 与 `yarn.lock` mtime 完全相同——不是手动同步生成的(手动两条命令时间会差几秒),更可能是某个工具或 IDE 同时触达 +- `pnpm-lock.yaml` 92 字节几乎为零,确认是被 `pnpm install --lockfile-only` 创建后未真正运行 `pnpm install` 留下的空壳 +- Dockerfile 用 yarn(CLAUDE.md L99 明示),但开发者本地用 npm 也合法(CLAUDE.md L14 示例 `npm install`) + +**结论**:本 phase 验证用 **`npm`**——理由: +1. CLAUDE.md「开发命令」节示例(L13-25)全是 `npm run *` +2. `package-lock.json` 体量完整,与 `package.json` 同步性最强 +3. 本 phase **不动依赖**(CONTEXT.md 锁定,不引入新 lib),不会触发 install / 修改任何 lockfile +4. `npm run lint` 与 `npx tsc --noEmit` 是仅读命令,与 lockfile 无关 + +**风险防御**:如果 plan 中出现 `npm install` 这类会修改 `package-lock.json` 的命令——**应当避开**;本 phase 没有任何安装步骤需求。 + +[VERIFIED: powershell Get-Item 三个 lockfile + powershell Get-Content pnpm-lock.yaml + CLAUDE.md L13-25, L99 + package.json L1-73] + +--- + +## Sources + +### Primary (HIGH confidence) + +- `lib/api/client.ts` L1-272 — Axios 单例 + 拦截器实际行为 +- `lib/api/ai-models.ts` L1-85 — 1:1 模板(CRUD 范式 + 解包模式) +- `lib/api/outfits.ts` L1-103 — 备选模板(更完整 CRUD) +- `lib/api/auth.ts` L1-131 — token 持久化与登录路径 +- `lib/api/types.ts` L1-318 — 共享类型集合 +- `lib/api/adapters.ts` L1-72 — 跨模块"API → 组件"适配器集合(不放 mapBackend\*) +- `lib/api/index.ts` L1-197 — barrel re-export 现状 +- `package.json` L1-73 — 脚本与依赖版本 +- `next.config.mjs` L1-53 — eslint/typescript ignoreDuringBuilds 配置 +- `docs/修改记录.md` L1-50 — 修改记录格式 + Phase 2 互引条目模板 +- `CLAUDE.md` L1-107 — 项目宪法 +- `.planning/REQUIREMENTS.md` — Active 段 CRED-FE-01 +- `.planning/ROADMAP.md` — Phase 1 详情 +- `.planning/codebase/STACK.md` / `ARCHITECTURE.md` / `STRUCTURE.md` / `INTEGRATIONS.md` / `CONVENTIONS.md` — brownfield 文档 +- powershell `Get-Item` 三个 lockfile 的 LastWriteTime + Length 数据 + +### Secondary (MEDIUM confidence) + +- 无(本 phase 所有结论都来自 read_first 直接证据,未依赖 WebSearch / Context7) + +### Tertiary (LOW confidence) + +- 无 + +--- + +## Metadata + +**Confidence breakdown**: +- 标准栈:HIGH — 完全照搬现有 `ai-models.ts`/`outfits.ts`,无新依赖 +- 架构:HIGH — 8 个核心问题全部 read_first 验证 +- pitfalls:HIGH — 5 个 pitfall 全部基于现有代码或 CONTEXT.md 自承的疑虑 +- 包管理器:MEDIUM — 选 `npm` 是基于 CLAUDE.md 示例 + lockfile 状态推断;本 phase 不动依赖,风险极低 + +**Research date**: 2026-05-08 +**Valid until**: 2026-06-07(30 天,仓库 stack 稳定;除非引入新 axios 拦截器或大幅 refactor `lib/api/`)