721 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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/`