45 KiB
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 落地:
- 响应拦截器不解包:
apiClient响应拦截器只console.log后透传response,不做response.data = data.data解包。仓库统一约定是在每个 API 函数里手动const data = response.data?.data || response.data做"双保险"提取。 - 1:1 模板 =
lib/api/ai-models.ts:与本 phase 语义最贴近(同一/ai-model页面、单数据集、CRUD 子集),路径风格、adapter、response.data?.data || response.data模式都可直接照抄。 - 路径风格:
baseURL已吃掉/api,调用方写/v1/admin/credential-slot/(不含/api)。 - 类型放置:业务专属类型放各模块文件本身(不放
types.ts),仅共享的ApiResponse<T>等放client.ts。新模块的CredentialSlot/CredentialSlotUpdatePayload应放credential-slot.ts内部。 - PUT body 不带
updated_at:仓库现有outfits.ts、ai-models.ts的 PATCH/PUT 全部只传业务字段,不带updated_at等服务端字段。沿用此约定。 index.ts导出风格混合:现有用export *(card / upload / food)+ 显式对象(usersApi/rolesApi)。新模块按 CONTEXT.md 锁定方案,使用具名 re-export(不与现有任一风格完全一致,但与 CLAUDE.md「barrel 文件」规范一致;CONTEXT.md 已明确写法)。npm run lint实际跑next lint(不含tsc --noEmit)。next.config.mjs的typescript.ignoreBuildErrors: true仅影响next build;执行tsc --noEmit仍会真实报错,但项目脚本未提供该入口,需单独命令npx tsc --noEmit。- 包管理器:
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)
- GET / PUT 同 URL
- 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:
// 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 应用):
// 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.tsL5、ai-models.tsL7、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 模板,可直接落地)
// 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 锁定的写法)
// 在文件末尾追加(与"具名 re-export"风格一致;index.ts 当前已有 export * + 显式对象混用,新模块用具名 re-export 可读性最高)
export {
getCredentialSlot,
updateCredentialSlot,
type CredentialSlot,
type CredentialSlotUpdatePayload,
} from './credential-slot'
docs/修改记录.md 顶部追加条目(基于现有「2026-05-07 Phase 2」条目格式)
### [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 适用范围摘录):
- 语言约束([VERIFIED: CLAUDE.md L98]):注释、commit message 用中文;面向用户的输出一律中文。本研究的 RESEARCH.md / 后续 PLAN.md / 修改记录条目均用中文。
- 修改记录强制规则([VERIFIED: CLAUDE.md L70-95]):每次代码改动必须在同一会话内追加到
docs/修改记录.md顶部(最新在前);条目按头部「修改格式说明」格式(文件路径 / 修改类型 / 修改内容 / 修改原因 4 段)。 - 跨项目互引规则([VERIFIED: CLAUDE.md L83-88]):
qy-lty-admin/docs/修改记录.md仅记录前端改动;跨项目联动需两端各写一条相互引用。本 Phase 1 不需要新建跨项目互引(后端 Phase 2 commit46d72b8已建立)。 - 包管理器警告([VERIFIED: CLAUDE.md L99]):「项目同时存在
package-lock.json和pnpm-lock.yaml;本地开发任选其一即可,但不要混用导致 lock 文件冲突。」本 phase 不动依赖,规避此风险。 - shadcn 组件约定([VERIFIED: CLAUDE.md L100]):
components/ui/是复制粘贴源码,可直接修改。本 Phase 1 不涉及 UI 组件。 - 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:
mapBackendBotadapter(snake → camel + 字段默认值) - L46-50:
getAiModel(id)单资源 GET 模式(与本 phasegetCredentialSlot()形态完全一致,差别仅在 URL 是否带 ID) - L65-73:
updateAiModel(id, modelData)部分更新(PATCH 而非 PUT;本 phase 用 PUT 全字段覆写,但 body 构造方式可借鉴)
- L7-20:
次选: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.tsL9:API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000/api"—/api已在 baseURLauth.tsL27:apiClient.post('/v1/admin/login/', ...)— 不含/apiai-models.tsL29:apiClient.get(\/ai/bots/?...`)— 不含/api,也不含/v1/admin`(DRF 接口前缀差异)outfits.tsL29: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.tsL1-318:包含ApiResponse<T>(共享)、User、Role、Permission、Outfit、Prop、Song(含rawData子结构)、Dance、Food、HomeDecor、AffinityLevel、AffinityRule、AiModel、Achievement——是已被多处消费的"前端类型"集合- 所有"后端原始 dict 类型"都不在
types.ts里:outfits.tsL5 直接用mapBackendOutfit(item: any)—— 不定义BackendOutfit类型ai-models.tsL7 同样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.tsL75-86):payload 仅包含name/description/clothing_attributes/rarity/image_url,无updated_at、created_atupdateAiModel(ai-models.tsL65-73):payload 仅name/description,无updated_atcreateOutfit(outfits.tsL52-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.jsonL9:"lint": "next lint"next lint是 Next.js 内置 ESLint 包装,[CITED: Next.js 官方文档默认行为]next.config.mjsL17:eslint.ignoreDuringBuilds: true—— 仅影响next build,不影响显式next lint命令next.config.mjsL20:typescript.ignoreBuildErrors: true—— 仅影响next build时的类型检查,不影响npx tsc --noEmit
关键结论:CONTEXT.md L13、L143-146 的成功标准「tsc --noEmit 通过(项目用 npm run lint 跑)」是误判——npm run lint 不会跑 tsc。
正确做法:本 phase 验证步骤应明确两条命令:
npm run lint(ESLint 检查)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.lockmtime 完全相同——不是手动同步生成的(手动两条命令时间会差几秒),更可能是某个工具或 IDE 同时触达pnpm-lock.yaml92 字节几乎为零,确认是被pnpm install --lockfile-only创建后未真正运行pnpm install留下的空壳- Dockerfile 用 yarn(CLAUDE.md L99 明示),但开发者本地用 npm 也合法(CLAUDE.md L14 示例
npm install)
结论:本 phase 验证用 npm——理由:
- CLAUDE.md「开发命令」节示例(L13-25)全是
npm run * package-lock.json体量完整,与package.json同步性最强- 本 phase 不动依赖(CONTEXT.md 锁定,不引入新 lib),不会触发 install / 修改任何 lockfile
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.tsL1-272 — Axios 单例 + 拦截器实际行为lib/api/ai-models.tsL1-85 — 1:1 模板(CRUD 范式 + 解包模式)lib/api/outfits.tsL1-103 — 备选模板(更完整 CRUD)lib/api/auth.tsL1-131 — token 持久化与登录路径lib/api/types.tsL1-318 — 共享类型集合lib/api/adapters.tsL1-72 — 跨模块"API → 组件"适配器集合(不放 mapBackend*)lib/api/index.tsL1-197 — barrel re-export 现状package.jsonL1-73 — 脚本与依赖版本next.config.mjsL1-53 — eslint/typescript ignoreDuringBuilds 配置docs/修改记录.mdL1-50 — 修改记录格式 + Phase 2 互引条目模板CLAUDE.mdL1-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/)