docs(01): qy-lty-admin Phase 1 RESEARCH.md(拦截器不解包 + ai-models.ts 1:1 模板 + npm run lint 仅 ESLint)

This commit is contained in:
pmc 2026-05-08 11:00:32 +08:00
parent c012b56573
commit a3d71f4d08

View File

@ -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<T>` 等放 `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<CredentialSlot>` + `updateCredentialSlot(payload): Promise<CredentialSlot>`
- **沿用 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/PlaywrightPhase 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 clientGET / 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<PaginatedResponse<AiModel>> => {
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<AiModel> => {
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<AiModel>): Promise<AiModel> => {
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<Resource>``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<CredentialSlot> => {
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<T>` 类型 | 重新定义壳层类型 | `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"
// ───── 后端响应原始 dictsnake_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)
}
```
### `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 2RBAC + 入口控件、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>`
- 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 L32auth.ts 例外,因为 login 接口直接消费整个 response.data]
### 2. 1:1 模板模块
**首选**`lib/api/ai-models.ts` L1-8585 行整文件可读)
- 与本 phase 同在 `/ai-model` 页面Phase 2 入口控件即 `/ai-model`
- 单数据集(不是嵌套资源)
- 包含完整 CRUDGET list / GET single / POST / PATCH / DELETE—— 本 phase 只用其中 GET single + PATCH 模式(升级为 PUT
- 关键参考行:
- L7-20`mapBackendBot` adaptersnake → 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<T>`(共享)、`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-472026-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 用 yarnCLAUDE.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 验证
- pitfallsHIGH — 5 个 pitfall 全部基于现有代码或 CONTEXT.md 自承的疑虑
- 包管理器MEDIUM — 选 `npm` 是基于 CLAUDE.md 示例 + lockfile 状态推断;本 phase 不动依赖,风险极低
**Research date**: 2026-05-08
**Valid until**: 2026-06-0730 天,仓库 stack 稳定;除非引入新 axios 拦截器或大幅 refactor `lib/api/`