Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 117 additions & 97 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,158 +1,178 @@
# qdmp-server-sdk

[千岛小程序开放平台 OpenAPI](https://open.qiandao.com/docs/api/auth-token) 官方 Server SDK,[Node.js](#nodejs)、[Java](#java)、[Go](#go) 三端实现,类型安全的业务接口封装 + 应用凭证自动缓存。
[![npm](https://img.shields.io/npm/v/@qdmp/qdmp-server-sdk?label=npm)](https://www.npmjs.com/package/@qdmp/qdmp-server-sdk)
[![Go Reference](https://pkg.go.dev/badge/github.com/EchoTechFE/qdmp-server-sdk/go.svg)](https://pkg.go.dev/github.com/EchoTechFE/qdmp-server-sdk/go)
[![Node CI](https://github.com/EchoTechFE/qdmp-server-sdk/actions/workflows/node-ci.yml/badge.svg?branch=main)](https://github.com/EchoTechFE/qdmp-server-sdk/actions/workflows/node-ci.yml)
[![Java CI](https://github.com/EchoTechFE/qdmp-server-sdk/actions/workflows/java-ci.yml/badge.svg?branch=main)](https://github.com/EchoTechFE/qdmp-server-sdk/actions/workflows/java-ci.yml)
[![Go CI](https://github.com/EchoTechFE/qdmp-server-sdk/actions/workflows/go-ci.yml/badge.svg?branch=main)](https://github.com/EchoTechFE/qdmp-server-sdk/actions/workflows/go-ci.yml)
[![License](https://img.shields.io/github/license/EchoTechFE/qdmp-server-sdk)](./LICENSE)

## Node.js
[千岛小程序开放平台 OpenAPI](https://open.qiandao.com/docs/api) 官方服务端 SDK,提供 Node.js、Go 和 Java 实现。三端共用一份接口定义,请求参数和响应结果都有对应类型。

## SDK 状态

| 语言 | 环境要求 | 获取方式 |
| --- | --- | --- |
| Node.js | Node.js 22+ | [npm](https://www.npmjs.com/package/@qdmp/qdmp-server-sdk) |
| Go | Go 1.24+ | [Go Reference](https://pkg.go.dev/github.com/EchoTechFE/qdmp-server-sdk/go) |
| Java | Java 11+ | 暂未发布到 Maven Central,可从 [`java/`](./java/) 构建 |

## Node.js 快速开始

安装:

```bash
npm install @qdmp/qdmp-server-sdk
```

初始化客户端后,用前端 `qd.login()` 返回的一次性授权码换取用户凭证:

```ts
import { QdmpClient, QdmpApiError, QdmpValidationError } from '@qdmp/qdmp-server-sdk'
import { QdmpClient } from '@qdmp/qdmp-server-sdk'

const qdmp = new QdmpClient({
appId: process.env.QDMP_APP_ID!,
appSecret: process.env.QDMP_APP_SECRET!,
})

// 前端 qd.login() 拿到的一次性授权码传到服务端,换用户授权凭证
const credential = await qdmp.auth.getUserAccessToken(code)
// => { accessToken, refreshToken, expiresAt, openId },自己按 openId 存起来
const context = { accessToken: credential.accessToken }

// 调业务接口:accessToken 每次显式传进去
const me = await qdmp.user.me({ accessToken: credential.accessToken })
await qdmp.mark.add({ accessToken: credential.accessToken }, { spuId: '123', rating: { value: 5 } })
await qdmp.mark.batchAdd({ accessToken: credential.accessToken }, { spuIds: ['123', '456'] })
await qdmp.comment.create({ accessToken: credential.accessToken }, { postId: '123', content: '很喜欢' })
const me = await qdmp.user.me(context)
console.log(me)
await qdmp.mark.add(context, {
spuId: '123',
rating: { value: 5 },
})
```

// accessToken 过期了,自己拿 refreshToken 换新的(SDK 不代管,也不自动重试)
access token 过期后,由业务代码发起续期并保存新结果:

```ts
const fresh = await qdmp.auth.refreshToken(credential.refreshToken)
await qdmp.user.me({ accessToken: fresh.accessToken })
```

## Go 快速开始

安装:

```bash
go get github.com/EchoTechFE/qdmp-server-sdk/go
```

下面的代码放在已有 `context.Context` 和授权码 `code` 的处理函数中:

```go
import (
"os"

qdmp "github.com/EchoTechFE/qdmp-server-sdk/go"
"github.com/EchoTechFE/qdmp-server-sdk/go/generated"
)

try {
await qdmp.wishspu.list({ accessToken: fresh.accessToken }, { offset: '0', limit: '20' })
} catch (err) {
if (err instanceof QdmpApiError) console.error(err.code, err.message, err.httpStatus)
else if (err instanceof QdmpValidationError) console.error('本地参数错误:', err.message)
client, err := qdmp.NewClient(qdmp.ClientOptions{
AppID: os.Getenv("QDMP_APP_ID"),
AppSecret: os.Getenv("QDMP_APP_SECRET"),
})
if err != nil {
return err
}

credential, err := client.Auth.GetUserAccessToken(ctx, code)
if err != nil {
return err
}

// 应用凭证,开发调试/后台任务才需要
const appCredential = await qdmp.auth.getAppAccessToken()
// => { accessToken, expiresAt, refreshToken, openId }(openId 恒为空串)
requestContext := qdmp.Context{AccessToken: credential.AccessToken}
_, err = client.User.Me(ctx, requestContext)
if err != nil {
return err
}

_, err = client.Mark.Add(ctx, requestContext, generated.MarkAddJSONBody{SpuId: "123"})
if err != nil {
return err
}

return nil
```

失败抛三类错误:`QdmpApiError`(业务失败,响应体 `code` 非 `'0'`)、`QdmpValidationError`(本地参数校验失败,不发请求)、`QdmpTransportError`(传输层问题,如收到重定向、响应体超过 10MB)。底层 `fetch` 本身的网络错误原样透出。
续期时调用 `client.Auth.RefreshToken(ctx, credential.RefreshToken)`,然后保存并使用返回的新 access token。

## Java

```gradle
dependencies {
implementation("io.github.echotechfe:qdmp-server-sdk:<version>")
}
Java SDK 暂未发布到 Maven Central。当前版本可在仓库中构建:

```bash
cd java
./gradlew build
```

API 用法如下:

```java
import io.github.echotechfe.qdmp.QdmpClient;
import io.github.echotechfe.qdmp.QdmpClientConfig;
import io.github.echotechfe.qdmp.QdmpContext;
import io.github.echotechfe.qdmp.generated.MarkAddRequest;

QdmpClient qdmp = new QdmpClient(
QdmpClientConfig.builder()
.appId(System.getenv("QDMP_APP_ID"))
.appSecret(System.getenv("QDMP_APP_SECRET"))
.build());

UserAccessTokenResult credential = qdmp.auth().getUserAccessToken(code);

// QdmpContext.of(accessToken) 是唯一入口,空/非法 token 在构造期就失败
QdmpContext ctx = QdmpContext.of(credential.getAccessToken());
UserMe200ResponseAllOfData me = qdmp.user().me(ctx);
qdmp.mark().add(ctx, new MarkAddRequest().spuId("123"));

// 过期了自己换,SDK 不代管
RefreshTokenResult fresh = qdmp.auth().refreshToken(credential.getRefreshToken());
qdmp.user().me(QdmpContext.of(fresh.getAccessToken()));
var credential = qdmp.auth().getUserAccessToken(code);
var context = QdmpContext.of(credential.getAccessToken());

AppAccessTokenResult appCredential = qdmp.auth().getAppAccessToken();
var me = qdmp.user().me(context);
qdmp.mark().add(context, new MarkAddRequest().spuId("123"));
```

业务失败抛 `QdmpApiError`,传输层异常抛 `QdmpTransportException`,`auth.*` 的参数校验失败抛 `QdmpValidationError`(均在 `io.github.echotechfe.qdmp.errors` 包下)。`QdmpContext.of()` 的 token 校验按 Java 惯例抛 `NullPointerException`(null)/ `IllegalArgumentException`(空白或含不能进 HTTP 头的字符)。
续期时调用 `qdmp.auth().refreshToken(credential.getRefreshToken())`,然后用返回的新 access token 创建 `QdmpContext`。

## Go
## 凭证怎么用

```bash
go get github.com/EchoTechFE/qdmp-server-sdk/go
```
平台有两种凭证,SDK 对它们的处理方式不同。

```go
client, err := qdmp.NewClient(qdmp.ClientOptions{
AppID: os.Getenv("QDMP_APP_ID"),
AppSecret: os.Getenv("QDMP_APP_SECRET"),
})

credential, err := client.Auth.GetUserAccessToken(ctx, code)

// 调业务接口:accessToken 每次显式传进去(ctx 仍是标准的 context.Context,管超时和取消)
qdmpCtx := qdmp.Context{AccessToken: credential.AccessToken}
me, err := client.User.Me(ctx, qdmpCtx)
_, err = client.Mark.Add(ctx, qdmpCtx, generated.MarkAddJSONBody{SpuId: "123"})
### 用户授权凭证

// 过期了自己换,SDK 不代管
fresh, err := client.Auth.RefreshToken(ctx, credential.RefreshToken)
me, err = client.User.Me(ctx, qdmp.Context{AccessToken: fresh.AccessToken})
用户授权凭证代表当前登录用户。服务端拿到前端 `qd.login()` 返回的授权码后,调用 `getUserAccessToken` 换取 access token、refresh token 和过期时间。返回结果可以按 `openId` 保存。

appCredential, err := client.Auth.GetAppAccessToken(ctx)
```

业务失败返回 `*qdmp.QdmpApiError`,缺凭证返回哨兵错误 `qdmp.ErrAccessTokenRequired`(`errors.Is` 判断),均是普通 `error`,不 panic。
SDK 不保存或自动续期用户凭证。每次业务调用都要显式传入 access token;过期后调用 `refreshToken`,由业务代码保存新的 access token。该接口不会更换 refresh token。

## 凭证生命周期模型
SDK 也不会自动重试失败的业务请求。遇到 HTTP 401 和错误码 `10005`、`10006` 时,是否续期并重试由业务代码决定。续期接口返回错误码 `10007`、`10008` 时,需要让用户重新授权。

- **用户授权凭证由调用方自己管**:SDK 不缓存、不代管、不自动续期。每次业务调用把 accessToken 显式传进去。
一个进程要服务海量终端用户,哪次调用属于哪个用户、凭证该存到哪、什么时候该续期,只有你自己知道;
你也可以完全不用 `getUserAccessToken`,自己实现拿凭证那一步,SDK 照样能用。
- **续期**:`auth.refreshToken(refreshToken)` 换一个新的 accessToken,一次性调用,SDK 不缓存结果。
`/auth/v1/refresh` 只返回新的 accessToken 和 expiresAt,**refreshToken 保持不变**。
它本身失效时返回 HTTP 200 + `10007`/`10008`,此时凭证已无法挽救,需要重新走一次「拿授权码 → 换凭证」。
- **SDK 不做任何自动重试**:业务调用撞上 HTTP 401 + `10005`/`10006`(access_token 失效/过期)时,
直接把 `QdmpApiError` 抛/返回给你,由你决定是否续期后重试。
- **应用凭证**:`getAppAccessToken()` 自动缓存 + 到期前 300 秒重新换取 + 单飞锁防并发重复换取,
持久化走可插拔的 `TokenStore`(默认内存实现,多实例部署可换 Redis 等共享存储)。
它返回的是完整凭证 `{accessToken, expiresAt, refreshToken, openId}`——应用凭证响应里同样带 refreshToken,
只是 `openId` 恒为空串。SDK 自己不拿它续期(到期直接重换),但如实交给你,要不要自己续期由你决定。
- **成功判定只看响应体 `code === '0'`,不看 HTTP 状态码**——refreshToken 过期是 HTTP 200 + `10008`,
access_token 失效才是 HTTP 401 + `10006`。
### 应用凭证

## 各分组凭证要求
应用凭证代表应用本身,适合开发调试、服务端联调和不需要用户身份的后台任务。以 Node.js 为例:

| 分组 | 要求 |
|---|---|
| `auth.*` | 不需要凭证(用 appId/appSecret 或 refreshToken) |
| `user.me` / `mark.*` / `wishspu.*` / `post.*` / `comment.*` | 必须是用户授权凭证,缺失时本地直接报错,不发请求 |
| `island.*` / `spu.*` / `tag.*` / `genai.*` | 应用凭证也实测调通过,但 SDK 不做静默 fallback,传哪种由调用方决定 |
```ts
const credential = await qdmp.auth.getAppAccessToken()
```

`x-echo-qdmp-version` 头只在 `standard` 鉴权方案下发送,`genai` 分组用 `x-openapi-access-token` + `x-openapi-app-id` 另一对头,`auth` 分组不需要——三端都从 `shared/generated/route-meta.json` 读取每个 operation 的 `authScheme`/`tokenRequired`。
`getAppAccessToken` 会缓存凭证,并在到期前 300 秒重新获取。默认缓存在当前进程;多实例部署可以实现 `TokenStore`,改用 Redis 等共享存储。

## 底层依赖
## 接口需要哪种凭证

| 语言 | HTTP 传输 | JSON |
|---|---|---|
| Node.js | 内置全局 `fetch`(`undici` 仅作为开发依赖,提供测试用的 `MockAgent`) | 内置 `JSON` |
| Java | OkHttp(`okhttp3`) | Jackson |
| Go | 标准库 `net/http` | 标准库 `encoding/json` |
| 接口分组 | 凭证要求 |
| --- | --- |
| `auth.*` | 不需要额外凭证,调用时使用 appId、appSecret 或 refresh token |
| `user.me`、`mark.*`、`wishspu.*`、`post.*`、`comment.*` | 必须传用户授权凭证;缺少凭证时不会发出请求 |
| `island.*`、`spu.*`、`tag.*`、`genai.*` | 显式传入 access token;应用凭证可用 |

类型/DTO 由 OpenAPI 3.0 spec(`shared/openapi.yaml`)生成,三端分别用 `openapi-typescript`、`openapi-generator-cli`、`oapi-codegen`——只生成类型,不生成完整 client,业务方法和请求逻辑都是手写的。
接口是否成功以响应体的 `code === '0'` 为准,不只看 HTTP 状态码。

## 目录结构
## 错误处理

```
qdmp-server-sdk/
├─ shared/ # 单一真源 openapi.yaml + 生成的路由元数据/错误码表
├─ node/ # npm: @qdmp/qdmp-server-sdk
├─ java/ # Maven: io.github.echotechfe:qdmp-server-sdk
└─ go/ # module: github.com/EchoTechFE/qdmp-server-sdk/go
```
- Node.js:业务错误为 `QdmpApiError`,本地参数错误为 `QdmpValidationError`,重定向或响应体过大等传输错误为 `QdmpTransportError`。底层 `fetch` 的网络错误会原样抛出。
- Java:业务错误为 `QdmpApiError`,参数错误为 `QdmpValidationError`,传输错误为 `QdmpTransportException`,都在 `io.github.echotechfe.qdmp.errors` 包中。
- Go:业务错误为 `*qdmp.QdmpApiError`;缺少凭证时返回 `qdmp.ErrAccessTokenRequired`,可用 `errors.Is` 判断。SDK 返回普通 `error`,不会 panic。

## 开发

本地环境搭建、代码生成、构建/测试/覆盖率命令见 [DEVELOPMENT.md](./DEVELOPMENT.md)。
`shared/openapi.yaml` 是三端共用的接口定义。代码生成、构建、测试和覆盖率命令见 [DEVELOPMENT.md](./DEVELOPMENT.md)。

## License

Expand Down
Loading