cursor-读取pdf生成相关的client api调用

我爱海鲸 2026-06-10 10:41:24 暂无标签

简介okhttp、api、client、pdf

可以把技能放到本机用户目录的 ~/.cursor/skills/<技能目录>/(Windows 一般是 C:\Users\你的用户名\.cursor\skills\)。这样任意项目打开后,只要 Cursor 会加载用户级 skills,就不必每个仓库复制一份。


注意:~/.cursor/skills-cursor/ 是 Cursor 内置技能目录,不要把自己的技能放进去;自定义技能用 项目 .cursor/skills/ 或 用户 ~/.cursor/skills/。

在项目的根目录下创建:.cursor\skills\mysql-ddl-generator

SKILL.md

---
name: external-api-client
description: Reads external API PDF/Markdown specs and scaffolds Spring Boot HTTP clients with Properties, enums, crypto utils, and shared OkHttp helpers. Use when creating 外部接口客户端, 对接第三方 API, 根据接口文档生成 Client, or reading PDF 接口规范.
---

# 外部接口 Client 生成

根据**接口规范文档**(PDF / Markdown / TXT)在**目标模块**中生成可编译的 Spring `@Component` 客户端。包名、模块路径、配置前缀、工具类全限定名均从**当前工作区**解析,技能不内置固定包名或厂商信息。

**找模板**:在目标模块搜索已有 `*Client`、`*Properties`、`*CryptoUtil`,按**同一分层与调用风格**复刻。

---

## 0. 工作流总览

```
Task Progress:
- [ ] Step 1: 定位文档与目标模块
- [ ] Step 2: 提取并结构化接口信息
- [ ] Step 3: 确认鉴权/签名/加密模式
- [ ] Step 4: 生成 Properties / enum / util
- [ ] Step 5: 生成 *Client 及方法
- [ ] Step 6: 补充配置样例与自检
```

---

## 1. 读取 PDF / 文档

### 1.1 文档来源优先级

1. 用户指定的 PDF / MD / TXT 路径
2. 目标模块 `doc/` 下同名或相关规范文件
3. 已有人工整理的 `.md` — 优先于 PDF,减少提取误差

### 1.2 提取 PDF 正文

在仓库根目录执行(需 Python 3 + `pdfplumber`):

```bash
pip install pdfplumber
python .cursor/skills/external-api-client/scripts/extract_pdf.py <pdf路径> [输出txt路径]
```

Windows 下若无 `python` 命令,可尝试 `py -3` 代替 `python`。

**提取失败时的降级顺序**(按优先级尝试,不要跳步臆造字段):

1. 同目录是否已有 `.txt` / `.md`
2. 模块 `doc/` 下是否有人工整理的规范 Markdown
3. 请用户提供文本版,或对扫描版 PDF 做 OCR
4. 仅在前述均不可用时,再向用户确认缺失字段

- 未指定输出路径时,默认写到 PDF 同目录、同名 `.txt`
- 提取后**通读 txt**,核对章节号、表格字段、示例 URL 是否完整

### 1.3 从文档整理「接口清单」

为每个接口记录(可写在回复或临时笔记,不必落库):

| 字段 | 说明 |
|------|------|
| 文档章节号 | 写入 JavaDoc |
| 接口名称 | 文档中的业务描述 |
| HTTP 方法 | GET / POST 等 |
| 路径 | 含测试/生产差异 |
| 公共参数 | Head、签名、appKey、时间戳等 |
| Body 字段 | 名、类型、必填、枚举含义 |
| 响应 | 是否只返回原始 JSON 字符串 |
| 特殊规则 | 如「参与签名的字段需截断」「文档字段名与语义名不一致」 |

---

## 2. 对齐目标模块

| 要确定的内容 | 做法 |
|-------------|------|
| 目标模块 | 用户指定;或在仓库中找对应 `pom` 子工程 |
| 包前缀 | 打开同模块已有 `@Component` / `@RestController`,复用 `package` 前缀 |
| Client 包 | 通常为 `...client` 或 `...client.<vendor>` |
| 配置类包 | 通常为 `...config`,命名 `{Vendor}Properties` |
| 枚举包 | `...client.enums` 或 `...enums` |
| 加解密工具 | 先搜 `util` 是否已有;无则放 `...util.<vendor>` |
| HTTP 工具 | 搜工作区已有 `OkHttpUtil` 或等价封装,**仅用当前检到的全限定名** |
| OkHttpClient Bean | 搜模块是否已注入 `@Resource OkHttpClient`;与现有 Client 一致 |

---

## 3. 代码生成约定

### 3.1 Properties

```java
@Data
@Component
@ConfigurationProperties(prefix = "模块配置前缀.vendor")
public class XxxProperties {
    private String baseUrl;
    // 文档中的 appKey / secret / channelId 等
}
```

- `prefix` 与同模块 `application*.yml` 已有风格一致
- 配置项 JavaDoc 注明测试/生产占位说明,**不写真实密钥或域名**
- **禁止**在 Client 中硬编码密钥、appKey、生产域名

### 3.2 Client 类骨架

```java
@Slf4j
@Component
public class XxxClient {

    @Resource
    private OkHttpClient okHttpClient;

    @Resource
    private XxxProperties xxxProperties;

    // 每个文档接口一个 public 方法,返回 String(原始响应)
}
```

### 3.3 方法体模式

1. **入参校验**:`StringUtils.isBlank` / `Objects.requireNonNull`,中文异常信息
2. **组装 Body**:项目已有的 JSON 工具(常见为 hutool `JSONObject` / `JSONArray`)
3. **构建 URL 或请求体**:私有方法集中处理签名、加密、公共 Head
4. **发起请求**:复用工作区 `OkHttpUtil` 的 `get` / `postJson` / `postForm`(传入注入的 `okHttpClient`)
5. **日志**:`log.debug` 记录接口语义与 url(**勿打 secret、token、完整敏感 Body**)
6. **JavaDoc**:写明文档章节、参数含义、必填/可空、返回值说明

### 3.4 测试 / 生产环境

文档区分环境时:

- 用注释保留测试路径,默认启用生产路径;或
- `Properties` 增加 `env` / 独立 `testBaseUrl`,由配置切换

### 3.5 何时拆分多个 Client

- 同一对接方、同一鉴权、路径前缀一致 → **一个 Client**,多方法
- 文档大章节不同、鉴权方式不同、或根 URL 不同 → **独立 Client**

### 3.6 枚举与常量

- 文档中的操作类型、状态码、标志位 → `enum`,提供 `getCode()` 或 `getValue()`
- 接口路径、超时、行数上限 → `private static final String`

### 3.7 鉴权模式速查

| 模式 | 生成要点 |
|------|----------|
| 对称加密 + 签名 + URL 参数 | Head/Body 合并 → 加密 → URLEncoder → 签名 → 拼 query |
| POST JSON + 公共 URL 参数 | `HttpUrl.Builder` 或字符串拼 query,Body 用紧凑 JSON |
| POST JSON + Header 签名 | 独立 `*CryptoUtil`,注意字段顺序与 canonical 化 |
| 仅 GET 查询串 | 参数 URLEncode 后拼接 |

生成前必须在文档中找到**算法、字段顺序、编码、示例**;优先复用同对接方已有工具类,勿跨厂商复制签名逻辑。

---

## 4. 配置与文档落盘

1. 在目标模块 `application.yml` 或 `application-*.yml` 增加配置块(占位值 + 注释,使用 `${ENV_VAR:}` 形式)
2. 若从 PDF 新提取:将 `.txt` 保留在 `doc/`,便于后续 diff
3. 可选:在 `doc/` 增加简短 `*.md` 映射表(章节 → Client 方法)

---

## 5. 完成前自检

- [ ] 每个文档接口都有对应 public 方法,JavaDoc 带章节号
- [ ] 必填参数在校验层拦截,错误信息为中文
- [ ] 无硬编码 secret、token、真实域名;测试/生产 URL 与文档一致
- [ ] 使用工作区已有 HTTP 封装 + 注入的 `OkHttpClient`
- [ ] 枚举/魔法值已提取为 enum 或常量
- [ ] 特殊规则(截断、字段别名)已按文档实现
- [ ] `pom` 已有所需依赖
- [ ] 编译通过;若模块有类似 Client 测试,可补 smoke 调用

---

## 6. 不要做的事

- 不要跳过 PDF 提取直接臆造字段
- 不要在 Client 内解析业务响应为强类型 DTO(除非用户明确要求);默认返回 `String`
- 不要复制其他对接方的签名逻辑到不匹配的接口
- 不要一次生成巨型 God Class;按文档章节或域名拆分
- 不要在 skill、注释、示例配置中写入真实厂商名、域名、密钥、appKey

---

## 附加资源

- 通用 Client 模式与鉴权拆分说明:[reference.md](reference.md)
- PDF 提取脚本:[scripts/extract_pdf.py](scripts/extract_pdf.py)

reference.md

# 外部接口 Client 参考(通用模式)

本文档供 `external-api-client` 技能在需要**加密 GET**、**拆分多 Client** 或**对照已有实现**时查阅。具体类名、包名、配置前缀以**当前工作区**搜索结果为准。

---

## 1. 推荐类职责划分

| 类 | 职责 |
|----|------|
| `{Vendor}Properties` | `@ConfigurationProperties`,承载 baseUrl、密钥、渠道号等 |
| `{Vendor}Client` | 主业务出站接口,每文档接口一个 public 方法 |
| `{Vendor}*Client`(可选第二个) | 文档章节/域名/鉴权不同时的拆分 Client |
| `{Vendor}CryptoUtil` / `AESUtil` 等 | 加解密、签名、canonical JSON |
| `OkHttpUtil`(或项目等价类) | 统一 HTTP,禁止在 Client 内重复封装 |

---

## 2. 加密 GET + Query 参数模式

适用于:业务入参放在加密后的 query 中,URL 另附签名与公共标识。

核心步骤(私有方法如 `buildFullUrl` / `buildSignedGetUrl`):

1. 校验 Properties 中必要配置非空
2. 构造 `params = { Head: { ...公共头 }, Body: <业务入参> }`
3. 序列化为 JSON 字符串
4. 按文档算法加密 → `URLEncoder.encode`
5. 按文档算法对密文或约定原文签名 → URL 编码
6. 拼接:`{baseUrl}{path}?<文档规定的 query 名>=...`

每个业务方法只负责填充 `Body` 与选择 `path`;测试/生产 path 差异用注释或配置切换。

---

## 3. 业务方法模板(脱敏示例)

```java
/**
 * 业务预校验接口(文档 x.x.x)
 */
public String preCheck(String serviceNo, Long productId, Long planId, OperTypeEnum operType) {
    if (StringUtils.isBlank(serviceNo)) {
        throw new IllegalArgumentException("serviceNo不能为空");
    }
    Objects.requireNonNull(productId, "productId不能为空");
    Objects.requireNonNull(planId, "planId不能为空");
    Objects.requireNonNull(operType, "operType不能为空");

    JSONObject body = new JSONObject();
    body.set("serviceNo", serviceNo);
    body.set("productId", productId);
    body.set("planId", planId);
    // 文档字段名可能与 Java 参数名不同,以文档为准
    body.set("operTypeCode", operType.getCode());

    String url = buildSignedGetUrl("/api/v1/preCheck", body);
    log.debug("调用预校验接口,url={}", url);
    return okHttpGet(url);
}
```

要点:

- 文档字段名 ≠ Java 参数名时,以**文档字段名**写入 Body
- 可选字段:`if (StringUtils.isNotBlank(x)) body.set(...)`
- JSON 嵌套:`body.set("extInfo", new JSONObject(extInfoJson))`
- 仅构建跳转 URL、不发起 HTTP 的场景:单独 public 方法返回 `String` url

---

## 4. 同文档内 POST 族与 GET 族拆分

当同一对接方文档中同时存在不同调用形态时,建议拆类:

| 项 | GET + 加密 query 族 | POST JSON 族 |
|----|---------------------|--------------|
| HTTP | GET | POST `application/json` |
| 根 URL | 通常 `properties.baseUrl` | 可能与 GET 族不同(文档另给) |
| Body | 存在于加密结构的 Body 节点 | 可能有「签名用 Body」与「实际请求体」两份 |
| 特殊规则 | — | 如参与签名的字段需截断、部分字段仅明文传输 |

不要强行共用同一个 `buildFullUrl` 塞入 POST 逻辑。

---

## 5. POST JSON + Header 签名模式

```java
private String doPost(String path, String bodyJson) {
    String canonicalBody = cryptoUtil.canonicalBodyForSign(bodyJson);
    String transactionId = cryptoUtil.generateTransactionId();
    String reqTime = cryptoUtil.generateReqTime();
    String sign = cryptoUtil.sign(transactionId, reqTime, secret, canonicalBody);

    Map<String, Object> head = new LinkedHashMap<>();
    head.put("transactionId", transactionId);
    head.put("reqTime", reqTime);
    head.put("sign", sign);
    // ... 文档要求的其他 head 字段

    JSONObject request = new JSONObject();
    request.set("head", head);
    request.set("body", JSONUtil.parseObj(bodyJson));

    String url = properties.getBaseUrl() + path;
    return OkHttpUtil.postJson(okHttpClient, url, request.toString(), headers);
}
```

注意 `LinkedHashMap` 保序、compact JSON、时区与时间格式均按文档示例。

---

## 6. 枚举模板

```java
public enum OperTypeEnum {
    CREATE(0),
    CANCEL(1),
    CHANGE(2);

    private final int code;

    OperTypeEnum(int code) { this.code = code; }

    public int getCode() { return code; }
}
```

---

## 7. application.yml 配置示例(占位)

```yaml
your-module:
  vendor:
    base-url: ${VENDOR_BASE_URL:https://api.example.com}
    app-key: ${VENDOR_APP_KEY:}
    secret: ${VENDOR_SECRET:}
    channel-id: ${VENDOR_CHANNEL_ID:}
```

键名使用 kebab-case,与 `@ConfigurationProperties` 字段驼峰映射一致;**禁止提交真实密钥**。

---

## 8. 对照已有 Client

新对接方生成前:

1. 在工作区搜索 `*Client`、`*Properties`,找**同模块、同对接方**已有实现
2. 有先例 → 严格对齐其分层、命名、HTTP 与日志风格
3. 无先例 → 按本文档 + 新接口规范从零生成,鉴权以实现文档示例为准

script/extract_pdf.py

#!/usr/bin/env python3
"""
Extract plain text from an API specification PDF for external-api-client skill.

Usage:
    python extract_pdf.py <input.pdf> [output.txt]

Requires: pip install pdfplumber
"""

from __future__ import annotations

import sys
from pathlib import Path


def extract_pdf_text(pdf_path: Path) -> str:
    try:
        import pdfplumber
    except ImportError as exc:
        raise SystemExit(
            "缺少 pdfplumber,请先执行: pip install pdfplumber"
        ) from exc

    if not pdf_path.is_file():
        raise SystemExit(f"文件不存在: {pdf_path}")

    parts: list[str] = []
    with pdfplumber.open(pdf_path) as pdf:
        for i, page in enumerate(pdf.pages, start=1):
            text = page.extract_text() or ""
            parts.append(f"\n\n--- Page {i} ---\n\n")
            parts.append(text)

    result = "".join(parts).strip()
    if not result:
        raise SystemExit(
            "未能提取到文本,可能为扫描版 PDF。请提供 OCR 结果或人工整理的 md/txt。"
        )
    return result


def main() -> None:
    if len(sys.argv) < 2:
        raise SystemExit("用法: python extract_pdf.py <input.pdf> [output.txt]")

    pdf_path = Path(sys.argv[1]).resolve()
    if len(sys.argv) >= 3:
        out_path = Path(sys.argv[2]).resolve()
    else:
        out_path = pdf_path.with_suffix(".txt")

    text = extract_pdf_text(pdf_path)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(text, encoding="utf-8")
    print(f"已提取 {len(text)} 字符 -> {out_path}")


if __name__ == "__main__":
    main()

你好:我的2025