可以把技能放到本机用户目录的 ~/.cursor/skills/<技能目录>/(Windows 一般是 C:\Users\你的用户名\.cursor\skills\)。这样任意项目打开后,只要 Cursor 会加载用户级 skills,就不必每个仓库复制一份。
注意:~/.cursor/skills-cursor/ 是 Cursor 内置技能目录,不要把自己的技能放进去;自定义技能用 项目 .cursor/skills/ 或 用户 ~/.cursor/skills/。
在项目的根目录下创建:.cursor\skills\mysql-ddl-generator
---
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)
# 外部接口 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()