45 KiB
Raw Blame History

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 后透传 responseresponse.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.tsai-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.mjstypescript.ignoreBuildErrors: true 仅影响 next build;执行 tsc --noEmit 仍会真实报错,但项目脚本未提供该入口,需单独命令 npx tsc --noEmit
  8. 包管理器yarn.lock + package-lock.json 同时存在且 mtime 完全一致;pnpm-lock.yaml 仅有 5 行 settings 是空壳。推荐用 npmCLAUDE.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.tslib/api/adapters.tslib/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 注入 (已存在,不在本 phaselib/api/client.ts 请求拦截器 单例拦截器自动注入,新模块零成本继承
401 未授权统一处理 (已存在,不在本 phaselib/api/client.ts 响应拦截器 单例拦截器自动重定向到 /login,新模块零成本继承
业务消费(页面 / 组件) Phase 2 / 3app/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。专属文件更内聚。
mapBackendCredentialSlotlib/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 条目
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.tsoutfits.tsai-models.tsfood.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>mapBackendBotmapBackendOutfit...)。本 phase 沿用 mapBackendCredentialSlot

Pattern 2: 响应数据解包(最关键)

What:响应拦截器自动提取 data.data,调用方必须手动 response.data?.data || response.dataWhy:仓库统一兼容两种后端响应形态——

  • 经过 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.dataundefined,会运行时 NPE。
  • mapBackendCredentialSlotadapters.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 3lib/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 wrongCONTEXT.md 第 113 行的注释自承认这是 read_first 待验证项;如果不验证就写成 return mapBackendCredentialSlot(response.data),运行时拿到的是整个壳层,raw.app_id / raw.access_token 都是 undefinedWhy 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 happensCLAUDE.md 第 60 行说「所有请求走 NEXT_PUBLIC_API_BASE_URL + /api/v1/admin/...」容易让人误以为 apiClient.get 的相对路径要含 /api,实际不需要。 How to avoid:检查 client.ts L9API_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 wrongPhase 3 表单组件如果直接把 slot.accessTokenMaskeddefaultValue,用户不改提交时就把 ********xyz9 这串脱敏字符当真值传给后端 PUT覆盖真正的明文。 Why it happens:字段名相近、运营不察觉。 How to avoid:本 phase 锁定的命名 accessTokenMasked vs accessToken 已经在 TS 编译期切断这条路(CredentialSlot.accessTokenMaskedCredentialSlotUpdatePayload.accessToken 字段名不同,无法互赋);Phase 1 的责任是把这个屏障建起来Phase 3 表单提交时只能拼装 CredentialSlotUpdatePayload(必须从空字符串 / 用户新输入起步)。 Warning signsPhase 3 出现 accessToken: slot.accessTokenMasked 这样的赋值——直接是 bug。

Pitfall 4: npm run lint 漏掉类型错误

What goes wrongpackage.json L9 "lint": "next lint" 只跑 ESLintnext.config.mjs L17 eslint.ignoreDuringBuilds: true构建时也不报TS 错误不会被它捕获。 Why it happensCLAUDE.md L23-24「类型检查 → npm run lint」措辞误导CONTEXT.md L13/L143 也按此假设写。实际 npm run linttsc --noEmitHow to avoid:本 phase 验证除了 npm run lint还要单独跑 npx tsc --noEmit。两者退出码都 0 才算通过。 Warning signsnpm run lint 0 退出但跑 npx tsc --noEmit 时报 Cannot find name 'CredentialSlot' 等错误。

Pitfall 5: 包管理器混用导致 lockfile 漂移

What goes wrongCLAUDE.md L99 警告「不要混用」。本仓库 package-lock.jsonyarn.lock mtime 完全相同2026/3/17 13:52:16pnpm-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 signsgit status 出现 pnpm-lock.yamlyarn.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"

// ───── 后端响应原始 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 锁定的写法)

// 在文件末尾追加(与"具名 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 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 helperhumpschange-case 手写 mapBackend* 函数 仓库历史选择 字段级控制力强,但需要手写每个映射
mapBackend* 放统一 adapters.ts 放各业务模块文件内 仓库历史选择 adapters.ts 实际只放"API 类型 → 组件类型"映射(见 apiSongToComponentSong),不放后端→前端映射

Deprecated/outdated

  • 无 — 本 phase 不涉及任何已废弃模式。

Assumptions Log

# Claim Section Risk if Wrong
A1 包管理器选 npmCLAUDE.md「开发命令」节示例如此 包管理器节 低 — 本 phase 不动依赖,不会触发 lockfile 重新生成;只跑 npm run lintnpx tsc --noEmit。若开发者本地装的是 yarn/pnpm命令字面替换即可
A2 next lint 只跑 ESLinttsc --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.jsonpnpm-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-20mapBackendBot adaptersnake → camel + 字段默认值)
    • L46-50getAiModel(id) 单资源 GET 模式(与本 phase getCredentialSlot() 形态完全一致,差别仅在 URL 是否带 ID
    • L65-73updateAiModel(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 L9API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000/api"/api 已在 baseURL
  • auth.ts L27apiClient.post('/v1/admin/login/', ...) — 不含 /api
  • ai-models.ts L29apiClient.get(\/ai/bots/?...`)— 不含/api,也不含 /v1/admin`DRF 接口前缀差异)
  • outfits.ts L29apiClient.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>(共享)、UserRolePermissionOutfitPropSong(含 rawData 子结构)、DanceFoodHomeDecorAffinityLevelAffinityRuleAiModelAchievement——是已被多处消费的"前端类型"集合
  • 所有"后端原始 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 提升类型安全)

结论的应用方式

  • CredentialSlotCredentialSlotUpdatePayloadBackendCredentialSlot 三个类型全部在 credential-slot.ts export / 内部 declare
  • 不修改 types.tsCONTEXT.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_atcreated_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-5export * from "./card" / "./upload" / "./food"barrel 风格)
  • L8-142export 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 L17eslint.ignoreDuringBuilds: true —— 仅影响 next build,不影响显式 next lint 命令
  • next.config.mjs L20typescript.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 lintESLint 检查)
  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.jsonyarn.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 lintnpx 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/