diff --git a/AGENTS.md b/AGENTS.md index a9390320..347b5cec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 限制。 diff --git a/API.md b/API.md index db75f1b0..fe423ba8 100644 --- a/API.md +++ b/API.md @@ -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 | @@ -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` 暴露到前端页面或日志中。 @@ -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` diff --git a/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java b/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java index 2dfe0a0e..6bde44e6 100644 --- a/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java +++ b/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java @@ -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; @@ -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 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 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 authRequestMap = Collections.synchronizedMap(new LinkedHashMap<>()); @@ -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); @@ -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(() -> { @@ -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) { @@ -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 ")) { @@ -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 parseScopes(final String scopeText) { final Set ret = new LinkedHashSet<>(); if (StringUtils.isBlank(scopeText)) { diff --git a/src/main/resources/skins/classic/mobile/verify/openid.ftl b/src/main/resources/skins/classic/mobile/verify/openid.ftl index bf870b95..4d6685af 100644 --- a/src/main/resources/skins/classic/mobile/verify/openid.ftl +++ b/src/main/resources/skins/classic/mobile/verify/openid.ftl @@ -77,8 +77,10 @@
授权内容
  • 个人信息:头像、昵称、用户名
  • +
  • 详细资料:简介、在线、关注
  • 积分信息:余额、记录
  • 发帖信息:公开发帖
  • +
  • VIP信息:等级、到期
登录即授权所选项
diff --git a/src/main/resources/skins/classic/pc/verify/openid.ftl b/src/main/resources/skins/classic/pc/verify/openid.ftl index 00194c01..65c9544e 100644 --- a/src/main/resources/skins/classic/pc/verify/openid.ftl +++ b/src/main/resources/skins/classic/pc/verify/openid.ftl @@ -81,8 +81,10 @@
授权内容
  • 个人信息:头像、昵称、用户名
  • +
  • 详细资料:简介、在线、关注
  • 积分信息:余额、记录
  • 发帖信息:公开发帖
  • +
  • VIP信息:等级、到期
登录即授权所选项