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

// ───── 后端响应原始 dict（snake_case，仅 adapter 内部用，不导出）────────────
interface BackendCredentialSlot {
  app_id: string
  access_token: string
  updated_at: string
}

// ───── 前端响应类型（camelCase，导出给 UI 层 import）──────────────────────
/**
 * 凭据槽位（后端响应）。
 * 注意：access_token 已是脱敏掩码（末 4 位明文），不要把它当明文回写。
 */
export interface CredentialSlot {
  appId: string
  accessTokenMasked: string  // 后端返回的脱敏字符串
  updatedAt: string          // ISO 8601
}

// ───── 提交载荷类型（camelCase 明文）──────────────────────────────────────
/**
 * 凭据槽位更新载荷。
 * 注意：accessToken 是明文，提交后将完整覆写后端记录。
 */
export interface CredentialSlotUpdatePayload {
  appId: string
  accessToken: string  // 明文
}

// ───── adapter（后端 → 前端）─────────────────────────────────────────────
function mapBackendCredentialSlot(raw: BackendCredentialSlot): CredentialSlot {
  return {
    appId: raw.app_id,
    accessTokenMasked: raw.access_token,
    updatedAt: raw.updated_at,
  }
}

// ───── API 函数 ──────────────────────────────────────────────────────────
/**
 * 读取当前凭据槽位（access_token 字段为脱敏掩码）。
 */
export const getCredentialSlot = async (): Promise<CredentialSlot> => {
  const response = await apiClient.get('/v1/admin/credential-slot/')
  const data = response.data?.data || response.data    // 仓库统一双保险解包
  return mapBackendCredentialSlot(data)
}

/**
 * 全字段覆写凭据槽位（access_token 必须为明文；响应里返回的同样是脱敏掩码）。
 */
export const updateCredentialSlot = async (
  payload: CredentialSlotUpdatePayload
): Promise<CredentialSlot> => {
  const body = {
    app_id: payload.appId,
    access_token: payload.accessToken,
    // 不带 updated_at —— 后端 auto_now 维护，与 updateOutfit / updateAiModel 风格一致
  }
  const response = await apiClient.put('/v1/admin/credential-slot/', body)
  const data = response.data?.data || response.data
  return mapBackendCredentialSlot(data)
}
```

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