Skip to content
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
- `BeforeRequestHandler` 获取当前用户优先用 `UserQueryService#getCurrentUser(request)`(底层 `Sessions.currentUser(request)`);`Sessions.getUser()` 仅读取 ThreadLocal,未 `Sessions.setUser(...)` 前通常为 `null`。
- `LoginCheckMidware#handle`:未登录统一 401(特殊 URI `/gen` 返回空 SVG);支持 `Sessions` 与 `apiKey` 两种登录态来源。
- 新接口若需“页面登录态 + apiKey 调用”双兼容,路由层优先挂 `loginCheck::handle`,处理方法再读取 `context.attr(User.USER)`;避免只挂 `permission` 导致拿不到当前用户对象。
- OAuth/OpenID 用户资源链路在 `OpenIdProcessor`:`/openid/login` 通过 `fishpi.scope` 请求 `profile.read/points.read/articles.read`,`/openid/verify` 成功后返回 Bearer `access_token` 与可轮换 `refresh_token`;资源接口只认 `Authorization: Bearer`,续签走匿名 `POST /openid/token`,不接收 `apiKey`、不支持 `userId` 代查。
- 文章页实时链路走 `ArticleChannel`:新增评论仍是 `type=comment`,评论 reaction 增量走 `type=commentReaction`,帖子本体 reaction 增量走 `type=articleReaction`;联调这类 Java 推送改动时,前端强刷还不够,必须让用户重启或重新编译服务端。
- `AnonymousViewCheckMidware#handle`:匿名访问触发验证码(2 小时首次访问 + 每 5 次访问),并结合 `anonymous.viewSkips`、文章匿名开关、匿名访问次数 Cookie 限制。
- `Server` 启动逻辑:`DEVELOPMENT` 模式会关闭 `Firewall` 与 `AnonymousViewCheck`(验证码盾),联调时不要误判“线上无校验”。
Expand Down
176 changes: 163 additions & 13 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,26 +479,130 @@ curl --location --request POST 'https://fishpi.cn/report' \

`GET /user/{用户名}/point`

### 查询积分记录
### 查询用户勋章

`GET /user/{用户名}/medal`

## OAuth 用户资源

### OAuth 授权范围

`GET /api/user/points`
`GET /openid/login`

分页查询当前用户积分记录。管理员可通过 `userId` 查询指定用户
第三方登录时可通过 `fishpi.scope` 请求用户资源权限。用户授权后,`POST /openid/verify` 成功响应会追加 `access_token` 和 `refresh_token`

请求:

| Key | 说明 | 示例 |
| ------ | ----------------------- | -------------------------------- |
| apiKey | 通用密钥 | oXTQTD4ljryXoIxa1lySgEl6aObrIhSS |
| p | 页码,默认 1 | 1 |
| size | 每页数量,默认 20,最大 200 | 20 |
| userId | 用户 oId,仅管理员可用 | 1659430635383 |
| Key | 说明 | 示例 |
| ------------ | ---------------------------- | ---------------------------- |
| fishpi.scope | 授权范围,空格或逗号分隔 | profile.read points.read |

请求示例:

```bash
curl --location --request GET 'https://fishpi.cn/api/user/points?apiKey=oXTQTD4ljryXoIxa1lySgEl6aObrIhSS&p=1&size=20' \
--header 'User-Agent: Mozilla/5.0'
curl --location --request GET 'https://fishpi.cn/openid/login?openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&openid.mode=checkid_setup&openid.return_to=https%3A%2F%2Fexample.com%2Fcallback&openid.realm=https%3A%2F%2Fexample.com&openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&fishpi.scope=profile.read%20points.read'
```

响应:

| Key | 说明 | 示例 |
| ------------ | ---------------------------- | ---------------------------- |
| scope | 用户最终授权范围 | profile.read points.read |
| token_type | 令牌类型 | Bearer |
| access_token | OAuth 用户资源访问令牌 | xxx |
| expires_in | 有效期,单位秒 | 604800 |
| refresh_token | OAuth 续签令牌 | xxx |
| refresh_expires_in | 续签令牌空闲有效期,单位秒 | 31104000 |

> `profile.read` 为基础权限;站点请求的权限为必选,用户可额外勾选更多权限。

### 续签 OAuth 访问令牌

`POST /openid/token`

使用 `refresh_token` 续签 OAuth 访问令牌。不接受 `apiKey`。

请求:
| Key | 说明 | 示例 |
| ------------- | ---------------------------- | ------------- |
| grant_type | 固定为 `refresh_token` | refresh_token |
| refresh_token | OAuth 续签令牌 | xxx |

请求示例:
```bash
curl --location --request POST 'https://fishpi.cn/openid/token' \
--header 'Content-Type: application/json' \
--data-raw '{
"grant_type": "refresh_token",
"refresh_token": "xxx"
}'
```

响应:
| Key | 说明 | 示例 |
| ---------------------- | ---------------------------- | ------------------------ |
| code | 0 为成功,-1 为失败 | 0 |
| msg | 错误消息 | |
| data | 响应数据 | |
| - token_type | 令牌类型 | Bearer |
| - access_token | OAuth 用户资源访问令牌 | xxx |
| - expires_in | 访问令牌有效期,单位秒 | 604800 |
| - refresh_token | 新 OAuth 续签令牌 | xxx |
| - refresh_expires_in | 续签令牌空闲有效期,单位秒 | 31104000 |
| - scope | 用户最终授权范围 | profile.read points.read |

> 每次续签都会返回新的 `refresh_token`,旧 `refresh_token` 会立即失效;续签令牌最长不超过首次授权后 720 天;请不要把 `refresh_token` 暴露到前端页面或日志中。

### 查询 OAuth 用户信息

`GET /openid/user/profile`

查询当前 OAuth 授权用户的基础信息。需要 `profile.read` 权限。

请求:

| Key | 说明 | 示例 |
| ------------- | ---------------- | ------------ |
| Authorization | Bearer 访问令牌 | Bearer xxx |

请求示例:

```bash
curl --location --request GET 'https://fishpi.cn/openid/user/profile' \
--header 'Authorization: Bearer xxx'
```

响应:

| Key | 说明 | 示例 |
| ---------------- | -------------------- | ----------------------------- |
| code | 0 为成功,-1 为失败 | 0 |
| msg | 错误消息 | |
| data | 响应数据 | |
| - userId | 用户 oId | 1659430635383 |
| - userName | 用户名 | admin |
| - userNickname | 昵称 | 管理员 |
| - userAvatarURL | 头像 | https://file.fishpi.cn/a.png |

### 查询 OAuth 用户积分记录

`GET /openid/user/points`

分页查询当前 OAuth 授权用户的积分余额和积分记录。需要 `points.read` 权限。

请求:

| Key | 说明 | 示例 |
| ------------- | ---------------------------- | ---------- |
| Authorization | Bearer 访问令牌 | Bearer xxx |
| p | 页码,默认 1 | 1 |
| size | 每页数量,默认 20,最大 200 | 20 |

请求示例:

```bash
curl --location --request GET 'https://fishpi.cn/openid/user/points?p=1&size=20' \
--header 'Authorization: Bearer xxx'
```

响应:
Expand All @@ -509,6 +613,7 @@ curl --location --request GET 'https://fishpi.cn/api/user/points?apiKey=oXTQTD4l
| msg | 错误消息 | |
| data | 响应数据 | |
| - userId | 用户 oId | 1659430635383 |
| - userPoint | 当前积分 | 183939 |
| - records | 积分记录列表 | |
| -- oId | 记录 oId | 1760000000000 |
| -- fromId | 支出用户 oId | 1659430635383 |
Expand All @@ -529,9 +634,54 @@ curl --location --request GET 'https://fishpi.cn/api/user/points?apiKey=oXTQTD4l
| -- paginationPageCount | 总页数 | 5 |
| -- paginationPageNums | 页码列表 | [1,2,3,4,5] |

### 查询用户勋章
### 查询 OAuth 用户发帖记录

`GET /user/{用户名}/medal`
`GET /openid/user/articles`

分页查询当前 OAuth 授权用户的公开发帖记录。需要 `articles.read` 权限。

请求:

| Key | 说明 | 示例 |
| ------------- | ---------------------------- | ---------- |
| Authorization | Bearer 访问令牌 | Bearer xxx |
| p | 页码,默认 1 | 1 |
| size | 每页数量,默认 20,最大 100 | 20 |

请求示例:

```bash
curl --location --request GET 'https://fishpi.cn/openid/user/articles?p=1&size=20' \
--header 'Authorization: Bearer xxx'
```

响应:

| Key | 说明 | 示例 |
| -------------------------------- | -------------------- | ------------- |
| code | 0 为成功,-1 为失败 | 0 |
| msg | 错误消息 | |
| data | 响应数据 | |
| - userId | 用户 oId | 1659430635383 |
| - articles | 发帖记录列表 | |
| -- oId | 帖子 oId | 1760000000000 |
| -- articleTitle | 标题 | 摸鱼 |
| -- articlePermalink | 链接 | /article/1760000000000 |
| -- articleTags | 标签 | 摸鱼,生活 |
| -- articleCreateTime | 创建时间 | 1760000000000 |
| -- articleUpdateTime | 更新时间 | 1760000000000 |
| -- articleCommentCount | 回帖数 | 3 |
| -- articleViewCount | 浏览数 | 100 |
| -- articleType | 帖子类型 | 0 |
| -- articlePerfect | 是否优选 | 0 |
| - pagination | 分页信息 | |
| -- paginationCurrentPageNum | 当前页 | 1 |
| -- paginationPageSize | 每页数量 | 20 |
| -- paginationRecordCount | 总记录数 | 100 |
| -- paginationPageCount | 总页数 | 5 |
| -- paginationPageNums | 页码列表 | [1,2,3,4,5] |

> 发帖记录只返回公开、非匿名、未删除发帖,不包含正文、草稿、IP、UA。

## 通知

Expand Down
20 changes: 20 additions & 0 deletions sql/20260622_openid_refresh_token.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS `symphony_openid_refresh_token` (
`oId` VARCHAR(19) NOT NULL,
`tokenHash` VARCHAR(128) NOT NULL,
`userId` VARCHAR(19) NOT NULL,
`realm` VARCHAR(512) NOT NULL DEFAULT '',
`scope` VARCHAR(255) NOT NULL DEFAULT '',
`familyId` VARCHAR(64) NOT NULL,
`parentId` VARCHAR(19) NOT NULL DEFAULT '',
`state` INT NOT NULL DEFAULT 0,
`createdAt` BIGINT NOT NULL,
`updatedAt` BIGINT NOT NULL,
`lastUsedAt` BIGINT NOT NULL DEFAULT 0,
`idleExpiresAt` BIGINT NOT NULL,
`maxExpiresAt` BIGINT NOT NULL,
PRIMARY KEY (`oId`),
UNIQUE KEY `uk_openid_refresh_token_hash` (`tokenHash`),
KEY `idx_openid_refresh_token_family_state` (`familyId`, `state`),
KEY `idx_openid_refresh_token_user_realm` (`userId`, `realm`(191)),
KEY `idx_openid_refresh_token_expires` (`idleExpiresAt`, `maxExpiresAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
48 changes: 48 additions & 0 deletions src/main/java/org/b3log/symphony/model/OpenIdRefreshToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Rhythm - A modern community (forum/BBS/SNS/blog) platform written in Java.
* Modified version from Symphony, Thanks Symphony :)
* Copyright (C) 2012-present, b3log.org
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.b3log.symphony.model;

/**
* OpenID refresh token model constants.
*/
public final class OpenIdRefreshToken {

private OpenIdRefreshToken() {
}

public static final String OPENID_REFRESH_TOKEN = "openid_refresh_token";

public static final String TOKEN_HASH = "tokenHash";
public static final String USER_ID = "userId";
public static final String REALM = "realm";
public static final String SCOPE = "scope";
public static final String FAMILY_ID = "familyId";
public static final String PARENT_ID = "parentId";
public static final String STATE = "state";
public static final String CREATED_AT = "createdAt";
public static final String UPDATED_AT = "updatedAt";
public static final String LAST_USED_AT = "lastUsedAt";
public static final String IDLE_EXPIRES_AT = "idleExpiresAt";
public static final String MAX_EXPIRES_AT = "maxExpiresAt";

public static final int STATE_ACTIVE = 0;
public static final int STATE_ROTATED = 1;
public static final int STATE_REVOKED = 2;
public static final int STATE_EXPIRED = 3;
}
Loading
Loading