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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +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` 代查。
- OAuth/OpenID 用户资源链路在 `OpenIdProcessor`:`/openid/login` 通过 `fishpi.scope` 请求 `profile.read/profile.detail.read/points.read/articles.read/membership.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 推送改动时,前端强刷还不够,必须让用户重启或重新编译服务端。
- 账号设置手机/邮箱验证码链路:PC/移动模板在 `skins/classic/*/home/settings/account.ftl`,前端在 `settings.js`,发送接口在 `SettingsProcessor#sendPhoneVC/sendEmailVC`;验证码形态调整需同步前端提交字段与后端校验方式。
- `AnonymousViewCheckMidware#handle`:匿名访问触发验证码(2 小时首次访问 + 每 5 次访问),并结合 `anonymous.viewSkips`、文章匿名开关、匿名访问次数 Cookie 限制。
Expand Down
90 changes: 86 additions & 4 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,19 +495,21 @@ curl --location --request POST 'https://fishpi.cn/report' \

| Key | 说明 | 示例 |
| ------------ | ---------------------------- | ---------------------------- |
| fishpi.scope | 授权范围,空格或逗号分隔 | profile.read points.read |
| fishpi.scope | 授权范围,空格或逗号分隔 | profile.read profile.detail.read membership.read |

> 可用范围:`profile.read` 基础信息,`profile.detail.read` 详细资料,`points.read` 积分信息,`articles.read` 发帖信息,`membership.read` VIP 信息。

请求示例:

```bash
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'
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%20profile.detail.read%20membership.read'
```

响应:

| Key | 说明 | 示例 |
| ------------ | ---------------------------- | ---------------------------- |
| scope | 用户最终授权范围 | profile.read points.read |
| scope | 用户最终授权范围 | profile.read profile.detail.read membership.read |
| token_type | 令牌类型 | Bearer |
| access_token | OAuth 用户资源访问令牌 | xxx |
| expires_in | 有效期,单位秒 | 604800 |
Expand Down Expand Up @@ -549,7 +551,7 @@ curl --location --request POST 'https://fishpi.cn/openid/token' \
| - expires_in | 访问令牌有效期,单位秒 | 604800 |
| - refresh_token | 新 OAuth 续签令牌 | xxx |
| - refresh_expires_in | 续签令牌空闲有效期,单位秒 | 31104000 |
| - scope | 用户最终授权范围 | profile.read points.read |
| - scope | 用户最终授权范围 | profile.read profile.detail.read membership.read |

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

Expand Down Expand Up @@ -584,6 +586,86 @@ curl --location --request GET 'https://fishpi.cn/openid/user/profile' \
| - userNickname | 昵称 | 管理员 |
| - userAvatarURL | 头像 | https://file.fishpi.cn/a.png |

### 查询 OAuth 用户详细信息

`GET /openid/user/detail`

查询当前 OAuth 授权用户的详细资料。需要 `profile.detail.read` 权限。

请求:

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

请求示例:

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

响应:

| Key | 说明 | 示例 |
| ---------------------- | -------------------- | ---------------------------- |
| code | 0 为成功,-1 为失败 | 0 |
| msg | 错误消息 | |
| data | 响应数据 | |
| - userName | 用户名 | admin |
| - userOnlineFlag | 是否在线 | true |
| - onlineMinute | 在线分钟数 | 1000 |
| - userURL | 个人链接 | https://example.com |
| - userNickname | 昵称 | 管理员 |
| - userCity | 公开城市 | 北京 |
| - userAvatarURL | 头像 | https://file.fishpi.cn/a.png |
| - userPoint | 当前积分 | 183939 |
| - userIntro | 简介 | 摸鱼中 |
| - oId | 用户 oId | 1659430635383 |
| - userNo | 用户编号 | 1 |
| - userAppRole | 站内身份 | 0 |
| - sysMetal | 展示勋章 | {"list":[]} |
| - followerCount | 粉丝数 | 10 |
| - followingUserCount | 关注数 | 5 |
| - userRole | 角色名 | 普通用户 |
| - cardBg | 卡片背景 | |

> 本接口不接受 `apiKey`,只返回当前 Bearer token 所属用户;不会返回邮箱、手机号、QQ、登录 IP、密码、2FA 密钥、token/key 或完整用户对象。`userCity` 仅在用户公开地理位置时返回。

### 查询 OAuth 用户 VIP 信息

`GET /openid/user/membership`

查询当前 OAuth 授权用户的 VIP 状态。需要 `membership.read` 权限。

请求:

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

请求示例:

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

响应:

| Key | 说明 | 示例 |
| ------------ | -------------------- | ------------- |
| code | 0 为成功,-1 为失败 | 0 |
| msg | 错误消息 | |
| data | 响应数据 | |
| - active | VIP 是否有效 | true |
| - state | 会员状态 | 1 |
| - lvCode | VIP 等级编码 | VIP1 |
| - expiresAt | 到期时间,毫秒;0 为永久 | 1760000000000 |
| - configJson | 用户 VIP 配置 | {} |

> 本接口不接受 `apiKey`,只返回当前 Bearer token 所属用户;非 VIP 或已过期时返回 `active=false`、`state=0`、`lvCode=""`、`expiresAt=0`、`configJson=""`。不会返回价格、支付、退款或后台维护记录。

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

`GET /openid/user/points`
Expand Down
143 changes: 140 additions & 3 deletions src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,29 @@
import org.b3log.latke.ioc.Inject;
import org.b3log.latke.ioc.Singleton;
import org.b3log.latke.model.Pagination;
import org.b3log.latke.model.User;
import org.b3log.latke.service.ServiceException;
import org.b3log.latke.util.Paginator;
import org.b3log.symphony.model.Article;
import org.b3log.symphony.model.Common;
import org.b3log.symphony.model.Follow;
import org.b3log.symphony.model.Membership;
import org.b3log.symphony.model.Pointtransfer;
import org.b3log.symphony.model.Role;
import org.b3log.symphony.model.SystemSettings;
import org.b3log.symphony.model.UserExt;
import org.b3log.symphony.processor.middleware.CSRFMidware;
import org.b3log.symphony.processor.middleware.LoginCheckMidware;
import org.b3log.symphony.service.ActivityMgmtService;
import org.b3log.symphony.service.ArticleQueryService;
import org.b3log.symphony.service.CloudService;
import org.b3log.symphony.service.DataModelService;
import org.b3log.symphony.service.FollowQueryService;
import org.b3log.symphony.service.MembershipQueryService;
import org.b3log.symphony.service.OpenIdRefreshTokenMgmtService;
import org.b3log.symphony.service.PointtransferQueryService;
import org.b3log.symphony.service.RoleQueryService;
import org.b3log.symphony.service.SystemSettingsService;
import org.b3log.symphony.service.UserQueryService;
import org.b3log.symphony.util.OpenIdUtil;
import org.b3log.symphony.util.Sessions;
Expand Down Expand Up @@ -84,16 +94,21 @@ public class OpenIdProcessor {
private static final String FISHPI_SCOPE_KEY = "fishpi.scope";
private static final String FISHPI_AUTH_REQUEST_ID_KEY = "fishpi.authRequestId";
private static final String SCOPE_PROFILE_READ = "profile.read";
private static final String SCOPE_PROFILE_DETAIL_READ = "profile.detail.read";
private static final String SCOPE_POINTS_READ = "points.read";
private static final String SCOPE_ARTICLES_READ = "articles.read";
private static final String SCOPE_MEMBERSHIP_READ = "membership.read";
private static final long OPENID_TMP_EXPIRES = 5 * 60 * 1000L;
private static final List<ScopeDefinition> SCOPE_DEFINITIONS = Arrays.asList(
new ScopeDefinition(SCOPE_PROFILE_READ, "个人信息"),
new ScopeDefinition(SCOPE_PROFILE_DETAIL_READ, "详细资料"),
new ScopeDefinition(SCOPE_POINTS_READ, "积分信息"),
new ScopeDefinition(SCOPE_ARTICLES_READ, "发帖信息")
new ScopeDefinition(SCOPE_ARTICLES_READ, "发帖信息"),
new ScopeDefinition(SCOPE_MEMBERSHIP_READ, "VIP信息")
);
private static final Set<String> ALL_SCOPES = new LinkedHashSet<>(Arrays.asList(
SCOPE_PROFILE_READ, SCOPE_POINTS_READ, SCOPE_ARTICLES_READ
SCOPE_PROFILE_READ, SCOPE_PROFILE_DETAIL_READ, SCOPE_POINTS_READ, SCOPE_ARTICLES_READ,
SCOPE_MEMBERSHIP_READ
));

private static final Map<String, AuthRequest> authRequestMap = Collections.synchronizedMap(new LinkedHashMap<>());
Expand All @@ -115,6 +130,21 @@ public class OpenIdProcessor {
@Inject
private OpenIdRefreshTokenMgmtService openIdRefreshTokenMgmtService;

@Inject
private CloudService cloudService;

@Inject
private FollowQueryService followQueryService;

@Inject
private MembershipQueryService membershipQueryService;

@Inject
private RoleQueryService roleQueryService;

@Inject
private SystemSettingsService systemSettingsService;

public static void register() {
final BeanManager beanManager = BeanManager.getInstance();
final LoginCheckMidware loginCheck = beanManager.getReference(LoginCheckMidware.class);
Expand All @@ -127,8 +157,10 @@ public static void register() {
Dispatcher.post("/openid/verify", openIdProcessor::verify, csrfMidware::fill);
Dispatcher.post("/openid/token", openIdProcessor::refreshToken);
Dispatcher.get("/openid/user/profile", openIdProcessor::getProfile);
Dispatcher.get("/openid/user/detail", openIdProcessor::getDetail);
Dispatcher.get("/openid/user/points", openIdProcessor::getPoints);
Dispatcher.get("/openid/user/articles", openIdProcessor::getArticles);
Dispatcher.get("/openid/user/membership", openIdProcessor::getMembership);

// 开启定时任务,清理过期的nonce
Symphonys.SCHEDULED_EXECUTOR_SERVICE.scheduleAtFixedRate(() -> {
Expand Down Expand Up @@ -525,12 +557,40 @@ public void getProfile(final RequestContext context) {

final JSONObject data = new JSONObject()
.put("userId", user.optString(Keys.OBJECT_ID))
.put("userName", user.optString(org.b3log.latke.model.User.USER_NAME))
.put("userName", user.optString(User.USER_NAME))
.put("userNickname", user.optString(UserExt.USER_NICKNAME))
.put("userAvatarURL", user.optString(UserExt.USER_AVATAR_URL));
renderSucc(context, data);
}

public void getDetail(final RequestContext context) {
final JSONObject user = requireOpenIdUser(context, SCOPE_PROFILE_DETAIL_READ);
if (null == user) {
return;
}

final String userId = user.optString(Keys.OBJECT_ID);
final JSONObject data = new JSONObject();
data.put(User.USER_NAME, user.optString(User.USER_NAME));
data.put(UserExt.USER_ONLINE_FLAG, user.optBoolean(UserExt.USER_ONLINE_FLAG));
data.put(UserExt.ONLINE_MINUTE, user.optInt(UserExt.ONLINE_MINUTE));
data.put(User.USER_URL, user.optString(User.USER_URL));
data.put(UserExt.USER_NICKNAME, user.optString(UserExt.USER_NICKNAME));
data.put(UserExt.USER_CITY, getPublicCity(user));
data.put(UserExt.USER_AVATAR_URL, user.optString(UserExt.USER_AVATAR_URL));
data.put(UserExt.USER_POINT, user.optInt(UserExt.USER_POINT));
data.put(UserExt.USER_INTRO, user.optString(UserExt.USER_INTRO));
data.put(Keys.OBJECT_ID, userId);
data.put(UserExt.USER_NO, user.optString(UserExt.USER_NO));
data.put(UserExt.USER_APP_ROLE, user.optString(UserExt.USER_APP_ROLE));
data.put("sysMetal", cloudService.getEnabledMedal(userId));
data.put("followerCount", followQueryService.getFollowerCount(userId, Follow.FOLLOWING_TYPE_C_USER));
data.put("followingUserCount", followQueryService.getFollowingCount(userId, Follow.FOLLOWING_TYPE_C_USER));
data.put(User.USER_ROLE, getRoleName(user));
data.put("cardBg", getCardBg(userId));
renderSucc(context, data);
}

public void getPoints(final RequestContext context) {
final JSONObject user = requireOpenIdUser(context, SCOPE_POINTS_READ);
if (null == user) {
Expand Down Expand Up @@ -577,6 +637,20 @@ public void getArticles(final RequestContext context) {
renderSucc(context, data);
}

public void getMembership(final RequestContext context) {
final JSONObject user = requireOpenIdUser(context, SCOPE_MEMBERSHIP_READ);
if (null == user) {
return;
}

try {
final JSONObject membership = membershipQueryService.getStatusByUserId(user.optString(Keys.OBJECT_ID));
renderSucc(context, buildMembershipData(membership));
} catch (final ServiceException e) {
renderError(context, "查询失败");
}
}

private JSONObject requireOpenIdUser(final RequestContext context, final String requiredScope) {
String authorization = StringUtils.trim(context.header("Authorization"));
if (StringUtils.isBlank(authorization) || !StringUtils.startsWithIgnoreCase(authorization, "Bearer ")) {
Expand Down Expand Up @@ -606,6 +680,69 @@ private JSONObject requireOpenIdUser(final RequestContext context, final String
return user;
}

private String getPublicCity(final JSONObject user) {
try {
if (user.optInt(UserExt.USER_GEO_STATUS) == UserExt.USER_GEO_STATUS_C_PUBLIC) {
return user.optString(UserExt.USER_CITY);
}
} catch (final Exception ignored) {
}

return "";
}

private String getRoleName(final JSONObject user) {
final JSONObject role = roleQueryService.getRole(user.optString(User.USER_ROLE));
if (null == role) {
return "";
}

return role.optString(Role.ROLE_NAME);
}

private String getCardBg(final String userId) {
final JSONObject systemSettings = systemSettingsService.getByUsrId(userId);
if (null == systemSettings) {
return "";
}

try {
final JSONObject settings = new JSONObject(systemSettings.optString(SystemSettings.SETTINGS));
return settings.optString("cardBg");
} catch (final Exception ignored) {
return "";
}
}

private JSONObject buildMembershipData(final JSONObject membership) {
if (null == membership) {
return buildInactiveMembershipData();
}

final int state = membership.optInt(Membership.STATE, 0);
final long expiresAt = membership.optLong(Membership.EXPIRES_AT, 0L);
final boolean active = 1 == state && (0L == expiresAt || expiresAt > System.currentTimeMillis());
if (!active) {
return buildInactiveMembershipData();
}

return new JSONObject()
.put("active", true)
.put(Membership.STATE, state)
.put(Membership.LV_CODE, membership.optString(Membership.LV_CODE))
.put(Membership.EXPIRES_AT, expiresAt)
.put(Membership.CONFIG_JSON, membership.optString(Membership.CONFIG_JSON));
}

private JSONObject buildInactiveMembershipData() {
return new JSONObject()
.put("active", false)
.put(Membership.STATE, 0)
.put(Membership.LV_CODE, "")
.put(Membership.EXPIRES_AT, 0L)
.put(Membership.CONFIG_JSON, "");
}

private Set<String> parseScopes(final String scopeText) {
final Set<String> ret = new LinkedHashSet<>();
if (StringUtils.isBlank(scopeText)) {
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/skins/classic/mobile/verify/openid.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@
<div style="margin-bottom: 16px" class="openid-intro-title">授权内容</div>
<ul>
<li>个人信息:头像、昵称、用户名</li>
<li>详细资料:简介、在线、关注</li>
<li>积分信息:余额、记录</li>
<li>发帖信息:公开发帖</li>
<li>VIP信息:等级、到期</li>
</ul>
<div class="openid-intro-title">登录即授权所选项</div>
</div>
Expand Down
Loading
Loading