feat: 移除无用截图

This commit is contained in:
liulujian 2026-07-27 14:26:28 +08:00
parent cab2d1cd37
commit 3007b73d77
37 changed files with 531 additions and 779 deletions

View File

@ -0,0 +1,530 @@
# OAuth2 Authorize 流程梳理与重构指南
> 本文梳理 `txw` 项目中 `OAuth2 授权码模式(authorization_code)` 的完整前后端流程,梳理涉及的前端代理跳转页、后端三套 Controller、授权码 / Token 生命周期、网关路由、数据库表与 Redis Key。
> 配套阅读:`docs/SSO认证模块详解.md`(已有 SSO 总体文档,本文聚焦 authorize 子流程)。
> 重构目标读者:在新项目里复刻 / 重构此功能,需要快速理解整体链路。
---
## 1. 涉及的文件 / 模块清单
| 角色 | 文件 / 路径 | 说明 |
|------|------------|------|
| 前端 - 门户 | `txw-mhzc-web/src/pages/index/views/dddl/authorize.vue` | 单点登录跳转中间页(模板为空) |
| 前端 - 门户 | `txw-mhzc-web/src/pages/index/api/login.js` | `authorize()` 函数,POST `/sso/oauth2/authorize` |
| 前端 - 门户 | `txw-mhzc-web/src/pages/index/router/routes.js` | 路由 `/authorize` → dddl/authorize.vue |
| 前端 - 门户 | `txw-mhzc-web/src/pages/index/router/index.js` | `base: window.STATIC_ENV_CONFIG.ROUTER_PREFIX`(`/view/mhzc`) |
| 前端 - 运营 | `txw-yygl-web/src/pages/index/api/login.js` | 同名 `authorize()``/oauth2/authorize` |
| 前端 - 共享 | `txw-gxzx-web/src/pages/index/api/login.js` | 同名 `authorize()``/oauth2/authorize` |
| 网关 | `docs/sql/nacos-config/txw-gateway.yaml` | `/sso/**``txw-sso`;白名单决定是否需要 token |
| 网关 | `txw-gateway/src/main/resources/bootstrap-nacos.yml` | 白名单配置(`/sso/oauth2/token` 在白名单) |
| 后端 - 控制器 | `txw-sso/.../controller/oauth2/OAuth2BasicController.java` | `POST /oauth2/authorize`(门户走这个) |
| 后端 - 控制器 | `txw-sso/.../controller/oauth2/OAuth2OpenController.java` | `POST /open/oauth2/authorize`(管理后台 sso.vue 走这个) |
| 后端 - 控制器 | `txw-sso/.../controller/oauth2/OAuth2TokenController.java` | Token CRUD:create/check/remove/refresh |
| 后端 - 服务 | `txw-sso/.../service/oauth2/OAuth2GrantServiceImpl.java` | 授权码授予 + Code 换 Token 逻辑 |
| 后端 - 服务 | `txw-sso/.../service/oauth2/OAuth2CodeServiceImpl.java` | 授权码创建 / 消费(5 分钟过期) |
| 后端 - 服务 | `txw-sso/.../service/oauth2/OAuth2TokenServiceImpl.java` | Access/Refresh Token 创建 / 刷新 / 校验 / 删除 |
| 后端 - 服务 | `txw-sso/.../service/oauth2/OAuth2ClientServiceImpl.java` | 客户端校验(带 `@Cacheable` 缓存) |
| 后端 - 工具 | `txw-sso/.../util/OAuth2Utils.java` | 构造重定向 URL、解析 scope 等 |
| 后端 - 实体 | `txw-sso/.../pojo/domain/oauth2/OAuth2{Code,Client,AccessToken,RefreshToken,Approve}DO` | 4 张核心表 |
| 已有文档 | `docs/SSO认证模块详解.md` | SSO 总览,DID 流程,登录流程 |
> **关键观察**:系统存在 **两套 authorize 入口**(`Basic` vs `Open`),`Basic` 用于门户体系(已登录用户代理换取 code),`Open` 用于管理后台(sso.vue 用户在授权页手动确认)。两者参数命名也略有差异(下表)。
---
## 2. 端到端时序图
下图覆盖了 **`/view/mhzc/authorize` 走的全链路**(门户场景,基于 `OAuth2BasicController`)。
```
┌────────┐ ┌──────────────┐ ┌────────┐ ┌────────────────┐ ┌──────────────┐
│ 浏览器 │ │ txw-mhzc-web │ │ Gateway│ │ txw-sso 服务 │ │ MySQL/Redis │
└───┬────┘ └──────┬───────┘ └───┬────┘ └──────┬─────────┘ └──────┬───────┘
│ │ │ │ │
│ 1.访问受保护资源 / 跳到本系统(已登录) │ │
│─────────────────────────────────>│ │ │
│ │ │ │ │
│ 2.302 -> /view/mhzc/authorize │ │ │
│ ?response_type=code │ │ │
&client_id=xxx │ │ │
&redirect_uri=https://app/cb │ │ │
&state=xxx&scope=user.read │ │ │
<──────────────────────────────────│ │ │
│ │ │ │ │
│ 3.GET /view/mhzc/authorize?... │ │ │
│─────────────────>│ │ │ │
│ │ (Vue Router base 剥离 /view/mhzc) │ │
│ │ 匹配 /authorize -> authorize.vue │ │
│ │ │ │ │
│ │ 4.created(): │ │
│ │ POST /sso/oauth2/authorize │ │
│ │ (带 Cookie 里的 token) │ │
│ │─────────────────>│ │ │
│ │ │ 5.路由 /sso/** │ │
│ │ │ 转发到 txw-sso │ │
│ │ │─────────────────>│ │
│ │ │ │ │
│ │ │ │ 6.OAuth2BasicController.authorize()
│ │ │ │ - 解析 response_type / client_id
│ │ │ │ - validOAuthClientFromCache (Cacheable)
│ │ │ │ - 从 Cookie 取 currentToken
│ │ │ │ │
│ │ │ │ 7.GrantService.grantAuthorizationCodeForCode
│ │ │ │ -> CodeService.createAuthorizationCode
│ │ │ │ - 生成 sqm (UUID)
│ │ │ │ - 写 txw_sso_code (5min 过期)
│ │ │ │ - access_token 字段存 currentToken
│ │ │ │─────────────────────>│
│ │ │ │ │
│ │ │ │ 8.返回 CommonResult<String> {redirectUrl}
│ │ │ │ redirectUrl = redirect_uri?code=xxx&state=xxx
│ │ │ │<─────────────────────│
│ │ │ │ │
│ │ 9.响应 CommonResult │ │
│ │<──────────────────────────────────│ │
│ │ │ │ │
│ │ 10.前端取 res.data, │ │
│ │ location.href = href │ │
<──────────────────────────────────│ │ │
│ │ │ │ │
│ 11.浏览器请求第三方 callback │ │ │
│ GET https://app/cb?code=xxx&state=xxx │ │
│═════════════════════════════════════════════════════>│ │
│ │ │ │ │
│ (第三方后端,不在本系统内) │ │ │
│ 12.第三方用 code 换 token: │ │
│ POST /sso/oauth2/token (BasicAuth: clientId+secret) │ │
│ grant_type=authorization_code&code=xxx&redirect_uri= │ │
│═════════════════════════════════════════════════════>│ │
│ │ │ │ │
│ │ │ │ 13.OAuth2BasicController.postAccessToken
│ │ │ │ - 校验客户端(clientId+secret)
│ │ │ │ - GrantService.grantAuthorizationCodeForAccessToken
│ │ │ │ - CodeService.consumeAuthorizationCode
│ │ │ │ (校验过期、删 code 行)
│ │ │ │ - 校验 clientId / redirectUri 匹配
│ │ │ │ - TokenService.createAccessToken
│ │ │ │ (同时写 txw_sso_access_token + Redis)
│ │ │ │─────────────────────>│
│ │ │ │ │
│ │ │ │ 14.返回 {access_token, refresh_token, expires_in}
│ │ │ │<─────────────────────│
<═════════════════════════════════════════════════════│ │
│ │ │ │ │
│ 15.第三方拿 token 调第三方自己的 API │ │
```
**重点提示:**
- 第 10 步,前端的 `authorize.vue` 模板是空的,**唯一作用就是把后端返回的 `redirectUrl``location.href` 跳走**。
- 第 12 步,真正的 token 换发是 **第三方系统** 用 BasicAuth + 授权码访问 `txw-sso``/oauth2/token`,**本系统前端不参与这一步**。
- 第 5 步,网关白名单只放行 `/sso/oauth2/token`,**没有放行 `/sso/oauth2/authorize`**,意味着 step 4 的 authorize 请求需要带上有效 Cookie token(否则会被网关挡掉 → 跳到登录页)。
---
## 3. 前端流程详解(门户)
### 3.1 路由与前缀
```js
// txw-mhzc-web/src/pages/index/router/index.js:57-65
const router = new VueRouter({
mode: 'history',
base: `${window.STATIC_ENV_CONFIG.ROUTER_PREFIX}/`, // = /view/mhzc/
routes: [mainRoutes],
});
```
```js
// txw-mhzc-web/src/pages/index/router/routes.js:20-22, 224-228
function authorize() {
return import('@/pages/index/views/dddl/authorize.vue');
}
...
{ name: 'authorize', path: '/authorize', component: authorize }
```
→ 浏览器访问 `/view/mhzc/authorize?...` 时,Vue 路由匹配 `/authorize`,加载 `authorize.vue`
### 3.2 中间页源码与行为
```vue
<!-- txw-mhzc-web/src/pages/index/views/dddl/authorize.vue -->
<template>
<div class="container"></div> <!-- 模板为空 -->
</template>
<script>
import { authorize } from "@/pages/index/api/login";
export default {
created() {
this.params.responseType = this.$route.query.response_type
this.params.clientId = this.$route.query.client_id
this.params.redirectUri = this.$route.query.redirect_uri
this.params.state = this.$route.query.state
if (this.$route.query.scope) {
this.params.scopes = this.$route.query.scope.split(' ')
}
this.doAuthorize(true, this.loginForm.scopes, null).then(res => {
const href = res.data // 后端返回的 redirectUrl
if (!href) { console.log('自动授权未通过!'); return }
location.href = href // 关键动作:跳走
})
},
methods: {
doAuthorize(autoApprove, checkedScopes, uncheckedScopes) {
return authorize(this.params.responseType, this.params.clientId,
this.params.redirectUri, this.params.state,
autoApprove, checkedScopes, uncheckedScopes)
}
}
}
</script>
```
### 3.3 `authorize()` API 调用
```js
// txw-mhzc-web/src/pages/index/api/login.js:189-214
export function authorize(responseType, clientId, redirectUri, state,
autoApprove, checkedScopes, uncheckedScopes) {
return fetch({
url: '/sso/oauth2/authorize',
headers: { 'Content-type': 'application/x-www-form-urlencoded' },
params: {
response_type: responseType,
client_id: clientId,
redirect_uri: redirectUri,
state,
auto_approve: autoApprove,
scope: JSON.stringify(scopes), // scopes 当前始终是 {},传空对象
},
method: 'post',
})
}
```
- 调用的是普通 `fetch`(走 `window.STATIC_ENV_CONFIG.API_PREFIX` 配置的 baseURL),不是 `fetchSso`
- 当前传 `scope = "{}"`,后端 `OAuth2BasicController` 不读这个字段,只有 `OAuth2OpenController` 才解析。
### 3.4 三个前端的差异
| 前端 | API 路径 | 调用的 fetch | 备注 |
|------|---------|--------------|------|
| `txw-mhzc-web` | `/sso/oauth2/authorize` | `fetch` | 走 API_PREFIX(实际是 `/znsb` 等) |
| `txw-yygl-web` | `/oauth2/authorize` | `fetch` | 由该 web 自己 baseURL 决定 |
| `txw-gxzx-web` | `/oauth2/authorize` | `fetch` | 同上 |
三处实现几乎完全一致,只有 URL 路径前缀不同;**重构时如果统一加 / 去掉前缀,三处都要同步**。
---
## 4. 后端流程详解
### 4.1 三个 Controller 对比
| Controller | 类路径 | 路径前缀 | 入参命名风格 | 关键特征 |
|-----------|--------|----------|-------------|---------|
| `OAuth2BasicController` | `controller/oauth2/` | `/oauth2` | `Response_type` / `Client_id` (Spring 大小写不敏感) | 门户已登录场景;**复用 Cookie 里的 token** 作为 `access_token` 存到 code 行 |
| `OAuth2OpenController` | `controller/oauth2/` | `/open/oauth2` | 同上 | 管理后台 sso.vue 走;**从 SecurityContext 取 loginUserId** |
| `OAuth2TokenController` | `controller/oauth2/` | `/oauth2/token` | — | 内部 Feign API + 外部 `/oauth2/token/{create,check,remove,refresh}` |
> **⚠️ 重构点**:`@RequestParam("Response_type")` 这种首字母大写的取名很奇怪,容易踩坑(Spring 走的是大小写不敏感匹配,但 Swagger/前端传参时容易混淆)。新项目建议统一成 `response_type` 小写。
### 4.2 `OAuth2BasicController.authorize()` 逐行解读
```java
@PostMapping("/authorize")
public CommonResult<String> authorize(HttpServletRequest request,
@RequestParam("Response_type") String responseType,
@RequestParam("Client_id") String clientId,
@RequestParam("Redirect_uri") String redirectUri,
@RequestParam(value = "State", required = false) String state) {
// 1. responseType 必须是 code 或 token
OAuth2GrantTypeEnum grantTypeEnum = getGrantTypeEnum(responseType);
// 2. 校验客户端:clientId 存在 / yxbz=Y / redirectUri 域名在白名单内
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(
clientId, null, grantTypeEnum.getGrantType(), null, redirectUri);
// 3. 从 Cookie 取当前用户已有的 accessToken,塞到 code 行
String currentToken = SecurityFrameworkUtils.obtainAuthorization(
SsoConstants.COOKIE_TOKEN_KEY, request);
// 4. 创建 code,返回拼好的 redirectUrl
return success(getAuthorizationCodeRedirect(client, redirectUri, state, currentToken));
}
```
`getAuthorizationCodeRedirect`:
```java
private String getAuthorizationCodeRedirect(OAuth2ClientDO client, String redirectUri,
String state, String currentToken) {
// 4.1 写 txw_sso_code 行(5 分钟过期)
String authorizationCode = oauth2GrantService.grantAuthorizationCodeForCode(
client.getClientid(), redirectUri, state, currentToken);
// 4.2 拼接 redirect_uri?code=xxx&state=xxx
return OAuth2Utils.buildAuthorizationCodeRedirectUri(
redirectUri, authorizationCode, state);
}
```
`OAuth2Utils.buildAuthorizationCodeRedirectUri` 输出的 URL 形如:
```
https://app.example.com/callback?code=4f1d...&state=abc123
```
### 4.3 授权码的「副作用」:带 token
注意 `OAuth2CodeServiceImpl.createAuthorizationCode(clientId, redirectUri, state, currentToken)`:
```java
OAuth2CodeDO codeDO = new OAuth2CodeDO()
.setSqm(generateCode()) // 授权码
.setYhUuid(SessionUtils.getYhUuid()) // 当前登录用户
.setClientid(clientId)
.setCdxdz(redirectUri)
.setRzzt(state)
.setAccessToken(currentToken) // ← 关键:把当前 Cookie 里的 token 也存了
.setGqsj(LocalDateTime.now().plusSeconds(5 * 60)); // 5 分钟
```
后续在 `OAuth2TokenServiceImpl.createAccessToken(OAuth2CodeDO, OAuth2ClientDO)` 中:
```java
final OAuth2AccessTokenDO accessTokenDO = createOAuth2AccessToken(
oAuth2RefreshToken, codeDO, client); // gllp = codeDO.getAccessToken()
final OAuth2AccessTokenDO tokenDO = oauth2AccessTokenMapper.selectByAccessToken(
accessTokenDO.getGllp()); // 用旧 token 找到原 access_token 行
final String oldGllp = tokenDO.getGllp();
if (!GyUtils.isNull(oldGllp)) {
oauth2AccessTokenMapper.updateGllp(tokenDO.getUuid(),
oldGllp + "," + accessTokenDO.getAccessToken()); // 多个第三方 token 关联到一个门户 token
} else {
oauth2AccessTokenMapper.updateGllp(tokenDO.getUuid(),
accessTokenDO.getAccessToken());
}
```
也就是说 **一个门户用户的 access_token,会通过 `gllp`(关联令牌)字段把多个第三方授权出来的 access_token 串起来**,做集中吊销。
**重构时**:如果不需要这种关联,可以把 `gllp` 字段和相关逻辑直接砍掉,代码会清爽很多。
### 4.4 Code → Token 的换发流程
`POST /oauth2/token`(走 `OAuth2BasicController`):
```java
@PostMapping("/token")
public CommonResult<OAuth2OpenAccessTokenRespVO> postAccessToken(
@RequestParam("grant_type") String grantType,
@RequestParam(value = "code", required = false) String code,
@RequestParam(value = "redirect_uri", required = false) String redirectUri,
@RequestParam(value = "client_id") String clientId,
@RequestParam(value = "client_secret") String clientSecret,
@RequestParam(value = "refresh_token", required = false) String refreshToken) {
OAuth2GrantTypeEnum grantTypeEnum = OAuth2GrantTypeEnum.getByGranType(grantType);
if (grantTypeEnum == null) throw exception0(BAD_REQUEST, "未知授权类型");
if (grantTypeEnum == IMPLICIT) throw exception0(BAD_REQUEST, "Token 接口不支持 implicit");
// 1. 校验 clientId + clientSecret + redirectUri 域名白名单
OAuth2ClientDO client = oauth2ClientService.validOAuthClientFromCache(
clientId, clientSecret, grantType, null, redirectUri);
// 2. 根据 grant_type 分支
switch (grantTypeEnum) {
case AUTHORIZATION_CODE:
accessTokenDO = oauth2GrantService.grantAuthorizationCodeForAccessToken(
client, code, redirectUri);
break;
case REFRESH_TOKEN:
accessTokenDO = oauth2TokenService.refreshAccessToken(
refreshToken, client.getClientid());
break;
...
}
return success(OAuth2OpenConvert.INSTANCE.convert(accessTokenDO));
}
```
`grantAuthorizationCodeForAccessToken(OAuth2ClientDO, code, redirectUri)`(关键消费逻辑):
```java
public OAuth2AccessTokenDO grantAuthorizationCodeForAccessToken(
OAuth2ClientDO client, String code, String redirectUri) {
// 1. 消费 code:校验存在、校验未过期、删行(一次性)
OAuth2CodeDO codeDO = oauth2CodeService.consumeAuthorizationCode(code);
// 2. 校验 clientId / redirectUri 一致性
if (!StrUtil.equals(client.getClientid(), codeDO.getClientid())) {
throw exception(OAUTH2_GRANT_CLIENT_ID_MISMATCH);
}
if (!StrUtil.equals(redirectUri, codeDO.getCdxdz())) {
throw exception(OAUTH2_GRANT_REDIRECT_URI_MISMATCH);
}
// 3. 真正创建 token (写 DB + Redis + 关联 gllp)
return oauth2TokenService.createAccessToken(codeDO, client);
}
```
---
## 5. 数据模型与生命周期
### 5.1 核心表与字段
| 表 | 关键字段 | 生命周期 | 备注 |
|----|---------|---------|------|
| `txw_sso_code` | `sqm`(授权码)、`yh_uuid`、`clientid`、`cdxdz`、`gqsj`、`access_token`(门户 token)、`rzzt`(state) | **5 分钟,一次性消费** | authorize 写入,token 换发后删除 |
| `txw_sso_access_token` | `access_token`、`refresh_token`、`yh_uuid`、`qyuuid`、`clientid`、`gqsj`、`gllp`(关联 token 串) | 由 `client.fwlpyxq` 决定(默认 2h) | 登录或 code 换发时写入 |
| `txw_sso_refresh_token` | `refresh_token`、`yh_uuid`、`clientid`、`gqsj` | 由 `client.sxlpyxq` 决定(默认 30d) | default 客户端不创建 |
| `txw_sso_client` | `clientid`、`sqmy`(secret)、`yxbz`、`fwlpyxq`、`sxlpyxq`、`cdxdz`(白名单,`;` 分隔)、`sqnr`(scope 列表) | 长期 | `cdxdz`**redirect_uri 域名白名单**,多个用 `;` 拼接 |
### 5.2 Redis Key
| Key | Value | TTL | 用途 |
|-----|-------|-----|------|
| `oauth2_access_token:{accessToken}` | `SessionInfo`(用户 + 企业 + 角色等) | 跟随 token 过期 | Token 校验加速,失效后回退到 MySQL |
| `oauth2_refresh_token:{refreshToken}` | (项目里没单独缓存,只走 MySQL) | — | — |
`OAuth2ClientServiceImpl.getOAuth2ClientFromCache``@Cacheable(cacheNames = "oauth2_client", key = "#clientId")`,所以 `txw_sso_client` 表的数据走 Spring Cache(具体 Redis key 看 `RedisKeyConstants.OAUTH_CLIENT`)。
### 5.3 状态机
```
[未登录] --loginByPassword/SMS/DID--> [已登录,access_token 写入 cookie]
|
| 访问第三方应用,被 302 跳回
v
[门户 authorize 页面] --POST /sso/oauth2/authorize--> [txw_sso_code 行已创建,5min TTL]
|
| 后端拼接 redirectUrl 返回,前端 location.href
v
[第三方 callback?code=xxx&state=xxx]
|
| 第三方后端用 clientId+secret 调 /sso/oauth2/token
v
[code 被消费,txw_sso_access_token + Redis 写入,gllp 关联到门户 token]
|
| 第三方用 access_token 调第三方自己的 API
v
[完成]
```
---
## 6. 网关与白名单
`docs/sql/nacos-config/txw-gateway.yaml`:
```yaml
- id: txw-sso
uri: grayLb://txw-sso
predicates:
- Path=/sso/**
```
`txw-gateway/src/main/resources/bootstrap-nacos.yml`(摘录白名单):
```yaml
whitelist:
urls:
- /sso/oauth2/token # 第三方换 token 时不带本系统 token
- /sso/auth/login
- /sso/auth/refresh-token
...
```
**注意**:`/sso/oauth2/authorize` 不在白名单 → **必须带本系统的 Cookie token 才能调通**,否则会被网关拦下走未登录处理。
`/sso/oauth2/token` 在白名单,因为第三方系统没有门户的 Cookie,这一步必须放行。
> **重构建议**:如果新项目要简化,可以把白名单做成「按接口粒度」配置,而不是 URL 前缀,这样更清晰。
---
## 7. 重构要点与踩坑提醒
### 7.1 必须保留的不变量
- **授权码 5 分钟过期、一次性消费**(否则安全性崩)。
- **`clientid` + `redirect_uri` 域名白名单**(否则开放重定向漏洞)。
- **token 必须支持 refresh**,且 access 过期前自动续期(`OAuth2TokenServiceImpl.checkAccessToken` 里有「剩余有效期 < 10 分钟时静默续期逻辑)。
- **code 与 token 的 `clientid` 必须一致,`redirect_uri` 必须一致**(换发 token 时双重校验)。
### 7.2 可砍掉的复杂度
- **`gllp` 关联令牌字段**:`txw_sso_access_token.gllp` 用来把一个门户 token 串上多个第三方 token,做集中吊销。如果第三方系统互不相关,直接去掉,token 表瘦一半。
- **`txw_sso_approve` 表**(`OAuth2ApproveDO`):目前代码里没有实际写入逻辑(只有 DO),可以直接删。
- **`OAuth2OpenController` 整套**:如果新项目不做后台管理 sso.vue,只保留 `OAuth2BasicController` 即可。
- **首字母大写的 `@RequestParam("Response_type")`**:统一改成小写 `response_type`,省去沟通成本。
- **scope 字段前后端对不上**:`authorize.vue` 永远传 `{}`,`OAuth2BasicController` 不读 scope,只有 `Open` 控制器读;如果新项目不打算支持 scope,直接砍掉相关参数。
### 7.3 容易踩的坑
- **`/sso/oauth2/authorize` 不在网关白名单**:很多新手以为「OAuth2 authorize 必须放行」,其实这一步本质是「已登录用户主动授权」,必须带本系统 token。
- **`/sso/oauth2/token` 在白名单**:这一步是第三方用 BasicAuth 调,**门户这边传不到 Cookie**,所以必须放行。
- **`OAuth2CodeDO.rzzt` 字段名误导**:`rzzt` 实际存的是 `state`,数据库列是 `rzzt_1`(因为 `rzzt` 是表 `txw_sso_code` 上一列「认证状态」的本意,新项目里分两个字段就好)。
- **`txw_sso_code.access_token` 列存的是门户 token**:见 4.3,是历史原因(让 `gllp` 能反查回原 token),新项目如果不要 `gllp`,这列也不需要。
- **`OAuth2TokenServiceImpl.checkAccessToken` 里的「静默续期」**:剩余 10 分钟内自动换新 token 并通过 `OAuth2AccessTokenCheckRespDTO.hasRefreshedToken` + `newAccessToken` 通知前端替换 cookie,**前端必须在拦截器里处理这个返回值**。新项目要么保留这个机制,要么前端主动 refresh,不要两头都不做导致用户突然掉登录。
- **`oauth2_token_check` 网关层校验**:`txw-gateway/application.yaml` 里 `checkAccessToken: http://txw-sso/sso/oauth2/token/check`,网关会拿请求里的 token 调一次这个接口校验,所有受保护接口都依赖它;**重构时这个配置不能丢**。
### 7.4 推荐的重构后模块划分
```
新项目 sso-service
├── controller/
│ ├── AuthorizeController # 合并 Basic + Open,统一成 POST /oauth2/authorize
│ ├── TokenController # POST /oauth2/token, GET /oauth2/token/check
│ └── ClientController # 客户端管理(可选)
├── service/
│ ├── OAuth2CodeService # 5min TTL,一次性消费
│ ├── OAuth2TokenService # create / check / refresh / revoke
│ └── OAuth2ClientService # 客户端校验 + Cacheable
├── domain/
│ ├── OAuth2Code
│ ├── OAuth2AccessToken
│ ├── OAuth2RefreshToken
│ └── OAuth2Client
└── util/
└── OAuth2Utils # 拼 redirectUrl、解析 scope
前端 (Vue 模板)
├── views/oauth/AuthorizeProxy.vue # 空模板,挂载后立刻调后端 /oauth2/authorize 后 location.href
└── api/oauth.js # authorize(), getAccessToken()...
```
---
## 8. 关键 API 速查表
| 接口 | 方法 | 白名单? | 入参 | 出参 | 走哪个 Controller |
|------|------|---------|------|------|------------------|
| `/sso/oauth2/authorize` | POST | 否(需 token) | `response_type`、`client_id`、`redirect_uri`、`state` | `{code: 0, data: "https://app/cb?code=xxx&state=xxx"}` | `OAuth2BasicController` |
| `/sso/oauth2/token` | POST | 是 | BasicAuth + `grant_type` + `code/refresh_token` + `redirect_uri` | `OAuth2OpenAccessTokenRespVO` | `OAuth2BasicController` |
| `/sso/oauth2/token/check` | GET | 网关内部用 | `accessToken` | `OAuth2AccessTokenCheckRespDTO`(带静默续期通知) | `OAuth2TokenController` |
| `/sso/oauth2/token/refresh` | PUT | 否 | `refreshToken`、`clientId` | 新 token | `OAuth2TokenController` |
| `/sso/open/oauth2/authorize` | POST | 否(需 token) | `response_type`、`client_id`、`scope`(JSON 字符串)、`redirect_uri`、`auto_approve`、`state` | 同上 | `OAuth2OpenController`(管理后台) |
| `/sso/open/oauth2/token` | POST | 是 | BasicAuth + `grant_type` + ... | 同上 | `OAuth2OpenController` |
| `/sso/userinfo/get` | POST | 是 | (Cookie token) | `OAuth2UserInfoRespVO` | `OAuth2UserController` |
---
## 9. 验证清单(给重构后跑通用的)
- [ ] 已登录用户访问第三方应用,302 跳到 `/view/mhzc/authorize?...` 后能正常 302 回第三方 callback 并带上 `code``state`
- [ ] 第三方用 `code``/sso/oauth2/token` 能成功换到 `access_token` / `refresh_token`
- [ ] 同一个 `code` 第二次用 → 返回 `OAUTH2_CODE_NOT_EXISTS`
- [ ] `code` 等 6 分钟后再用 → 返回 `OAUTH2_CODE_EXPIRE`
- [ ] 用 A 客户端的 `code` 配合 B 客户端的 `client_id/secret` → 返回 `OAUTH2_GRANT_CLIENT_ID_MISMATCH`
- [ ] 用 `redirect_uri=https://evil.com` → 返回 `OAUTH2_CLIENT_REDIRECT_URI_NOT_MATCH`
- [ ] `access_token` 过期前 10 分钟内再请求 → 响应里 `hasRefreshedToken=true` + `newAccessToken`,前端应替换 cookie
- [ ] 退出登录时能吊销关联的 `gllp` 链(如果保留 `gllp`)
---
*文档生成时间: 2026-07-09*
*基于 txw-mhzc-web 0c2f8d8 / txw-sso 主干代码梳理*

View File

@ -1,64 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
// 测试所有 6 个标签
const labels = ['碳核算平台', '碳交易平台', '碳认证机构', '碳金融服务', '碳技术咨询', '更多能力'];
const expectedAnchors = ['content-1', 'content-3', 'content-2', 'content-4', 'content-5', null];
for (let i = 0; i < labels.length; i++) {
await page.locator('.capability-card').nth(i).click();
await page.waitForFunction(() => location.href.includes('gxnlpt'), { timeout: 5000 });
await page.waitForTimeout(2500);
const data = await page.evaluate(() => {
const sideItems = document.querySelectorAll('.gxnlpt-side-item');
const active = Array.from(sideItems).map((el, idx) => ({
idx, text: el.textContent.trim(), active: el.classList.contains('is-active')
})).find(x => x.active);
const content1 = document.getElementById('content-1');
const content2 = document.getElementById('content-2');
const content3 = document.getElementById('content-3');
const content4 = document.getElementById('content-4');
const content5 = document.getElementById('content-5');
const rects = {};
for (const el of [content1, content2, content3, content4, content5]) {
if (el) rects[el.id] = Math.round(el.getBoundingClientRect().top);
}
return {
url: location.href,
scrollTop: document.querySelector('.content-wrap').scrollTop,
activeTab: active,
sectionTops: rects,
};
});
console.log(`[${labels[i]}]`, JSON.stringify(data));
// 回到首页
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
}
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

View File

@ -1,67 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
page.on('console', (m) => {
const t = m.text();
console.log(`[browser:${m.type()}]`, t);
});
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
console.log('home loaded');
// 滚动到 capability 区域
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
// 点击 "碳核算平台"
const cardIndex = 0;
await page.locator('.capability-card').nth(cardIndex).click();
// 多等几秒看是否跳转
await page.waitForTimeout(4000);
console.log('url:', page.url());
// 检查 router-view 内的内容
const data = await page.evaluate(() => {
const outlet = document.querySelector('.portal-route-outlet');
if (!outlet) return { hasOutlet: false };
const child = outlet.firstElementChild;
return {
hasOutlet: true,
hasChild: !!child,
childTag: child ? child.tagName : null,
childClass: child ? child.className : null,
hasGongXingNeng: !!document.querySelector('.gxnlpt-shell'),
hasCapabilitySection: !!document.querySelector('.capability-section'),
hasContent1: !!document.getElementById('content-1'),
bodyText: (document.body.innerText || '').slice(0, 300),
};
});
console.log('data:', data);
await page.screenshot({ path: 'tmp-debug.png', fullPage: false });
// 等待 5s 后再次检查
await page.waitForTimeout(5000);
const data2 = await page.evaluate(() => ({
hasGongXingNeng: !!document.querySelector('.gxnlpt-shell'),
hasCapabilitySection: !!document.querySelector('.capability-section'),
hasContent1: !!document.getElementById('content-1'),
url: location.href,
}));
console.log('data2:', data2);
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

View File

@ -1,93 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.on('console', (m) => {
const t = m.text();
if (t.includes('anchor') || t.includes('Capability') || t.includes('section')) {
console.log('[browser]', t);
}
});
// 1) 打开首页
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
// 等到 capability 区域可见
await page.waitForSelector('.capability-section', { timeout: 20000 });
console.log('home loaded');
// 滚动到 capability 区域
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
// 截图 home
await page.screenshot({ path: 'tmp-home.png', fullPage: false });
// 取各 capability 卡的文本
const cards = await page.$$eval('.capability-card .capability-name', (els) => els.map((e) => e.textContent.trim()));
console.log('capability cards:', cards);
// 点击 "碳核算平台"
const cardIndex = cards.indexOf('碳核算平台');
console.log('clicking index', cardIndex);
await page.locator('.capability-card').nth(cardIndex).click();
await page.waitForTimeout(2500);
console.log('after click url:', page.url());
// 等 section 出现
const hasContent1 = await page.locator('#content-1').count();
console.log('content-1 exists:', hasContent1);
if (hasContent1) {
const inView = await page.locator('#content-1').evaluate((el) => {
const r = el.getBoundingClientRect();
return { top: r.top, bottom: r.bottom, vh: window.innerHeight };
});
console.log('content-1 rect after click:', inView);
}
await page.screenshot({ path: 'tmp-gxnlpt-content-1.png', fullPage: false });
// 回到首页
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
// 点击 "碳认证机构" -> content-2
const idx2 = cards.indexOf('碳认证机构');
await page.locator('.capability-card').nth(idx2).click();
await page.waitForTimeout(2500);
console.log('after click content-2 url:', page.url());
const r2 = await page.locator('#content-2').evaluate((el) => {
const r = el.getBoundingClientRect();
return { top: r.top, bottom: r.bottom };
});
console.log('content-2 rect after click:', r2);
// 点击 "更多能力"
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
const moreIdx = cards.indexOf('更多能力');
await page.locator('.capability-card').nth(moreIdx).click();
await page.waitForTimeout(2000);
console.log('after click 更多能力 url:', page.url());
await page.screenshot({ path: 'tmp-gxnlpt-more.png', fullPage: false });
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

View File

@ -1,70 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
// 点击
await page.locator('.capability-card').nth(0).click();
await page.waitForTimeout(3500);
// 查看 DOM 状态
const data = await page.evaluate(() => {
const out = {};
const wrap = document.querySelector('.content-wrap');
out.wrapScrollTop = wrap ? wrap.scrollTop : null;
out.wrapScrollHeight = wrap ? wrap.scrollHeight : null;
out.wrapClientHeight = wrap ? wrap.clientHeight : null;
out.wrapClasses = wrap ? wrap.className : null;
out.wrapStyleHeight = wrap ? getComputedStyle(wrap).height : null;
out.htmlClasses = document.documentElement.className;
out.htmlData = (function() {
const r = document.documentElement.getBoundingClientRect();
return { width: r.width, height: r.height };
})();
const root = document.documentElement;
out.homeFigmaScale = getComputedStyle(root).getPropertyValue('--home-figma-scale');
out.portalShellMinDesignHeight = getComputedStyle(root).getPropertyValue('--portal-shell-min-design-height');
out.portalLandingFirstScreenHeight = getComputedStyle(root).getPropertyValue('--portal-landing-first-screen-height');
out.pageNavHeight = getComputedStyle(root).getPropertyValue('--page-nav-height');
const outlet = document.querySelector('.portal-route-outlet');
out.outletChildren = outlet ? outlet.children.length : null;
const firstChild = outlet && outlet.firstElementChild;
out.firstChildClass = firstChild ? firstChild.className : null;
out.firstChildBoundingRect = firstChild ? (function() {
const r = firstChild.getBoundingClientRect();
return { top: r.top, left: r.left, width: r.width, height: r.height };
})() : null;
out.firstChildStyle = firstChild ? firstChild.getAttribute('style') : null;
// 找 content-1
const c1 = document.getElementById('content-1');
out.content1Rect = c1 ? (function() {
const r = c1.getBoundingClientRect();
return { top: r.top, left: r.left, width: r.width, height: r.height };
})() : null;
return out;
});
console.log(JSON.stringify(data, null, 2));
// 不截图
// await page.screenshot({ path: 'tmp-debug2.png', fullPage: false });
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View File

@ -1,41 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
await page.locator('.capability-card').nth(0).click();
await page.waitForTimeout(3500);
// 检查视口不同位置是什么元素
const points = await page.evaluate(() => {
const results = [];
const pts = [[720, 100], [720, 300], [720, 500], [720, 700], [200, 200], [1200, 200], [720, 850]];
for (const [x, y] of pts) {
const els = document.elementsFromPoint(x, y);
results.push({ x, y, els: els.slice(0, 5).map((el) => ({ tag: el.tagName, cls: (el.className || '').slice(0, 80), id: el.id })) });
}
return results;
});
console.log('points:');
for (const p of points) {
console.log(` (${p.x},${p.y}):`, JSON.stringify(p.els));
}
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

View File

@ -1,46 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
const page = await ctx.newPage();
// 不使用 keep-alive
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
// 滚动到 capability 区域
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
// 用 evaluate 在点击前修改 router 行为,禁用 keep-alive
// 不行;改用:点击后等久一点,确认 router 完成
await page.locator('.capability-card').nth(0).click();
// 等待 URL 变化
await page.waitForFunction(() => location.href.includes('gxnlpt'), { timeout: 5000 });
// 等待 gxnlpt-page 真正在视口里
await page.waitForFunction(() => {
const el = document.querySelector('.gxnlpt-page');
if (!el) return false;
const r = el.getBoundingClientRect();
return r.top >= -10 && r.top < 200;
}, { timeout: 5000 });
await page.waitForTimeout(2000);
// 截 viewport
await page.screenshot({ path: 'tmp-final-1.png', fullPage: false });
// 截元素
const el = await page.locator('.gxnlpt-page').first();
await el.screenshot({ path: 'tmp-final-2.png' });
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 KiB

View File

@ -1,50 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
// 模拟 真实用户使用:刷新一次后不依赖 keep-alive
// 第一次访问 gxnlpt
await page.goto(`${BASE}/view/mhzc/gxnlpt`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.gxnlpt-page', { timeout: 20000 });
await page.waitForTimeout(1500);
// 直接访问首页 + 点击, 不等待 keep-alive 命中
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
// 点击 "碳交易平台"
await page.locator('.capability-card').nth(1).click();
await page.waitForFunction(() => location.href.includes('gxnlpt'), { timeout: 5000 });
await page.waitForTimeout(2000);
// 检查实际显示
const data = await page.evaluate(() => {
const out = {};
out.url = location.href;
out.scrollTop = document.querySelector('.content-wrap').scrollTop;
const sections = ['content-1', 'content-2', 'content-3', 'content-4', 'content-5'].map((id) => {
const el = document.getElementById(id);
return { id, top: el ? Math.round(el.getBoundingClientRect().top) : null };
});
out.sections = sections;
return out;
});
console.log('after click 碳交易平台:', JSON.stringify(data, null, 2));
await page.screenshot({ path: 'tmp-no-keepalive.png', fullPage: false });
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View File

@ -1,57 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
await page.locator('.capability-card').nth(0).click();
await page.waitForTimeout(3500);
// 视口截图
await page.screenshot({ path: 'tmp-pix-1.png', fullPage: false });
// 用 dom snapshot用 canvas 截屏)
await page.evaluate(() => {
return new Promise((resolve) => {
// 强制重排
document.body.offsetHeight;
resolve();
});
});
await page.waitForTimeout(500);
await page.screenshot({ path: 'tmp-pix-2.png', fullPage: false });
// 显式滚动到顶部
await page.evaluate(() => {
const w = document.querySelector('.content-wrap');
if (w) w.scrollTop = 0;
});
await page.waitForTimeout(500);
await page.screenshot({ path: 'tmp-pix-3.png', fullPage: false });
// 直接用 html2canvas 方式:通过截图 element 方式
const gxnlptEl = await page.locator('.gxnlpt-page').first();
await gxnlptEl.screenshot({ path: 'tmp-pix-4.png' });
// 强制重排 + 再次截图
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
await page.waitForTimeout(500);
await page.screenshot({ path: 'tmp-pix-5.png', fullPage: false });
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View File

@ -1,54 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
await page.locator('.capability-card').nth(0).click();
await page.waitForTimeout(3500);
// 给 gxnlpt-page 元素截图
const gxnlptEl = await page.locator('.gxnlpt-page').first();
await gxnlptEl.screenshot({ path: 'tmp-gxnlpt-el.png' });
// 给视口截图
await page.screenshot({ path: 'tmp-snap-viewport.png', fullPage: false });
// 给 body 截图
const body = await page.locator('body').first();
await body.screenshot({ path: 'tmp-snap-body.png' });
// 检查 gxnlpt-page 的可见性
const r = await gxnlptEl.evaluate((el) => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height, bottom: rect.bottom, right: rect.right },
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
zIndex: style.zIndex,
transform: style.transform,
transformOrigin: style.transformOrigin,
position: style.position,
};
});
console.log('gxnlpt:', JSON.stringify(r, null, 2));
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

View File

@ -1,42 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
// 直接访问 gxnlpt?anchor=content-1不要先访问 home看看非 keep-alive 场景是否正常
await page.goto(`${BASE}/view/mhzc/gxnlpt?anchor=content-1`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.gxnlpt-page', { timeout: 20000 });
await page.waitForTimeout(3500);
await page.screenshot({ path: 'tmp-direct-1.png', fullPage: false });
// 滚动到顶部
await page.evaluate(() => {
const w = document.querySelector('.content-wrap');
if (w) w.scrollTop = 0;
});
await page.waitForTimeout(500);
await page.screenshot({ path: 'tmp-direct-2.png', fullPage: false });
// 在视口顶部 evaluate 一下
const data = await page.evaluate(() => {
const outlet = document.querySelector('.portal-route-outlet');
const child = outlet && outlet.firstElementChild;
return {
outletChildCount: outlet ? outlet.children.length : 0,
childClass: child && child.className,
contentWrapScrollTop: document.querySelector('.content-wrap').scrollTop,
url: location.href,
};
});
console.log('data:', JSON.stringify(data));
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View File

@ -1,39 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
// 滚动到 capability 区域
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
// 截图 home capability
await page.screenshot({ path: 'tmp-zoom-1-home.png', fullPage: false });
// 点击 "碳核算平台"
await page.locator('.capability-card').nth(0).click();
await page.waitForTimeout(3000);
console.log('url:', page.url());
await page.screenshot({ path: 'tmp-zoom-2-after-click.png', fullPage: false });
// 滚动到顶部再截图
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(800);
await page.screenshot({ path: 'tmp-zoom-3-top.png', fullPage: false });
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

View File

@ -1,75 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
// 滚动到 capability 区域
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
// 点击 "碳核算平台"
await page.locator('.capability-card').nth(0).click();
await page.waitForTimeout(3000);
console.log('url:', page.url());
// 检查实际 DOM
const data = await page.evaluate(() => {
const outlet = document.querySelector('.portal-route-outlet');
const child = outlet && outlet.firstElementChild;
const sidebar = !!document.querySelector('.gxnlpt-sidebar');
return {
hasOutlet: !!outlet,
hasChild: !!child,
childClass: child ? child.className : null,
hasGongXingNeng: !!document.querySelector('.gxnlpt-shell'),
hasGxnlptSidebar: sidebar,
hasContent1: !!document.getElementById('content-1'),
activeSection: (function() {
const els = document.querySelectorAll('[id^="content-"]');
const inView = [];
els.forEach((el) => {
const r = el.getBoundingClientRect();
if (r.top >= -50 && r.top < 800) {
inView.push({ id: el.id, top: Math.round(r.top) });
}
});
return inView;
})(),
visibleViewport: (function() {
const els = document.querySelectorAll('.gxnlpt-block, [id^="content-"], .gxnlpt-side-nav, .gxnlpt-sidebar');
const all = [];
for (const el of els) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
all.push({
tag: el.tagName,
cls: el.className && el.className.slice(0, 60),
id: el.id,
top: Math.round(r.top),
bottom: Math.round(r.bottom),
left: Math.round(r.left),
});
}
return all;
})(),
};
});
console.log(JSON.stringify(data, null, 2));
await page.screenshot({ path: 'tmp-zoom2.png', fullPage: false });
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View File

@ -1,32 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
// 滚动到 capability 区域
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
// 点击 "碳核算平台"
await page.locator('.capability-card').nth(0).click();
await page.waitForTimeout(5000);
console.log('url:', page.url());
// 不调用 evaluate直接截图
await page.screenshot({ path: 'tmp-zoom3-no-eval.png', fullPage: false });
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

View File

@ -1,49 +0,0 @@
import { chromium } from 'playwright';
const BASE = 'http://localhost:9027';
(async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/view/mhzc/home`, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.capability-section', { timeout: 20000 });
// 滚动到 capability 区域
await page.evaluate(() => {
const el = document.getElementById('section-capability');
if (el) el.scrollIntoView({ behavior: 'instant', block: 'start' });
});
await page.waitForTimeout(800);
// 点击 "碳核算平台"
await page.locator('.capability-card').nth(0).click();
// 不停截图
for (let i = 0; i < 10; i++) {
await page.waitForTimeout(1000);
const info = await page.evaluate(() => {
const contentWrap = document.querySelector('.content-wrap');
const gxnlptSidebar = document.querySelector('.gxnlpt-sidebar');
const home = document.querySelector('.capability-section');
const content1 = document.getElementById('content-1');
return {
url: location.href,
scrollTop: contentWrap ? contentWrap.scrollTop : null,
scrollHeight: contentWrap ? contentWrap.scrollHeight : null,
hasGxnlptSidebar: !!gxnlptSidebar,
hasHome: !!home,
content1Top: content1 ? Math.round(content1.getBoundingClientRect().top) : null,
};
});
console.log(`t=${i+1}s`, JSON.stringify(info));
}
await page.screenshot({ path: 'tmp-zoom4.png', fullPage: false });
await browser.close();
})().catch((e) => {
console.error('TEST ERROR', e);
process.exit(1);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View File

@ -0,0 +1 @@
{"dlzh":"","qymc":"上海链坤数字科技有限公司","pageNo":1,"pageSize":10}