Compare commits
5 Commits
208d397236
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cda2c0898 | |||
| 78ebc67e2a | |||
| c657fbe01a | |||
| a451726e52 | |||
| 9ecb392a41 |
@@ -4,8 +4,12 @@
|
||||
#端口
|
||||
PORT=3000
|
||||
|
||||
#飞书机器人Webhook地址,用于接收相关通知
|
||||
#飞书机器人Webhook地址/应用密钥,用于接收相关通知
|
||||
LARK_WEBHOOK_URL=
|
||||
LARK_APP_ID=
|
||||
LARK_APP_SECRET=
|
||||
LARK_REDIRECT_URI=http://localhost:9000/oauth/callback
|
||||
ADMIN_IDS=
|
||||
|
||||
#Gitea相关配置,注意后边要带/api/v1
|
||||
GITEA_BASE_URL=https://git.langcore.net/api/v1
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,6 +23,4 @@ logs/
|
||||
.vscode/
|
||||
.cache/
|
||||
sync_database.sqlite
|
||||
|
||||
//暂时忽略
|
||||
mappingsEditor.js
|
||||
larkbot.md
|
||||
32
data/larkRules.json
Normal file
32
data/larkRules.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"id": "589d9419-00e1-4628-adbb-0582d35a8e9a",
|
||||
"createdAt": "2026-02-02T05:24:04.963Z",
|
||||
"enabled": true,
|
||||
"name": "里程碑",
|
||||
"event": "issue.milestoned",
|
||||
"channel": "group",
|
||||
"atUsers": [
|
||||
"13397559898"
|
||||
],
|
||||
"filterKey": null,
|
||||
"filterValue": null,
|
||||
"updatedAt": "2026-02-02T05:31:16.958Z"
|
||||
},
|
||||
{
|
||||
"id": "df9f9646-a6f5-40cc-8434-9da677af5f86",
|
||||
"createdAt": "2026-02-02T05:29:44.294Z",
|
||||
"enabled": true,
|
||||
"name": "标签",
|
||||
"event": "issue.label_updated",
|
||||
"channel": "group",
|
||||
"atUsers": [
|
||||
"13397559898"
|
||||
],
|
||||
"filterKey": "label",
|
||||
"filterValue": "类型/Bug",
|
||||
"updatedAt": "2026-02-02T05:31:20.010Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
"loren/SyncbotPlayground": {
|
||||
"jira": {
|
||||
"projectId": "10600",
|
||||
"projectKey": "TEST",
|
||||
"projectKey": "TASKBOT",
|
||||
"sprintField": "customfield_10105"
|
||||
},
|
||||
"priorities": {
|
||||
@@ -33,8 +33,8 @@
|
||||
},
|
||||
"types": {
|
||||
"类型/故事": "10100",
|
||||
"类型/Bug": "10004",
|
||||
"类型/任务": "10100"
|
||||
"类型/任务": "10100",
|
||||
"类型/Bug": "10004"
|
||||
},
|
||||
"transitions": {
|
||||
"close": "31",
|
||||
69
index.js
69
index.js
@@ -6,9 +6,12 @@ const config = require('./src/config/env');
|
||||
const { getConfiguredRepos } = require('./src/config/mappings');
|
||||
const { handleIssueEvent } = require('./src/logic/syncManager');
|
||||
const { handleJiraHook } = require('./src/logic/jiraSyncManager');
|
||||
const { notify, inferEventType } = require('./src/logic/larkNotifier');
|
||||
const editorRoutes = require('./src/routes/control');
|
||||
const logger = require('./src/utils/logger');
|
||||
|
||||
const { larkAuthMiddleware, loginHandler, callbackHandler } = require('./src/middleware/larkAuth');
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const requestCounts = new Map();
|
||||
@@ -47,29 +50,25 @@ setInterval(() => {
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
//内网访问控制中间件:保护管理界面,只允许dotenv配置的域名访问
|
||||
const internalOnlyMiddleware = async (c, next) => {
|
||||
const pathname = new URL(c.req.url).pathname;
|
||||
//鉴权路由
|
||||
app.get('/login', loginHandler);
|
||||
app.get('/oauth/callback', callbackHandler);
|
||||
|
||||
if (pathname.startsWith('/hooks/')) {
|
||||
return await next();
|
||||
}
|
||||
// 全局鉴权中间件 (飞书 OAuth)
|
||||
app.use('*', larkAuthMiddleware);
|
||||
|
||||
const host = (c.req.header('host') || '').split(':')[0];
|
||||
const allowedHosts = config.app.dashboardAllowedHosts;
|
||||
// 获取当前用户信息
|
||||
const { meHandler, adminMiddleware } = require('./src/middleware/larkAuth');
|
||||
app.get('/api/me', meHandler);
|
||||
|
||||
if (!allowedHosts.some(allowed => host === allowed || host.endsWith('.' + allowed))) {
|
||||
logger.security(`Blocked access from unauthorized host: ${host}`, {
|
||||
path: pathname,
|
||||
ip: c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'
|
||||
});
|
||||
return c.text('Forbidden - Access denied from this domain', 403);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
app.use('*', internalOnlyMiddleware);
|
||||
// 敏感接口保护 (仅管理员可访问)
|
||||
// 注意:Hono 的中间件匹配顺序很重要,specific routes should be handled or protected explicitly
|
||||
app.use('/api/env', adminMiddleware);
|
||||
app.use('/api/restart', adminMiddleware);
|
||||
app.use('/api/logs', adminMiddleware);
|
||||
app.use('/api/logs/clear', adminMiddleware);
|
||||
app.use('/api/history', adminMiddleware);
|
||||
app.use('/api/status', adminMiddleware);
|
||||
|
||||
//Gitea webhook处理入口
|
||||
app.post('/hooks/gitea', rateLimiter, async (c) => {
|
||||
@@ -100,16 +99,31 @@ app.post('/hooks/gitea', rateLimiter, async (c) => {
|
||||
//解析JSON
|
||||
const body = JSON.parse(rawBody);
|
||||
|
||||
//Payload结构验证
|
||||
if (!body || !body.issue || !body.repository) {
|
||||
logger.warn('Invalid payload structure', { ip });
|
||||
return c.json({ error: 'Invalid payload structure' }, 400);
|
||||
//触发飞书通知(所有事件类型)
|
||||
const eventType = inferEventType(event, body);
|
||||
if (eventType) {
|
||||
notify(eventType, body).catch(err =>
|
||||
logger.error('[LarkNotifier] Async notify error:', err.message)
|
||||
);
|
||||
}
|
||||
|
||||
//Issue 事件处理(原有逻辑)
|
||||
if (event === 'issues' || event === 'issue_comment') {
|
||||
//异步处理,不阻塞webhook返回
|
||||
handleIssueEvent(body).catch(err => logger.error('Async handler error', err.message));
|
||||
} else {
|
||||
if (body.issue && body.repository) {
|
||||
handleIssueEvent(body).catch(err => logger.error('Async handler error', err.message));
|
||||
} else {
|
||||
logger.warn('Invalid payload structure for issue event', { ip });
|
||||
}
|
||||
}
|
||||
//PR 事件 - 仅记录日志(未来可扩展)
|
||||
else if (event === 'pull_request') {
|
||||
logger.info(`[PR Event] ${body.action} - ${body.pull_request?.title || 'Unknown'}`, { ip });
|
||||
}
|
||||
//Release 事件 - 仅记录日志(未来可扩展)
|
||||
else if (event === 'release') {
|
||||
logger.info(`[Release Event] ${body.action} - ${body.release?.name || 'Unknown'}`, { ip });
|
||||
}
|
||||
else {
|
||||
logger.info(`Ignored event type: ${event}`, { ip });
|
||||
}
|
||||
|
||||
@@ -146,6 +160,7 @@ const configuredRepos = getConfiguredRepos();
|
||||
//控制台首页
|
||||
app.get('/', (c) => c.redirect('/dashboard'));
|
||||
app.get('/dashboard', serveStatic({ path: './public/dashboard.html' }));
|
||||
app.get('/error.html', serveStatic({ path: './public/error.html' }));
|
||||
|
||||
app.route('/api', editorRoutes);
|
||||
app.route('/editor/api', editorRoutes);
|
||||
|
||||
487
larkauth.md
Normal file
487
larkauth.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# 获取授权码
|
||||
|
||||
本接口用于发起用户授权,应用在用户同意授权后将获得授权码 `code`。请注意授权码的有效期为 5 分钟,且只能被使用一次。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 本接口实际为授权页面,适用于网页应用的授权场景。在需要用户授权时,应用应将用户重定向至本授权页面。当用户在授权页面点击授权后(在飞书客户端内打开网页应用时可免确认直接跳转),浏览器将跳转至 `redirect_uri` 所指定的地址,并携带 `code` 查询参数(即授权码)。
|
||||
|
||||
- 开发者可通过授权码获取 `user_access_token`,以调用 OpenAPI(例如[获取用户信息](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/authen-v1/user_info/get))。有关获取 `user_access_token` 的详细步骤,可参考[获取 user_access_token](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token)。
|
||||
- 通过本接口配合[获取 user_access_token](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token)以及[获取用户信息](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/authen-v1/user_info/get),应用可实现飞书授权登录。
|
||||
- 完整的用户授权链路实现,可参考[浏览器应用接入指南](https://open.feishu.cn/document/common-capabilities/sso/web-application-end-user-consent/guide)。
|
||||
- 在打开授权页面时,需要通过拼接 `scope` 查询参数声明应用所需的用户授权权限。例如,获取通讯录基本信息的权限对应的 `scope` 键为 `contact:contact.base:readonly`。
|
||||
- 用户授予应用的权限是累积的,最新生成的 `user_access_token` 将包含用户历史上已授予的所有权限。
|
||||
- 当应用使用 `user_access_token` 调用某个 OpenAPI 时,必须确保该 `user_access_token` 具备[目标 OpenAPI 所需的权限](https://open.feishu.cn/document/ukTMukTMukTM/uQjN3QjL0YzN04CN2cDN),否则调用将失败。
|
||||
|
||||
## 请求
|
||||
|
||||
基本 |
|
||||
---|---
|
||||
HTTP URL | https://accounts.feishu.cn/open-apis/authen/v1/authorize
|
||||
HTTP Method | GET
|
||||
接口频率限制 | 1000 次/分钟、50 次/秒
|
||||
支持的应用类型 | Custom App、Store App
|
||||
权限要求<br>**调用该 API 所需的权限。开启其中任意一项权限即可调用** | 无
|
||||
|
||||
### 查询参数
|
||||
|
||||
> 为了确保 URL 构造 & 编码正确,建议使用相关的 URL 标准库来完成 URL 的解析和构建,避免手动拼接。
|
||||
|
||||
名称 | 类型 | 必填 | 描述
|
||||
---|---|---|---
|
||||
client_id | string | 是 | 应用的 App ID,可以在开发者后台的**凭证与基础信息**页面查看 App ID。有关 App ID 的详细介绍,请参考[通用参数](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology)。<br>**示例值:** `cli_a5d611352af9d00b`
|
||||
response_type | string | 是 | 应用通知授权服务器所需的授权类型,对于授权码流程,固定值`code` <br>**示例值:** `code`
|
||||
redirect_uri | string | 是 | 应用重定向地址,在用户授权成功后会跳转至该地址,同时会携带 `code` 以及 `state` 参数(如有传递 `state` 参数)。<br>请注意: <br>1. 该地址需经过 URL 编码;<br>2. 调用本接口前,你需要在开发者后台应用的**安全设置**页面,将用于接受 OAuth 回调的 HTTP GET 请求接口地址配置为应用的重定向 URL。重定向 URL 支持配置多个,只有在重定向 URL 列表中的 URL 才会通过开放平台的安全校验。详情请参考[配置重定向域名](https://open.feishu.cn/document/uYjL24iN/uYjN3QjL2YzN04iN2cDN)。<br>**示例值:** `https://example.com/api/oauth/callback`
|
||||
scope | string | 否 | 用户需要增量授予应用的权限。<br>**格式要求:** `scope` 参数为空格分隔,区分大小写的字符串。<br>**注意**:<br>- 开发者需要根据业务场景,在[开发者后台](https://open.larkoffice.com/app)的 **权限管理** 模块中完成调用 OpenAPI 所需的 `scope` 申请后,自主拼接 `scope` 参数。如果没有在应用后台为应用申请相应权限,则实际使用应用时用户会遇到 20027 报错。<br>- 应用最多一次可以请求用户授予 50 个 `scope`。详情参考 [API 权限列表](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/scope-list)。<br>- 如果后续需要获取 `refresh_token`,此处需要添加 `offline_access` 权限。详情参考 [刷新 user_access_token](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/refresh-user-access-token)):<br>offline_access(offline_access) <br>**示例值:** `contact:contact bitable:app:readonly`
|
||||
state | string | 否 | 用来维护请求和回调之间状态的附加字符串,在授权完成回调时会原样回传此参数。应用可以根据此字符串来判断上下文关系,同时该参数也可以用以防止 CSRF 攻击,请务必校验 `state` 参数前后是否一致。<br>**示例值:** `RANDOMSTRING`
|
||||
code_challenge | string | 否 | 用于通过 PKCE(Proof Key for Code Exchange)流程增强授权码的安全性。<br>**示例值:** `E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM`<br>有关 PKCE 的详细信息,请参阅[RFC-7636 - Proof Key for Code Exchange by OAuth Public Clients](https://datatracker.ietf.org/doc/html/rfc7636)。
|
||||
code_challenge_method | string | 否 | 生成 `code_challenge` 的方法。<br>**可选值**: <br>1. **`S256`**(推荐): <br>使用 SHA-256 哈希算法计算 `code_verifier` 的哈希值,并将结果进行 Base64URL 编码,生成 `code_challenge`。 <br>2. **`plain`**(默认值): <br>直接将 `code_verifier` 作为 `code_challenge`,无需进行额外处理。<br>以上 `code_verifier` 是指在发起授权前,本地生成的随机字符串。
|
||||
|
||||
### 请求示例
|
||||
|
||||
> 注意仅为示例请求 URL,请根据前文描述将其中的查询参数替换为真实的值
|
||||
|
||||
``` http
|
||||
https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=cli_a5d611352af9d00b&response_type=code&redirect_uri=https%3A%2F%2Fexample.com%2Fapi%2Foauth%2Fcallback&scope=bitable:app:readonly%20contact:contact&state=RANDOMSTRING
|
||||
```
|
||||
|
||||
## 响应
|
||||
|
||||
### 成功响应
|
||||
当用户同意授权后,浏览器将重定向至发起授权时给定的的 `redirect_uri` 地址,同时携带 `code` 和 `state` 参数。
|
||||
|
||||
名称 | 描述
|
||||
---|---
|
||||
code | 授权码,用于获取 `user_access_token`。<br>**字符集:** [A-Z] / [a-z] / [0-9] / "-" / "_"<br>**长度:** 请开发者至少预留 64 位字符<br>**示例值:** `2Wd5g337vo5BZXUz-3W5KECsWUmIzJ_FJ1eFD59fD1AJIibIZljTu3OLK-HP_UI1`
|
||||
state | 打开授权页时传入的 `state` 参数的原值,如未传入此处不会返回。
|
||||
|
||||
示例:
|
||||
```http
|
||||
https://example.com/api/oauth/callback?code=2Wd5g337vo5BZXUz-3W5KECsWUmIzJ_FJ1eFD59fD1AJIibIZljTu3OLK-HP_UI1&state=RANDOMSTRING
|
||||
```
|
||||
|
||||
### 失败响应
|
||||
当用户拒绝授权时,浏览器将重定向至发起授权时给定的 `redirect_uri` 地址,同时携带 `error` 和 `state` 查询参数。 当前 `error` 参数的固定值为 `access_denied`,请妥善处理拒绝授权时的情况。
|
||||
|
||||
名称 | 描述
|
||||
---|---
|
||||
error | 错误信息,当前固定为 `access_denied`
|
||||
state | 打开授权页时传入的 `state` 参数的原值,如未传入此处不会返回
|
||||
|
||||
示例:
|
||||
```http
|
||||
https://example.com/api/oauth/callback?error=access_denied&state=RANDOMSTRING
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 用户授权应用时报错 20027
|
||||
|
||||
**问题现象**:用户在授权应用时报错 20027
|
||||
|
||||

|
||||
|
||||
**问题原因**:打开授权页时拼接的 scope 参数中包含当前应用未开通的权限。
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. 确认需要用户授权的权限范围。
|
||||
2. 前往[开发者后台](https://open.feishu.cn/app),在对应应用的 **开发配置** > **权限管理** > **API 权限** 功能页开通相应的权限。具体操作参考[申请 API 权限](https://open.feishu.cn/document/ukTMukTMukTM/uQjN3QjL0YzN04CN2cDN)。
|
||||
3. 调用当前接口,自主拼接已在应用内开通的权限。
|
||||
|
||||
### 如何获取包含目标权限的 user_access_token
|
||||
|
||||
在调用 OpenAPI 时,如果 `user_access_token` 缺少所需权限,将会返回以下错误:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 99991679,
|
||||
"error": {
|
||||
"log_id": "202407260711088FB107A76E0100002087",
|
||||
"permission_violations": [
|
||||
{
|
||||
"subject": "task:task:read",
|
||||
"type": "action_privilege_required"
|
||||
},
|
||||
{
|
||||
"subject": "task:task:write",
|
||||
"type": "action_privilege_required"
|
||||
}
|
||||
]
|
||||
},
|
||||
"msg": "Unauthorized. You do not have permission to perform the requested operation on the resource. Please request user re-authorization and try again. required one of these privileges: [task:task:read, task:task:write]"
|
||||
}
|
||||
```
|
||||
|
||||
为避免因 `user_access_token` 权限不足导致 OpenAPI 调用失败,开发者可通过 `scope` 查询参数请求用户授予相应权限,具体有以下两种方式:
|
||||
|
||||
1. 一次性拼接所有需要用户授权的 `scope`,在无新增权限需求的情况下,无需重复授权。需注意单次拼接的 `scope` 数量上限为 50 个。
|
||||
2. 或者,根据 OpenAPI 调用返回的错误码及 `permission_violations` 字段,识别当前操作所需的额外权限。随后可重新生成授权链接,提示用户补充授权,并使用新生成的 `user_access_token` 继续调用 OpenAPI。
|
||||
|
||||
建议开发者遵循最小权限原则,仅要求用户授予必要的权限。
|
||||
|
||||
### redirect_uri 中带 # 时的说明
|
||||
|
||||
标准 [RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax](https://datatracker.ietf.org/doc/html/rfc3986#section-3)中约定,URI 中 `#` 后面的内容称为 fragment,位置处于 URI 最后。如果业务授权请求参数 `redirect_uri` 拼接了 `#`,授权完成后的重定向会将 `#` 和 fragment 内容拼接到 URI 最后。业务在解析获取 `code` 时需要特别注意。
|
||||
|
||||
`redirect_uri` 示例:
|
||||
|
||||
```
|
||||
https://example.com/api/oauth/callback/#/login
|
||||
```
|
||||
|
||||
请求示例:
|
||||
```
|
||||
GET https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=cli_a5d611352af9d00b&redirect_uri=https%3A%2F%2Fexample.com%2Fapi%2Foauth%2Fcallback%2F%23%2Flogin%0A&scope=bitable:app:readonly%20contact:contact&state=RANDOMSTRING
|
||||
```
|
||||
|
||||
回调后浏览器地址栏中的值示例:
|
||||
```shell
|
||||
https://example.com/api/oauth/callback?code=2Wd5g337vo5BZXUz-3W5KECsWUmIzJ_FJ1eFD59fD1AJIibIZljTu3OLK-HP_UI1&state=RANDOMSTRING#/login
|
||||
```
|
||||
|
||||
# 获取 user_access_token
|
||||
OAuth 令牌接口,可用于获取 <code>user_access_token</code> 以及 <code>refresh_token</code>。<code>user_access_token</code> 为用户访问凭证,使用该凭证可以以用户身份调用 OpenAPI。<code>refresh_token</code> 为刷新凭证,可以用来获取新的 <code>user_access_token</code>。
|
||||
|
||||
- 获取 `user_access_token` 前需要先获取授权码,详见[获取授权码](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code)。请注意授权码的有效期为 5 分钟,且只能被使用一次。
|
||||
- 用户授权时,用户必须拥有[应用的使用权限](https://open.feishu.cn/document/home/introduction-to-scope-and-authorization/availability),否则调用本接口将会报错误码 20010。
|
||||
- 获取到的 `user_access_token` 存在有效期,如何刷新 <code>user_access_token</code> 详见[刷新 user_access_token](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/refresh-user-access-token)。
|
||||
- 如果你需要获取用户信息,详见[获取用户信息](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/authen-v1/user_info/get)。
|
||||
**注意事项**:本接口实现遵循 [RFC 6749 - The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749) ,你可以使用[标准的 OAuth 客户端库](https://oauth.net/code/)进行接入(**推荐**)
|
||||
|
||||
## 请求
|
||||
|
||||
基本 |
|
||||
---|---
|
||||
HTTP URL | https://open.feishu.cn/open-apis/authen/v2/oauth/token
|
||||
HTTP Method | POST
|
||||
接口频率限制 | [1000 次/分钟、50 次/秒](https://open.feishu.cn/document/ukTMukTMukTM/uUzN04SN3QjL1cDN)
|
||||
支持的应用类型 | Custom App、Store App
|
||||
权限要求<br>**调用该 API 所需的权限。开启其中任意一项权限即可调用<br>** | 无
|
||||
字段权限要求 | `refresh_token` 以及 `refresh_token_expires_in` 字段仅在具备以下权限时返回:<br>offline_access(offline_access)
|
||||
|
||||
### 请求头
|
||||
|
||||
名称 | 类型 | 必填 | 描述
|
||||
---|---|---|---
|
||||
Content-Type | string | 是 | 请求体类型。<br>**固定值:**`application/json; charset=utf-8`
|
||||
|
||||
### 请求体
|
||||
|
||||
名称 | 类型 | 必填 | 描述
|
||||
---|---|---|---
|
||||
grant_type | string | 是 | 授权类型。<br>**固定值:**`authorization_code`
|
||||
client_id | string | 是 | 应用的 App ID。应用凭证 App ID 和 App Secret 获取方式:<br>1. 登录[飞书开发者后台](https://open.feishu.cn/app)。<br>2. 进入应用详情页,在左侧导航栏,单击 **凭证与基础信息**。<br>3. 在 **应用凭证** 区域,获取并保存 **App ID** 和 **App Secret**。<br>**示例值:**`cli_a5ca35a685b0x26e`
|
||||
client_secret | string | 是 | 应用的 App Secret。应用凭证 App ID 和 App Secret 获取方式:<br>1. 登录[飞书开发者后台](https://open.feishu.cn/app)。<br>2. 进入应用详情页,在左侧导航栏,单击 **凭证与基础信息**。<br>3. 在 **应用凭证** 区域,获取并保存 **App ID** 和 **App Secret**。<br>**示例值:**`baBqE5um9LbFGDy3X7LcfxQX1sqpXlwy`
|
||||
code | string | 是 | 授权码,详见[获取授权码](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code)。<br>**示例值:**`a61hb967bd094dge949h79bbexd16dfe`
|
||||
redirect_uri | string | 否 | 在构造授权页页面链接时所拼接的应用回调地址。<br>**示例值:**`https://example.com/api/oauth/callback`
|
||||
code_verifier | string | 否 | 在发起授权前,本地生成的随机字符串,用于 PKCE(Proof Key for Code Exchange)流程。使用 PKCE 时,该值为必填项。 <br>有关 PKCE 的详细介绍,请参阅 [RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients](https://datatracker.ietf.org/doc/html/rfc7636)。<br>**长度限制:** 最短 43 字符,最长 128 字符<br>**可用字符集:** [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"<br>**示例值:**`TxYmzM4PHLBlqm5NtnCmwxMH8mFlRWl_ipie3O0aVzo`
|
||||
scope | string | 否 | 该参数用于缩减 `user_access_token` 的权限范围。<br>例如:<br>1. 在[获取授权码](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code)时通过 `scope` 参数授权了 `contact:user.base:readonly contact:contact.base:readonly contact:user.employee:readonly` 三个权限。<br>2. 在当前接口可通过 `scope` 参数传入 `contact:user.base:readonly`,将 `user_access_token` 的权限缩减为 `contact:user.base:readonly` 这一个。<br>**注意**:<br>- 如果不指定当前参数,生成的 `user_access_token` 将包含用户授权时的所有权限。<br>- 当前参数不能传入重复的权限,否则会接口调用会报错,返回错误码 20067。<br>- 当前参数不能传入未授权的权限(即[获取授权码](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code)时用户已授权范围外的其他权限),否则接口调用会报错,返回错误码 20068。<br>- 多次调用当前接口缩减权限的范围不会叠加。例如,用户授予了权限 A 和 B,第一次调用该接口缩减为权限 A,则 `user_access_token` 只包含权限 A;第二次调用该接口缩减为权限 B,则 `user_access_token` 只包含权限 B。 <br>- 生效的权限列表可通过本接口返回值 scope 查看。<br>**格式要求:** 以空格分隔的 `scope` 列表<br>**示例值:**`auth:user.id:read task:task:read`
|
||||
|
||||
### 请求体示例
|
||||
```json
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": "cli_a5ca35a685b0x26e",
|
||||
"client_secret": "baBqE5um9LbFGDy3X7LcfxQX1sqpXlwy",
|
||||
"code": "a61hb967bd094dge949h79bbexd16dfe",
|
||||
"redirect_uri": "https://example.com/api/oauth/callback",
|
||||
"code_verifier": "TxYmzM4PHLBlqm5NtnCmwxMH8mFlRWl_ipie3O0aVzo"
|
||||
}
|
||||
```
|
||||
## 响应
|
||||
响应体类型为 `application/json; charset=utf-8`。
|
||||
|
||||
### 响应体
|
||||
**注意事项**:**响应体中的 `access_token` 和 `refresh_token` 长度较长**,一般在 1~2KB 之间,且可能由于 `scope` 数量的变多或后续变更导致长度进一步增加,建议预留 4KB 的存储容量
|
||||
|
||||
名称 | 类型 | 描述
|
||||
---|---|---
|
||||
code | int | 错误码,为 0 时表明请求成功,非 0 表示失败,请参照下文[错误码](#错误码)一节进行相应处理
|
||||
access_token | string | 即 `user_access_token`,仅在请求成功时返回
|
||||
expires_in | int | 即 `user_access_token` 的有效期,单位为秒,仅在请求成功时返回<br>**注意事项**:建议使用该字段以确定 `user_access_token` 的过期时间,不要硬编码有效期
|
||||
refresh_token | string | 用于刷新 `user_access_token`,详见[刷新 user_access_token](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/refresh-user-access-token)。该字段仅在请求成功且用户授予 `offline_access` 权限时返回。<br>**注意事项**:如果你在获取 `user_access_token` 时设置了 `scope` 请求参数,且需要返回 `refresh_token`,则需要在 `scope` 参数中包括 `offline_access`。另外,`refresh_token` 仅能被使用一次。
|
||||
refresh_token_expires_in | int | 即 `refresh_token` 的有效期,单位为秒,仅在返回 `refresh_token` 时返回。<br>**注意事项**:建议在到期前调用[刷新 user_access_token](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/refresh-user-access-token) 接口获取新的 `refresh_token`。
|
||||
token_type | string | 值固定为 `Bearer`,仅在请求成功时返回
|
||||
scope | string | 本次请求所获得的 `access_token` 所具备的权限列表,以空格分隔,仅在请求成功时返回
|
||||
error | string | 错误类型,仅在请求失败时返回
|
||||
error_description | string | 具体的错误信息,仅在请求失败时返回
|
||||
|
||||
### 响应体示例
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"access_token": "eyJhbGciOiJFUzI1NiIs**********X6wrZHYKDxJkWwhdkrYg",
|
||||
"expires_in": 7200, // 非固定值,请务必根据响应体中返回的实际值来确定 access_token 的有效期
|
||||
"refresh_token": "eyJhbGciOiJFUzI1NiIs**********XXOYOZz1mfgIYHwM8ZJA",
|
||||
"refresh_token_expires_in": 604800, // 非固定值,请务必根据响应体中返回的实际值来确定 refresh_token 的有效期
|
||||
"scope": "auth:user.id:read offline_access task:task:read user_profile",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
失败响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 20050,
|
||||
"error": "server_error",
|
||||
"error_description": "An unexpected server error occurred. Please retry your request."
|
||||
}
|
||||
```
|
||||
|
||||
### 错误码
|
||||
|
||||
HTTP 状态码 | 错误码 | 描述 | 排查建议
|
||||
---|---|---|---
|
||||
400 | 20001 | The request is missing a required parameter. | 必要参数缺失,请检查请求时传入的参数是否有误
|
||||
400 | 20002 | The client secret is invalid. | 应用认证失败,请检查提供的 `client_id` 与 `client_secret` 是否正确
|
||||
400 | 20003 | The authorization code is not found. Please note that an authorization code can only be used once. | 无效的授权码,请检查授权码是否有效,注意授权码仅能使用一次
|
||||
400 | 20004 | The authorization code has expired. | 授权码已经过期,请在授权码生成后的 5 分钟内使用
|
||||
400 | 20008 | The user does not exist. | 用户不存在,请检查发起授权的用户的当前状态
|
||||
400 | 20009 | The specified app is not installed. | 租户未安装应用,请检查应用状态
|
||||
400 | 20010 | The user does not have permission to use this app. | 用户无应用使用权限,请检查发起授权的用户是否仍具有应用使用权限
|
||||
400 | 20024 | The provided authorization code or refresh token does not match the provided client ID. | 提供的授权码与 `client_id` 不匹配,请勿混用不同应用的凭证
|
||||
400 | 20036 | The specified grant_type is not supported. | 无效的 `grant_type`,请检查请求体中 `grant_type` 字段的取值
|
||||
400 | 20048 | The specified app does not exist. | 应用不存在,请检查应用状态
|
||||
400 | 20049 | PKCE code challenge failed. | PKCE 校验失败,请检查请求体中 `code_verifier` 字段是否存在且有效
|
||||
500 | 20050 | An unexpected server error occurred. Please retry your request. | 内部服务错误,请稍后重试,如果持续报错请联系[技术支持](https://applink.feishu.cn/TLJpeNdW)
|
||||
400 | 20063 | The request is malformed. Please check your request. | 请求体中缺少必要字段,请根据具体的错误信息补齐字段
|
||||
400 | 20065 | The authorization code has been used. Please note that an authorization code can only be used once. | 授权码已被使用,授权码仅能使用一次,请检查是否有被重复使用
|
||||
400 | 20066 | The user status is invalid. | 用户状态非法,请检查发起授权的用户的当前状态
|
||||
400 | 20067 | The provided scope list contains duplicate scopes. Please ensure all scopes are unique. | 无效的 `scope` 列表,其中存在重复项,请确保传入的 `scope` 列表中没有重复项
|
||||
400 | 20068 | The provided scope list contains scopes that are not permitted. Please ensure all scopes are allowed. | 无效的 `scope` 列表,其中存在用户未授权的权限。当前接口 `scope` 参数传入的权限必须是[获取授权码](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code)时 `scope` 参数值的子集。<br>例如,在获取授权码时,用户授权了权限 A、B,则当前接口 `scope` 可传入的值只有权限 A、B,若传入权限 C 则会返回当前错误码。
|
||||
400 | 20069 | The specified app is not enabled. | 应用未启用,请检查应用状态
|
||||
400 | 20070 | Multiple authentication methods were provided. Please only use one to proceed. | 请求时同时使用了 `Basic Authentication` 和 `client_secret` 两种身份验证方式。请仅使用 `client_id`、`client_secret` 身份验证方式调用本接口。
|
||||
400 | 20071 | The provided redirect URI does not match the one used during authorization. | 无效的 `redirect_uri`,请确保 `redirect_uri` 与[获取授权码](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code)时传入的 `redirect_uri` 保持一致
|
||||
503 | 20072 | The server is temporarily unavailable. Please retry your request. | 服务暂不可用,请稍后重试
|
||||
|
||||
## 代码示例
|
||||
**注意事项**:此处提供的代码示例**仅供参考**,请勿直接在生产环境使用
|
||||
|
||||
### Golang
|
||||
|
||||
运行下面示例程序的步骤:
|
||||
1. 点击下方代码块右上角复制按钮,将代码复制到本地文件中,保存为 `main.go`;
|
||||
2. 参照注释部分,完成配置;
|
||||
3. 在 `main.go` 所在目录下新建 `.env` 文件,内容如下:
|
||||
```bash
|
||||
APP_ID=cli_xxxxxx # 仅为示例值,请使用你的应用的 App ID,获取方式:开发者后台 -> 基础信息 -> 凭证与基础信息 -> 应用凭证 -> App ID
|
||||
APP_SECRET=xxxxxx # 仅为示例值,请使用你的应用的 App Secret,获取方式:开发者后台 -> 基础信息 -> 凭证与基础信息 -> 应用凭证 -> App Secret
|
||||
```
|
||||
4. 在 `main.go` 所在目录执行以下命令:
|
||||
```bash
|
||||
go mod init oauth-test
|
||||
go get github.com/gin-gonic/gin
|
||||
go get github.com/gin-contrib/sessions
|
||||
go get github.com/gin-contrib/sessions/cookie
|
||||
go get github.com/joho/godotenv
|
||||
go get golang.org/x/oauth2
|
||||
go run main.go
|
||||
```
|
||||
5. 浏览器打开 [http://localhost:8080](http://localhost:8080) ,按照页面提示完成授权流程;
|
||||
|
||||
```javascript
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
var oauthEndpoint = oauth2.Endpoint{
|
||||
AuthURL: "https://accounts.feishu.cn/open-apis/authen/v1/authorize",
|
||||
TokenURL: "https://open.feishu.cn/open-apis/authen/v2/oauth/token",
|
||||
}
|
||||
|
||||
var oauthConfig = &oauth2.Config{
|
||||
ClientID: os.Getenv("APP_ID"),
|
||||
ClientSecret: os.Getenv("APP_SECRET"),
|
||||
RedirectURL: "http://localhost:8080/callback", // 请先添加该重定向 URL,配置路径:开发者后台 -> 开发配置 -> 安全设置 -> 重定向 URL -> 添加
|
||||
Endpoint: oauthEndpoint,
|
||||
Scopes: []string{"offline_access"}, // 如果你不需要 refresh_token,请注释掉该行,否则你需要先申请 offline_access 权限方可使用,配置路径:开发者后台 -> 开发配置 -> 权限管理
|
||||
}
|
||||
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
|
||||
// 使用 Cookie 存储 session
|
||||
store := cookie.NewStore([]byte("secret")) // 此处仅为示例,务必不要硬编码密钥
|
||||
r.Use(sessions.Sessions("mysession", store))
|
||||
|
||||
r.GET("/", indexController)
|
||||
r.GET("/login", loginController)
|
||||
r.GET("/callback", oauthCallbackController)
|
||||
|
||||
fmt.Println("Server running on http://localhost:8080")
|
||||
log.Fatal(r.Run(":8080"))
|
||||
}
|
||||
|
||||
func indexController(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
var username string
|
||||
session := sessions.Default(c)
|
||||
if session.Get("user") != nil {
|
||||
username = session.Get("user").(string)
|
||||
}
|
||||
html := fmt.Sprintf(`<html><head><style>body{font-family:Arial,sans-serif;background:#f4f4f4;margin:0;display:flex;justify-content:center;align-items:center;height:100vh}.container{text-align:center;background:#fff;padding:30px;border-radius:10px;box-shadow:0 0 10px rgba(0,0,0,0.1)}a{padding:10px 20px;font-size:16px;color:#fff;background:#007bff;border-radius:5px;text-decoration:none;transition:0.3s}a:hover{background:#0056b3}}</style></head><body>[返回主页](/)
|
||||
</body></html>`, user.Data.Name)
|
||||
c.String(http.StatusOK, html)
|
||||
}
|
||||
```
|
||||
|
||||
# 刷新 user_access_token
|
||||
OAuth 令牌接口,可用于刷新 <code>user_access_token</code> 以及获取新的 <code>refresh_token</code>。
|
||||
|
||||
- `user_access_token` 为用户访问凭证,使用该凭证可以以用户身份调用 OpenAPI,该凭证存在有效期,可通过 `refresh_token` 进行刷新。
|
||||
- 用户授权时,用户必须拥有[应用的使用权限](https://open.feishu.cn/document/home/introduction-to-scope-and-authorization/availability),否则调用本接口将会报错误码 20010。
|
||||
- `refresh_token` 用于获取新的 `user_access_token`,且仅能使用一次。在获取新的 `user_access_token` 时会返回新的 `refresh_token`,原 `refresh_token` 立即失效。
|
||||
|
||||
- 首次获取 `refresh_token` 的方式参见[获取 user_access_token](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token)。
|
||||
**注意事项**:本接口实现遵循 [RFC 6749 - The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749) ,你可以使用[标准的 OAuth 客户端库](https://oauth.net/code/)进行接入(**推荐**)
|
||||
|
||||
## 前置工作
|
||||
### 开通 offline_access 权限
|
||||
获取 `refresh_token` 需前往开放平台应用后台的**权限管理**模块开通 `offline_access` 权限,并在[发起授权](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code)时在 `scope` 参数中声明该权限。
|
||||
|
||||

|
||||
|
||||
在开通 `offline_access` 权限后,如需获取 `refresh_token`,具体的请求参数设置如下:
|
||||
1. 首先在[发起授权](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code)时,授权链接的`scope` 参数中必须拼接 `offline_access`,例如:
|
||||
```
|
||||
https://accounts.feishu.cn/open-apis/authen/v1/authorize?client_id=cli_a5d611352af9d00b&redirect_uri=https%3A%2F%2Fexample.com%2Fapi%2Foauth%2Fcallback&scope=bitable:app:readonly%20offline_access
|
||||
```
|
||||
2. 在[获取 user_access_token](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token)时,
|
||||
+ 如果不需要缩减权限,即该接口的 `scope` 参数为空,则无需做其他操作,即可正常获得 `refresh_token`;
|
||||
+ 如果需要缩减权限,即该接口的 `scope` 参数不为空,
|
||||
+ 且需要获取 `refresh_token`,则此处的 `scope` 参数中需要拼接 `offline_access`;
|
||||
+ 如不需要获取 `refresh_token`,则无需特殊处理;
|
||||
3. 在[刷新 user_access_token](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/refresh-user-access-token)时,同第二步的逻辑。
|
||||
|
||||
### 开启刷新 user_access_token 的安全设置
|
||||
**注意事项**:- 如果你看不到此开关则无需关注,其默认处于开启状态。
|
||||
- 完成配置后需要发布应用使配置生效。具体操作参见[发布企业自建应用](https://open.feishu.cn/document/home/introduction-to-custom-app-development/self-built-application-development-process#baf09c7d)、[发布商店应用](https://open.feishu.cn/document/uMzNwEjLzcDMx4yM3ATM/uYjMyUjL2IjM14iNyITN)。
|
||||
|
||||
前往开放平台应用后台的**安全设置**模块,打开刷新 `user_access_token` 的开关。
|
||||
|
||||

|
||||
|
||||
## 请求
|
||||
**注意事项**:为了避免刷新 `user_access_token` 的行为被滥用,在用户授权应用 365 天后,应用必须通过用户[重新授权](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token)的方式来获取 `user_access_token` 与 `refresh_token`。如果 `refresh_token` 到期后继续刷新`user_access_token`将报错(错误码为 20037),可参考以下[错误码描述信息](#错误码)进行处理。
|
||||
**注意事项**:刷新后请更新本地 `user_access_token` 和 `refresh_token`,原令牌将无法再使用(`user_access_token` 会有一分钟的豁免时间以供应用完成令牌轮转)。
|
||||
|
||||
基本 |
|
||||
---|---
|
||||
HTTP URL | https://open.feishu.cn/open-apis/authen/v2/oauth/token
|
||||
HTTP Method | POST
|
||||
接口频率限制 | [1000 次/分钟、50 次/秒](https://open.feishu.cn/document/ukTMukTMukTM/uUzN04SN3QjL1cDN)
|
||||
支持的应用类型 | Custom App、Store App
|
||||
权限要求<br>**调用该 API 所需的权限。开启其中任意一项权限即可调用<br>** | 无
|
||||
字段权限要求 | `refresh_token` 以及 `refresh_token_expires_in` 字段仅在具备以下权限时返回:<br>offline_access(offline_access)
|
||||
|
||||
### 请求头
|
||||
|
||||
名称 | 类型 | 必填 | 描述
|
||||
---|---|---|---
|
||||
Content-Type | string | 是 | 请求体类型。<br>**固定值:**`application/json; charset=utf-8`
|
||||
|
||||
### 请求体
|
||||
|
||||
名称 | 类型 | 必填 | 描述
|
||||
---|---|---|---
|
||||
grant_type | string | 是 | 授权类型。<br>**固定值:**`refresh_token`
|
||||
client_id | string | 是 | 应用的 App ID,可以在开发者后台中的应用详情页面找到该值。<br>**示例值:**`cli_a5ca35a685b0x26e`
|
||||
client_secret | string | 是 | 应用的 App Secret,可以在开发者后台中的应用详情页面找到该值,详见:[如何获取应用的 App ID](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-app-id)。<br>**示例值:**`baBqE5um9LbFGDy3X7LcfxQX1sqpXlwy`
|
||||
refresh_token | string | 是 | 刷新令牌,用于刷新 `user_access_token` 以及 `refresh_token`。<br>**注意事项**:请务必注意本接口仅支持[获取 user_access_token](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token)和[刷新 user_access_token](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/refresh-user-access-token)接口返回的 `refresh_token`<br>**示例值:**`eyJhbGciOiJFUzI1NiIs**********XXOYOZz1mfgIYHwM8ZJA`
|
||||
scope | string | 否 | 该参数用于缩减 `user_access_token` 的权限范围。<br>例如:<br>1. 在[获取授权码](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code)时通过 `scope` 参数授权了 `contact:user.base:readonly contact:contact.base:readonly contact:user.employee:readonly` 三个权限。<br>2. 在当前接口可通过 `scope` 参数传入 `contact:user.base:readonly`,将 `user_access_token` 的权限缩减为 `contact:user.base:readonly` 这一个。<br>**注意**:<br>- 如果不指定当前参数,生成的 `user_access_token` 将包含用户授权时的所有权限。<br>- 当前参数不能传入重复的权限,否则会接口调用会报错,返回错误码 20067。<br>- 当前参数不能传入未授权的权限(即[获取授权码](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code)时用户已授权范围外的其他权限),否则接口调用会报错,返回错误码 20068。<br>- 多次调用当前接口缩减权限的范围不会叠加。例如,用户授予了权限 A 和 B,第一次调用该接口缩减为权限 A,则 `user_access_token` 只包含权限 A;第二次调用该接口缩减为权限 B,则 `user_access_token` 只包含权限 B。 <br>- 生效的权限列表可通过本接口返回值 scope 查看。<br>**格式要求:** 以空格分隔的 `scope` 列表<br>**示例值:**`auth:user.id:read task:task:read`
|
||||
|
||||
### 请求体示例
|
||||
```json
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": "cli_a5ca35a685b0x26e",
|
||||
"client_secret": "baBqE5um9LbFGDy3X7LcfxQX1sqpXlwy",
|
||||
"refresh_token": "eyJhbGciOiJFUzI1NiIs**********XXOYOZz1mfgIYHwM8ZJA"
|
||||
}
|
||||
```
|
||||
## 响应
|
||||
响应体类型为 `application/json; charset=utf-8`。
|
||||
|
||||
### 响应体
|
||||
|
||||
名称 | 类型 | 描述
|
||||
---|---|---
|
||||
code | int | 错误码,为 0 时表明请求成功,非 0 表示失败,请参照下文错误码一节妥善处理
|
||||
access_token | string | 即 `user_access_token`,仅在请求成功时返回
|
||||
expires_in | int | 即 `user_access_token` 的有效期,单位为秒,仅在请求成功时返回<br>**注意事项**:建议使用该字段以确定 `user_access_token` 的过期时间,不要硬编码有效期
|
||||
refresh_token | string | 用于刷新 `user_access_token`,该字段仅在请求成功且用户授予 `offline_access` 权限时返回:<br>offline_access(offline_access)<br>**注意事项**:如果你在获取 `user_access_token` 时设置了 `scope` 请求参数,且需要返回 `refresh_token`,则需要在 `scope` 参数中包括 `offline_access`。另外,`refresh_token` 仅能被使用一次。
|
||||
refresh_token_expires_in | int | 即 `refresh_token` 的有效期,单位为秒,仅在返回 `refresh_token` 时返回。<br>**注意事项**:建议在到期前重新调用当前接口获取新的 `refresh_token`。
|
||||
token_type | string | 值固定为 `Bearer`,仅在请求成功时返回
|
||||
scope | string | 本次请求所获得的 `access_token` 所具备的权限列表,以空格分隔,仅在请求成功时返回
|
||||
error | string | 错误类型,仅在请求失败时返回
|
||||
error_description | string | 具体的错误信息,仅在请求失败时返回
|
||||
|
||||
### 响应体示例
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"access_token": "eyJhbGciOiJFUzI1NiIs**********X6wrZHYKDxJkWwhdkrYg",
|
||||
"expires_in": 7200, // 非固定值,请务必根据响应体中返回的实际值来确定 access_token 的有效期
|
||||
"refresh_token": "eyJhbGciOiJFUzI1NiIs**********VXOYOZYZmfgIYHWM0ZJA",
|
||||
"refresh_token_expires_in": 604800, // 非固定值,请务必根据响应体中返回的实际值来确定 refresh_token 的有效期
|
||||
"scope": "auth:user.id:read offline_access task:task:read user_profile",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
失败响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 20050,
|
||||
"error": "server_error",
|
||||
"error_description": "An unexpected server error occurred. Please retry your request."
|
||||
}
|
||||
```
|
||||
|
||||
### 错误码
|
||||
|
||||
HTTP 状态码 | 错误码 | 描述 | 排查建议
|
||||
---|---|---|---
|
||||
400 | 20001 | The request is missing a required parameter. | 必要参数缺失,请检查请求时传入的参数是否有误
|
||||
400 | 20002 | The client secret is invalid. | 应用认证失败,请检查提供的 `client_id` 与 `client_secret` 是否正确。获取方式参见 [如何获取应用的 App ID](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-app-id)。
|
||||
400 | 20008 | The user does not exist. | 用户不存在,请检查发起授权的用户的当前状态
|
||||
400 | 20009 | The specified app is not installed. | 租户未安装应用,请检查应用状态
|
||||
400 | 20010 | The user does not have permission to use this app. | 用户无应用使用权限,请检查发起授权的用户是否仍具有应用使用权限
|
||||
400 | 20024 | The provided authorization code or refresh token does not match the provided client ID. | 提供的 `refresh_token` 与 `client_id` 不匹配,请勿混用不同应用的凭证
|
||||
400 | 20026 | The refresh token passed is invalid. Please check the value. | 请检查请求体中 `refresh_token` 字段的取值<br>请注意本接口仅支持 v2 版本接口下发的 `refresh_token`
|
||||
400 | 20036 | The specified grant_type is not supported. | 无效的 `grant_type`,请检查请求体中 `grant_type` 字段的取值
|
||||
400 | 20037 | The refresh token passed has expired. Please generate a new one. | `refresh_token` 已过期,请[重新发起授权流程](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token)以获取新的 `refresh_token`
|
||||
400 | 20048 | The specified app does not exist. | 应用不存在,请检查应用状态
|
||||
500 | 20050 | An unexpected server error occurred. Please retry your request. | 内部服务错误,请稍后重试,如果持续报错请联系[技术支持](https://applink.feishu.cn/TLJpeNdW)
|
||||
400 | 20063 | The request is malformed. Please check your request. | 请求体中缺少必要字段,请根据具体的错误信息补齐字段
|
||||
400 | 20064 | The refresh token has been revoked. Please note that a refresh token can only be used once. | `refresh_token` 已被撤销,请[重新发起授权流程](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token)以获取新的 `refresh_token`
|
||||
400 | 20066 | The user status is invalid. | 用户状态非法,请检查发起授权的用户的当前状态
|
||||
400 | 20067 | The provided scope list contains duplicate scopes. Please ensure all scopes are unique. | 无效的 `scope` 列表,其中存在重复项,请确保传入的 `scope` 列表中没有重复项
|
||||
400 | 20068 | The provided scope list contains scopes that are not permitted. Please ensure all scopes are allowed. | 无效的 `scope` 列表,其中存在用户未授权的权限。当前接口 `scope` 参数传入的权限必须是[获取授权码](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code)时 `scope` 参数值的子集。<br>例如,在获取授权码时,用户授权了权限 A、B,则当前接口 `scope` 可传入的值只有权限 A、B,若传入权限 C 则会返回当前错误码。
|
||||
400 | 20069 | The specified app is not enabled. | 应用未启用,请检查应用状态
|
||||
400 | 20070 | Multiple authentication methods were provided. Please only use one to proceed. | 请求时同时使用了 `Basic Authentication` 和 `client_secret` 两种身份验证方式。请仅使用 `client_id`、`client_secret` 身份验证方式调用本接口。
|
||||
503 | 20072 | The server is temporarily unavailable. Please retry your request. | 服务暂不可用,请稍后重试
|
||||
400 | 20073 | The refresh token has been used. Please note that a refresh token can only be used once. | 请[重新发起授权流程](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/get-user-access-token)以获取新的 `refresh_token`
|
||||
400 | 20074 | The specified app is not allowed to refresh token. | 请在应用管理后台检查是否开启了刷新 `user_access_token` 开关,注意发版后生效
|
||||
|
||||
@@ -24,14 +24,39 @@ function switchTab(tab) {
|
||||
|
||||
//如果切换到日志页,开始实时加载
|
||||
if (tab === 'logs') {
|
||||
startLogStreaming();
|
||||
checkAdminStatus().then(allowed => {
|
||||
if (allowed) {
|
||||
startLogStreaming();
|
||||
} else {
|
||||
const container = document.getElementById('content-logs');
|
||||
container.innerHTML = `
|
||||
<div class="w-full h-[calc(100vh-100px)] rounded-lg overflow-hidden bg-white shadow-sm border border-slate-200">
|
||||
<iframe src="/error.html" class="w-full h-full border-0"></iframe>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
stopLogStreaming();
|
||||
}
|
||||
|
||||
//如果切换到设置页,加载 .env 文件
|
||||
if (tab === 'settings') {
|
||||
loadEnvFile();
|
||||
checkAdminStatus().then(allowed => {
|
||||
if (allowed) {
|
||||
loadEnvFile();
|
||||
} else {
|
||||
const container = document.getElementById('content-settings');
|
||||
// 使用 iframe 嵌入错误页,保持侧边栏导航
|
||||
container.innerHTML = `
|
||||
<div class="w-full h-[calc(100vh-100px)] rounded-lg overflow-hidden bg-white shadow-sm border border-slate-200">
|
||||
<iframe src="/error.html" class="w-full h-full border-0"></iframe>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
return; // 等待检查结果,暂不加载内容
|
||||
}
|
||||
|
||||
//如果切换到使用指南页,加载 README
|
||||
@@ -40,6 +65,17 @@ function switchTab(tab) {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAdminStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/me');
|
||||
const data = await res.json();
|
||||
return data.loggedIn && data.isAdmin;
|
||||
} catch (e) {
|
||||
console.error('Check admin status failed:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
//日志流控制
|
||||
let logInterval = null;
|
||||
let lastLogSize = 0;
|
||||
@@ -101,6 +137,17 @@ function escapeHtml(text) {
|
||||
async function loadDashboardData() {
|
||||
try {
|
||||
const res = await fetch('/api/status');
|
||||
|
||||
if (res.status === 403) {
|
||||
document.getElementById('today-syncs').textContent = '无权限';
|
||||
document.getElementById('repo-count').textContent = '无权限';
|
||||
document.getElementById('error-count').textContent = '无权限';
|
||||
document.getElementById('uptime').textContent = '系统运行时间: 无权限';
|
||||
updateServiceStatus('unknown');
|
||||
loadHistory(); // 也会处理 403
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -124,6 +171,13 @@ async function loadDashboardData() {
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const res = await fetch('/api/history');
|
||||
|
||||
if (res.status === 403) {
|
||||
const tbody = document.getElementById('history-table');
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-8 text-center text-sm text-slate-500">无权限查看历史记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.history) {
|
||||
@@ -163,6 +217,15 @@ function updateServiceStatus(status) {
|
||||
运行中
|
||||
`;
|
||||
statusText.textContent = '运行中';
|
||||
} else if (status === 'unknown') {
|
||||
badge.className = 'px-3 py-1 rounded-full text-xs font-medium flex items-center border bg-slate-100 text-slate-600 border-slate-200';
|
||||
badge.innerHTML = `
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
状态未知
|
||||
`;
|
||||
statusText.textContent = '状态未知';
|
||||
} else {
|
||||
badge.className = 'px-3 py-1 rounded-full text-xs font-medium flex items-center border bg-slate-100 text-slate-600 border-slate-200';
|
||||
badge.innerHTML = `
|
||||
|
||||
@@ -107,10 +107,14 @@
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-slate-50 text-slate-900">
|
||||
<div class="flex">
|
||||
<div class="flex relative">
|
||||
<!-- 移动端遮罩 -->
|
||||
<div id="sidebar-overlay" onclick="toggleSidebar()"
|
||||
class="fixed inset-0 bg-gray-900/50 z-20 hidden transition-opacity opacity-0"></div>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<div
|
||||
class="w-64 bg-slate-900 text-slate-300 flex flex-col h-screen fixed left-0 top-0 border-r border-slate-800">
|
||||
<div id="sidebar"
|
||||
class="w-64 bg-slate-900 text-slate-300 flex flex-col h-screen fixed left-0 top-0 border-r border-slate-800 z-30 transform -translate-x-full md:translate-x-0 transition-transform duration-300 ease-in-out">
|
||||
<div class="h-16 flex items-center px-6 border-b border-slate-800 bg-slate-950">
|
||||
<svg class="w-6 h-6 text-indigo-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
@@ -147,6 +151,24 @@
|
||||
</svg>
|
||||
映射配置
|
||||
</button>
|
||||
<button onclick="switchTab('lark')" id="tab-lark"
|
||||
class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
|
||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9">
|
||||
</path>
|
||||
</svg>
|
||||
飞书提醒
|
||||
</button>
|
||||
<button onclick="switchTab('guide')" id="tab-guide"
|
||||
class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
|
||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253">
|
||||
</path>
|
||||
</svg>
|
||||
使用指南
|
||||
</button>
|
||||
<button onclick="switchTab('settings')" id="tab-settings"
|
||||
class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
|
||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -158,15 +180,6 @@
|
||||
</svg>
|
||||
系统设置
|
||||
</button>
|
||||
<button onclick="switchTab('guide')" id="tab-guide"
|
||||
class="tab-btn w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-md transition-colors duration-150 group">
|
||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253">
|
||||
</path>
|
||||
</svg>
|
||||
使用指南
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-slate-800">
|
||||
@@ -178,15 +191,28 @@
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="flex-1 ml-64 p-8 overflow-y-auto">
|
||||
<main class="flex-1 ml-0 md:ml-64 p-4 md:p-8 overflow-y-auto w-full">
|
||||
<!-- 移动端顶部导航 -->
|
||||
<div class="md:hidden flex items-center justify-between mb-6">
|
||||
<button onclick="toggleSidebar()" class="p-2 -ml-2 text-slate-600 hover:bg-slate-100 rounded-md">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="font-bold text-slate-900">TaskBot控制台</span>
|
||||
<div class="w-8"></div> <!-- 占位保持居中 -->
|
||||
</div>
|
||||
|
||||
<!-- Dashboard 标签页 -->
|
||||
<div id="content-dashboard" class="tab-content">
|
||||
<header class="mb-8 flex justify-between items-end">
|
||||
<header class="mb-6 md:mb-8 flex flex-col md:flex-row md:justify-between md:items-end gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900 tracking-tight">运维概览</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Jira-Gitea双向同步机器人控制中心</p>
|
||||
<h1 class="text-xl md:text-2xl font-bold text-slate-900 tracking-tight hidden md:block">运维概览
|
||||
</h1>
|
||||
<p class="text-sm text-slate-500 mt-1 hidden md:block">Jira-Gitea双向同步机器人控制中心</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex items-center space-x-2 self-end md:self-auto">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-sm font-medium text-slate-600" id="uptime">加载中...</span>
|
||||
</div>
|
||||
@@ -261,9 +287,9 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<button onclick="controlBot('restart')"
|
||||
class="flex items-center justify-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
class="flex items-center justify-center px-4 py-3 md:py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15">
|
||||
@@ -272,7 +298,7 @@
|
||||
重启服务
|
||||
</button>
|
||||
<button onclick="clearLogs()"
|
||||
class="flex items-center justify-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
class="flex items-center justify-center px-4 py-3 md:py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
|
||||
@@ -281,7 +307,7 @@
|
||||
清空日志
|
||||
</button>
|
||||
<button onclick="refreshStatus()"
|
||||
class="flex items-center justify-center px-4 py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
class="flex items-center justify-center px-4 py-3 md:py-2 border border-slate-300 text-sm font-medium rounded-md text-slate-700 bg-white hover:bg-slate-50 transition-colors">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15">
|
||||
@@ -326,7 +352,7 @@
|
||||
|
||||
<!-- Mapping 标签页 - 嵌入原有的映射编辑器 -->
|
||||
<div id="content-mapping" class="tab-content hidden">
|
||||
<iframe src="/editor/error.html?code=施工中" class="w-full border-0 rounded-lg shadow-sm bg-white"
|
||||
<iframe src="/editor/editor.html" class="w-full border-0 rounded-lg shadow-sm bg-white"
|
||||
style="height: calc(100vh - 100px);"></iframe>
|
||||
</div>
|
||||
|
||||
@@ -414,11 +440,50 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 飞书提醒标签页 -->
|
||||
<div id="content-lark" class="tab-content hidden">
|
||||
<iframe src="/editor/larkReminder.html" class="w-full border-0 rounded-lg shadow-sm bg-white"
|
||||
style="height: calc(100vh - 100px);"></iframe>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/editor/dashboard-app.js"></script>
|
||||
<script>
|
||||
// 侧边栏切换逻辑
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
const isClosed = sidebar.classList.contains('-translate-x-full');
|
||||
|
||||
if (isClosed) {
|
||||
// 打开
|
||||
sidebar.classList.remove('-translate-x-full');
|
||||
overlay.classList.remove('hidden');
|
||||
setTimeout(() => overlay.classList.remove('opacity-0'), 10);
|
||||
} else {
|
||||
// 关闭
|
||||
sidebar.classList.add('-translate-x-full');
|
||||
overlay.classList.add('opacity-0');
|
||||
setTimeout(() => overlay.classList.add('hidden'), 300);
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖原始 switchTab 以在移动端自动关闭侧边栏
|
||||
const originalSwitchTab = window.switchTab;
|
||||
window.switchTab = function (tabId) {
|
||||
if (originalSwitchTab) originalSwitchTab(tabId);
|
||||
|
||||
// 如果是移动端(屏幕宽度小于 768px),点击后关闭侧边栏
|
||||
if (window.innerWidth < 768) {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (!sidebar.classList.contains('-translate-x-full')) {
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
//默认显示运维概览
|
||||
|
||||
@@ -257,6 +257,23 @@
|
||||
(Transitions)</h3>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded p-3 mb-3 text-xs text-yellow-800 hidden"
|
||||
id="transWarning"></div>
|
||||
|
||||
<!-- 手动扫描 Transition -->
|
||||
<div class="mb-4 p-3 bg-purple-50 border border-purple-200 rounded">
|
||||
<p class="text-xs text-purple-700 mb-2">输入工单 Key 扫描该工单当前可用的状态流转,扫描结果会追加到下拉菜单中</p>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" id="transIssueKey" placeholder="例如: TEST-123"
|
||||
class="flex-1 border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 outline-none uppercase">
|
||||
<button onclick="scanTransitions()" id="scanTransBtn"
|
||||
class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded text-sm font-medium transition whitespace-nowrap">
|
||||
扫描流转
|
||||
</button>
|
||||
</div>
|
||||
<div id="transResult" class="mt-2 text-sm"></div>
|
||||
<div id="transError"
|
||||
class="hidden mt-2 bg-red-50 text-red-700 p-2 rounded text-xs border border-red-200"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div class="flex items-center justify-between bg-gray-50 p-3 rounded border">
|
||||
<span class="font-mono text-sm font-medium text-gray-700">关闭 (Close)</span>
|
||||
|
||||
@@ -1,61 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>错误 - TaskBot</title>
|
||||
<title>无权访问</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: 'Inter', system-ui, sans-serif; }
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div class="max-w-md w-full px-6">
|
||||
<div class="text-center">
|
||||
<div class="mb-6">
|
||||
<svg class="w-24 h-24 mx-auto text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-6xl font-bold text-slate-900 mb-2" id="error-code">404</h1>
|
||||
<h2 class="text-xl font-medium text-slate-700 mb-4" id="error-title">页面未找到</h2>
|
||||
<p class="text-sm text-slate-500 mb-8" id="error-message">抱歉,您访问的页面不存在或已被移除。</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<button onclick="window.parent.switchTab('dashboard')" class="block w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-3 px-6 rounded-lg transition-colors">
|
||||
返回控制台
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center">
|
||||
<p class="text-xs text-slate-400">TaskBot v1.1.0</p>
|
||||
|
||||
<body class="bg-gray-50 flex items-center justify-center h-screen">
|
||||
<div class="text-center p-8 bg-white rounded-lg shadow-lg max-w-md w-full border border-gray-100">
|
||||
<div class="mb-6 inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 text-red-500">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">访问被拒绝</h1>
|
||||
<p class="text-gray-500 mb-6">您没有权限访问此页面 (系统设置)。请联系管理员获取权限。</p>
|
||||
<button id="requestBtn" onclick="requestPermission()"
|
||||
class="px-5 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition-colors font-medium flex items-center justify-center mx-auto">
|
||||
<span>点击发送权限请求</span>
|
||||
</button>
|
||||
<p id="msg" class="text-sm mt-4 h-5"></p>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code') || '404';
|
||||
const title = params.get('title') || '';
|
||||
const message = params.get('message') || '';
|
||||
|
||||
const errorMap = {
|
||||
'400': { title: '请求错误', message: '请求参数有误,请检查后重试。' },
|
||||
'401': { title: '未授权', message: '您没有权限访问此资源,请先登录。' },
|
||||
'403': { title: '禁止访问', message: '您没有权限访问此页面。' },
|
||||
'404': { title: '页面未找到', message: '抱歉,您访问的页面不存在或已被移除。' },
|
||||
'500': { title: '服务器错误', message: '服务器遇到了一些问题,请稍后再试。' },
|
||||
'503': { title: '服务不可用', message: '服务暂时不可用,请稍后再试。' },
|
||||
'施工中': { title: '图形化编辑映射功能暂不可用', message: '该功能预计2026/01/30下午上线' }
|
||||
};
|
||||
|
||||
const error = errorMap[code] || errorMap['404'];
|
||||
|
||||
document.getElementById('error-code').textContent = code;
|
||||
document.getElementById('error-title').textContent = title || error.title;
|
||||
document.getElementById('error-message').textContent = message || error.message;
|
||||
document.title = `${code} ${error.title} - TaskBot`;
|
||||
async function requestPermission() {
|
||||
const btn = document.getElementById('requestBtn');
|
||||
const msg = document.getElementById('msg');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
btn.innerHTML = '<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>发送中...';
|
||||
msg.textContent = '';
|
||||
msg.className = 'text-sm mt-4 h-5 text-gray-500';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/request-permission', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
msg.textContent = data.message;
|
||||
msg.className = 'text-sm mt-4 h-5 text-emerald-600 font-medium';
|
||||
btn.innerHTML = '已发送请求';
|
||||
} else {
|
||||
msg.textContent = data.error;
|
||||
msg.className = 'text-sm mt-4 h-5 text-rose-500';
|
||||
resetBtn(btn);
|
||||
}
|
||||
} catch (e) {
|
||||
msg.textContent = e.message;
|
||||
msg.className = 'text-sm mt-4 h-5 text-rose-500';
|
||||
resetBtn(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function resetBtn(btn) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = '点击发送权限请求';
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
131
public/larkReminder.html
Normal file
131
public/larkReminder.html
Normal file
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>飞书提醒配置</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 text-gray-800 min-h-screen p-6">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">飞书提醒配置</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">配置规则,当Gitea事件发生时自动发送飞书通知</p>
|
||||
</header>
|
||||
|
||||
<!-- 新建规则按钮 -->
|
||||
<div class="mb-6">
|
||||
<button onclick="showRuleModal()"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
|
||||
+ 新建规则
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 规则列表 -->
|
||||
<div class="bg-white rounded-lg shadow border border-gray-200 overflow-hidden">
|
||||
<div class="px-4 py-3 bg-gray-50 border-b border-gray-200">
|
||||
<h3 class="text-sm font-semibold text-gray-700">已配置的规则</h3>
|
||||
</div>
|
||||
<div id="rulesList" class="divide-y divide-gray-200">
|
||||
<div class="p-8 text-center text-gray-400">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 规则编辑模态窗口 -->
|
||||
<div id="ruleModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-xl font-bold mb-4" id="modalTitle">新建规则</h2>
|
||||
<form id="ruleForm" class="space-y-4">
|
||||
<input type="hidden" id="ruleId">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">规则名称</label>
|
||||
<input type="text" id="ruleName" required placeholder="如: 新Issue通知"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">触发事件</label>
|
||||
<select id="ruleEvent" required onchange="handleEventChange()"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
<option value="">-- 选择事件 --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 动态过滤条件 -->
|
||||
<div id="filterField" class="hidden bg-gray-50 p-3 rounded border border-gray-200">
|
||||
<label id="filterLabel" class="block text-sm font-medium text-gray-700 mb-1">过滤条件</label>
|
||||
<input type="text" id="ruleFilterValue"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
<input type="hidden" id="ruleFilterKey">
|
||||
<p id="filterHelp" class="text-xs text-gray-500 mt-1"></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">消息渠道</label>
|
||||
<select id="ruleChannel" onchange="toggleChannelFields()"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
<option value="group">群聊</option>
|
||||
<option value="private">私聊</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 私聊配置 -->
|
||||
<div id="privateFields" class="hidden">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">接收者(手机号或邮箱)</label>
|
||||
<input type="text" id="targetContact" placeholder="如: 13800138000 或 test@example.com"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- @ 用户(仅群聊显示) -->
|
||||
<div id="atUsersField">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">@ 用户(手机号/邮箱,逗号分隔)</label>
|
||||
<input type="text" id="atUsers" placeholder="13800138000,test@example.com 或 all"
|
||||
class="w-full border rounded px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 outline-none">
|
||||
<p class="text-xs text-gray-400 mt-1">填 all 表示 @所有人</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" id="ruleEnabled" checked class="mr-2">
|
||||
<span class="text-sm text-gray-700">启用规则</span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="modalError"
|
||||
class="hidden mt-3 bg-red-50 text-red-700 p-3 rounded text-sm border border-red-200"></div>
|
||||
|
||||
<div class="mt-6 flex justify-between">
|
||||
<button onclick="testSend()"
|
||||
class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded text-sm font-medium transition">
|
||||
测试发送
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="closeRuleModal()"
|
||||
class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded text-sm font-medium transition">
|
||||
取消
|
||||
</button>
|
||||
<button onclick="saveRule()"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="larkReminder.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
285
public/larkReminder.js
Normal file
285
public/larkReminder.js
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* 飞书提醒规则管理前端
|
||||
*/
|
||||
|
||||
let eventTypes = [];
|
||||
let currentRules = [];
|
||||
|
||||
//页面加载
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await loadEventTypes();
|
||||
await loadRules();
|
||||
});
|
||||
|
||||
//加载事件类型
|
||||
async function loadEventTypes() {
|
||||
try {
|
||||
const res = await fetch('/api/lark/events');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
eventTypes = data.events;
|
||||
const select = document.getElementById('ruleEvent');
|
||||
eventTypes.forEach(e => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = e.value;
|
||||
opt.text = e.label;
|
||||
select.add(opt);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load event types:', e);
|
||||
}
|
||||
}
|
||||
|
||||
//加载规则列表
|
||||
async function loadRules() {
|
||||
const container = document.getElementById('rulesList');
|
||||
try {
|
||||
const res = await fetch('/api/lark/rules');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
currentRules = data.rules;
|
||||
renderRules();
|
||||
} else {
|
||||
container.innerHTML = `<div class="p-4 text-red-600">${data.error}</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="p-4 text-red-600">加载失败: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
//渲染规则列表
|
||||
function renderRules() {
|
||||
const container = document.getElementById('rulesList');
|
||||
|
||||
if (currentRules.length === 0) {
|
||||
container.innerHTML = `<div class="p-8 text-center text-gray-400">暂无规则,点击上方按钮新建</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = currentRules.map(rule => {
|
||||
const eventLabel = eventTypes.find(e => e.value === rule.event)?.label || rule.event;
|
||||
const channelLabel = rule.channel === 'group' ? '群聊' : '私聊';
|
||||
const statusClass = rule.enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500';
|
||||
const statusLabel = rule.enabled ? '启用' : '禁用';
|
||||
const targetInfo = rule.channel === 'private' && rule.target ? `→ ${rule.target}` : '';
|
||||
const filterInfo = rule.filterValue ?
|
||||
`<span class="mr-3 text-xs bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded">过滤: ${escapeHtml(rule.filterValue)}</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="p-4 flex items-center justify-between hover:bg-gray-50">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">${escapeHtml(rule.name)}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded ${statusClass}">${statusLabel}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
<span class="mr-3">事件: ${eventLabel}</span>
|
||||
${filterInfo}
|
||||
<span class="mr-3">渠道: ${channelLabel} ${targetInfo}</span>
|
||||
${rule.atUsers?.length ? `<span>@ ${rule.atUsers.length}人</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="editRule('${rule.id}')"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm px-2 py-1">编辑</button>
|
||||
<button onclick="deleteRule('${rule.id}')"
|
||||
class="text-red-600 hover:text-red-800 text-sm px-2 py-1">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
//显示规则编辑窗口
|
||||
function showRuleModal(rule = null) {
|
||||
document.getElementById('modalTitle').textContent = rule ? '编辑规则' : '新建规则';
|
||||
document.getElementById('ruleId').value = rule?.id || '';
|
||||
document.getElementById('ruleName').value = rule?.name || '';
|
||||
document.getElementById('ruleEvent').value = rule?.event || '';
|
||||
document.getElementById('ruleChannel').value = rule?.channel || 'group';
|
||||
document.getElementById('targetContact').value = rule?.target || '';
|
||||
document.getElementById('atUsers').value = rule?.atUsers?.join(',') || '';
|
||||
document.getElementById('ruleEnabled').checked = rule?.enabled !== false;
|
||||
document.getElementById('ruleFilterValue').value = rule?.filterValue || '';
|
||||
//不需要设置 filterKey,因为它由 event 决定
|
||||
|
||||
toggleChannelFields();
|
||||
handleEventChange(); // 更新过滤字段显示
|
||||
hideModalError();
|
||||
document.getElementById('ruleModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
//关闭模态窗口
|
||||
function closeRuleModal() {
|
||||
document.getElementById('ruleModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
//切换渠道相关字段
|
||||
function toggleChannelFields() {
|
||||
const channel = document.getElementById('ruleChannel').value;
|
||||
document.getElementById('privateFields').classList.toggle('hidden', channel !== 'private');
|
||||
document.getElementById('atUsersField').classList.toggle('hidden', channel !== 'group');
|
||||
}
|
||||
|
||||
//处理事件变更,显示/隐藏过滤字段
|
||||
function handleEventChange() {
|
||||
const event = document.getElementById('ruleEvent').value;
|
||||
const container = document.getElementById('filterField');
|
||||
const label = document.getElementById('filterLabel');
|
||||
const input = document.getElementById('ruleFilterValue');
|
||||
const keyInput = document.getElementById('ruleFilterKey');
|
||||
const help = document.getElementById('filterHelp');
|
||||
|
||||
// 默认隐藏
|
||||
container.classList.add('hidden');
|
||||
keyInput.value = '';
|
||||
|
||||
if (event === 'issue.assigned') {
|
||||
container.classList.remove('hidden');
|
||||
label.textContent = '指定被指派人 (用户名)';
|
||||
input.placeholder = '例如: zhangsan';
|
||||
keyInput.value = 'assignee';
|
||||
help.textContent = '留空则通知所有指派事件,填写用户名则仅当指派给该用户时通知';
|
||||
} else if (event === 'issue.label_updated') {
|
||||
container.classList.remove('hidden');
|
||||
label.textContent = '指定标签 (名称)';
|
||||
input.placeholder = '例如: bug';
|
||||
keyInput.value = 'label';
|
||||
help.textContent = '留空则通知所有标签变更,填写标签名则仅当该标签变更时通知';
|
||||
}
|
||||
}
|
||||
|
||||
//保存规则
|
||||
async function saveRule() {
|
||||
const id = document.getElementById('ruleId').value;
|
||||
const channel = document.getElementById('ruleChannel').value;
|
||||
const filterKey = document.getElementById('ruleFilterKey').value;
|
||||
const filterValue = document.getElementById('ruleFilterValue').value.trim();
|
||||
|
||||
const rule = {
|
||||
name: document.getElementById('ruleName').value.trim(),
|
||||
event: document.getElementById('ruleEvent').value,
|
||||
channel,
|
||||
enabled: document.getElementById('ruleEnabled').checked,
|
||||
atUsers: [],
|
||||
filterKey: filterKey || null,
|
||||
filterValue: filterValue || null
|
||||
};
|
||||
|
||||
if (channel === 'private') {
|
||||
rule.target = document.getElementById('targetContact').value.trim();
|
||||
} else {
|
||||
rule.atUsers = document.getElementById('atUsers').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
if (!rule.name || !rule.event) {
|
||||
showModalError('请填写规则名称和选择事件');
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel === 'private' && !rule.target) {
|
||||
showModalError('请填写接收者手机号或邮箱');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = id ? `/api/lark/rules/${id}` : '/api/lark/rules';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rule)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
closeRuleModal();
|
||||
await loadRules();
|
||||
} else {
|
||||
showModalError(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
showModalError('保存失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
//编辑规则
|
||||
function editRule(id) {
|
||||
const rule = currentRules.find(r => r.id === id);
|
||||
if (rule) showRuleModal(rule);
|
||||
}
|
||||
|
||||
//删除规则
|
||||
async function deleteRule(id) {
|
||||
if (!confirm('确定要删除这条规则吗?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/lark/rules/${id}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await loadRules();
|
||||
} else {
|
||||
alert('删除失败: ' + data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
//测试发送
|
||||
async function testSend() {
|
||||
const channel = document.getElementById('ruleChannel').value;
|
||||
|
||||
const payload = {
|
||||
channel,
|
||||
atUsers: [],
|
||||
message: '这是一条测试消息\n来自 Gitea-Jira 同步机器人'
|
||||
};
|
||||
|
||||
if (channel === 'private') {
|
||||
payload.target = document.getElementById('targetContact').value.trim();
|
||||
if (!payload.target) {
|
||||
showModalError('请先填写接收者手机号或邮箱');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
payload.atUsers = document.getElementById('atUsers').value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/lark/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
alert('测试消息发送成功!请检查飞书。');
|
||||
} else {
|
||||
showModalError('发送失败: ' + data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
showModalError('发送失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
//显示/隐藏错误
|
||||
function showModalError(msg) {
|
||||
const el = document.getElementById('modalError');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideModalError() {
|
||||
document.getElementById('modalError').classList.add('hidden');
|
||||
}
|
||||
|
||||
//HTML 转义
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
1124
public/mappingsEditor.js
Normal file
1124
public/mappingsEditor.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,14 @@ const config = {
|
||||
baseUrl: process.env.JIRA_BASE_URL,
|
||||
botId: process.env.JIRA_BOT_ID || '',
|
||||
botName: process.env.JIRA_BOT_NAME
|
||||
},
|
||||
lark: {
|
||||
appId: process.env.LARK_APP_ID || '',
|
||||
appSecret: process.env.LARK_APP_SECRET || '',
|
||||
webhookUrl: process.env.LARK_WEBHOOK_URL || '',
|
||||
webhookSecret: process.env.LARK_WEBHOOK_SECRET || '',
|
||||
redirectUri: process.env.LARK_REDIRECT_URI || '',
|
||||
adminIds: (process.env.ADMIN_IDS || '').split(',').map(id => id.trim()).filter(id => id)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
121
src/config/larkRules.js
Normal file
121
src/config/larkRules.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 飞书提醒规则管理
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const DATA_FILE = path.join(__dirname, '../../data/larkRules.json');
|
||||
|
||||
//确保数据文件存在
|
||||
function ensureDataFile() {
|
||||
const dir = path.dirname(DATA_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(DATA_FILE)) {
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify({ rules: [] }, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
//读取所有规则
|
||||
function getAllRules() {
|
||||
ensureDataFile();
|
||||
try {
|
||||
const data = fs.readFileSync(DATA_FILE, 'utf-8');
|
||||
return JSON.parse(data).rules || [];
|
||||
} catch (e) {
|
||||
logger.error('[LarkRules] Failed to read rules:', e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
//保存所有规则
|
||||
function saveAllRules(rules) {
|
||||
ensureDataFile();
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify({ rules }, null, 2));
|
||||
}
|
||||
|
||||
//获取单个规则
|
||||
function getRule(id) {
|
||||
return getAllRules().find(r => r.id === id);
|
||||
}
|
||||
|
||||
//创建规则
|
||||
function createRule(rule) {
|
||||
const rules = getAllRules();
|
||||
const newRule = {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
enabled: true,
|
||||
...rule
|
||||
};
|
||||
rules.push(newRule);
|
||||
saveAllRules(rules);
|
||||
logger.info(`[LarkRules] created rule: ${newRule.name} (${newRule.id})`);
|
||||
return newRule;
|
||||
}
|
||||
|
||||
//更新规则
|
||||
function updateRule(id, updates) {
|
||||
const rules = getAllRules();
|
||||
const index = rules.findIndex(r => r.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error('Rule not found');
|
||||
}
|
||||
rules[index] = { ...rules[index], ...updates, id, updatedAt: new Date().toISOString() };
|
||||
saveAllRules(rules);
|
||||
logger.info(`[LarkRules] updated rule: ${rules[index].name} (${id})`);
|
||||
return rules[index];
|
||||
}
|
||||
|
||||
//删除规则
|
||||
function deleteRule(id) {
|
||||
const rules = getAllRules();
|
||||
const index = rules.findIndex(r => r.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error('Rule not found');
|
||||
}
|
||||
const deleted = rules.splice(index, 1)[0];
|
||||
saveAllRules(rules);
|
||||
logger.info(`[LarkRules] deleted rule: ${deleted.name} (${id})`);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
//根据事件类型获取匹配的规则
|
||||
function getRulesByEvent(eventType) {
|
||||
return getAllRules().filter(r => r.enabled && r.event === eventType);
|
||||
}
|
||||
|
||||
//支持的事件类型
|
||||
const EVENT_TYPES = [
|
||||
{ value: 'issue.opened', label: '新建 Issue' },
|
||||
{ value: 'issue.closed', label: '关闭 Issue' },
|
||||
{ value: 'issue.reopened', label: '重开 Issue' },
|
||||
{ value: 'issue.assigned', label: '指派 Issue' },
|
||||
{ value: 'issue.unassigned', label: '取消指派 Issue' },
|
||||
{ value: 'issue.edited', label: '编辑 Issue' },
|
||||
{ value: 'issue.label_updated', label: 'Issue 标签变更' },
|
||||
{ value: 'issue.milestoned', label: 'Issue 里程碑变更' },
|
||||
{ value: 'issue.comment', label: 'Issue 评论' },
|
||||
{ value: 'pr.opened', label: '新建合并请求' },
|
||||
{ value: 'pr.closed', label: '关闭合并请求' },
|
||||
{ value: 'pr.merged', label: '合并请求已合并' },
|
||||
{ value: 'pr.reopened', label: '重开合并请求' },
|
||||
{ value: 'release.created', label: '创建发布' },
|
||||
{ value: 'release.published', label: '发布版本' },
|
||||
{ value: 'release.deleted', label: '删除发布' },
|
||||
{ value: 'release.updated', label: '更新发布' }
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
getAllRules,
|
||||
getRule,
|
||||
createRule,
|
||||
updateRule,
|
||||
deleteRule,
|
||||
getRulesByEvent,
|
||||
EVENT_TYPES
|
||||
};
|
||||
@@ -2,7 +2,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
//读取配置文件路径
|
||||
const configPath = path.join(__dirname, '../../mappings.json');
|
||||
const configPath = path.join(__dirname, '../../data/mappings.json');
|
||||
|
||||
let mappingsConfig = null;
|
||||
|
||||
|
||||
226
src/logic/larkNotifier.js
Normal file
226
src/logic/larkNotifier.js
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 飞书通知调度器
|
||||
* 根据事件触发规则并发送飞书消息
|
||||
*/
|
||||
|
||||
const larkService = require('../services/lark');
|
||||
const larkRules = require('../config/larkRules');
|
||||
const logger = require('../utils/logger');
|
||||
const config = require('../config/env');
|
||||
|
||||
/**
|
||||
* 构建消息内容
|
||||
*/
|
||||
function buildMessage(rule, eventType, payload) {
|
||||
const { repository, issue, pull_request, release, sender, action } = payload;
|
||||
const repoName = repository ? `${repository.owner?.username || repository.owner?.login}/${repository.name}` : 'Unknown';
|
||||
|
||||
//事件对象(Issue/PR/Release)
|
||||
const target = issue || pull_request || release;
|
||||
const targetTitle = target?.title || target?.name || 'Unknown';
|
||||
const targetNumber = target?.number || target?.id || '';
|
||||
const targetUrl = target?.html_url || target?.url || '';
|
||||
|
||||
//操作者
|
||||
const actor = sender?.username || sender?.login || 'Unknown';
|
||||
|
||||
//根据事件类型构建消息
|
||||
const eventLabels = {
|
||||
'issue.opened': '新建工单',
|
||||
'issue.closed': '关闭工单',
|
||||
'issue.reopened': '重开工单',
|
||||
'issue.assigned': '指派工单',
|
||||
'issue.unassigned': '取消指派',
|
||||
'issue.edited': '编辑工单',
|
||||
'issue.label_updated': '标签变更',
|
||||
'issue.milestoned': '里程碑变更',
|
||||
'issue.comment': '新评论',
|
||||
'pr.opened': '新合并请求',
|
||||
'pr.closed': '关闭合并请求',
|
||||
'pr.merged': '合并请求已合并',
|
||||
'pr.reopened': '重开合并请求',
|
||||
'release.created': '创建发布',
|
||||
'release.published': '发布版本',
|
||||
'release.deleted': '删除发布',
|
||||
'release.updated': '更新发布'
|
||||
};
|
||||
|
||||
const eventLabel = eventLabels[eventType] || eventType;
|
||||
|
||||
//如果用户配置了自定义模板,使用模板
|
||||
if (rule.messageTemplate) {
|
||||
return rule.messageTemplate
|
||||
.replace(/\{repo\}/g, repoName)
|
||||
.replace(/\{event\}/g, eventLabel)
|
||||
.replace(/\{title\}/g, targetTitle)
|
||||
.replace(/\{number\}/g, targetNumber)
|
||||
.replace(/\{url\}/g, targetUrl)
|
||||
.replace(/\{actor\}/g, actor)
|
||||
.replace(/\{action\}/g, action || '');
|
||||
}
|
||||
|
||||
//默认消息格式
|
||||
return `${eventLabel}\n仓库: ${repoName}\n标题: ${targetTitle}${targetNumber ? ` #${targetNumber}` : ''}\n操作者: ${actor}${targetUrl ? `\n链接: ${targetUrl}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送通知
|
||||
*/
|
||||
async function sendNotification(rule, eventType, payload) {
|
||||
const message = buildMessage(rule, eventType, payload);
|
||||
let atOpenIds = [];
|
||||
|
||||
//将 @ 用户列表转换为 open_id(支持手机号/邮箱)
|
||||
if (rule.atUsers && rule.atUsers.length > 0) {
|
||||
for (const user of rule.atUsers) {
|
||||
if (user === 'all') {
|
||||
atOpenIds.push('all');
|
||||
} else if (user.startsWith('ou_')) {
|
||||
//已经是 open_id
|
||||
atOpenIds.push(user);
|
||||
} else {
|
||||
//尝试通过手机号/邮箱获取 open_id
|
||||
try {
|
||||
const openId = await larkService.getOpenIdByContact(user);
|
||||
atOpenIds.push(openId);
|
||||
} catch (e) {
|
||||
logger.warn(`[LarkNotifier] Failed to get open_id for ${user}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (rule.channel === 'group') {
|
||||
const webhookUrl = larkService.getDefaultWebhookUrl();
|
||||
const secret = larkService.getDefaultWebhookSecret();
|
||||
|
||||
if (!webhookUrl) {
|
||||
logger.warn(`[LarkNotifier] No webhook URL configured (LARK_WEBHOOK_URL)`);
|
||||
return;
|
||||
}
|
||||
|
||||
await larkService.sendWebhookText(webhookUrl, message, secret, atOpenIds);
|
||||
logger.info(`[LarkNotifier] Sent webhook notification for ${eventType} (rule: ${rule.name})`);
|
||||
} else if (rule.channel === 'private' && rule.target) {
|
||||
//私聊 - 使用应用机器人 API
|
||||
let targetOpenId = rule.target;
|
||||
|
||||
//如果 target 不是 open_id,尝试转换
|
||||
if (!rule.target.startsWith('ou_')) {
|
||||
try {
|
||||
targetOpenId = await larkService.getOpenIdByContact(rule.target);
|
||||
} catch (e) {
|
||||
logger.error(`[LarkNotifier] Failed to get open_id for target ${rule.target}: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await larkService.sendText(targetOpenId, 'open_id', message, atOpenIds);
|
||||
logger.info(`[LarkNotifier] Sent private notification for ${eventType} to ${rule.target} (rule: ${rule.name})`);
|
||||
} else {
|
||||
logger.warn(`[LarkNotifier] Rule ${rule.name} has invalid channel/target config`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[LarkNotifier] Failed to send notification for ${eventType} (rule: ${rule.name}):`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发通知 - 主入口
|
||||
*/
|
||||
async function notify(eventType, payload) {
|
||||
let rules = larkRules.getRulesByEvent(eventType);
|
||||
|
||||
if (rules.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
//根据条件过滤规则
|
||||
rules = rules.filter(rule => {
|
||||
//如果没有配置过滤条件,则匹配所有
|
||||
if (!rule.filterKey || !rule.filterValue) return true;
|
||||
|
||||
//指派工单 - 过滤指派人
|
||||
if (eventType === 'issue.assigned') {
|
||||
const filterValue = rule.filterValue;
|
||||
|
||||
// 1. 检查当前被指派的人 (payload.issue.assignee)
|
||||
const assignedUser = payload.issue?.assignee?.username || payload.issue?.assignee?.login;
|
||||
if (assignedUser === filterValue) return true;
|
||||
|
||||
// 2. 检查指派人列表 (payload.issue.assignees)
|
||||
const assignees = payload.issue?.assignees || [];
|
||||
const isInAssignees = assignees.some(u => (u.username || u.login) === filterValue);
|
||||
if (isInAssignees) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
//标签变更 - 过滤标签名
|
||||
if (eventType === 'issue.label_updated') {
|
||||
const labels = payload.issue?.labels || [];
|
||||
const hasLabel = labels.some(l => l.name === rule.filterValue);
|
||||
return hasLabel;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (rules.length === 0) return;
|
||||
|
||||
logger.info(`[LarkNotifier] Event ${eventType} matched ${rules.length} rule(s)`);
|
||||
|
||||
//并行发送所有匹配规则的通知
|
||||
await Promise.allSettled(
|
||||
rules.map(rule => sendNotification(rule, eventType, payload))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Gitea webhook 事件头和 payload 推断事件类型
|
||||
*/
|
||||
function inferEventType(eventHeader, payload) {
|
||||
const { action, pull_request, release, issue } = payload;
|
||||
|
||||
//Pull Request 事件
|
||||
if (eventHeader === 'pull_request' || pull_request) {
|
||||
if (action === 'opened') return 'pr.opened';
|
||||
if (action === 'closed' && pull_request?.merged) return 'pr.merged';
|
||||
if (action === 'closed') return 'pr.closed';
|
||||
if (action === 'reopened') return 'pr.reopened';
|
||||
return null;
|
||||
}
|
||||
|
||||
//Release 事件
|
||||
if (eventHeader === 'release' || release) {
|
||||
if (action === 'created') return 'release.created';
|
||||
if (action === 'published') return 'release.published';
|
||||
if (action === 'deleted') return 'release.deleted';
|
||||
if (action === 'updated') return 'release.updated';
|
||||
return null;
|
||||
}
|
||||
|
||||
//Issue 事件
|
||||
if (eventHeader === 'issues' || eventHeader === 'issue_comment' || issue) {
|
||||
if (eventHeader === 'issue_comment') return 'issue.comment';
|
||||
if (action === 'opened') return 'issue.opened';
|
||||
if (action === 'closed') return 'issue.closed';
|
||||
if (action === 'reopened') return 'issue.reopened';
|
||||
if (action === 'assigned') return 'issue.assigned';
|
||||
if (action === 'unassigned') return 'issue.unassigned';
|
||||
if (action === 'edited') return 'issue.edited';
|
||||
if (action === 'label_updated') return 'issue.label_updated';
|
||||
if (action === 'milestoned' || action === 'demilestoned') return 'issue.milestoned';
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
notify,
|
||||
inferEventType,
|
||||
buildMessage,
|
||||
sendNotification
|
||||
};
|
||||
179
src/middleware/larkAuth.js
Normal file
179
src/middleware/larkAuth.js
Normal file
@@ -0,0 +1,179 @@
|
||||
const { getCookie, setCookie } = require('hono/cookie');
|
||||
const axios = require('axios');
|
||||
const config = require('../config/env');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const SESSION_COOKIE_NAME = 'issuebot_session';
|
||||
const COOKIE_SECRET = config.lark.appSecret; // 使用 appSecret 作为 cookie 签名密钥
|
||||
|
||||
// 飞书 OAuth 配置
|
||||
const AUTH_URL = 'https://accounts.feishu.cn/open-apis/authen/v1/authorize';
|
||||
const TOKEN_URL = 'https://open.feishu.cn/open-apis/authen/v2/oauth/token';
|
||||
const USER_INFO_URL = 'https://open.feishu.cn/open-apis/authen/v1/user_info';
|
||||
|
||||
/**
|
||||
* 飞书鉴权中间件
|
||||
*/
|
||||
const larkAuthMiddleware = async (c, next) => {
|
||||
const path = new URL(c.req.url).pathname;
|
||||
|
||||
// 白名单路径
|
||||
const whitelist = ['/login', '/oauth/callback', '/favicon.ico'];
|
||||
if (path.startsWith('/hooks/') || whitelist.includes(path) || path.startsWith('/assets/')) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
// 检查 Session
|
||||
const session = await getCookie(c, SESSION_COOKIE_NAME, COOKIE_SECRET);
|
||||
if (!session) {
|
||||
// 未登录,重定向到登录页
|
||||
// 如果是 API 请求,返回 401
|
||||
if (path.startsWith('/api/')) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
return c.redirect('/login');
|
||||
}
|
||||
|
||||
// 将用户信息注入 request
|
||||
const parsedSession = typeof session === 'string' ? JSON.parse(session) : session;
|
||||
c.set('user', parsedSession);
|
||||
await next();
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录处理:重定向到飞书授权页
|
||||
*/
|
||||
const loginHandler = (c) => {
|
||||
if (!config.lark.redirectUri) {
|
||||
return c.text('Configuration Error: LARK_REDIRECT_URI is missing', 500);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.lark.appId,
|
||||
redirect_uri: config.lark.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'contact:user.base:readonly' // 基础权限,即可获取 Open ID
|
||||
});
|
||||
|
||||
return c.redirect(`${AUTH_URL}?${params.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* OAuth 回调处理
|
||||
*/
|
||||
const callbackHandler = async (c) => {
|
||||
const code = c.req.query('code');
|
||||
const error = c.req.query('error');
|
||||
|
||||
if (error) {
|
||||
logger.error(`[LarkAuth] Auth failed: ${error}`);
|
||||
return c.text(`Authentication failed: ${error}`, 403);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return c.text('Missing authorization code', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 获取 user_access_token
|
||||
const tokenRes = await axios.post(TOKEN_URL, {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: config.lark.appId,
|
||||
client_secret: config.lark.appSecret,
|
||||
code: code,
|
||||
redirect_uri: config.lark.redirectUri
|
||||
});
|
||||
|
||||
const tokenData = tokenRes.data;
|
||||
if (tokenData.code !== 0) {
|
||||
throw new Error(`Token request failed: ${tokenData.msg || tokenData.error_description}`);
|
||||
}
|
||||
|
||||
const accessToken = tokenData.access_token;
|
||||
|
||||
// 2. 获取用户信息
|
||||
const userRes = await axios.get(USER_INFO_URL, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
});
|
||||
|
||||
const userData = userRes.data;
|
||||
if (userData.code !== 0) {
|
||||
throw new Error(`User info request failed: ${userData.msg}`);
|
||||
}
|
||||
|
||||
// 检查是否为管理员
|
||||
const openId = userData.data.open_id;
|
||||
const isAdmin = config.lark.adminIds.includes(openId);
|
||||
|
||||
// 3. 创建 Session
|
||||
const user = {
|
||||
name: userData.data.name,
|
||||
open_id: openId,
|
||||
avatar: userData.data.avatar_url,
|
||||
isAdmin: isAdmin,
|
||||
loggedInAt: Date.now()
|
||||
};
|
||||
|
||||
// 设置 Cookie
|
||||
await setCookie(c, SESSION_COOKIE_NAME, JSON.stringify(user), {
|
||||
httpOnly: true,
|
||||
secure: false, // 本地开发可能不是 HTTPS,如果上生产建议由反代处理或根据环境设置
|
||||
maxAge: 7 * 24 * 60 * 60, // 7天
|
||||
secret: COOKIE_SECRET
|
||||
});
|
||||
|
||||
logger.info(`[LarkAuth] User logged in: ${user.name} (${user.open_id}), Admin: ${isAdmin}`);
|
||||
if (!isAdmin) {
|
||||
logger.info(`[LarkAuth] To make this user admin, add their Open ID to .env: ADMIN_IDS=${user.open_id}`);
|
||||
}
|
||||
|
||||
return c.redirect('/dashboard');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[LarkAuth] Callback error:', error.message);
|
||||
return c.text(`Login failed: ${error.message}`, 500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 管理员鉴权中间件
|
||||
*/
|
||||
const adminMiddleware = async (c, next) => {
|
||||
const session = await getCookie(c, SESSION_COOKIE_NAME, COOKIE_SECRET);
|
||||
|
||||
// 如果 Session 中没有 isAdmin 字段(旧Session),默认为 false
|
||||
const parsedSession = typeof session === 'string' ? JSON.parse(session) : session;
|
||||
const isAdmin = parsedSession && parsedSession.isAdmin;
|
||||
|
||||
if (!isAdmin) {
|
||||
logger.security(`Blocked non-admin access to ${c.req.url}`, { user: parsedSession?.name });
|
||||
return c.json({ error: 'Forbidden: Admin access only' }, 403);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前用户信息 API
|
||||
*/
|
||||
const meHandler = async (c) => {
|
||||
const session = await getCookie(c, SESSION_COOKIE_NAME, COOKIE_SECRET);
|
||||
if (!session) {
|
||||
return c.json({ loggedIn: false });
|
||||
}
|
||||
const parsedSession = typeof session === 'string' ? JSON.parse(session) : session;
|
||||
return c.json({
|
||||
loggedIn: true,
|
||||
name: parsedSession.name,
|
||||
avatar: parsedSession.avatar,
|
||||
isAdmin: !!parsedSession.isAdmin
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
larkAuthMiddleware,
|
||||
loginHandler,
|
||||
callbackHandler,
|
||||
adminMiddleware,
|
||||
meHandler
|
||||
};
|
||||
@@ -8,10 +8,12 @@ const axios = require('axios');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../utils/logger');
|
||||
const config = require('../config/env');
|
||||
const larkService = require('../services/lark');
|
||||
|
||||
const control = new Hono();
|
||||
|
||||
const MAPPINGS_PATH = path.join(__dirname, '../../mappings.json');
|
||||
const MAPPINGS_PATH = path.join(__dirname, '../../data/mappings.json');
|
||||
const LOGS_DIR = path.join(__dirname, '../../logs');
|
||||
const README_PATH = path.join(__dirname, '../../how-to-use.md');
|
||||
|
||||
@@ -536,4 +538,138 @@ control.post('/proxy-jira', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 飞书提醒规则 API ====================
|
||||
const larkRules = require('../config/larkRules');
|
||||
|
||||
//获取所有规则
|
||||
control.get('/lark/rules', (c) => {
|
||||
try {
|
||||
const rules = larkRules.getAllRules();
|
||||
return c.json({ success: true, rules });
|
||||
} catch (e) {
|
||||
logger.error('[Lark] Get rules error:', e.message);
|
||||
return c.json({ success: false, error: e.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
//获取支持的事件类型
|
||||
control.get('/lark/events', (c) => {
|
||||
return c.json({ success: true, events: larkRules.EVENT_TYPES });
|
||||
});
|
||||
|
||||
//创建规则
|
||||
control.post('/lark/rules', async (c) => {
|
||||
try {
|
||||
const rule = await c.req.json();
|
||||
const created = larkRules.createRule(rule);
|
||||
return c.json({ success: true, rule: created });
|
||||
} catch (e) {
|
||||
logger.error('[Lark] Create rule error:', e.message);
|
||||
return c.json({ success: false, error: e.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
//更新规则
|
||||
control.put('/lark/rules/:id', async (c) => {
|
||||
try {
|
||||
const id = c.req.param('id');
|
||||
const updates = await c.req.json();
|
||||
const updated = larkRules.updateRule(id, updates);
|
||||
return c.json({ success: true, rule: updated });
|
||||
} catch (e) {
|
||||
logger.error('[Lark] Update rule error:', e.message);
|
||||
return c.json({ success: false, error: e.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
//删除规则
|
||||
control.delete('/lark/rules/:id', (c) => {
|
||||
try {
|
||||
const id = c.req.param('id');
|
||||
larkRules.deleteRule(id);
|
||||
return c.json({ success: true });
|
||||
} catch (e) {
|
||||
logger.error('[Lark] Delete rule error:', e.message);
|
||||
return c.json({ success: false, error: e.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
//测试发送
|
||||
control.post('/lark/test', async (c) => {
|
||||
try {
|
||||
const { channel, target, atUsers, message } = await c.req.json();
|
||||
const testMessage = message || '这是一条测试消息\n来自 Gitea-Jira 同步机器人';
|
||||
|
||||
//处理 @ 用户列表(支持手机号/邮箱)
|
||||
let atOpenIds = [];
|
||||
if (atUsers && atUsers.length > 0) {
|
||||
for (const user of atUsers) {
|
||||
if (user === 'all' || user.startsWith('ou_')) {
|
||||
atOpenIds.push(user);
|
||||
} else {
|
||||
try {
|
||||
const openId = await larkService.getOpenIdByContact(user);
|
||||
atOpenIds.push(openId);
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: `无法获取用户 ${user} 的 open_id: ${e.message}` }, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (channel === 'group') {
|
||||
const webhookUrl = larkService.getDefaultWebhookUrl();
|
||||
const secret = larkService.getDefaultWebhookSecret();
|
||||
if (!webhookUrl) {
|
||||
return c.json({ success: false, error: '请在环境变量中配置 LARK_WEBHOOK_URL' }, 400);
|
||||
}
|
||||
await larkService.sendWebhookText(webhookUrl, testMessage, secret, atOpenIds);
|
||||
} else if (channel === 'private' && target) {
|
||||
let targetOpenId = target;
|
||||
if (!target.startsWith('ou_')) {
|
||||
try {
|
||||
targetOpenId = await larkService.getOpenIdByContact(target);
|
||||
} catch (e) {
|
||||
return c.json({ success: false, error: `无法获取目标用户 ${target} 的 open_id: ${e.message}` }, 400);
|
||||
}
|
||||
}
|
||||
await larkService.sendText(targetOpenId, 'open_id', testMessage, atOpenIds);
|
||||
} else {
|
||||
return c.json({ success: false, error: '请选择正确的渠道' }, 400);
|
||||
}
|
||||
|
||||
return c.json({ success: true, message: '测试消息发送成功' });
|
||||
} catch (e) {
|
||||
logger.error('[Lark] Test send error:', e.message);
|
||||
return c.json({ success: false, error: e.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// 申请管理员权限
|
||||
control.post('/request-permission', async (c) => {
|
||||
try {
|
||||
//从 Hono context 中获取已登录用户(由鉴权中间件注入)
|
||||
const user = c.get('user');
|
||||
if (!user || !user.open_id) {
|
||||
return c.json({ success: false, error: '未获取到用户信息,请重新登录' }, 401);
|
||||
}
|
||||
|
||||
const adminIds = config.lark.adminIds;
|
||||
if (!adminIds || adminIds.length === 0) {
|
||||
return c.json({ success: false, error: '系统未配置管理员,无法发送请求' }, 500);
|
||||
}
|
||||
|
||||
const targetAdmin = adminIds[0]; //默认发给第一位管理员
|
||||
const message = `@${user.name} 正在申请机器人面板管理员权限\nopenid:${user.open_id}`;
|
||||
|
||||
await larkService.sendText(targetAdmin, 'open_id', message);
|
||||
|
||||
return c.json({ success: true, message: '申请已发送,请等待管理员处理' });
|
||||
} catch (e) {
|
||||
logger.error('[Control] Request permission error:', e.message);
|
||||
return c.json({ success: false, error: `发送失败: ${e.message}` }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = control;
|
||||
|
||||
|
||||
266
src/services/lark.js
Normal file
266
src/services/lark.js
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 飞书消息服务
|
||||
* 支持应用机器人(私聊/群聊)和自定义机器人(群聊 webhook)
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const config = require('../config/env');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
let accessToken = null;
|
||||
let tokenExpireAt = 0;
|
||||
|
||||
/**
|
||||
* 获取应用机器人的 access_token(用于私聊)
|
||||
*/
|
||||
async function getAccessToken() {
|
||||
const { appId, appSecret } = config.lark;
|
||||
|
||||
if (!appId || !appSecret) {
|
||||
throw new Error('飞书应用未配置:请设置 LARK_APP_ID 和 LARK_APP_SECRET');
|
||||
}
|
||||
|
||||
//如果 token 还有效,直接返回
|
||||
if (accessToken && Date.now() < tokenExpireAt - 60000) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ app_id: appId, app_secret: appSecret })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`获取飞书 access_token 失败: ${data.msg}`);
|
||||
}
|
||||
|
||||
accessToken = data.tenant_access_token;
|
||||
tokenExpireAt = Date.now() + data.expire * 1000;
|
||||
|
||||
logger.info('[Lark] Access token refreshed');
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名(用于自定义机器人 webhook)
|
||||
*/
|
||||
function genSign(timestamp, secret) {
|
||||
const stringToSign = `${timestamp}\n${secret}`;
|
||||
const hmac = crypto.createHmac('sha256', stringToSign);
|
||||
return hmac.digest('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过自定义机器人 webhook 发送消息(群聊)
|
||||
*/
|
||||
async function sendWebhook(webhookUrl, message, secret = null) {
|
||||
const body = { ...message };
|
||||
|
||||
if (secret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
body.timestamp = timestamp;
|
||||
body.sign = genSign(timestamp, secret);
|
||||
}
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`飞书 webhook 发送失败: ${data.msg || JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过应用机器人 API 发送消息(私聊/群聊)
|
||||
* @param {string} receiveId - 接收者 ID(open_id/user_id/chat_id)
|
||||
* @param {string} receiveIdType - ID 类型:open_id | user_id | union_id | email | chat_id
|
||||
* @param {string} msgType - 消息类型:text | post | interactive
|
||||
* @param {object} content - 消息内容
|
||||
*/
|
||||
async function sendMessage(receiveId, receiveIdType, msgType, content) {
|
||||
const token = await getAccessToken();
|
||||
|
||||
const res = await fetch(`https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
receive_id: receiveId,
|
||||
msg_type: msgType,
|
||||
content: typeof content === 'string' ? content : JSON.stringify(content)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`飞书消息发送失败: ${data.msg || JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本消息(便捷方法)
|
||||
*/
|
||||
async function sendText(receiveId, receiveIdType, text, atUsers = []) {
|
||||
let finalText = text;
|
||||
|
||||
//添加 @ 用户
|
||||
if (atUsers && atUsers.length > 0) {
|
||||
const atTags = atUsers.map(uid =>
|
||||
uid === 'all' ? '<at user_id="all">所有人</at>' : `<at user_id="${uid}"></at>`
|
||||
).join(' ');
|
||||
finalText = `${atTags} ${text}`;
|
||||
}
|
||||
|
||||
return sendMessage(receiveId, receiveIdType, 'text', { text: finalText });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送富文本消息
|
||||
*/
|
||||
async function sendPost(receiveId, receiveIdType, title, content, atUsers = []) {
|
||||
const postContent = {
|
||||
zh_cn: {
|
||||
title,
|
||||
content: [[]]
|
||||
}
|
||||
};
|
||||
|
||||
//添加 @ 用户
|
||||
if (atUsers && atUsers.length > 0) {
|
||||
atUsers.forEach(uid => {
|
||||
postContent.zh_cn.content[0].push({
|
||||
tag: 'at',
|
||||
user_id: uid
|
||||
});
|
||||
});
|
||||
postContent.zh_cn.content[0].push({ tag: 'text', text: ' ' });
|
||||
}
|
||||
|
||||
//添加正文
|
||||
postContent.zh_cn.content[0].push({ tag: 'text', text: content });
|
||||
|
||||
return sendMessage(receiveId, receiveIdType, 'post', { post: postContent });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 webhook 文本消息(便捷方法)
|
||||
*/
|
||||
async function sendWebhookText(webhookUrl, text, secret = null, atUsers = []) {
|
||||
let finalText = text;
|
||||
|
||||
if (atUsers && atUsers.length > 0) {
|
||||
const atTags = atUsers.map(uid =>
|
||||
uid === 'all' ? '<at user_id="all">所有人</at>' : `<at user_id="${uid}"></at>`
|
||||
).join(' ');
|
||||
finalText = `${atTags} ${text}`;
|
||||
}
|
||||
|
||||
return sendWebhook(webhookUrl, {
|
||||
msg_type: 'text',
|
||||
content: { text: finalText }
|
||||
}, secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 webhook 富文本消息
|
||||
*/
|
||||
async function sendWebhookPost(webhookUrl, title, content, secret = null, atUsers = []) {
|
||||
const postContent = [[{ tag: 'text', text: content }]];
|
||||
|
||||
if (atUsers && atUsers.length > 0) {
|
||||
const atElements = atUsers.map(uid => ({
|
||||
tag: 'at',
|
||||
user_id: uid
|
||||
}));
|
||||
postContent[0] = [...atElements, { tag: 'text', text: ' ' }, ...postContent[0]];
|
||||
}
|
||||
|
||||
return sendWebhook(webhookUrl, {
|
||||
msg_type: 'post',
|
||||
content: {
|
||||
post: {
|
||||
zh_cn: { title, content: postContent }
|
||||
}
|
||||
}
|
||||
}, secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过手机号或邮箱获取用户 open_id
|
||||
* @param {string} identifier - 手机号或邮箱
|
||||
* @returns {string} open_id
|
||||
*/
|
||||
async function getOpenIdByContact(identifier) {
|
||||
const token = await getAccessToken();
|
||||
|
||||
const isEmail = identifier.includes('@');
|
||||
const payload = isEmail
|
||||
? { emails: [identifier] }
|
||||
: { mobiles: [identifier] };
|
||||
|
||||
const res = await fetch('https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id?user_id_type=open_id', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new Error(`获取用户 open_id 失败: ${data.msg}`);
|
||||
}
|
||||
|
||||
const userList = data.data?.user_list || [];
|
||||
if (userList.length === 0 || !userList[0].user_id) {
|
||||
throw new Error(`未找到用户: ${identifier}`);
|
||||
}
|
||||
|
||||
return userList[0].user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置的默认 webhook URL
|
||||
*/
|
||||
function getDefaultWebhookUrl() {
|
||||
return config.lark.webhookUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置的默认 webhook secret
|
||||
*/
|
||||
function getDefaultWebhookSecret() {
|
||||
return config.lark.webhookSecret || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAccessToken,
|
||||
genSign,
|
||||
sendWebhook,
|
||||
sendMessage,
|
||||
sendText,
|
||||
sendPost,
|
||||
sendWebhookText,
|
||||
sendWebhookPost,
|
||||
getOpenIdByContact,
|
||||
getDefaultWebhookUrl,
|
||||
getDefaultWebhookSecret
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user