Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfebcee886 | ||
|
|
9b656cebee | ||
|
|
0d89b7fe8f | ||
|
|
360169ee64 | ||
|
|
59cf99ae22 |
@@ -33,3 +33,5 @@ node_modules/
|
||||
# 前端构建产物不入库,仅保留 .gitkeep 以满足 go:embed
|
||||
web/dist/*
|
||||
!web/dist/.gitkeep
|
||||
|
||||
art/
|
||||
@@ -21,6 +21,8 @@
|
||||
* 发送钉钉机器人消息
|
||||
* 公开 GitHub 仓库
|
||||
* **账号 (Account)**:集中管理第三方凭据,支持连通性测试,敏感字段(密码/Cookie/Token)不会回显
|
||||
* **消息网关 (Gateway)**:为外部系统生成 Webhook 地址,投递进来的消息会触发绑定在该网关上的「网关规则」
|
||||
* **网关规则**:与定时器规则分开存储、分开管理,规则里的消息字段(msg / message / body)会被投递内容替换
|
||||
* **执行日志**:每次规则执行与提醒都有记录,规则支持手动测试
|
||||
* **配置测试**:账号可一键测试连通性;规则可手动触发验证
|
||||
|
||||
@@ -101,6 +103,20 @@ cd web/frontend && npm run dev
|
||||
|
||||
**第一个注册的用户自动成为管理员**。
|
||||
|
||||
### API Key
|
||||
|
||||
每个用户在注册时会自动生成 API Key,可在 WebUI 的“设置”页面查看。调用需要认证的 API 时,在请求头中任选其一:
|
||||
|
||||
```http
|
||||
X-API-Key: gb_<your-api-key>
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```http
|
||||
Authorization: Bearer gb_<your-api-key>
|
||||
```
|
||||
|
||||
## 数据库
|
||||
|
||||
默认 SQLite,可选 PostgreSQL,详见 [docs/database.md](docs/database.md)。
|
||||
@@ -120,12 +136,25 @@ cd web/frontend && npm run dev
|
||||
* [钉钉机器人](docs/dingtalk-config.md)
|
||||
* [GitHub](docs/github-config.md)
|
||||
* [饭碗警告](docs/fwalert-config.md)
|
||||
* [消息网关](docs/gateway-config.md)
|
||||
|
||||
## 开发:新增一种规则类型
|
||||
|
||||
driver 只负责把动作做出去:规则本体与关联账号由 `handler/runner` 组装成 `model.RuleTask` 后传进来,
|
||||
driver 内部不访问数据库、不写日志,需要账号凭据时从 `task.Account` 里解析。
|
||||
|
||||
1. 在 `drivers/<name>/` 下实现:
|
||||
* `tool.go`:解析规则 / 账号配置的 `ParseXxx` 函数
|
||||
* 规则验证器(实现 `ruleConfigChecker.RuleValidator`,`Meta()` 返回表单元数据)
|
||||
* 执行器(实现 `runner.RuleExecutor`)
|
||||
* 执行器(实现 `runner.RuleExecutor`,方法签名为 `Execute(ctx, *model.RuleTask)`)
|
||||
* 对外动作(底层函数)保持原子:一次调用只做一件事,用 `retry.Do(ctx, ...)` 包裹重试
|
||||
* 如需第三方凭据,再实现账号验证器(`accountConfigChecker.AccountValidator`,可选实现 `AccountTester` 支持连通性测试)
|
||||
2. 在 `internal/ruleConfigChecker/reg.go`、`internal/accountConfigChecker/reg.go`、`handler/runner/interface.go` 中注册
|
||||
2. 在 `internal/ruleConfigChecker/reg.go`、`internal/accountConfigChecker/reg.go`、`handler/runner/reg.go` 中注册
|
||||
3. 前端无需改动 —— WebUI 会根据 `Meta()` 返回的字段描述自动渲染配置表单
|
||||
|
||||
## 开发:新增一种消息网关
|
||||
|
||||
1. 在 `internal/gateway/` 下实现 `Gateway` 接口(`GetType()` / `Meta()` / `Deliver()`),
|
||||
`Deliver` 拿到的 `Task` 里已经包含网关与待触发的规则,实现里不访问数据库
|
||||
2. 在 `internal/gateway/reg.go` 中注册
|
||||
3. 前端无需改动 —— 新建网关时的类型选项与说明来自 `Meta()`
|
||||
|
||||
@@ -181,7 +181,7 @@ func HandleTestAccount(c *gin.Context) {
|
||||
|
||||
// HandleCheckDeleteAccount 检查删除账号请求
|
||||
//
|
||||
// 删除账号会同时删除相关规则,先请求该接口获取受影响的规则
|
||||
// 删除账号会同时删除相关规则(含消息网关规则),先请求该接口获取受影响的规则
|
||||
func HandleCheckDeleteAccount(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
@@ -201,7 +201,16 @@ func HandleCheckDeleteAccount(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, rules)
|
||||
gatewayRules, err := getGatewayRulesByAccountID(accountID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取相关网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"rules": rules,
|
||||
"gateway_rules": gatewayRules,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleDeleteAccount 删除账号及其关联规则
|
||||
@@ -218,6 +227,18 @@ func HandleDeleteAccount(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查账号是否归属请求用户
|
||||
ownerUID, err := getAccountOwnerUID(accountID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取账号所属用户失败")
|
||||
return
|
||||
}
|
||||
|
||||
if ownerUID != userInfo.ID {
|
||||
response.Forbidden(c, "无权限操作该账号")
|
||||
return
|
||||
}
|
||||
|
||||
// 删除相关规则
|
||||
rules, err := getRulesByAccountID(accountID, userInfo.ID)
|
||||
if err != nil {
|
||||
@@ -231,6 +252,19 @@ func HandleDeleteAccount(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 删除相关的消息网关规则
|
||||
gatewayRules, err := getGatewayRulesByAccountID(accountID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取相关网关规则失败")
|
||||
return
|
||||
}
|
||||
for _, oneRule := range gatewayRules {
|
||||
if err := rule.DeleteGatewayRuleByID(oneRule.ID, userInfo.ID); err != nil {
|
||||
response.ServerError(c, "删除相关网关规则失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 删除账号
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
|
||||
@@ -45,6 +45,20 @@ func getRulesByAccountID(accountID uint, uid uint) ([]*model.Rule, error) {
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// getGatewayRulesByAccountID 获取账号相关的消息网关规则列表
|
||||
func getGatewayRulesByAccountID(accountID uint, uid uint) ([]*model.GatewayRule, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := []*model.GatewayRule{}
|
||||
if err := gormDB.Where("account_id = ? AND uid = ?", accountID, uid).Find(&rules).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// maskAccount 掩码账号配置中的敏感字段,避免 Cookie/密码原样返回给前端
|
||||
func maskAccount(account model.Account) model.Account {
|
||||
fields := accountConfigChecker.InitValidatorRegistry().FieldsOf(account.Type)
|
||||
@@ -59,3 +73,17 @@ func maskAccounts(accounts []model.Account) []model.Account {
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
// getAccountOwnerUID 获取账号所属用户的 UID
|
||||
func getAccountOwnerUID(accountID uint) (uint, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
if err := gormDB.Select("uid").Where("id = ?", accountID).First(&account).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return account.UID, nil
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ func HandleGetOverview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var ruleCount, accountCount int64
|
||||
var ruleCount, gatewayCount, gatewayRuleCount, accountCount int64
|
||||
gormDB.Model(&model.Rule{}).Where("uid = ?", userInfo.ID).Count(&ruleCount)
|
||||
gormDB.Model(&model.MessageGateway{}).Where("uid = ?", userInfo.ID).Count(&gatewayCount)
|
||||
gormDB.Model(&model.GatewayRule{}).Where("uid = ?", userInfo.ID).Count(&gatewayRuleCount)
|
||||
gormDB.Model(&model.Account{}).Where("uid = ?", userInfo.ID).Count(&accountCount)
|
||||
|
||||
recentLogs := []model.ExecutionLog{}
|
||||
@@ -62,13 +64,15 @@ func HandleGetOverview(c *gin.Context) {
|
||||
}
|
||||
|
||||
overview := gin.H{
|
||||
"timer_count": len(timers),
|
||||
"enabled_timers": enabledCount,
|
||||
"triggered_count": triggeredCount,
|
||||
"rule_count": ruleCount,
|
||||
"account_count": accountCount,
|
||||
"server_time": now,
|
||||
"recent_logs": recentLogs,
|
||||
"timer_count": len(timers),
|
||||
"enabled_timers": enabledCount,
|
||||
"triggered_count": triggeredCount,
|
||||
"rule_count": ruleCount,
|
||||
"gateway_count": gatewayCount,
|
||||
"gateway_rule_count": gatewayRuleCount,
|
||||
"account_count": accountCount,
|
||||
"server_time": now,
|
||||
"recent_logs": recentLogs,
|
||||
}
|
||||
|
||||
if urgent != nil {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package gateway 提供消息网关的 HTTP 接口。
|
||||
//
|
||||
// 数据库读写都发生在这一层,真正的投递逻辑在 internal/gateway 里,
|
||||
// 这里只负责鉴权、取数、组装投递任务。
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ssdomei232/goodBaby/api/response"
|
||||
"github.com/ssdomei232/goodBaby/api/user"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
gatewaycore "github.com/ssdomei232/goodBaby/internal/gateway"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// webhookRequest 外部系统投递消息的请求体
|
||||
type webhookRequest struct {
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// HandleList 获取当前用户的所有消息网关
|
||||
func HandleList(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
items := []model.MessageGateway{}
|
||||
if err := dbConn.Where("uid = ?", userInfo.ID).Order("id DESC").Find(&items).Error; err != nil {
|
||||
response.ServerError(c, "获取网关失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, items)
|
||||
}
|
||||
|
||||
// HandleCreate 创建消息网关
|
||||
func HandleCreate(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.MessageGatewayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "输入参数错误")
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if err := req.Validate(); err != nil {
|
||||
response.FromError(c, err, "创建网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 不填类型时用默认网关,填了就必须是已注册的类型
|
||||
if req.Type == "" {
|
||||
req.Type = model.GatewayTypeWebhook
|
||||
}
|
||||
if _, ok := gatewaycore.InitGatewayRegistry().Resolve(req.Type); !ok {
|
||||
response.BadRequest(c, "不支持的消息网关类型: "+req.Type)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := gatewaycore.NewToken()
|
||||
if err != nil {
|
||||
response.ServerError(c, "生成网关 Token 失败")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "创建网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
item := model.MessageGateway{
|
||||
UID: userInfo.ID,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Token: token,
|
||||
CreateAt: time.Now().Unix(),
|
||||
}
|
||||
if err := dbConn.Create(&item).Error; err != nil {
|
||||
response.ServerError(c, "创建网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
// HandleDelete 删除消息网关,绑定在它下面的规则一并删除
|
||||
func HandleDelete(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
|
||||
gatewayID, err := parseID(c.Param("gatewayID"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "网关 ID 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "删除网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
err = dbConn.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("gateway_id = ? AND uid = ?", gatewayID, userInfo.ID).
|
||||
Delete(&model.GatewayRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ? AND uid = ?", gatewayID, userInfo.ID).
|
||||
Delete(&model.MessageGateway{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "删除网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, "网关已删除")
|
||||
}
|
||||
|
||||
// HandleWebhook 接收外部系统投递的消息,触发绑定在该网关上的规则
|
||||
func HandleWebhook(c *gin.Context) {
|
||||
var req webhookRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Message) == "" {
|
||||
response.BadRequest(c, "message 不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "网关不可用")
|
||||
return
|
||||
}
|
||||
|
||||
var target model.MessageGateway
|
||||
if err := dbConn.Where("token = ?", c.Param("token")).First(&target).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, "网关不存在")
|
||||
return
|
||||
}
|
||||
|
||||
rules := []model.GatewayRule{}
|
||||
if err := dbConn.Where("uid = ? AND gateway_id = ? AND enabled = ?", target.UID, target.ID, true).
|
||||
Find(&rules).Error; err != nil {
|
||||
response.ServerError(c, "读取网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 投递需要在 HTTP 请求内返回结果,因此用较短的超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := gatewaycore.InitGatewayRegistry().Deliver(ctx, target.Type, &gatewaycore.Task{
|
||||
Gateway: &target,
|
||||
Rules: rules,
|
||||
Message: gatewaycore.Message{
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Content: strings.TrimSpace(req.Message),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, result)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package gateway
|
||||
|
||||
import "strconv"
|
||||
|
||||
// parseID 解析 URL 里的数字 ID
|
||||
func parseID(raw string) (uint, error) {
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
@@ -37,6 +37,9 @@ func HandleGetLogs(c *gin.Context) {
|
||||
if ruleID := c.Query("rule_id"); ruleID != "" {
|
||||
query = query.Where("rule_id = ?", ruleID)
|
||||
}
|
||||
if gatewayRuleID := c.Query("gateway_rule_id"); gatewayRuleID != "" {
|
||||
query = query.Where("gateway_rule_id = ?", gatewayRuleID)
|
||||
}
|
||||
if success := c.Query("success"); success == "true" || success == "false" {
|
||||
query = query.Where("success = ?", success == "true")
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@ import (
|
||||
"github.com/ssdomei232/goodBaby/api/user"
|
||||
"github.com/ssdomei232/goodBaby/configs"
|
||||
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
|
||||
"github.com/ssdomei232/goodBaby/internal/gateway"
|
||||
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
|
||||
)
|
||||
|
||||
// HandleGetProviders 返回所有账号类型与规则类型的元数据
|
||||
// HandleGetProviders 返回所有账号类型、规则类型与消息网关类型的元数据
|
||||
//
|
||||
// 前端据此动态渲染配置表单,新增驱动时前端无需改动。
|
||||
func HandleGetProviders(c *gin.Context) {
|
||||
response.OK(c, gin.H{
|
||||
"accounts": accountConfigChecker.InitValidatorRegistry().Metas(),
|
||||
"rules": ruleConfigChecker.InitValidatorRegistry().Metas(),
|
||||
"gateways": gateway.InitGatewayRegistry().Metas(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ func NotFound(c *gin.Context, msg string) { Fail(c, http.StatusNotFound, msg) }
|
||||
// ServerError 服务端错误
|
||||
func ServerError(c *gin.Context, msg string) { Fail(c, http.StatusInternalServerError, msg) }
|
||||
|
||||
// Forbidden 无权限
|
||||
func Forbidden(c *gin.Context, msg string) { Fail(c, http.StatusForbidden, msg) }
|
||||
|
||||
// FromError 按错误类型选择合适的状态码:
|
||||
// 参数校验错误返回 400,其余返回 500
|
||||
func FromError(c *gin.Context, err error, fallback string) {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ssdomei232/goodBaby/api/response"
|
||||
"github.com/ssdomei232/goodBaby/api/user"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/handler/runner"
|
||||
"github.com/ssdomei232/goodBaby/internal/gateway"
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// HandleGetAllGatewayRules 获取用户的消息网关规则,支持按 gateway_id 过滤
|
||||
//
|
||||
// 消息网关规则和定时器规则是两张表,互不影响,因此这里单独一组接口。
|
||||
func HandleGetAllGatewayRules(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "服务器内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
query := gormDB.Where("uid = ?", userInfo.ID)
|
||||
if raw := c.Query("gateway_id"); raw != "" {
|
||||
gatewayID, err := parseID(raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "gateway_id 格式错误")
|
||||
return
|
||||
}
|
||||
query = query.Where("gateway_id = ?", gatewayID)
|
||||
}
|
||||
|
||||
rules := []model.GatewayRule{}
|
||||
if err := query.Order("id DESC").Find(&rules).Error; err != nil {
|
||||
response.ServerError(c, "获取网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, maskGatewayRules(rules))
|
||||
}
|
||||
|
||||
// HandleCreateGatewayRule 创建消息网关规则
|
||||
func HandleCreateGatewayRule(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.GatewayRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "输入参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
newRule := model.GatewayRule{
|
||||
UID: userInfo.ID,
|
||||
Name: req.Name,
|
||||
GatewayID: req.GatewayID,
|
||||
AccountID: req.AccountID,
|
||||
Type: req.Type,
|
||||
ConfigJson: req.ConfigJson,
|
||||
Enabled: boolOr(req.Enabled, true),
|
||||
CreateAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if err := validateGatewayRule(&req, &newRule); err != nil {
|
||||
response.FromError(c, err, "创建网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "服务器内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := gormDB.Create(&newRule).Error; err != nil {
|
||||
response.ServerError(c, "创建网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, maskGatewayRule(newRule))
|
||||
}
|
||||
|
||||
// HandleEditGatewayRule 编辑消息网关规则
|
||||
func HandleEditGatewayRule(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
ruleID, err := parseID(c.Param("ruleID"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "规则 ID 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := findGatewayRule(ruleID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "网关规则不存在")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.GatewayRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "输入参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
updated := *existing
|
||||
updated.Name = req.Name
|
||||
updated.Type = req.Type
|
||||
updated.GatewayID = req.GatewayID
|
||||
updated.AccountID = req.AccountID
|
||||
updated.Enabled = boolOr(req.Enabled, existing.Enabled)
|
||||
// 前端提交的敏感字段可能是掩码占位符,用旧配置补回
|
||||
updated.ConfigJson = unmaskRuleConfig(req.Type, req.ConfigJson, existing.ConfigJson)
|
||||
|
||||
req.ConfigJson = updated.ConfigJson
|
||||
if err := validateGatewayRule(&req, &updated); err != nil {
|
||||
response.FromError(c, err, "更新网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "服务器内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := gormDB.Save(&updated).Error; err != nil {
|
||||
response.ServerError(c, "更新网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, maskGatewayRule(updated))
|
||||
}
|
||||
|
||||
// HandleDeleteGatewayRule 删除消息网关规则
|
||||
func HandleDeleteGatewayRule(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
ruleID, err := parseID(c.Param("ruleID"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "规则 ID 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := findGatewayRule(ruleID, userInfo.ID); err != nil {
|
||||
response.NotFound(c, "网关规则不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := DeleteGatewayRuleByID(ruleID, userInfo.ID); err != nil {
|
||||
response.ServerError(c, "删除网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, "网关规则删除成功")
|
||||
}
|
||||
|
||||
// HandleTestGatewayRule 立即执行一次网关规则,用于验证配置
|
||||
//
|
||||
// 与投递不同:这里用规则里保存的配置,不覆盖外部消息。
|
||||
func HandleTestGatewayRule(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
ruleID, err := parseID(c.Param("ruleID"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "规则 ID 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := findGatewayRule(ruleID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "网关规则不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 用一条示例消息走一次真实的投递覆盖,让用户看到规则最终会发出什么内容
|
||||
if configJSON, applied, err := gateway.ApplyMessage(target.ConfigJson, gateway.TestMessage); err == nil && applied {
|
||||
target.ConfigJson = configJSON
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := runner.ExecuteGatewayRuleWithContext(ctx, target, model.TriggerManual); err != nil {
|
||||
response.BadRequest(c, fmt.Sprintf("规则执行失败: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, "规则执行成功")
|
||||
}
|
||||
|
||||
// validateGatewayRule 校验网关规则的通用字段、关联对象与类型专属配置
|
||||
func validateGatewayRule(req *model.GatewayRuleRequest, rule *model.GatewayRule) error {
|
||||
if err := req.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := checkGatewayExists(rule.GatewayID, rule.UID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleMeta, err := ruleMetaOf(req.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 标题/内容由投递请求提供,页面不填,这里补占位值后再校验
|
||||
rule.ConfigJson = meta.FillGatewayMessages(rule.ConfigJson, ruleMeta.Fields)
|
||||
if err := validateRuleConfig(req.Type, rule.ConfigJson); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountID, err := checkAccount(rule.AccountID, rule.UID, ruleMeta.AccountType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rule.AccountID = accountID
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -162,6 +162,16 @@ func HandleDeleteRule(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ownerUID, err := getRuleOwnerUID(ruleID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取规则所属用户失败")
|
||||
return
|
||||
}
|
||||
if ownerUID != userInfo.ID {
|
||||
response.Forbidden(c, "无权限操作该规则")
|
||||
return
|
||||
}
|
||||
|
||||
if err := DeleteRuleByID(ruleID, userInfo.ID); err != nil {
|
||||
response.ServerError(c, "删除规则失败")
|
||||
return
|
||||
@@ -209,19 +219,24 @@ func validateRule(req *model.RuleRequest, rule *model.Rule) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(req.Type)
|
||||
if !ok {
|
||||
return model.ErrValidation(fmt.Sprintf("不支持的规则类型: %s", req.Type))
|
||||
}
|
||||
|
||||
// 检查关联的 Timer 与账号是否存在且属于当前用户
|
||||
if err := checkRuleConfigAccountAndTimerExist(rule, ruleMeta.AccountType); err != nil {
|
||||
// 检查关联的 Timer 是否存在、账号是否可用
|
||||
if err := checkTimerExists(rule.TimerID, rule.UID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ruleConfigChecker.InitValidatorRegistry().Validate(req.Type, rule.ConfigJson); err != nil {
|
||||
return model.ErrValidation(fmt.Sprintf("规则配置验证失败: %s", err.Error()))
|
||||
ruleMeta, err := ruleMetaOf(req.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRuleConfig(req.Type, rule.ConfigJson); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountID, err := checkAccount(rule.AccountID, rule.UID, ruleMeta.AccountType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rule.AccountID = accountID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func boolOr(v *bool, fallback bool) bool {
|
||||
return *v
|
||||
}
|
||||
|
||||
// findRule 查找属于该用户的规则
|
||||
// findRule 查找属于该用户的定时器规则
|
||||
func findRule(ruleID, uid uint) (*model.Rule, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
@@ -39,7 +39,21 @@ func findRule(ruleID, uid uint) (*model.Rule, error) {
|
||||
return &target, nil
|
||||
}
|
||||
|
||||
// DeleteRuleByID 根据 ID 删除规则
|
||||
// findGatewayRule 查找属于该用户的消息网关规则
|
||||
func findGatewayRule(ruleID, uid uint) (*model.GatewayRule, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var target model.GatewayRule
|
||||
if err := gormDB.Where("id = ? AND uid = ?", ruleID, uid).First(&target).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &target, nil
|
||||
}
|
||||
|
||||
// DeleteRuleByID 根据 ID 删除定时器规则
|
||||
func DeleteRuleByID(id uint, uid uint) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
@@ -49,50 +63,99 @@ func DeleteRuleByID(id uint, uid uint) error {
|
||||
return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.Rule{}).Error
|
||||
}
|
||||
|
||||
// checkRuleConfigAccountAndTimerExist 检查关联的 Timer 与账号是否存在且归属当前用户
|
||||
//
|
||||
// requiredAccountType 为空表示该规则类型不需要账号。
|
||||
func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType string) error {
|
||||
// DeleteGatewayRuleByID 根据 ID 删除消息网关规则
|
||||
func DeleteGatewayRuleByID(id uint, uid uint) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.GatewayRule{}).Error
|
||||
}
|
||||
|
||||
// checkTimerExists 检查关联的 Timer 是否存在且归属当前用户
|
||||
func checkTimerExists(timerID, uid uint) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := gormDB.Model(&model.Timer{}).
|
||||
Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil {
|
||||
if err := gormDB.Model(&model.Timer{}).Where("id = ? AND uid = ?", timerID, uid).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return model.ErrValidation("关联的 Timer 不存在")
|
||||
}
|
||||
|
||||
if requiredAccountType == "" {
|
||||
// 该类型不需要账号,忽略前端可能传来的 account_id
|
||||
rule.AccountID = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
if rule.AccountID == 0 {
|
||||
return model.ErrValidation("该规则类型必须关联一个账号")
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
if err := gormDB.Where("id = ? AND uid = ?", rule.AccountID, rule.UID).First(&account).Error; err != nil {
|
||||
return model.ErrValidation("关联的账号不存在")
|
||||
}
|
||||
if account.Type != requiredAccountType {
|
||||
return model.ErrValidation(fmt.Sprintf("规则需要 %s 类型的账号,但关联的是 %s", requiredAccountType, account.Type))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// maskRule 掩码规则配置中的敏感字段
|
||||
func maskRule(target model.Rule) model.Rule {
|
||||
if ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(target.Type); ok {
|
||||
target.ConfigJson = meta.Mask(target.ConfigJson, ruleMeta.Fields)
|
||||
// checkGatewayExists 检查关联的消息网关是否存在且归属当前用户
|
||||
func checkGatewayExists(gatewayID, uid uint) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := gormDB.Model(&model.MessageGateway{}).Where("id = ? AND uid = ?", gatewayID, uid).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return model.ErrValidation("关联的消息网关不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAccount 校验规则关联的账号存在、归属当前用户且类型匹配。
|
||||
//
|
||||
// requiredType 为空表示该规则类型不需要账号,此时返回 0,调用方据此清掉 account_id。
|
||||
func checkAccount(accountID, uid uint, requiredType string) (uint, error) {
|
||||
if requiredType == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if accountID == 0 {
|
||||
return 0, model.ErrValidation("该规则类型必须关联一个账号")
|
||||
}
|
||||
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
if err := gormDB.Where("id = ? AND uid = ?", accountID, uid).First(&account).Error; err != nil {
|
||||
return 0, model.ErrValidation("关联的账号不存在")
|
||||
}
|
||||
if account.Type != requiredType {
|
||||
return 0, model.ErrValidation(
|
||||
fmt.Sprintf("规则需要 %s 类型的账号,但关联的是 %s", requiredType, account.Type))
|
||||
}
|
||||
return accountID, nil
|
||||
}
|
||||
|
||||
// ruleMetaOf 取规则类型的元数据,不支持的类型返回校验错误
|
||||
func ruleMetaOf(ruleType string) (meta.RuleMeta, error) {
|
||||
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType)
|
||||
if !ok {
|
||||
return meta.RuleMeta{}, model.ErrValidation(fmt.Sprintf("不支持的规则类型: %s", ruleType))
|
||||
}
|
||||
return ruleMeta, nil
|
||||
}
|
||||
|
||||
// validateRuleConfig 校验类型专属的规则配置
|
||||
func validateRuleConfig(ruleType, configJSON string) error {
|
||||
if err := ruleConfigChecker.InitValidatorRegistry().Validate(ruleType, configJSON); err != nil {
|
||||
return model.ErrValidation(fmt.Sprintf("规则配置验证失败: %s", err.Error()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maskRule 掩码定时器规则配置中的敏感字段
|
||||
func maskRule(target model.Rule) model.Rule {
|
||||
target.ConfigJson = maskConfig(target.Type, target.ConfigJson)
|
||||
return target
|
||||
}
|
||||
|
||||
@@ -103,3 +166,53 @@ func maskRules(rules []model.Rule) []model.Rule {
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
// maskGatewayRule 掩码消息网关规则配置中的敏感字段
|
||||
func maskGatewayRule(target model.GatewayRule) model.GatewayRule {
|
||||
target.ConfigJson = maskConfig(target.Type, target.ConfigJson)
|
||||
return target
|
||||
}
|
||||
|
||||
func maskGatewayRules(rules []model.GatewayRule) []model.GatewayRule {
|
||||
masked := make([]model.GatewayRule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
masked = append(masked, maskGatewayRule(r))
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
// maskConfig 掩码规则配置中的敏感字段
|
||||
func maskConfig(ruleType, configJSON string) string {
|
||||
if ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType); ok {
|
||||
return meta.Mask(configJSON, ruleMeta.Fields)
|
||||
}
|
||||
return configJSON
|
||||
}
|
||||
|
||||
// getRuleOwnerUID 获取定时器规则所属用户的 UID
|
||||
func getRuleOwnerUID(ruleID uint) (uint, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var target model.Rule
|
||||
if err := gormDB.Select("uid").Where("id = ?", ruleID).First(&target).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return target.UID, nil
|
||||
}
|
||||
|
||||
// getGatewayRuleOwnerUID 获取消息网关规则所属用户的 UID
|
||||
func getGatewayRuleOwnerUID(ruleID uint) (uint, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var target model.GatewayRule
|
||||
if err := gormDB.Select("uid").Where("id = ?", ruleID).First(&target).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return target.UID, nil
|
||||
}
|
||||
|
||||
@@ -70,3 +70,17 @@ func signTimer(timer *model.Timer) error {
|
||||
timer.Triggered = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTimerOwnerUID 获取 Timer 的所属用户 ID
|
||||
func getTimerOwnerUID(timerID uint) (uint, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var timer model.Timer
|
||||
if err := gormDB.Select("uid").Where("id = ?", timerID).First(&timer).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return timer.UID, nil
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -48,11 +50,44 @@ func createUser(u *model.User) error {
|
||||
return err
|
||||
}
|
||||
u.Password = hashedPassword
|
||||
if u.APIKey == "" {
|
||||
u.APIKey, err = generateAPIKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.CreateAt = time.Now().Unix()
|
||||
|
||||
return gormDB.Create(u).Error
|
||||
}
|
||||
|
||||
func generateAPIKey() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "gb_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func ensureAPIKey(user *model.User) error {
|
||||
if user.APIKey != "" {
|
||||
return nil
|
||||
}
|
||||
key, err := generateAPIKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gormDB.Model(&model.User{}).Where("id = ? AND (api_key IS NULL OR api_key = '')", user.ID).Update("api_key", key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
user.APIKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUsernameTaken 用户名是否已被占用
|
||||
func IsUsernameTaken(username string) (bool, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
@@ -134,6 +169,9 @@ func GetUserByID(id uint) (*model.User, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureAPIKey(&user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
@@ -176,11 +214,10 @@ func validateNotifyConfig(raw *string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var config struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*raw), &config); err != nil {
|
||||
return fmt.Errorf("钉钉配置 JSON 格式错误: %v", err)
|
||||
// 复用钉钉驱动里的解析逻辑,避免两边对配置格式的理解不一致
|
||||
config, err := dingtalk.ParseAccountConfig(*raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.AccessToken == "" {
|
||||
return fmt.Errorf("钉钉配置中 access_token 不能为空")
|
||||
|
||||
@@ -210,6 +210,27 @@ func HandleUpdateNotifyConfig(c *gin.Context) {
|
||||
// AuthMiddleware 认证中间件,同时把当前用户放进 context 供后续 handler 复用
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
apiKey := c.GetHeader("X-API-Key")
|
||||
if apiKey == "" {
|
||||
auth := c.GetHeader("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(auth) > len(prefix) && auth[:len(prefix)] == prefix {
|
||||
apiKey = auth[len(prefix):]
|
||||
}
|
||||
}
|
||||
if apiKey != "" {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err == nil {
|
||||
var user model.User
|
||||
if gormDB.Where("api_key = ?", apiKey).First(&user).Error == nil {
|
||||
c.Set(contextKey, &user)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
response.AbortWith(c, http.StatusUnauthorized, "API Key 无效")
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
uid, ok := session.Get("uid").(uint)
|
||||
if !ok {
|
||||
|
||||
@@ -82,8 +82,11 @@ volumes:
|
||||
|
||||
两种数据库之间**没有**自动数据迁移。切换驱动后是一个全新的空库,需要重新注册账号并配置规则。
|
||||
|
||||
如果要搬运已有数据,得自己从 SQLite 导出再导入 PostgreSQL(表结构一致:`users` / `timers` / `rules` / `accounts` / `execution_logs`)。
|
||||
如果要搬运已有数据,得自己从 SQLite 导出再导入 PostgreSQL(表结构一致:`users` / `timers` / `rules` / `gateway_rules` / `message_gateways` / `accounts` / `execution_logs`)。
|
||||
|
||||
## 建表
|
||||
|
||||
首次启动会自动执行 `AutoMigrate` 建表并补齐新增字段,不需要手动执行 SQL。
|
||||
|
||||
升级到 v2.2.0 时,如果 `rules` 表里还有靠 `gateway_id` 标识的历史消息网关规则,
|
||||
启动阶段会自动把它们搬到 `gateway_rules` 表,不需要手工处理。
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 消息网关配置
|
||||
|
||||
消息网关让外部系统来触发 goodBaby 的动作:为网关生成一个 Webhook 地址,
|
||||
外部系统往这个地址 POST 一条消息,绑定在该网关上的「网关规则」就会被执行。
|
||||
|
||||
## 创建网关
|
||||
|
||||
在 WebUI 的「消息网关」页面点击「新建网关」:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| 网关类型 | 目前只有 `webhook`,新类型由 `internal/gateway` 注册后自动出现在这里 |
|
||||
| 网关名称 | 便于识别的名字,例如「生产环境告警」 |
|
||||
|
||||
创建后会得到一个地址:
|
||||
|
||||
```text
|
||||
https://<你的域名>/api/v1/gateways/gw_xxxxxxxx/webhook
|
||||
```
|
||||
|
||||
Token 由服务端随机生成,无法在页面上修改;删除网关会连同它的网关规则一起删除。
|
||||
|
||||
## 投递消息
|
||||
|
||||
```bash
|
||||
curl -X POST https://<你的域名>/api/v1/gateways/gw_xxxxxxxx/webhook \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"title": "数据库告警", "message": "主库连接数超过 90%"}'
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `message` | 是 | 消息正文,会覆盖网关规则配置里的消息字段 |
|
||||
| `title` | 否 | 消息标题,规则配置里存在 `title` 字段时会被替换 |
|
||||
|
||||
响应里会给出本次触发的规则总数与失败明细:
|
||||
|
||||
```json
|
||||
{ "code": 200, "data": { "total": 2, "failed": ["发送邮件: 连接 SMTP 服务器失败"] } }
|
||||
```
|
||||
|
||||
## 网关规则
|
||||
|
||||
网关规则在「网关规则」页面维护,和定时器规则是两张表、两个页面,互不影响。
|
||||
只需配置「发给谁」这类固定信息:名称 / 类型 / 关联账号 / 类型专属配置 / 启用。
|
||||
|
||||
**消息标题与内容不用在规则里预先写**:邮件标题、邮件正文、QQ 消息内容、钉钉标题与内容、
|
||||
B 站动态与私信内容、饭碗警告内容这些字段在「网关规则」页面上不会让用户填写
|
||||
(页面上只显示一行提示),投递时统一由请求里的 `title` / `message` 填充。
|
||||
|
||||
没有消息字段的规则(例如「公开 GitHub 仓库」「删除阿里云 DNS 记录」)也能挂到网关上,
|
||||
这类规则固定按自己的配置执行,投递的消息只起到触发器的作用。
|
||||
|
||||
规则编辑页的「测试」按钮会用一条示例消息走一次真实的投递覆盖,
|
||||
方便你在执行日志里确认最终发出的内容。
|
||||
@@ -0,0 +1,54 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
)
|
||||
|
||||
type AliDNSAccountConfigValidator struct{}
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) GetType() string { return AccountType }
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.AK == "" || cfg.SK == "" {
|
||||
return fmt.Errorf("阿里云账号配置不完整")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aliDNSClient, err := newClient(cfg.AK, cfg.SK)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = aliDNSClient.DescribeDnsProductInstances(&client.DescribeDnsProductInstancesRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) Meta() meta.AccountMeta {
|
||||
return meta.AccountMeta{
|
||||
Type: AccountType,
|
||||
Label: "阿里云",
|
||||
Description: "阿里云国内站",
|
||||
Fields: []meta.Field{
|
||||
{Key: "ak", Label: "Access Key", Type: meta.FieldPassword, Required: true, Secret: true, Placeholder: "xxxxxxxxx"},
|
||||
{Key: "sk", Label: "Secret Key", Type: meta.FieldPassword, Required: true, Secret: true, Placeholder: "xxxxxxxxx"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,27 @@ package alidns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
alidns "github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
func deleteAliDNSRecord(ctx context.Context, rule *model.Rule) error {
|
||||
aliDNSClient, err := getAliDNSClient(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteRecordConfig, err := getDeleteRecordConfig(rule)
|
||||
// DeleteRecord 用给定账号删除一条阿里云 DNS 解析记录
|
||||
func DeleteRecord(ctx context.Context, account *AliDNSAccount, config *DeleteRecordConfig) error {
|
||||
client, err := newClient(account.AK, account.SK)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = aliDNSClient.DeleteDomainRecord(&client.DeleteDomainRecordRequest{
|
||||
RecordId: &deleteRecordConfig.RecordID,
|
||||
})
|
||||
if err != nil {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
_, err := client.DeleteDomainRecord(&alidns.DeleteDomainRecordRequest{
|
||||
RecordId: &config.RecordID,
|
||||
})
|
||||
return err
|
||||
}); err != nil {
|
||||
return fmt.Errorf("删除阿里云DNS记录失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package alidns
|
||||
|
||||
const (
|
||||
AccountType = "alidns"
|
||||
RuleTypeDeleteRecord = "alidns-delete-record"
|
||||
)
|
||||
|
||||
type AliDNSAccount struct {
|
||||
AK string `json:"ak"`
|
||||
SK string `json:"sk"`
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
)
|
||||
|
||||
// 阿里云删除记录规则验证器
|
||||
type AliDNSDeleteRecordRuleValidator struct{}
|
||||
|
||||
func (v *AliDNSDeleteRecordRuleValidator) GetType() string {
|
||||
return RuleTypeDeleteRecord
|
||||
}
|
||||
|
||||
func (v *AliDNSDeleteRecordRuleValidator) Validate(configJSON string) error {
|
||||
config, err := ParseDeleteRecordConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.RecordID == "" {
|
||||
return fmt.Errorf("阿里云删除记录规则配置中 record_id 不能为空")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *AliDNSDeleteRecordRuleValidator) Meta() meta.RuleMeta {
|
||||
return meta.RuleMeta{
|
||||
Type: RuleTypeDeleteRecord,
|
||||
Label: "删除阿里云DNS解析记录",
|
||||
Description: "触发时删除阿里云DNS解析记录",
|
||||
AccountType: AccountType,
|
||||
Fields: []meta.Field{
|
||||
{Key: "record_id", Label: "记录ID", Type: meta.FieldString, Required: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// DeleteAliDNSRecordExecutor 删除阿里云DNS记录执行器
|
||||
type DeleteAliDNSRecordExecutor struct{}
|
||||
|
||||
func (e *DeleteAliDNSRecordExecutor) GetType() string {
|
||||
return RuleTypeDeleteRecord
|
||||
}
|
||||
|
||||
func (e *DeleteAliDNSRecordExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := ParseDeleteRecordConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return DeleteRecord(ctx, account, config)
|
||||
}
|
||||
@@ -2,48 +2,50 @@ package alidns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
alidns "github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||
"github.com/alibabacloud-go/tea/tea"
|
||||
"github.com/aliyun/credentials-go/credentials"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
func getAliDNSClient(rule *model.Rule) (client *alidns.Client, err error) {
|
||||
var accountConfig AliDNSAccount
|
||||
|
||||
// get config
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &accountConfig); err != nil {
|
||||
return nil, err
|
||||
// ParseAccountConfig 解析阿里云账号配置
|
||||
func ParseAccountConfig(config string) (*AliDNSAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析阿里云账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
// init aliyun account config
|
||||
var account AliDNSAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析阿里云账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseDeleteRecordConfig 解析删除解析记录规则配置
|
||||
func ParseDeleteRecordConfig(configJSON string) (*DeleteRecordConfig, error) {
|
||||
var config DeleteRecordConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析阿里云删除记录规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// newClient 用 AccessKey 创建阿里云 DNS 客户端
|
||||
func newClient(ak string, sk string) (*alidns.Client, error) {
|
||||
credentialsConfig := new(credentials.Config).
|
||||
SetType("access_key").
|
||||
SetAccessKeyId(accountConfig.AK).
|
||||
SetAccessKeySecret(accountConfig.SK)
|
||||
SetAccessKeyId(ak).
|
||||
SetAccessKeySecret(sk)
|
||||
|
||||
akCredential, err := credentials.NewCredential(credentialsConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config := &openapi.Config{}
|
||||
config.Credential = akCredential
|
||||
config.Endpoint = tea.String("alidns.aliyuncs.com")
|
||||
|
||||
// create aliyun account client
|
||||
client, _ = alidns.NewClient(config)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func getDeleteRecordConfig(rule *model.Rule) (*DeleteRecordConfig, error) {
|
||||
var deleteRecordConfig DeleteRecordConfig
|
||||
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &deleteRecordConfig); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("创建阿里云凭据失败: %w", err)
|
||||
}
|
||||
|
||||
return &deleteRecordConfig, nil
|
||||
return alidns.NewClient(&openapi.Config{
|
||||
Credential: akCredential,
|
||||
Endpoint: tea.String("alidns.aliyuncs.com"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -15,7 +14,7 @@ func (v *BilibiliAccountConfigValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *BilibiliAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -29,7 +28,7 @@ func (v *BilibiliAccountConfigValidator) Validate(config string) error {
|
||||
|
||||
// Test 用 cookie 拉一次账号信息,验证 cookie 是否还有效
|
||||
func (v *BilibiliAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -64,11 +63,3 @@ func (v *BilibiliAccountConfigValidator) Meta() meta.AccountMeta {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseAccount(config string) (*BiliAccount, error) {
|
||||
var cfg BiliAccount
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析B站账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -6,32 +6,22 @@ import (
|
||||
|
||||
"github.com/CuteReimu/bilibili/v2"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// SendBiliDynamicMsg 在 Bilibili 动态发送消息
|
||||
// SendDynamic 用给定账号发送一条 B 站动态
|
||||
//
|
||||
// 暂时没有处理 429 和 403 的区别对待
|
||||
func SendBiliDynamicMsg(ctx context.Context, rule *model.Rule) error {
|
||||
biliClient, err := getBiliClient(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取B站客户端失败: %w", err)
|
||||
}
|
||||
|
||||
biliDynamicConfig, err := getBiliDynamicConfig(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取B站动态配置失败: %w", err)
|
||||
}
|
||||
|
||||
dynamicParams := bilibili.CreateDynamicParam{
|
||||
func SendDynamic(ctx context.Context, account *BiliAccount, config *BiliDynamicConfig) error {
|
||||
client := newClient(account.RawCookies)
|
||||
params := bilibili.CreateDynamicParam{
|
||||
DynamicId: 0,
|
||||
Type: 4,
|
||||
Rid: 0,
|
||||
Content: biliDynamicConfig.Msg,
|
||||
Content: config.Msg,
|
||||
}
|
||||
|
||||
if err := retry.Do(ctx, func() error {
|
||||
_, err := biliClient.CreateDynamic(dynamicParams)
|
||||
_, err := client.CreateDynamic(params)
|
||||
return err
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送B站动态失败: %w", err)
|
||||
|
||||
@@ -2,54 +2,34 @@ package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/CuteReimu/bilibili/v2"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// 私信消息类型:1 为文字
|
||||
// privateMsgTypeText 私信消息类型:1 为文字
|
||||
const privateMsgTypeText = 1
|
||||
|
||||
// SendBiliPrivateMessage 向配置中的每个 UID 发送一条 B 站私信
|
||||
func SendBiliPrivateMessage(ctx context.Context, rule *model.Rule) error {
|
||||
client, err := getBiliClient(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取B站客户端失败: %w", err)
|
||||
}
|
||||
// SendPrivateMessage 用给定账号向配置中的每个 UID 发送一条 B 站私信
|
||||
//
|
||||
// 每个接收者单独重试、互不阻塞,最后汇总失败信息交给上层记录。
|
||||
func SendPrivateMessage(ctx context.Context, account *BiliAccount, config *BiliPrivateMessageConfig) error {
|
||||
client := newClient(account.RawCookies)
|
||||
|
||||
config, err := getPrivateMessageConfig(rule)
|
||||
// 发私信需要带上自己的 UID,从账号信息里取
|
||||
selfUID, err := fetchSelfUID(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 发私信需要带上自己的 UID,从账号信息里取
|
||||
account, err := client.GetAccountInformation()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取B站账号信息失败(cookie 可能已失效): %w", err)
|
||||
}
|
||||
if account == nil || account.Mid == 0 {
|
||||
return fmt.Errorf("B站 cookie 无效或已过期")
|
||||
}
|
||||
|
||||
var fails []string
|
||||
for _, uid := range config.ReceiverUids {
|
||||
err := retry.Do(ctx, func() error {
|
||||
_, err := client.SendPrivateMessage(bilibili.SendPrivateMessageParam{
|
||||
SenderUid: account.Mid,
|
||||
ReceiverId: int(uid),
|
||||
ReceiverType: 1,
|
||||
MsgType: privateMsgTypeText,
|
||||
Timestamp: int(time.Now().Unix()),
|
||||
Content: buildTextContent(config.Msg),
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendPrivateMessage(client, selfUID, uid, config.Msg)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("UID %d: %v", uid, err))
|
||||
}
|
||||
}
|
||||
@@ -61,20 +41,34 @@ func SendBiliPrivateMessage(ctx context.Context, rule *model.Rule) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildTextContent 文字私信的 content 是一个 JSON 字符串 {"content":"..."}
|
||||
func buildTextContent(msg string) string {
|
||||
payload, err := json.Marshal(map[string]string{"content": msg})
|
||||
if err != nil {
|
||||
// msg 是普通字符串,序列化不会失败;兜底也返回合法 JSON
|
||||
return `{"content":""}`
|
||||
// fetchSelfUID 取当前账号的 UID,私信接口需要它
|
||||
func fetchSelfUID(ctx context.Context, client *bilibili.Client) (int, error) {
|
||||
var selfUID int
|
||||
if err := retry.Do(ctx, func() error {
|
||||
info, err := client.GetAccountInformation()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info == nil || info.Mid == 0 {
|
||||
return retry.Permanent(fmt.Errorf("B站 cookie 无效或已过期"))
|
||||
}
|
||||
selfUID = info.Mid
|
||||
return nil
|
||||
}); err != nil {
|
||||
return 0, fmt.Errorf("获取B站账号信息失败: %w", err)
|
||||
}
|
||||
return string(payload)
|
||||
return selfUID, nil
|
||||
}
|
||||
|
||||
func getPrivateMessageConfig(rule *model.Rule) (*BiliPrivateMessageConfig, error) {
|
||||
var config BiliPrivateMessageConfig
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析B站私信规则配置失败: %w", err)
|
||||
}
|
||||
return &config, nil
|
||||
// sendPrivateMessage 发送一条私信
|
||||
func sendPrivateMessage(client *bilibili.Client, senderUID int, receiverUID int64, msg string) error {
|
||||
_, err := client.SendPrivateMessage(bilibili.SendPrivateMessageParam{
|
||||
SenderUid: senderUID,
|
||||
ReceiverId: int(receiverUID),
|
||||
ReceiverType: 1,
|
||||
MsgType: privateMsgTypeText,
|
||||
Timestamp: int(time.Now().Unix()),
|
||||
Content: buildTextContent(msg),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -15,9 +14,9 @@ func (v *BilibiliDynamicRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error {
|
||||
var config BiliDynamicConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析B站动态规则配置失败: %v", err)
|
||||
config, err := ParseDynamicConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Msg == "" {
|
||||
@@ -39,9 +38,9 @@ func (v *BilibiliPrivateMessageRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *BilibiliPrivateMessageRuleValidator) Validate(configJSON string) error {
|
||||
var config BiliPrivateMessageConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析B站私信规则配置失败: %v", err)
|
||||
config, err := ParsePrivateMessageConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Msg == "" {
|
||||
@@ -77,7 +76,7 @@ func (v *BilibiliPrivateMessageRuleValidator) Meta() meta.RuleMeta {
|
||||
Placeholder: "2",
|
||||
Help: "对方空间地址 space.bilibili.com/ 后面的那串数字",
|
||||
},
|
||||
{Key: "msg", Label: "私信内容", Type: meta.FieldTextarea, Required: true},
|
||||
{Key: "msg", Label: "私信内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -91,11 +90,12 @@ func (v *BilibiliDynamicRuleValidator) Meta() meta.RuleMeta {
|
||||
AccountType: AccountType,
|
||||
Fields: []meta.Field{
|
||||
{
|
||||
Key: "msg",
|
||||
Label: "动态内容",
|
||||
Type: meta.FieldTextarea,
|
||||
Required: true,
|
||||
Placeholder: "要发送的动态正文",
|
||||
Key: "msg",
|
||||
Label: "动态内容",
|
||||
Type: meta.FieldTextarea,
|
||||
Required: true,
|
||||
GatewayMessage: true,
|
||||
Placeholder: "要发送的动态正文",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
@@ -14,10 +13,18 @@ func (e *BilibiliDynamicExecutor) GetType() string {
|
||||
return RuleTypeDynamic
|
||||
}
|
||||
|
||||
func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行B站动态规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendBiliDynamicMsg(ctx, rule)
|
||||
config, err := ParseDynamicConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendDynamic(ctx, account, config)
|
||||
}
|
||||
|
||||
// BilibiliPrivateMessageExecutor B站私信执行器
|
||||
@@ -27,8 +34,16 @@ func (e *BilibiliPrivateMessageExecutor) GetType() string {
|
||||
return RuleTypePrivateMessage
|
||||
}
|
||||
|
||||
func (e *BilibiliPrivateMessageExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行B站私信规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *BilibiliPrivateMessageExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendBiliPrivateMessage(ctx, rule)
|
||||
config, err := ParsePrivateMessageConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendPrivateMessage(ctx, account, config)
|
||||
}
|
||||
|
||||
@@ -2,34 +2,55 @@ package bilibili
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/CuteReimu/bilibili/v2"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// ParseAccountConfig 解析 B 站账号配置
|
||||
func ParseAccountConfig(config string) (*BiliAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析B站账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var account BiliAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析B站账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseDynamicConfig 解析 B 站动态规则配置
|
||||
func ParseDynamicConfig(configJSON string) (*BiliDynamicConfig, error) {
|
||||
var config BiliDynamicConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析B站动态规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// ParsePrivateMessageConfig 解析 B 站私信规则配置
|
||||
func ParsePrivateMessageConfig(configJSON string) (*BiliPrivateMessageConfig, error) {
|
||||
var config BiliPrivateMessageConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析B站私信规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// newClient 按账号配置创建 B 站客户端
|
||||
func newClient(rawCookies string) *bilibili.Client {
|
||||
client := bilibili.New()
|
||||
client.SetRawCookies(rawCookies)
|
||||
return client
|
||||
}
|
||||
|
||||
// 获取 Bilibili Client
|
||||
func getBiliClient(rule *model.Rule) (*bilibili.Client, error) {
|
||||
var accountConfig BiliAccount
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &accountConfig); err != nil {
|
||||
return nil, err
|
||||
// buildTextContent 文字私信的 content 是一个 JSON 字符串 {"content":"..."}
|
||||
func buildTextContent(msg string) string {
|
||||
payload, err := json.Marshal(map[string]string{"content": msg})
|
||||
if err != nil {
|
||||
// msg 是普通字符串,序列化不会失败;兜底也返回合法 JSON
|
||||
return `{"content":""}`
|
||||
}
|
||||
|
||||
return newClient(accountConfig.RawCookies), nil
|
||||
}
|
||||
|
||||
// 获取 Bilibili Dynamic Config
|
||||
func getBiliDynamicConfig(rule *model.Rule) (*BiliDynamicConfig, error) {
|
||||
var biliDynamicConfig BiliDynamicConfig
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &biliDynamicConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &biliDynamicConfig, nil
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
@@ -2,32 +2,51 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/CatchZeng/dingtalk/pkg/dingtalk"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// ErrNoUserConfig 表示用户没有配置钉钉提醒
|
||||
var ErrNoUserConfig = fmt.Errorf("用户未配置钉钉机器人")
|
||||
|
||||
// SendDingTalkMsg 使用用户配置的钉钉机器人发送提醒
|
||||
//
|
||||
// 用户没有配置钉钉机器人时返回 ErrNoUserConfig
|
||||
func SendDingTalkMsg(user *model.User, title string, msg string) error {
|
||||
config := getDingTalkConfigFromUser(user)
|
||||
if config == nil {
|
||||
if user.DingTalkConfig == nil {
|
||||
return ErrNoUserConfig
|
||||
}
|
||||
return send(config, title, msg)
|
||||
}
|
||||
|
||||
// ErrNoUserConfig 表示用户没有配置钉钉提醒
|
||||
var ErrNoUserConfig = fmt.Errorf("用户未配置钉钉机器人")
|
||||
config, err := ParseAccountConfig(*user.DingTalkConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func send(config *DingTalkConfig, title, msg string) error {
|
||||
client := dingtalk.NewClient(config.AccessToken, config.Secret)
|
||||
message := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
|
||||
|
||||
if _, _, err := client.Send(message); err != nil {
|
||||
if err := send(config, title, msg); err != nil {
|
||||
return fmt.Errorf("发送钉钉消息失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMessage 用规则里配置的机器人发送消息
|
||||
func SendMessage(ctx context.Context, config *DingTalkRuleConfig) error {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return send(&config.DingTalkConfig, config.Title, config.Msg)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送钉钉消息失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// send 调用一次机器人接口
|
||||
func send(config *DingTalkConfig, title, msg string) error {
|
||||
client := dingtalk.NewClient(config.AccessToken, config.Secret)
|
||||
message := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
|
||||
|
||||
_, _, err := client.Send(message)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -42,17 +41,8 @@ func (v *DingTalkRuleValidator) Meta() meta.RuleMeta {
|
||||
Fields: []meta.Field{
|
||||
{Key: "access_token", Label: "Access Token", Type: meta.FieldPassword, Required: true, Secret: true, Help: "机器人 Webhook 中 access_token 参数的值"},
|
||||
{Key: "secret", Label: "加签 Secret", Type: meta.FieldPassword, Secret: true, Help: "机器人安全设置选择“加签”时填写"},
|
||||
{Key: "title", Label: "消息标题", Type: meta.FieldString, Required: true},
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, Help: "支持 Markdown"},
|
||||
{Key: "title", Label: "消息标题", Type: meta.FieldString, Required: true, GatewayMessage: true},
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true, Help: "支持 Markdown"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRuleConfig 解析钉钉规则配置
|
||||
func ParseRuleConfig(configJSON string) (*DingTalkRuleConfig, error) {
|
||||
var config DingTalkRuleConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析钉钉规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
@@ -15,15 +13,12 @@ func (e *DingTalkExecutor) GetType() string {
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *DingTalkExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行钉钉规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
|
||||
config, err := ParseRuleConfig(rule.ConfigJson)
|
||||
func (e *DingTalkExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
// 钉钉机器人凭据直接写在规则配置里,不需要关联账号
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return retry.Do(ctx, func() error {
|
||||
return send(&config.DingTalkConfig, config.Title, config.Msg)
|
||||
})
|
||||
return SendMessage(ctx, config)
|
||||
}
|
||||
|
||||
@@ -2,19 +2,27 @@ package dingtalk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func getDingTalkConfigFromUser(user *model.User) *DingTalkConfig {
|
||||
if user.DingTalkConfig == nil {
|
||||
return nil
|
||||
// ParseAccountConfig 解析钉钉机器人凭据配置
|
||||
func ParseAccountConfig(config string) (*DingTalkConfig, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析钉钉机器人配置失败: 配置为空")
|
||||
}
|
||||
|
||||
var config DingTalkConfig
|
||||
err := json.Unmarshal([]byte(*user.DingTalkConfig), &config)
|
||||
if err != nil {
|
||||
return nil
|
||||
var account DingTalkConfig
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析钉钉机器人配置失败: %v", err)
|
||||
}
|
||||
return &config
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseRuleConfig 解析钉钉规则配置
|
||||
func ParseRuleConfig(configJSON string) (*DingTalkRuleConfig, error) {
|
||||
var config DingTalkRuleConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析钉钉规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
|
||||
@@ -18,7 +17,7 @@ func (v *EmailAccountConfigValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *EmailAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := parseAccountConfig(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,7 +55,7 @@ func (v *EmailAccountConfigValidator) Validate(config string) error {
|
||||
|
||||
// Test 连接 SMTP 服务器并完成认证;填写了测试收件地址时会真的发一封测试邮件
|
||||
func (v *EmailAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccountConfig(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -103,11 +102,3 @@ func (v *EmailAccountConfigValidator) Meta() meta.AccountMeta {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseAccountConfig(config string) (*EmailAccountConfig, error) {
|
||||
var cfg EmailAccountConfig
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析邮箱账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -3,38 +3,31 @@ package email
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
// SendMail 向规则配置里的所有地址发送邮件。
|
||||
// SendMail 用给定 SMTP 账号向规则配置里的所有地址发送邮件。
|
||||
//
|
||||
// 每个地址单独重试,互不阻塞;全部失败/部分失败都会汇总成错误返回,
|
||||
// 每个地址单独重试、互不阻塞;全部失败/部分失败都会汇总成错误返回,
|
||||
// 由 runner 记录到执行日志中。
|
||||
func SendMail(ctx context.Context, rule *model.Rule) error {
|
||||
emailRule, err := GetEmailRuleFromRule(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取邮件规则配置失败: %w", err)
|
||||
}
|
||||
|
||||
accountConfig, err := GetEmailAccountFromRule(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取邮件账户配置失败: %w", err)
|
||||
}
|
||||
|
||||
func SendMail(ctx context.Context, account *EmailAccountConfig, config *EmailRule) error {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
fails []string
|
||||
)
|
||||
|
||||
for _, destination := range emailRule.Destinations {
|
||||
for _, destination := range config.Destinations {
|
||||
wg.Add(1)
|
||||
go func(address string) {
|
||||
defer wg.Done()
|
||||
if err := sendMailMsgWithRetry(ctx, accountConfig, emailRule, address); err != nil {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendMail(ctx, account, config, address)
|
||||
}); err != nil {
|
||||
mu.Lock()
|
||||
fails = append(fails, fmt.Sprintf("%s: %v", address, err))
|
||||
mu.Unlock()
|
||||
@@ -44,8 +37,10 @@ func SendMail(ctx context.Context, rule *model.Rule) error {
|
||||
wg.Wait()
|
||||
|
||||
if len(fails) > 0 {
|
||||
// 并发发送的顺序不确定,排序后错误信息才是稳定的
|
||||
sort.Strings(fails)
|
||||
return fmt.Errorf("%d/%d 封邮件发送失败: %s",
|
||||
len(fails), len(emailRule.Destinations), strings.Join(fails, "; "))
|
||||
len(fails), len(config.Destinations), strings.Join(fails, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
|
||||
@@ -16,9 +15,9 @@ func (v *EmailRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *EmailRuleValidator) Validate(configJSON string) error {
|
||||
var config EmailRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析邮件规则配置失败: %v", err)
|
||||
config, err := ParseRuleConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Title == "" {
|
||||
@@ -51,8 +50,8 @@ func (v *EmailRuleValidator) Meta() meta.RuleMeta {
|
||||
Docs: "docs/email-config.md",
|
||||
AccountType: AccountType,
|
||||
Fields: []meta.Field{
|
||||
{Key: "title", Label: "邮件标题", Type: meta.FieldString, Required: true},
|
||||
{Key: "msg", Label: "邮件正文", Type: meta.FieldTextarea, Required: true},
|
||||
{Key: "title", Label: "邮件标题", Type: meta.FieldString, Required: true, GatewayMessage: true},
|
||||
{Key: "msg", Label: "邮件正文", Type: meta.FieldTextarea, Required: true, GatewayMessage: true},
|
||||
{Key: "destinations", Label: "收件人", Type: meta.FieldStringList, Required: true, Placeholder: "someone@example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
@@ -14,8 +13,16 @@ func (e *EmailExecutor) GetType() string {
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *EmailExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行邮件规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *EmailExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendMail(ctx, rule)
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendMail(ctx, account, config)
|
||||
}
|
||||
|
||||
@@ -5,28 +5,29 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"github.com/wneessen/go-mail"
|
||||
)
|
||||
|
||||
// GetEmailAccountFromRule 从 Rule 中获取 EmailAccount 配置
|
||||
func GetEmailAccountFromRule(rule *model.Rule) (*EmailAccountConfig, error) {
|
||||
var emailAccountConfig EmailAccountConfig
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &emailAccountConfig); err != nil {
|
||||
return nil, err
|
||||
// ParseAccountConfig 解析邮箱账号配置
|
||||
func ParseAccountConfig(config string) (*EmailAccountConfig, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析邮箱账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
return &emailAccountConfig, nil
|
||||
|
||||
var account EmailAccountConfig
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析邮箱账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// GetEmailRuleFromRule 从 Rule 中获取 EmailRule 配置
|
||||
func GetEmailRuleFromRule(rule *model.Rule) (*EmailRule, error) {
|
||||
var emailRule EmailRule
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &emailRule); err != nil {
|
||||
return nil, err
|
||||
// ParseRuleConfig 解析邮件规则配置
|
||||
func ParseRuleConfig(configJSON string) (*EmailRule, error) {
|
||||
var config EmailRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析邮件规则配置失败: %v", err)
|
||||
}
|
||||
return &emailRule, nil
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// newSMTPClient 按账号配置里的加密方式创建 SMTP 客户端
|
||||
@@ -54,6 +55,7 @@ func newSMTPClient(cfg *EmailAccountConfig) (*mail.Client, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// buildMessage 组装一封邮件
|
||||
func buildMessage(cfg *EmailAccountConfig, address, title, body string) (*mail.Msg, error) {
|
||||
message := mail.NewMsg()
|
||||
if err := message.From(cfg.FromOrDefault()); err != nil {
|
||||
@@ -67,19 +69,14 @@ func buildMessage(cfg *EmailAccountConfig, address, title, body string) (*mail.M
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func sendMailMsgWithRetry(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
|
||||
return retry.Do(ctx, func() error {
|
||||
return sendMailMsg(ctx, cfg, rule, address)
|
||||
})
|
||||
}
|
||||
|
||||
func sendMailMsg(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
|
||||
// sendMail 给一个地址发送一封邮件
|
||||
func sendMail(ctx context.Context, cfg *EmailAccountConfig, config *EmailRule, address string) error {
|
||||
client, err := newSMTPClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message, err := buildMessage(cfg, address, rule.Title, rule.Msg)
|
||||
message, err := buildMessage(cfg, address, config.Title, config.Msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
package fwalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
// send 发送消息
|
||||
// Send 发送一条饭碗警告
|
||||
func Send(ctx context.Context, config *FwAlertRuleConfig) error {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return send(config)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送饭碗警告失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// send 调用一次 Webhook
|
||||
func send(config *FwAlertRuleConfig) error {
|
||||
restyClient := resty.New()
|
||||
req := restyClient.R()
|
||||
|
||||
req.SetBody(FwAlertRequest{
|
||||
Message: config.Msg,
|
||||
})
|
||||
|
||||
resp, err := req.Execute(resty.MethodPost, config.WebhookURL)
|
||||
resp, err := resty.New().R().
|
||||
SetBody(FwAlertRequest{Message: config.Msg}).
|
||||
Execute(resty.MethodPost, config.WebhookURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode() != 200 {
|
||||
return fmt.Errorf("发送饭碗警告失败: %s", resp.Status())
|
||||
return fmt.Errorf("Webhook 返回状态码 %s", resp.Status())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package fwalert
|
||||
|
||||
const RuleTypeFwalert = "fwalert"
|
||||
// RuleType 饭碗警告规则类型标识
|
||||
const RuleType = "fwalert"
|
||||
|
||||
type FwAlertRuleConfig struct {
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
package fwalert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
)
|
||||
|
||||
// FwalertRuleValidator 饭碗警告规则验证器
|
||||
type FwalertRuleValidator struct{}
|
||||
|
||||
func (v *FwalertRuleValidator) GetType() string {
|
||||
return RuleTypeFwalert
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (v *FwalertRuleValidator) Validate(configJSON string) error {
|
||||
var config FwAlertRuleConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
|
||||
config, err := ParseRuleConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(config.WebhookURL)
|
||||
@@ -40,23 +40,14 @@ func (v *FwalertRuleValidator) Validate(configJSON string) error {
|
||||
|
||||
func (v *FwalertRuleValidator) Meta() meta.RuleMeta {
|
||||
return meta.RuleMeta{
|
||||
Type: RuleTypeFwalert,
|
||||
Type: RuleType,
|
||||
Label: "发送饭碗警告",
|
||||
Description: "触发时发送饭碗警告消息。",
|
||||
Docs: "docs/fwalert-config.md",
|
||||
AccountType: "",
|
||||
Fields: []meta.Field{
|
||||
{Key: "webhook_url", Label: "Webhook URL", Type: meta.FieldString, Required: true, Help: "饭碗警告的触发地址", Placeholder: "https://fwalert.com/xxx-xxx-xxx-xxx-xxx"},
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, Help: "最大长度 1000 个字符"},
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true, Help: "最大长度 1000 个字符"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRuleConfig 解析饭碗警告规则配置
|
||||
func ParseRuleConfig(configJSON string) (*FwAlertRuleConfig, error) {
|
||||
var config FwAlertRuleConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package fwalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
@@ -12,18 +10,15 @@ import (
|
||||
type FwalertExecutor struct{}
|
||||
|
||||
func (e *FwalertExecutor) GetType() string {
|
||||
return RuleTypeFwalert
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *FwalertExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行饭碗警告规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
|
||||
config, err := ParseRuleConfig(rule.ConfigJson)
|
||||
func (e *FwalertExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
// 饭碗警告的凭据就是 Webhook 地址,直接写在规则配置里
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return retry.Do(ctx, func() error {
|
||||
return send(config)
|
||||
})
|
||||
return Send(ctx, config)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package fwalert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ParseRuleConfig 解析饭碗警告规则配置
|
||||
func ParseRuleConfig(configJSON string) (*FwAlertRuleConfig, error) {
|
||||
var config FwAlertRuleConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/go-github/v84/github"
|
||||
@@ -18,7 +17,7 @@ func (v *GitHubAccountConfigValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *GitHubAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -35,7 +34,7 @@ func (v *GitHubAccountConfigValidator) Validate(config string) error {
|
||||
|
||||
// Test 用 token 拉一次当前用户信息,验证 token 是否有效
|
||||
func (v *GitHubAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -65,11 +64,3 @@ func (v *GitHubAccountConfigValidator) Meta() meta.AccountMeta {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseAccount(config string) (*GithubAccount, error) {
|
||||
var cfg GithubAccount
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -6,29 +6,24 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// MakeRepositoryPublic 将规则中配置的仓库设置为 public
|
||||
func MakeRepositoryPublic(ctx context.Context, rule *model.Rule) error {
|
||||
reposConfig, account, err := GetGithubReposAndAccountFromRule(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// MakeRepositoryPublic 把配置里的仓库逐个设置为 public
|
||||
//
|
||||
// 每个仓库单独重试、互不阻塞,最后汇总失败信息交给上层记录。
|
||||
func MakeRepositoryPublic(ctx context.Context, account *GithubAccount, config *GithubReposConfig) error {
|
||||
var fails []string
|
||||
for _, repo := range reposConfig.Repos {
|
||||
err := retry.Do(ctx, func() error {
|
||||
for _, repo := range config.Repos {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return SetRepositoryPublic(ctx, account.Token, account.Owner, repo)
|
||||
})
|
||||
if err != nil {
|
||||
fails = append(fails, fmt.Sprintf("%s: %v", repo, err))
|
||||
}); err != nil {
|
||||
fails = append(fails, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(fails) > 0 {
|
||||
return fmt.Errorf("%d/%d 个仓库设置为 public 失败: %s",
|
||||
len(fails), len(reposConfig.Repos), strings.Join(fails, "; "))
|
||||
len(fails), len(config.Repos), strings.Join(fails, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -15,9 +14,9 @@ func (v *GithubMakeRepositoryPublicRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *GithubMakeRepositoryPublicRuleValidator) Validate(configJSON string) error {
|
||||
var config GithubReposConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析 GitHub 规则配置失败: %v", err)
|
||||
config, err := ParseReposConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(config.Repos) == 0 {
|
||||
|
||||
@@ -2,7 +2,6 @@ package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
@@ -14,8 +13,16 @@ func (e *GithubMakeRepoPublicExecutor) GetType() string {
|
||||
return RuleTypeMakeRepoPublic
|
||||
}
|
||||
|
||||
func (e *GithubMakeRepoPublicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行GitHub仓库公开规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *GithubMakeRepoPublicExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return MakeRepositoryPublic(ctx, rule)
|
||||
config, err := ParseReposConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return MakeRepositoryPublic(ctx, account, config)
|
||||
}
|
||||
|
||||
@@ -6,33 +6,37 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/google/go-github/v84/github"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// GetGithubReposAndAccountFromRule 从 rule 中获取 GithubReposConfig 和 GithubAccount
|
||||
func GetGithubReposAndAccountFromRule(rule *model.Rule) (*GithubReposConfig, *GithubAccount, error) {
|
||||
var githubReposConfig GithubReposConfig
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &githubReposConfig); err != nil {
|
||||
return nil, nil, fmt.Errorf("解析 GitHub 规则配置失败: %w", err)
|
||||
// ParseAccountConfig 解析 GitHub 账号配置
|
||||
func ParseAccountConfig(config string) (*GithubAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析GitHub账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var githubAccount GithubAccount
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &githubAccount); err != nil {
|
||||
return nil, nil, err
|
||||
var account GithubAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub账号配置失败: %v", err)
|
||||
}
|
||||
|
||||
return &githubReposConfig, &githubAccount, nil
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// SetRepositoryPublic 将仓库设置为 public
|
||||
// ParseReposConfig 解析仓库规则配置
|
||||
func ParseReposConfig(configJSON string) (*GithubReposConfig, error) {
|
||||
var config GithubReposConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// SetRepositoryPublic 把一个仓库设置为 public
|
||||
func SetRepositoryPublic(ctx context.Context, token, owner, repo string) error {
|
||||
client := github.NewClient(nil).WithAuthToken(token)
|
||||
|
||||
opts := &github.Repository{
|
||||
if _, _, err := client.Repositories.Edit(ctx, owner, repo, &github.Repository{
|
||||
Visibility: github.Ptr("public"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("设置仓库 %s 为 public 失败: %w", repo, err)
|
||||
}
|
||||
|
||||
_, _, err := client.Repositories.Edit(ctx, owner, repo, opts)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -17,7 +16,7 @@ func (v *OneBotAccountConfigValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *OneBotAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -39,7 +38,7 @@ func (v *OneBotAccountConfigValidator) Validate(config string) error {
|
||||
|
||||
// Test 调用 get_login_info 验证地址与 Token
|
||||
func (v *OneBotAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -62,11 +61,3 @@ func (v *OneBotAccountConfigValidator) Meta() meta.AccountMeta {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseAccount(config string) (*OneBotAccount, error) {
|
||||
var cfg OneBotAccount
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析OneBot账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -6,40 +6,29 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// SendOneBotMsg 向规则中配置的群和好友发送消息
|
||||
func SendOneBotMsg(ctx context.Context, rule *model.Rule) error {
|
||||
oneBotConfig, err := getOneBotConfigFromRule(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oneBotAccount, err := getOneBotAccountFromRule(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取OneBot账户失败: %w", err)
|
||||
}
|
||||
|
||||
// SendMessage 向规则中配置的群和好友发送消息
|
||||
//
|
||||
// 每个目标单独重试、互不阻塞,最后汇总失败信息交给上层记录。
|
||||
func SendMessage(ctx context.Context, account *OneBotAccount, config *OneBotConfig) error {
|
||||
var fails []string
|
||||
total := 0
|
||||
|
||||
for _, groupID := range oneBotConfig.SendGroups {
|
||||
for _, groupID := range config.SendGroups {
|
||||
total++
|
||||
err := retry.Do(ctx, func() error {
|
||||
return sendGroupMsg(oneBotAccount, groupID, oneBotConfig.Msg)
|
||||
})
|
||||
if err != nil {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendGroupMsg(account, groupID, config.Msg)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("群 %d: %v", groupID, err))
|
||||
}
|
||||
}
|
||||
|
||||
for _, userID := range oneBotConfig.SendUsers {
|
||||
for _, userID := range config.SendUsers {
|
||||
total++
|
||||
err := retry.Do(ctx, func() error {
|
||||
return sendPrivateMsg(oneBotAccount, userID, oneBotConfig.Msg)
|
||||
})
|
||||
if err != nil {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendPrivateMsg(account, userID, config.Msg)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("好友 %d: %v", userID, err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -15,9 +14,9 @@ func (v *OneBotRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *OneBotRuleValidator) Validate(configJSON string) error {
|
||||
var config OneBotConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析OneBot规则配置失败: %v", err)
|
||||
config, err := ParseRuleConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Msg == "" {
|
||||
@@ -44,7 +43,7 @@ func (v *OneBotRuleValidator) Meta() meta.RuleMeta {
|
||||
Description: "触发时通过 OneBot 向指定的群或好友发送消息。",
|
||||
AccountType: AccountType,
|
||||
Fields: []meta.Field{
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true},
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true},
|
||||
{Key: "send_groups", Label: "群号", Type: meta.FieldNumberList, Placeholder: "123456789"},
|
||||
{Key: "send_users", Label: "好友 QQ 号", Type: meta.FieldNumberList, Placeholder: "123456789"},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ package onebot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
@@ -14,8 +13,16 @@ func (e *OneBotExecutor) GetType() string {
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *OneBotExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行OneBot规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *OneBotExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendOneBotMsg(ctx, rule)
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendMessage(ctx, account, config)
|
||||
}
|
||||
|
||||
@@ -4,37 +4,37 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
napcat "github.com/ssdomei232/napcat-http-go-sdk"
|
||||
)
|
||||
|
||||
// ParseAccountConfig 解析 OneBot 账号配置
|
||||
func ParseAccountConfig(config string) (*OneBotAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析OneBot账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var account OneBotAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析OneBot账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseRuleConfig 解析 OneBot 规则配置
|
||||
func ParseRuleConfig(configJSON string) (*OneBotConfig, error) {
|
||||
var config OneBotConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析OneBot规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// newClient 创建 OneBot HTTP 客户端
|
||||
func newClient(account *OneBotAccount) *napcat.Client {
|
||||
return napcat.NewClient(account.Token, account.URL)
|
||||
}
|
||||
|
||||
// 从 Rule 中获取 OneBotConfig
|
||||
func getOneBotConfigFromRule(rule *model.Rule) (*OneBotConfig, error) {
|
||||
var oneBotConfig OneBotConfig
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &oneBotConfig); err != nil {
|
||||
return nil, fmt.Errorf("解析OneBot规则配置失败: %w", err)
|
||||
}
|
||||
return &oneBotConfig, nil
|
||||
}
|
||||
|
||||
// 从 Rule 中获取 OneBotAccount
|
||||
func getOneBotAccountFromRule(rule *model.Rule) (*OneBotAccount, error) {
|
||||
var oneBotAccount OneBotAccount
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &oneBotAccount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &oneBotAccount, nil
|
||||
}
|
||||
|
||||
// 发送一条群消息
|
||||
//
|
||||
// SDK 只返回传输层错误,这里自己解析 OneBot 的响应信封,
|
||||
// 否则 token 错误、群号不存在这类失败会被当成发送成功。
|
||||
// sendGroupMsg 发送一条群消息
|
||||
func sendGroupMsg(account *OneBotAccount, groupID int64, msg string) error {
|
||||
return call(account, "/send_group_msg", map[string]any{
|
||||
"group_id": groupID,
|
||||
@@ -42,7 +42,7 @@ func sendGroupMsg(account *OneBotAccount, groupID int64, msg string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 发送一条私聊消息
|
||||
// sendPrivateMsg 发送一条私聊消息
|
||||
func sendPrivateMsg(account *OneBotAccount, userID int64, msg string) error {
|
||||
return call(account, "/send_private_msg", map[string]any{
|
||||
"user_id": userID,
|
||||
@@ -50,6 +50,10 @@ func sendPrivateMsg(account *OneBotAccount, userID int64, msg string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// call 调用一次 OneBot HTTP API。
|
||||
//
|
||||
// SDK 只返回传输层错误,这里自己解析 OneBot 的响应信封,
|
||||
// 否则 token 错误、群号不存在这类失败会被当成发送成功。
|
||||
func call(account *OneBotAccount, endpoint string, payload map[string]any) error {
|
||||
var resp apiResponse
|
||||
if err := newClient(account).DoRequest("POST", endpoint, payload, &resp); err != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package rainyun
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -12,13 +11,11 @@ import (
|
||||
// RainyunAccountConfigValidator Rainyun账号配置验证器
|
||||
type RainyunAccountConfigValidator struct{}
|
||||
|
||||
func (v *RainyunAccountConfigValidator) GetType() string {
|
||||
return AccountType
|
||||
}
|
||||
func (v *RainyunAccountConfigValidator) GetType() string { return AccountType }
|
||||
|
||||
func (v *RainyunAccountConfigValidator) Validate(config string) error {
|
||||
// 解析配置
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -30,16 +27,8 @@ func (v *RainyunAccountConfigValidator) Validate(config string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAccount(config string) (*RainyunAccount, error) {
|
||||
var cfg RainyunAccount
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析雨云账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (v *RainyunAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,10 +3,17 @@ package rainyun
|
||||
const (
|
||||
// AccountType Rainyun 账号类型标识
|
||||
AccountType = "rainyun"
|
||||
// RuleType 发送 Rainyun 消息的规则类型标识
|
||||
RuleType = "rainyun-workorder"
|
||||
// RuleTypeWorkOrder 发送 Rainyun 工单消息的规则类型标识
|
||||
RuleTypeWorkOrder = "rainyun-workorder"
|
||||
RuleTypeRunAway = "rainyun-runaway"
|
||||
)
|
||||
|
||||
// pageSize 拉取云服务器列表时每页的条数
|
||||
const pageSize = 20
|
||||
|
||||
// runAwayConfirmText 跑路规则要求用户输入的免责声明
|
||||
const runAwayConfirmText = "我已知晓"
|
||||
|
||||
type RainyunWorkOrderRule struct {
|
||||
Title string `json:"title"`
|
||||
Msg string `json:"msg"`
|
||||
|
||||
@@ -6,121 +6,147 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
rain "github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/common"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rcs"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rgs"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/workorder"
|
||||
)
|
||||
|
||||
func SendRainyunWorkorderMsg(ctx context.Context, rule *model.Rule) error {
|
||||
rainyunConfig, err := getRainyunConfigFromRule(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
// SendWorkOrder 用给定账号发送一条雨云工单
|
||||
func SendWorkOrder(ctx context.Context, account *RainyunAccount, config *RainyunWorkOrderRule) error {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return createWorkOrder(account, config)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送雨云工单失败: %w", err)
|
||||
}
|
||||
|
||||
rainyunAccount, err := getRainyunAccountFromRule(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = retry.Do(ctx, func() error {
|
||||
return sendRainyunWorkorderMsg(rainyunConfig, rainyunAccount)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendRainyunWorkorderMsg(rainyunRuleConfig *RainyunWorkOrderRule, rainyunAccount *RainyunAccount) error {
|
||||
client := rain.NewClient(rainyunAccount.APIKey)
|
||||
workorderClient := workorder.Client{Client: client}
|
||||
// createWorkOrder 调用一次工单接口
|
||||
func createWorkOrder(account *RainyunAccount, config *RainyunWorkOrderRule) error {
|
||||
workorderClient := workorder.Client{Client: rain.NewClient(account.APIKey)}
|
||||
|
||||
_, err := workorderClient.CreateWorkOrder(&workorder.CreateWorkerorderRequest{
|
||||
Content: rainyunRuleConfig.Msg,
|
||||
Content: config.Msg,
|
||||
IsAuthed: false,
|
||||
IsUrgent: 1,
|
||||
Title: rainyunRuleConfig.Title,
|
||||
Title: config.Title,
|
||||
Type: "reward",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
func RainyunRunAway(ctx context.Context, rule *model.Rule) error {
|
||||
rainyunAccount, err := getRainyunAccountFromRule(rule)
|
||||
// RunAway 重装账号下所有云服务器(一键跑路)
|
||||
func RunAway(ctx context.Context, account *RainyunAccount) error {
|
||||
client := rain.NewClient(account.APIKey)
|
||||
|
||||
rcsFails, rcsTotal, err := reinstallAllRcs(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := rain.NewClient(rainyunAccount.APIKey)
|
||||
|
||||
var fails []string
|
||||
total := 0
|
||||
|
||||
// 重装所有RCS
|
||||
rcsClient := rcs.Client{Client: client}
|
||||
options := rain.EncodingStandardQueryParameters(1, 20)
|
||||
rcsList, err := rcsClient.GetRcsList(options)
|
||||
rgsFails, rgsTotal, err := reinstallAllRgs(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rcsList.Data.TotalRecords == 0 {
|
||||
return nil
|
||||
} else if rcsList.Data.TotalRecords > 20 {
|
||||
rcsTotal := rcsList.Data.TotalRecords
|
||||
options := rain.EncodingStandardQueryParameters(1, rcsTotal)
|
||||
rcsList, err = rcsClient.GetRcsList(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, rcs := range rcsList.Data.Records {
|
||||
total++
|
||||
err := retry.Do(context.Background(), func() error {
|
||||
return reinstallOneRCS(rcs.ID, &rcsClient)
|
||||
})
|
||||
if err != nil {
|
||||
fails = append(fails, fmt.Sprintf("RCS %d: %v", rcs.ID, err))
|
||||
}
|
||||
}
|
||||
|
||||
// 重装所有RGS
|
||||
rgsClient := rgs.Client{Client: client}
|
||||
rgsOptions := rain.EncodingStandardQueryParameters(1, 20)
|
||||
rgsList, err := rgsClient.GetRgsList(rgsOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rgsList.Data.TotalRecords == 0 {
|
||||
return nil
|
||||
} else if rgsList.Data.TotalRecords > 20 {
|
||||
rgsTotal := rgsList.Data.TotalRecords
|
||||
rgsOptions := rain.EncodingStandardQueryParameters(1, rgsTotal)
|
||||
rgsList, err = rgsClient.GetRgsList(rgsOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, rgs := range rgsList.Data.Records {
|
||||
total++
|
||||
err := retry.Do(context.Background(), func() error {
|
||||
return reinstallOneRGS(rgs.ID, &rgsClient)
|
||||
})
|
||||
if err != nil {
|
||||
fails = append(fails, fmt.Sprintf("RGS %d: %v", rgs.ID, err))
|
||||
}
|
||||
}
|
||||
|
||||
fails := append(rcsFails, rgsFails...)
|
||||
total := rcsTotal + rgsTotal
|
||||
if len(fails) > 0 {
|
||||
return fmt.Errorf("%d/%d 台云服务器重装失败: %s", len(fails), total, strings.Join(fails, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reinstallAllRcs 重装全部 RCS,返回失败信息与云服务器总数
|
||||
func reinstallAllRcs(ctx context.Context, client *rain.Client) ([]string, int, error) {
|
||||
rcsClient := rcs.Client{Client: client}
|
||||
|
||||
ids, err := listRcsIDs(&rcsClient)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var fails []string
|
||||
for _, id := range ids {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return reinstallOneRCS(id, &rcsClient)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("RCS %d: %v", id, err))
|
||||
}
|
||||
}
|
||||
return fails, len(ids), nil
|
||||
}
|
||||
|
||||
// reinstallAllRgs 重装全部 RGS,返回失败信息与云服务器总数
|
||||
func reinstallAllRgs(ctx context.Context, client *rain.Client) ([]string, int, error) {
|
||||
rgsClient := rgs.Client{Client: client}
|
||||
|
||||
ids, err := listRgsIDs(&rgsClient)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var fails []string
|
||||
for _, id := range ids {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return reinstallOneRGS(id, &rgsClient)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("RGS %d: %v", id, err))
|
||||
}
|
||||
}
|
||||
return fails, len(ids), nil
|
||||
}
|
||||
|
||||
// listRcsIDs 取账号下全部 RCS 的 ID,超过一页时重新按总数拉取
|
||||
func listRcsIDs(client *rcs.Client) ([]int, error) {
|
||||
first, err := client.GetRcsList(rain.EncodingStandardQueryParameters(1, pageSize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(first.Data.Records))
|
||||
for _, server := range first.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
if first.Data.TotalRecords <= pageSize {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
all, err := client.GetRcsList(rain.EncodingStandardQueryParameters(1, first.Data.TotalRecords))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids = ids[:0]
|
||||
for _, server := range all.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// listRgsIDs 取账号下全部 RGS 的 ID,超过一页时重新按总数拉取
|
||||
func listRgsIDs(client *rgs.Client) ([]int, error) {
|
||||
first, err := client.GetRgsList(rain.EncodingStandardQueryParameters(1, pageSize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(first.Data.Records))
|
||||
for _, server := range first.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
if first.Data.TotalRecords <= pageSize {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
all, err := client.GetRgsList(rain.EncodingStandardQueryParameters(1, first.Data.TotalRecords))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids = ids[:0]
|
||||
for _, server := range all.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package rainyun
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -11,13 +10,13 @@ import (
|
||||
type RainyunWorkorderRuleValidator struct{}
|
||||
|
||||
func (v *RainyunWorkorderRuleValidator) GetType() string {
|
||||
return RuleType
|
||||
return RuleTypeWorkOrder
|
||||
}
|
||||
|
||||
func (v *RainyunWorkorderRuleValidator) Validate(configJSON string) error {
|
||||
var config RainyunWorkOrderRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析雨云规则配置失败: %v", err)
|
||||
config, err := ParseWorkOrderConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Title == "" {
|
||||
@@ -33,7 +32,7 @@ func (v *RainyunWorkorderRuleValidator) Validate(configJSON string) error {
|
||||
|
||||
func (v *RainyunWorkorderRuleValidator) Meta() meta.RuleMeta {
|
||||
return meta.RuleMeta{
|
||||
Type: RuleType,
|
||||
Type: RuleTypeWorkOrder,
|
||||
Label: "发送雨云工单",
|
||||
Description: "触发时发送雨云工单",
|
||||
AccountType: AccountType,
|
||||
@@ -47,16 +46,16 @@ func (v *RainyunWorkorderRuleValidator) Meta() meta.RuleMeta {
|
||||
type RainyunRunAwayRuleValidator struct{}
|
||||
|
||||
func (v *RainyunRunAwayRuleValidator) GetType() string {
|
||||
return "rainyun-runaway"
|
||||
return RuleTypeRunAway
|
||||
}
|
||||
|
||||
func (v *RainyunRunAwayRuleValidator) Validate(configJSON string) error {
|
||||
var config RainyunRunAwayRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析雨云规则配置失败: %v", err)
|
||||
config, err := ParseRunAwayConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.IK != "我已知晓" {
|
||||
if config.IK != runAwayConfirmText {
|
||||
return fmt.Errorf("请输入正确内容")
|
||||
}
|
||||
|
||||
@@ -65,7 +64,7 @@ func (v *RainyunRunAwayRuleValidator) Validate(configJSON string) error {
|
||||
|
||||
func (v *RainyunRunAwayRuleValidator) Meta() meta.RuleMeta {
|
||||
return meta.RuleMeta{
|
||||
Type: "rainyun-runaway",
|
||||
Type: RuleTypeRunAway,
|
||||
Label: "重置雨云账号中所有云服务器(一键跑路)",
|
||||
Description: "触发时会重装雨云账号下所有云服务器来实现跑路",
|
||||
AccountType: AccountType,
|
||||
|
||||
@@ -2,32 +2,53 @@ package rainyun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// RainyunExecutor 雨云执行器
|
||||
// RainyunWorkorderExecutor 雨云工单执行器
|
||||
type RainyunWorkorderExecutor struct{}
|
||||
|
||||
func (e *RainyunWorkorderExecutor) GetType() string {
|
||||
return RuleType
|
||||
return RuleTypeWorkOrder
|
||||
}
|
||||
|
||||
func (e *RainyunWorkorderExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行雨云工单规则: %s", rule.Name)
|
||||
func (e *RainyunWorkorderExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendRainyunWorkorderMsg(ctx, rule)
|
||||
config, err := ParseWorkOrderConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendWorkOrder(ctx, account, config)
|
||||
}
|
||||
|
||||
// RainyunRunAwayExecutor 雨云跑路执行器
|
||||
type RainyunRunAwayExecutor struct{}
|
||||
|
||||
func (e *RainyunRunAwayExecutor) GetType() string {
|
||||
return "rainyun-runaway"
|
||||
return RuleTypeRunAway
|
||||
}
|
||||
|
||||
func (e *RainyunRunAwayExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行雨云跑路规则: %s", rule.Name)
|
||||
return RainyunRunAway(ctx, rule)
|
||||
func (e *RainyunRunAwayExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 跑路规则只要求用户填一句免责声明,执行时重新校验一次
|
||||
config, err := ParseRunAwayConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.IK != runAwayConfirmText {
|
||||
return fmt.Errorf("请输入正确内容")
|
||||
}
|
||||
|
||||
return RunAway(ctx, account)
|
||||
}
|
||||
|
||||
@@ -4,31 +4,43 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/public"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rcs"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rgs"
|
||||
)
|
||||
|
||||
// 从 Rule 中获取 RainyunConfig
|
||||
func getRainyunConfigFromRule(rule *model.Rule) (*RainyunWorkOrderRule, error) {
|
||||
var rainyunConfig RainyunWorkOrderRule
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &rainyunConfig); err != nil {
|
||||
return nil, fmt.Errorf("解析Rainyun规则配置失败: %w", err)
|
||||
// ParseAccountConfig 解析雨云账号配置
|
||||
func ParseAccountConfig(config string) (*RainyunAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析雨云账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
return &rainyunConfig, nil
|
||||
|
||||
var account RainyunAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析雨云账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// 从 Rule 中获取 RainyunAccount
|
||||
func getRainyunAccountFromRule(rule *model.Rule) (*RainyunAccount, error) {
|
||||
var rainyunAccount RainyunAccount
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &rainyunAccount); err != nil {
|
||||
return nil, err
|
||||
// ParseWorkOrderConfig 解析雨云工单规则配置
|
||||
func ParseWorkOrderConfig(configJSON string) (*RainyunWorkOrderRule, error) {
|
||||
var config RainyunWorkOrderRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析雨云工单规则配置失败: %v", err)
|
||||
}
|
||||
return &rainyunAccount, nil
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// ParseRunAwayConfig 解析雨云跑路规则配置
|
||||
func ParseRunAwayConfig(configJSON string) (*RainyunRunAwayRule, error) {
|
||||
var config RainyunRunAwayRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析雨云跑路规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// reinstallOneRCS 重装一台 RCS
|
||||
func reinstallOneRCS(rcsID int, rcsClient *rcs.Client) error {
|
||||
rcsDetail, err := rcsClient.GetRcsDetails(rcsID)
|
||||
if err != nil {
|
||||
@@ -42,20 +54,17 @@ func reinstallOneRCS(rcsID int, rcsClient *rcs.Client) error {
|
||||
|
||||
for _, os := range rcsOSList.Data {
|
||||
if os.Region == rcsDetail.Data.Data.Node.Region && os.OsType == "linux" {
|
||||
rcsReinstallRequest := &rcs.ReinstallRcsRequest{
|
||||
_, err := rcsClient.ReinstallRcs(rcsID, &rcs.ReinstallRcsRequest{
|
||||
OsID: os.ID,
|
||||
ResetOsd: true,
|
||||
}
|
||||
_, err := rcsClient.ReinstallRcs(rcsID, rcsReinstallRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return fmt.Errorf("没有找到可用的 linux 镜像")
|
||||
}
|
||||
|
||||
// reinstallOneRGS 重装一台 RGS
|
||||
func reinstallOneRGS(rgsID int, rgsClient *rgs.Client) error {
|
||||
rgsDetail, err := rgsClient.GetRgsDetails(rgsID)
|
||||
if err != nil {
|
||||
@@ -69,16 +78,12 @@ func reinstallOneRGS(rgsID int, rgsClient *rgs.Client) error {
|
||||
|
||||
for _, os := range rgsOSList.Data {
|
||||
if os.Region == rgsDetail.Data.Data.Node.Region && os.OsType == "linux" {
|
||||
rgsReinstallRequest := &rcs.ReinstallRcsRequest{
|
||||
_, err := rgsClient.Reinstallgs(rgsID, &rcs.ReinstallRcsRequest{
|
||||
OsID: os.ID,
|
||||
ResetOsd: true,
|
||||
}
|
||||
_, err := rgsClient.Reinstallgs(rgsID, rgsReinstallRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return fmt.Errorf("没有找到可用的 linux 镜像")
|
||||
}
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// LoadAccountConfig 读取账号并把 Config 字段反序列化到 out。
|
||||
// GetAccount 读取一个账号,返回数据库里的原始记录。
|
||||
//
|
||||
// 各个 driver 之前都各自实现了一遍这段逻辑,其中 github driver 甚至
|
||||
// 直接把驱动内的结构体当成 gorm model 查询,这里统一收敛。
|
||||
func LoadAccountConfig(accountID uint, out any) error {
|
||||
// 规则执行需要的账号凭据由上层(handler/runner)在这里取好再传给 driver,
|
||||
// driver 自己不碰数据库。accountID 为 0 表示该规则不需要账号,返回 (nil, nil)。
|
||||
func GetAccount(accountID uint) (*model.Account, error) {
|
||||
if accountID == 0 {
|
||||
return fmt.Errorf("该规则没有关联账号")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
gormDB, err := GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
if err := gormDB.First(&account, accountID).Error; err != nil {
|
||||
return fmt.Errorf("获取账号(ID: %d)失败: %w", accountID, err)
|
||||
return nil, fmt.Errorf("获取账号(ID: %d)失败: %w", accountID, err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(account.Config), out); err != nil {
|
||||
return fmt.Errorf("解析账号(ID: %d)配置失败: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ func MustInit() *gorm.DB {
|
||||
if err := ensureAdmin(gormDB); err != nil {
|
||||
log.Fatalf("初始化管理员失败: %v", err)
|
||||
}
|
||||
if err := migrateGatewayRules(gormDB); err != nil {
|
||||
log.Fatalf("迁移消息网关规则失败: %v", err)
|
||||
}
|
||||
return gormDB
|
||||
}
|
||||
|
||||
@@ -136,7 +139,9 @@ func AutoMigrate(gormDB *gorm.DB) error {
|
||||
&model.User{},
|
||||
&model.Timer{},
|
||||
&model.Rule{},
|
||||
&model.GatewayRule{},
|
||||
&model.Account{},
|
||||
&model.ExecutionLog{},
|
||||
&model.MessageGateway{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// legacyRule 是迁移前的 rules 表结构,只用来读出已经废弃的 gateway_id
|
||||
type legacyRule struct {
|
||||
ID uint
|
||||
UID uint
|
||||
GatewayID uint
|
||||
Name string
|
||||
AccountID uint
|
||||
Type string
|
||||
ConfigJson string
|
||||
Enabled bool
|
||||
CreateAt int64
|
||||
}
|
||||
|
||||
// migrateGatewayRules 把历史数据里混在 rules 表中的消息网关规则搬到 gateway_rules 表。
|
||||
//
|
||||
// v2.1 之前消息网关规则和定时器规则共用 rules 表,靠 gateway_id 区分;
|
||||
// 现在两者分表存储,这里做一次性搬迁,避免老用户升级后规则凭空消失。
|
||||
func migrateGatewayRules(gormDB *gorm.DB) error {
|
||||
if !gormDB.Migrator().HasColumn("rules", "gateway_id") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var legacy []legacyRule
|
||||
if err := gormDB.Table("rules").Where("gateway_id <> 0").Find(&legacy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(legacy) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return gormDB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, rule := range legacy {
|
||||
moved := model.GatewayRule{
|
||||
UID: rule.UID,
|
||||
GatewayID: rule.GatewayID,
|
||||
Name: rule.Name,
|
||||
AccountID: rule.AccountID,
|
||||
Type: rule.Type,
|
||||
ConfigJson: rule.ConfigJson,
|
||||
Enabled: rule.Enabled,
|
||||
CreateAt: rule.CreateAt,
|
||||
}
|
||||
if err := tx.Create(&moved).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM rules WHERE id = ?", rule.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Printf("已迁移 %d 条消息网关规则到 gateway_rules 表", len(legacy))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -6,20 +6,16 @@ import (
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/drivers/bilibili"
|
||||
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
|
||||
"github.com/ssdomei232/goodBaby/drivers/email"
|
||||
"github.com/ssdomei232/goodBaby/drivers/fwalert"
|
||||
"github.com/ssdomei232/goodBaby/drivers/github"
|
||||
"github.com/ssdomei232/goodBaby/drivers/onebot"
|
||||
"github.com/ssdomei232/goodBaby/drivers/rainyun"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// RuleExecutor 规则执行器接口
|
||||
// RuleExecutor 规则执行器接口。
|
||||
//
|
||||
// 执行一条规则需要的一切(规则本体、账号凭据)都由上层组装成 model.RuleTask,
|
||||
// driver 只负责把动作做出去:不查数据库、不写日志,因此每个实现都能单独测试。
|
||||
type RuleExecutor interface {
|
||||
// Execute 执行规则,ctx 决定了重试的最长时间
|
||||
Execute(ctx context.Context, rule *model.Rule) error
|
||||
Execute(ctx context.Context, task *model.RuleTask) error
|
||||
// GetType 获取执行器支持的规则类型
|
||||
GetType() string
|
||||
}
|
||||
@@ -42,13 +38,13 @@ func (er *ExecutorRegistry) Register(executor RuleExecutor) {
|
||||
}
|
||||
|
||||
// Execute 根据规则类型执行规则
|
||||
func (er *ExecutorRegistry) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
executor, exists := er.executors[rule.Type]
|
||||
func (er *ExecutorRegistry) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
executor, exists := er.executors[task.Rule.Type]
|
||||
if !exists {
|
||||
return fmt.Errorf("不支持的规则类型: %s", rule.Type)
|
||||
return fmt.Errorf("不支持的规则类型: %s", task.Rule.Type)
|
||||
}
|
||||
|
||||
return executor.Execute(ctx, rule)
|
||||
return executor.Execute(ctx, task)
|
||||
}
|
||||
|
||||
// GetSupportedTypes 获取所有支持的规则类型
|
||||
@@ -66,28 +62,6 @@ var (
|
||||
globalExecutorRegistry *ExecutorRegistry
|
||||
)
|
||||
|
||||
// InitExecutorRegistry 初始化执行器注册表并注册所有执行器
|
||||
func InitExecutorRegistry() *ExecutorRegistry {
|
||||
registryOnce.Do(func() {
|
||||
registry := NewExecutorRegistry()
|
||||
|
||||
// 注册所有规则执行器
|
||||
registry.Register(&bilibili.BilibiliDynamicExecutor{})
|
||||
registry.Register(&bilibili.BilibiliPrivateMessageExecutor{})
|
||||
registry.Register(&email.EmailExecutor{})
|
||||
registry.Register(&github.GithubMakeRepoPublicExecutor{})
|
||||
registry.Register(&onebot.OneBotExecutor{})
|
||||
registry.Register(&dingtalk.DingTalkExecutor{})
|
||||
registry.Register(&fwalert.FwalertExecutor{})
|
||||
registry.Register(&rainyun.RainyunWorkorderExecutor{})
|
||||
registry.Register(&rainyun.RainyunRunAwayExecutor{})
|
||||
// 未来添加新规则类型时,在这里注册即可
|
||||
|
||||
globalExecutorRegistry = registry
|
||||
})
|
||||
return globalExecutorRegistry
|
||||
}
|
||||
|
||||
// GetGlobalExecutorRegistry 获取全局执行器注册表
|
||||
func GetGlobalExecutorRegistry() *ExecutorRegistry {
|
||||
if globalExecutorRegistry == nil {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"github.com/ssdomei232/goodBaby/drivers/alidns"
|
||||
"github.com/ssdomei232/goodBaby/drivers/bilibili"
|
||||
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
|
||||
"github.com/ssdomei232/goodBaby/drivers/email"
|
||||
"github.com/ssdomei232/goodBaby/drivers/fwalert"
|
||||
"github.com/ssdomei232/goodBaby/drivers/github"
|
||||
"github.com/ssdomei232/goodBaby/drivers/onebot"
|
||||
"github.com/ssdomei232/goodBaby/drivers/rainyun"
|
||||
)
|
||||
|
||||
// InitExecutorRegistry 初始化执行器注册表并注册所有执行器
|
||||
func InitExecutorRegistry() *ExecutorRegistry {
|
||||
registryOnce.Do(func() {
|
||||
registry := NewExecutorRegistry()
|
||||
|
||||
// 注册所有规则执行器
|
||||
registry.Register(&bilibili.BilibiliDynamicExecutor{})
|
||||
registry.Register(&bilibili.BilibiliPrivateMessageExecutor{})
|
||||
registry.Register(&email.EmailExecutor{})
|
||||
registry.Register(&github.GithubMakeRepoPublicExecutor{})
|
||||
registry.Register(&onebot.OneBotExecutor{})
|
||||
registry.Register(&dingtalk.DingTalkExecutor{})
|
||||
registry.Register(&fwalert.FwalertExecutor{})
|
||||
registry.Register(&rainyun.RainyunWorkorderExecutor{})
|
||||
registry.Register(&rainyun.RainyunRunAwayExecutor{})
|
||||
registry.Register(&alidns.DeleteAliDNSRecordExecutor{})
|
||||
// 未来添加新规则类型时,在这里注册即可
|
||||
|
||||
globalExecutorRegistry = registry
|
||||
})
|
||||
return globalExecutorRegistry
|
||||
}
|
||||
@@ -53,7 +53,18 @@ func Runner(timer *model.Timer) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ExecuteRule 执行单个规则并记录执行日志
|
||||
// NewTask 组装执行一条规则所需的输入:规则本体 + 从数据库取出的关联账号。
|
||||
//
|
||||
// 数据库访问只发生在这一层,driver 拿到的 task 已经是自包含的。
|
||||
func NewTask(rule *model.Rule) (*model.RuleTask, error) {
|
||||
account, err := db.GetAccount(rule.AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.RuleTask{Rule: rule, Account: account}, nil
|
||||
}
|
||||
|
||||
// ExecuteRule 执行单条规则并记录执行日志
|
||||
func ExecuteRule(rule *model.Rule, trigger string) error {
|
||||
ctx, cancel := retry.ExecutionContext()
|
||||
defer cancel()
|
||||
@@ -62,24 +73,54 @@ func ExecuteRule(rule *model.Rule, trigger string) error {
|
||||
|
||||
// ExecuteRuleWithContext 使用给定 context 执行规则,手动测试时可传入较短的超时
|
||||
func ExecuteRuleWithContext(ctx context.Context, rule *model.Rule, trigger string) error {
|
||||
err := GetGlobalExecutorRegistry().Execute(ctx, rule)
|
||||
|
||||
message := "执行成功"
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
log.Printf("执行规则失败 [ID: %d, Type: %s]: %v", rule.ID, rule.Type, err)
|
||||
}
|
||||
|
||||
logstore.Record(&model.ExecutionLog{
|
||||
task, err := NewTask(rule)
|
||||
return execTask(ctx, task, err, &model.ExecutionLog{
|
||||
UID: rule.UID,
|
||||
RuleID: rule.ID,
|
||||
RuleName: rule.Name,
|
||||
RuleType: rule.Type,
|
||||
TimerID: rule.TimerID,
|
||||
Trigger: trigger,
|
||||
Success: err == nil,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// ExecuteGatewayRuleWithContext 执行一条消息网关规则,日志里带上网关信息。
|
||||
//
|
||||
// 与定时器规则走同一条执行链路,区别只有触发来源与日志字段。
|
||||
func ExecuteGatewayRuleWithContext(ctx context.Context, rule *model.GatewayRule, trigger string) error {
|
||||
task, err := NewTask(rule.AsRule())
|
||||
return execTask(ctx, task, err, &model.ExecutionLog{
|
||||
UID: rule.UID,
|
||||
RuleName: rule.Name,
|
||||
RuleType: rule.Type,
|
||||
GatewayID: rule.GatewayID,
|
||||
GatewayRuleID: rule.ID,
|
||||
Trigger: trigger,
|
||||
})
|
||||
}
|
||||
|
||||
// execTask 执行规则并写入执行日志。
|
||||
//
|
||||
// taskErr 是组装输入时的错误(例如账号已被删除):这时不会走到 driver,
|
||||
// 但同样要留下一条失败日志,方便用户在 WebUI 上看到原因。
|
||||
func execTask(ctx context.Context, task *model.RuleTask, taskErr error, entry *model.ExecutionLog) error {
|
||||
if taskErr != nil {
|
||||
return record(entry, taskErr)
|
||||
}
|
||||
|
||||
log.Printf("执行规则 [%s] %s (触发来源: %s)", entry.RuleType, entry.RuleName, entry.Trigger)
|
||||
return record(entry, GetGlobalExecutorRegistry().Execute(ctx, task))
|
||||
}
|
||||
|
||||
// record 写入执行日志并返回原始错误
|
||||
func record(entry *model.ExecutionLog, err error) error {
|
||||
entry.Success = err == nil
|
||||
entry.Message = "执行成功"
|
||||
if err != nil {
|
||||
entry.Message = err.Error()
|
||||
log.Printf("执行规则失败 [%s] %s: %v", entry.RuleType, entry.RuleName, err)
|
||||
}
|
||||
|
||||
logstore.Record(entry)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package accountConfigChecker
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/drivers/alidns"
|
||||
"github.com/ssdomei232/goodBaby/drivers/bilibili"
|
||||
"github.com/ssdomei232/goodBaby/drivers/email"
|
||||
"github.com/ssdomei232/goodBaby/drivers/github"
|
||||
@@ -26,6 +27,7 @@ func InitValidatorRegistry() *ValidatorRegistry {
|
||||
r.Register(&github.GitHubAccountConfigValidator{})
|
||||
r.Register(&onebot.OneBotAccountConfigValidator{})
|
||||
r.Register(&rainyun.RainyunAccountConfigValidator{})
|
||||
r.Register(&alidns.AliDNSAccountConfigValidator{})
|
||||
|
||||
registry = r
|
||||
})
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package gateway 定义消息网关的扩展接口与注册表。
|
||||
//
|
||||
// 与 drivers 的分工:driver 负责把一条规则执行出去(发邮件、发消息……),
|
||||
// gateway 负责把外部系统投递进来的消息派发给绑定在网关上的规则。
|
||||
// 新增一种网关只要实现 Gateway 并在 reg.go 里注册。
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// Message 是外部系统投递进来的一条消息
|
||||
type Message struct {
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
// Result 是一次投递的汇总结果
|
||||
type Result struct {
|
||||
Total int `json:"total"`
|
||||
Failed []string `json:"failed"`
|
||||
}
|
||||
|
||||
// Task 是一次投递所需的全部输入:网关与待触发的规则都由上层从数据库取出
|
||||
type Task struct {
|
||||
Gateway *model.MessageGateway
|
||||
Rules []model.GatewayRule
|
||||
Message Message
|
||||
}
|
||||
|
||||
// Gateway 一种消息网关
|
||||
type Gateway interface {
|
||||
// GetType 获取网关类型标识
|
||||
GetType() string
|
||||
// Meta 返回给 WebUI 展示用的元数据
|
||||
Meta() meta.GatewayMeta
|
||||
// Deliver 把投递进来的消息派发给 task 里的规则
|
||||
Deliver(ctx context.Context, task *Task) (*Result, error)
|
||||
}
|
||||
|
||||
// Registry 网关注册表
|
||||
type Registry struct {
|
||||
gateways map[string]Gateway
|
||||
}
|
||||
|
||||
// NewRegistry 创建新的网关注册表
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
gateways: make(map[string]Gateway),
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册消息网关
|
||||
func (r *Registry) Register(g Gateway) {
|
||||
r.gateways[g.GetType()] = g
|
||||
}
|
||||
|
||||
// Resolve 按类型取网关实现,类型为空时回退到默认类型
|
||||
func (r *Registry) Resolve(gatewayType string) (Gateway, bool) {
|
||||
if gatewayType == "" {
|
||||
gatewayType = model.GatewayTypeWebhook
|
||||
}
|
||||
|
||||
g, exists := r.gateways[gatewayType]
|
||||
return g, exists
|
||||
}
|
||||
|
||||
// Deliver 按网关类型派发一次投递
|
||||
func (r *Registry) Deliver(ctx context.Context, gatewayType string, task *Task) (*Result, error) {
|
||||
g, exists := r.Resolve(gatewayType)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("不支持的消息网关类型: %s", gatewayType)
|
||||
}
|
||||
|
||||
return g.Deliver(ctx, task)
|
||||
}
|
||||
|
||||
// GetSupportedTypes 获取所有支持的网关类型
|
||||
func (r *Registry) GetSupportedTypes() []string {
|
||||
types := make([]string, 0, len(r.gateways))
|
||||
for t := range r.gateways {
|
||||
types = append(types, t)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// Metas 返回所有网关类型的元数据,按类型名排序
|
||||
func (r *Registry) Metas() []meta.GatewayMeta {
|
||||
metas := make([]meta.GatewayMeta, 0, len(r.gateways))
|
||||
for _, g := range r.gateways {
|
||||
metas = append(metas, g.Meta())
|
||||
}
|
||||
sort.Slice(metas, func(i, j int) bool { return metas[i].Type < metas[j].Type })
|
||||
return metas
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package gateway
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
registry *Registry
|
||||
)
|
||||
|
||||
// InitGatewayRegistry 返回全局唯一的消息网关注册表
|
||||
func InitGatewayRegistry() *Registry {
|
||||
once.Do(func() {
|
||||
r := NewRegistry()
|
||||
|
||||
// 注册所有消息网关
|
||||
r.Register(&WebhookGateway{})
|
||||
// 未来添加新网关类型时,在这里注册即可
|
||||
|
||||
registry = r
|
||||
})
|
||||
return registry
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// NewToken 生成一个消息网关的访问 Token
|
||||
func NewToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "gw_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/handler/runner"
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// messageFields 规则配置里可以被投递消息覆盖的字段名
|
||||
var messageFields = []string{"msg", "message", "body"}
|
||||
|
||||
// titleField 规则配置里可以被投递标题覆盖的字段名
|
||||
const titleField = "title"
|
||||
|
||||
// TestMessage 是「测试」网关规则时使用的示例投递内容,
|
||||
// 让用户在页面上就能看到消息被替换后的效果。
|
||||
var TestMessage = Message{
|
||||
Title: "goodBaby 测试标题",
|
||||
Content: "这是一条来自消息网关的测试消息",
|
||||
}
|
||||
|
||||
// WebhookGateway 默认网关:外部系统 POST 一条消息来触发规则
|
||||
type WebhookGateway struct{}
|
||||
|
||||
func (g *WebhookGateway) GetType() string {
|
||||
return model.GatewayTypeWebhook
|
||||
}
|
||||
|
||||
func (g *WebhookGateway) Meta() meta.GatewayMeta {
|
||||
return meta.GatewayMeta{
|
||||
Type: model.GatewayTypeWebhook,
|
||||
Label: "Webhook",
|
||||
Description: "为外部系统生成一个 Webhook 地址,POST 一条消息即可触发绑定在该网关上的规则。",
|
||||
Docs: "docs/gateway-config.md",
|
||||
PayloadHint: `{"title": "监控告警", "message": "服务已恢复"}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver 逐条执行绑定在网关上的规则
|
||||
//
|
||||
// 单条规则失败不影响其它规则,失败原因汇总在 Result 里返回。
|
||||
func (g *WebhookGateway) Deliver(ctx context.Context, task *Task) (*Result, error) {
|
||||
// 初始化为空切片:nil 切片会被序列化成 JSON null,前端读 failed.length 时会报错
|
||||
failed := []string{}
|
||||
|
||||
for i := range task.Rules {
|
||||
rule := task.Rules[i]
|
||||
|
||||
executed := rule
|
||||
configJSON, applied, err := ApplyMessage(rule.ConfigJson, task.Message)
|
||||
if err != nil {
|
||||
failed = append(failed, rule.Name+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if applied {
|
||||
// 用投递进来的消息执行,但不改动数据库里的规则配置
|
||||
executed.ConfigJson = configJSON
|
||||
}
|
||||
if err := runner.ExecuteGatewayRuleWithContext(ctx, &executed, model.TriggerGateway); err != nil {
|
||||
failed = append(failed, rule.Name+": "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{Total: len(task.Rules), Failed: failed}, nil
|
||||
}
|
||||
|
||||
// ApplyMessage 把投递进来的消息覆盖到规则配置的消息字段上。
|
||||
//
|
||||
// 返回覆盖后的配置;applied 为 false 表示配置里没有可覆盖的消息字段
|
||||
// (例如「公开 GitHub 仓库」「删除 DNS 记录」),此时按规则里保存的配置执行。
|
||||
func ApplyMessage(configJSON string, msg Message) (string, bool, error) {
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return configJSON, false, fmt.Errorf("解析规则配置失败: %v", err)
|
||||
}
|
||||
|
||||
matched := false
|
||||
for _, key := range messageFields {
|
||||
if _, ok := config[key]; ok {
|
||||
if msg.Content != "" {
|
||||
config[key] = msg.Content
|
||||
}
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
if msg.Title != "" {
|
||||
if _, ok := config[titleField]; ok {
|
||||
config[titleField] = msg.Title
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
return configJSON, false, nil
|
||||
}
|
||||
|
||||
out, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return configJSON, false, fmt.Errorf("生成规则配置失败: %v", err)
|
||||
}
|
||||
return string(out), true, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package meta
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// GatewayMessagePlaceholder 是「由投递内容提供」的字段占位值。
|
||||
//
|
||||
// 这些字段在「网关规则」页面不由用户填写,真实内容来自 webhook 请求;
|
||||
// 但规则配置里必须保留该字段,投递时才知道要覆盖哪一个。
|
||||
const GatewayMessagePlaceholder = "由消息网关投递内容替换"
|
||||
|
||||
// GatewayMessageFields 返回所有由投递内容提供的字段
|
||||
func GatewayMessageFields(fields []Field) []Field {
|
||||
matched := make([]Field, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if f.GatewayMessage {
|
||||
matched = append(matched, f)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// FillGatewayMessages 给配置里「由投递内容提供」的字段补上占位值。
|
||||
//
|
||||
// 网关规则提交的配置里通常没有 title / msg 这类字段(前端不让填),
|
||||
// 校验前先补齐,规则才会既通过校验、又能在投递时被覆盖。
|
||||
func FillGatewayMessages(configJSON string, fields []Field) string {
|
||||
messageFields := GatewayMessageFields(fields)
|
||||
if len(messageFields) == 0 || configJSON == "" {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(configJSON), &raw); err != nil {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, f := range messageFields {
|
||||
if value, ok := raw[f.Key].(string); ok && value != "" {
|
||||
continue
|
||||
}
|
||||
raw[f.Key] = GatewayMessagePlaceholder
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
filled, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return configJSON
|
||||
}
|
||||
return string(filled)
|
||||
}
|
||||
@@ -26,8 +26,11 @@ type Field struct {
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Help string `json:"help,omitempty"`
|
||||
// Secret 为 true 的字段在读取接口中会被掩码
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
// GatewayMessage 为 true 的字段由消息网关投递的内容提供(msg / title 之类):
|
||||
// 在「网关规则」页面不需要用户填写,投递时会被请求里的 message / title 替换
|
||||
GatewayMessage bool `json:"gateway_message,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// AccountMeta 账号类型的元数据
|
||||
@@ -51,6 +54,16 @@ type RuleMeta struct {
|
||||
Fields []Field `json:"fields"`
|
||||
}
|
||||
|
||||
// GatewayMeta 消息网关类型的元数据
|
||||
type GatewayMeta struct {
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Docs string `json:"docs,omitempty"`
|
||||
// PayloadHint WebUI 上展示的请求体示例
|
||||
PayloadHint string `json:"payload_hint,omitempty"`
|
||||
}
|
||||
|
||||
// SecretKeys 返回需要掩码的字段名
|
||||
func SecretKeys(fields []Field) []string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
|
||||
@@ -31,6 +31,16 @@ func Do(ctx context.Context, op func() error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Permanent 标记一个重试没有意义的错误(例如凭据无效、配置错误)
|
||||
//
|
||||
// driver 里凡是重试也救不回来的失败都包一层,避免白白退避几个小时。
|
||||
func Permanent(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
|
||||
// ExecutionTimeout 返回配置中的规则执行超时时间
|
||||
func ExecutionTimeout() time.Duration {
|
||||
config, err := configs.GetConfig()
|
||||
|
||||
@@ -3,6 +3,7 @@ package ruleConfigChecker
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/drivers/alidns"
|
||||
"github.com/ssdomei232/goodBaby/drivers/bilibili"
|
||||
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
|
||||
"github.com/ssdomei232/goodBaby/drivers/email"
|
||||
@@ -32,6 +33,7 @@ func InitValidatorRegistry() *ValidatorRegistry {
|
||||
r.Register(&fwalert.FwalertRuleValidator{})
|
||||
r.Register(&rainyun.RainyunWorkorderRuleValidator{})
|
||||
r.Register(&rainyun.RainyunRunAwayRuleValidator{})
|
||||
r.Register(&alidns.AliDNSDeleteRecordRuleValidator{})
|
||||
|
||||
registry = r
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/ssdomei232/goodBaby/api/account"
|
||||
"github.com/ssdomei232/goodBaby/api/admin"
|
||||
"github.com/ssdomei232/goodBaby/api/dashboard"
|
||||
"github.com/ssdomei232/goodBaby/api/gateway"
|
||||
apilog "github.com/ssdomei232/goodBaby/api/log"
|
||||
apimeta "github.com/ssdomei232/goodBaby/api/meta"
|
||||
"github.com/ssdomei232/goodBaby/api/rule"
|
||||
@@ -65,6 +66,7 @@ func main() {
|
||||
v1.GET("/site", apimeta.HandleGetSiteInfo)
|
||||
v1.POST("/user/registry", user.HandleRegistry)
|
||||
v1.POST("/user/login", user.HandleLogin)
|
||||
v1.POST("/gateways/:token/webhook", gateway.HandleWebhook)
|
||||
}
|
||||
|
||||
// 需要认证的路由组
|
||||
@@ -104,6 +106,15 @@ func main() {
|
||||
rules.DELETE("/:ruleID", rule.HandleDeleteRule)
|
||||
}
|
||||
|
||||
gatewayRules := authorized.Group("/gateway-rules")
|
||||
{
|
||||
gatewayRules.GET("/", rule.HandleGetAllGatewayRules)
|
||||
gatewayRules.POST("/", rule.HandleCreateGatewayRule)
|
||||
gatewayRules.PUT("/:ruleID", rule.HandleEditGatewayRule)
|
||||
gatewayRules.POST("/:ruleID/test", rule.HandleTestGatewayRule)
|
||||
gatewayRules.DELETE("/:ruleID", rule.HandleDeleteGatewayRule)
|
||||
}
|
||||
|
||||
accounts := authorized.Group("/accounts")
|
||||
{
|
||||
accounts.GET("/", account.HandleGetAllAccounts)
|
||||
@@ -114,6 +125,13 @@ func main() {
|
||||
accounts.DELETE("/:accountID", account.HandleDeleteAccount)
|
||||
}
|
||||
|
||||
gateways := authorized.Group("/gateways")
|
||||
{
|
||||
gateways.GET("/", gateway.HandleList)
|
||||
gateways.POST("/", gateway.HandleCreate)
|
||||
gateways.DELETE("/:gatewayID", gateway.HandleDelete)
|
||||
}
|
||||
|
||||
logs := authorized.Group("/logs")
|
||||
{
|
||||
logs.GET("/", apilog.HandleGetLogs)
|
||||
@@ -151,7 +169,7 @@ func corsMiddleware(allowedOrigins []string) gin.HandlerFunc {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization")
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package model
|
||||
|
||||
// GatewayRule 是挂在消息网关上的通知规则。
|
||||
//
|
||||
// 与 Rule 的区别只有触发来源:Rule 由定时器到期触发,
|
||||
// GatewayRule 由外部系统投递到网关的消息触发,两者互不干扰。
|
||||
type GatewayRule struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UID uint `gorm:"index" json:"uid"`
|
||||
GatewayID uint `gorm:"index" json:"gateway_id"`
|
||||
Name string `json:"name"`
|
||||
AccountID uint `json:"account_id"`
|
||||
Type string `json:"type"`
|
||||
ConfigJson string `json:"config_json"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
}
|
||||
|
||||
// AsRule 把网关规则换成统一的执行结构。
|
||||
//
|
||||
// 执行层只认识 Rule:投递消息前把外部消息覆盖到 ConfigJson 上,
|
||||
// 再交给同一个 runner 执行即可。
|
||||
func (r *GatewayRule) AsRule() *Rule {
|
||||
return &Rule{
|
||||
UID: r.UID,
|
||||
Name: r.Name,
|
||||
AccountID: r.AccountID,
|
||||
Type: r.Type,
|
||||
ConfigJson: r.ConfigJson,
|
||||
Enabled: r.Enabled,
|
||||
CreateAt: r.CreateAt,
|
||||
}
|
||||
}
|
||||
|
||||
// GatewayRuleRequest 创建/编辑消息网关规则的请求体
|
||||
type GatewayRuleRequest struct {
|
||||
Name string `json:"name"`
|
||||
GatewayID uint `json:"gateway_id"`
|
||||
AccountID uint `json:"account_id"`
|
||||
Type string `json:"type"`
|
||||
ConfigJson string `json:"config_json"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Validate 校验网关规则请求中与类型无关的通用部分
|
||||
func (r *GatewayRuleRequest) Validate() error {
|
||||
if r.Name == "" {
|
||||
return ErrValidation("规则名称不能为空")
|
||||
}
|
||||
if len(r.Name) > 64 {
|
||||
return ErrValidation("规则名称过长")
|
||||
}
|
||||
if r.Type == "" {
|
||||
return ErrValidation("规则类型不能为空")
|
||||
}
|
||||
if r.GatewayID == 0 {
|
||||
return ErrValidation("规则必须关联一个消息网关")
|
||||
}
|
||||
if r.ConfigJson == "" {
|
||||
return ErrValidation("规则配置不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,9 +2,10 @@ package model
|
||||
|
||||
// 执行日志的触发来源
|
||||
const (
|
||||
TriggerTimer = "timer" // Timer 到期自动触发
|
||||
TriggerManual = "manual" // 用户在 WebUI 手动测试
|
||||
TriggerRemind = "remind" // 提醒通知
|
||||
TriggerTimer = "timer" // Timer 到期自动触发
|
||||
TriggerManual = "manual" // 用户在 WebUI 手动测试
|
||||
TriggerRemind = "remind" // 提醒通知
|
||||
TriggerGateway = "gateway" // 外部系统投递到消息网关触发
|
||||
)
|
||||
|
||||
// ExecutionLog 记录每一次规则执行/提醒的结果,供 WebUI 展示
|
||||
@@ -15,8 +16,11 @@ type ExecutionLog struct {
|
||||
RuleName string `json:"rule_name"`
|
||||
RuleType string `json:"rule_type"`
|
||||
TimerID uint `json:"timer_id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
CreateAt int64 `gorm:"index" json:"create_at"`
|
||||
// GatewayID / GatewayRuleID 只有消息网关投递触发时才有值
|
||||
GatewayID uint `json:"gateway_id"`
|
||||
GatewayRuleID uint `json:"gateway_rule_id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
CreateAt int64 `gorm:"index" json:"create_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
// GatewayTypeWebhook 默认的网关类型:外部系统 POST 一条消息来触发规则
|
||||
const GatewayTypeWebhook = "webhook"
|
||||
|
||||
type MessageGateway struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UID uint `gorm:"index" json:"uid"`
|
||||
Name string `json:"name"`
|
||||
// Type 网关类型,见 internal/gateway 注册表
|
||||
Type string `gorm:"default:webhook" json:"type"`
|
||||
Token string `gorm:"uniqueIndex;size:67" json:"token"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
}
|
||||
|
||||
type MessageGatewayRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Validate 校验网关请求中与类型无关的通用部分
|
||||
func (r *MessageGatewayRequest) Validate() error {
|
||||
if r.Name == "" {
|
||||
return ErrValidation("网关名称不能为空")
|
||||
}
|
||||
if len(r.Name) > 64 {
|
||||
return ErrValidation("网关名称过长")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (r *RuleRequest) Validate() error {
|
||||
return ErrValidation("规则类型不能为空")
|
||||
}
|
||||
if r.TimerID == 0 {
|
||||
return ErrValidation("必须关联一个 Timer")
|
||||
return ErrValidation("规则必须关联一个定时器")
|
||||
}
|
||||
if r.ConfigJson == "" {
|
||||
return ErrValidation("规则配置不能为空")
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
// RuleTask 是一次规则执行所需的全部输入。
|
||||
//
|
||||
// 规则本体与关联账号都由上层(handler/runner、消息网关)从数据库取出后传进来,
|
||||
// driver 只负责把动作做出去,所以底层实现里不会再出现任何数据库操作。
|
||||
type RuleTask struct {
|
||||
// Rule 规则本体,永远不会为空
|
||||
Rule *Rule
|
||||
// Account 规则关联的账号;该规则类型不需要账号时为 nil
|
||||
Account *Account
|
||||
}
|
||||
|
||||
// AccountConfig 返回关联账号的配置 JSON,没有关联账号时返回空字符串
|
||||
func (t *RuleTask) AccountConfig() string {
|
||||
if t == nil || t.Account == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Account.Config
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type User struct {
|
||||
CreateAt int64 `json:"create_at"`
|
||||
Username string `gorm:"uniqueIndex" json:"username"`
|
||||
Password string `json:"-"`
|
||||
APIKey string `gorm:"index;size:67" json:"api_key"`
|
||||
// 管理员可以修改系统配置。第一个注册的用户自动成为管理员。
|
||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||
// 钉钉机器人配置(JSON 字符串),用于接收提醒
|
||||
|
||||
@@ -6,10 +6,14 @@ import type {
|
||||
AdminConfigResponse,
|
||||
DashboardOverview,
|
||||
ExecutionLog,
|
||||
GatewayRule,
|
||||
GatewayRuleRequest,
|
||||
LogPage,
|
||||
MessageGateway,
|
||||
Providers,
|
||||
Rule,
|
||||
RuleRequest,
|
||||
AccountDeleteImpact,
|
||||
SiteInfo,
|
||||
Timer,
|
||||
TimerRequest,
|
||||
@@ -65,13 +69,22 @@ export const ruleApi = {
|
||||
remove: (id: number) => api.delete<string>(`/rules/${id}`),
|
||||
}
|
||||
|
||||
export const gatewayRuleApi = {
|
||||
list: (gatewayId?: number) =>
|
||||
api.get<GatewayRule[]>('/gateway-rules/', gatewayId ? { gateway_id: gatewayId } : undefined),
|
||||
create: (body: GatewayRuleRequest) => api.post<GatewayRule>('/gateway-rules/', body),
|
||||
update: (id: number, body: GatewayRuleRequest) => api.put<GatewayRule>(`/gateway-rules/${id}`, body),
|
||||
test: (id: number) => api.post<string>(`/gateway-rules/${id}/test`),
|
||||
remove: (id: number) => api.delete<string>(`/gateway-rules/${id}`),
|
||||
}
|
||||
|
||||
export const accountApi = {
|
||||
list: (type?: string) =>
|
||||
api.get<Account[]>('/accounts/', type ? { type } : undefined),
|
||||
create: (body: AccountRequest) => api.post<Account>('/accounts/', body),
|
||||
update: (id: number, body: AccountRequest) => api.put<Account>(`/accounts/${id}`, body),
|
||||
test: (id: number) => api.post<string>(`/accounts/${id}/test`),
|
||||
checkDelete: (id: number) => api.get<Rule[]>(`/accounts/${id}/check`),
|
||||
checkDelete: (id: number) => api.get<AccountDeleteImpact>(`/accounts/${id}/check`),
|
||||
remove: (id: number) => api.delete<string>(`/accounts/${id}`),
|
||||
}
|
||||
|
||||
@@ -81,6 +94,12 @@ export const logApi = {
|
||||
clear: () => api.delete<string>('/logs/'),
|
||||
}
|
||||
|
||||
export const gatewayApi = {
|
||||
list: () => api.get<MessageGateway[]>('/gateways/'),
|
||||
create: (name: string, type: string) => api.post<MessageGateway>('/gateways/', { name, type }),
|
||||
remove: (id: number) => api.delete<string>(`/gateways/${id}`),
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getConfig: () => api.get<AdminConfigResponse>('/admin/config'),
|
||||
updateConfig: (body: AdminConfig) => api.put<AdminConfig>('/admin/config', body),
|
||||
|
||||
@@ -10,7 +10,8 @@ export interface UserInfo {
|
||||
create_at: number
|
||||
username: string
|
||||
is_admin: boolean
|
||||
dingtalk_config: string | null
|
||||
dingtalk_config: string | null
|
||||
api_key: string
|
||||
}
|
||||
|
||||
export interface Timer {
|
||||
@@ -48,6 +49,28 @@ export interface Rule {
|
||||
create_at: number
|
||||
}
|
||||
|
||||
/** 消息网关规则:由外部系统投递到网关的消息触发,与定时器规则分开存储 */
|
||||
export interface GatewayRule {
|
||||
id: number
|
||||
uid: number
|
||||
gateway_id: number
|
||||
name: string
|
||||
account_id: number
|
||||
type: string
|
||||
config_json: string
|
||||
enabled: boolean
|
||||
create_at: number
|
||||
}
|
||||
|
||||
export interface MessageGateway {
|
||||
id: number
|
||||
uid: number
|
||||
name: string
|
||||
type: string
|
||||
token: string
|
||||
create_at: number
|
||||
}
|
||||
|
||||
export interface RuleRequest {
|
||||
name: string
|
||||
timer_id: number
|
||||
@@ -57,6 +80,15 @@ export interface RuleRequest {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface GatewayRuleRequest {
|
||||
name: string
|
||||
gateway_id: number
|
||||
account_id: number
|
||||
type: string
|
||||
config_json: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: number
|
||||
uid: number
|
||||
@@ -89,6 +121,8 @@ export interface MetaField {
|
||||
placeholder?: string
|
||||
help?: string
|
||||
secret?: boolean
|
||||
/** 由消息网关投递的请求内容提供,网关规则页面不需要用户填写 */
|
||||
gateway_message?: boolean
|
||||
default?: unknown
|
||||
}
|
||||
|
||||
@@ -110,9 +144,18 @@ export interface RuleMeta {
|
||||
fields: MetaField[]
|
||||
}
|
||||
|
||||
export interface GatewayMeta {
|
||||
type: string
|
||||
label: string
|
||||
description?: string
|
||||
docs?: string
|
||||
payload_hint?: string
|
||||
}
|
||||
|
||||
export interface Providers {
|
||||
accounts: AccountMeta[]
|
||||
rules: RuleMeta[]
|
||||
gateways: GatewayMeta[]
|
||||
}
|
||||
|
||||
export interface ExecutionLog {
|
||||
@@ -122,7 +165,9 @@ export interface ExecutionLog {
|
||||
rule_name: string
|
||||
rule_type: string
|
||||
timer_id: number
|
||||
trigger: 'timer' | 'manual' | 'remind'
|
||||
gateway_id: number
|
||||
gateway_rule_id: number
|
||||
trigger: 'timer' | 'manual' | 'remind' | 'gateway'
|
||||
success: boolean
|
||||
message: string
|
||||
create_at: number
|
||||
@@ -140,6 +185,8 @@ export interface DashboardOverview {
|
||||
enabled_timers: number
|
||||
triggered_count: number
|
||||
rule_count: number
|
||||
gateway_count: number
|
||||
gateway_rule_count: number
|
||||
account_count: number
|
||||
server_time: number
|
||||
recent_logs: ExecutionLog[]
|
||||
@@ -147,6 +194,12 @@ export interface DashboardOverview {
|
||||
urgent_seconds_left?: number
|
||||
}
|
||||
|
||||
/** 删除账号时会被一并删除的规则 */
|
||||
export interface AccountDeleteImpact {
|
||||
rules: Rule[]
|
||||
gateway_rules: GatewayRule[]
|
||||
}
|
||||
|
||||
export interface SiteInfo {
|
||||
name: string
|
||||
enable_registry: boolean
|
||||
|
||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,569 @@
|
||||
/* ============================================================
|
||||
妄想症 · Paranoia 主题(致敬篇)
|
||||
------------------------------------------------------------
|
||||
在 <html> 上挂 .paranoia 即启用,可与 .dark 叠加:
|
||||
|
||||
html.paranoia 现实 —— 白之章,近乎空白的纸色
|
||||
html.paranoia.dark 妄想 —— 黑之章,石蒜红与深渊
|
||||
|
||||
取色依据原作 PV 的用色习惯:
|
||||
石蒜红 #e0245e 彼岸花与零羽
|
||||
洛天依蓝 #66ccff 泠珞(也是 goodBaby 原本的品牌色)
|
||||
言和青 #7ee8e0 / 言和黄 #f2d16b 颜语的守护者与加害者两面
|
||||
============================================================ */
|
||||
|
||||
html.paranoia {
|
||||
/* 标题用衬线体,制造「档案 / 卷宗」的观感 */
|
||||
--pa-serif: 'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', STSong, SimSun, serif;
|
||||
--pa-mono: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
|
||||
--pa-crimson: #e0245e;
|
||||
--pa-crimson-soft: #f2568a;
|
||||
--pa-blue: #66ccff;
|
||||
--pa-cyan: #7ee8e0;
|
||||
--pa-yellow: #f2d16b;
|
||||
--pa-violet: #b98cff;
|
||||
/* 卡片不再是圆润的「气泡」,而是切边的纸片 */
|
||||
--gb-radius-card: 6px;
|
||||
}
|
||||
|
||||
/* ---------- 现实:白之章 ---------- */
|
||||
|
||||
html.paranoia:not(.dark) {
|
||||
--gb-primary: #b3163c;
|
||||
--gb-primary-deep: #8c112f;
|
||||
--gb-ink: #17121a;
|
||||
--gb-ink-soft: #2a1c25;
|
||||
--gb-bg: #f5f1ee;
|
||||
--gb-card: #fffdfc;
|
||||
--gb-shadow-card: 0 1px 2px rgb(90 24 48 / 0.05), 0 1px 10px rgb(90 24 48 / 0.05);
|
||||
--gb-shadow-hover: 0 10px 26px rgb(120 20 50 / 0.14);
|
||||
|
||||
--pa-line: rgb(179 22 60 / 0.16);
|
||||
--pa-line-soft: rgb(179 22 60 / 0.08);
|
||||
--pa-line-strong: rgb(179 22 60 / 0.36);
|
||||
--pa-glow: rgb(179 22 60 / 0.16);
|
||||
--pa-ghost: rgb(43 20 32 / 0.055);
|
||||
--pa-grain-opacity: 0.05;
|
||||
--pa-veil: rgb(255 253 252 / 0.72);
|
||||
|
||||
--el-color-primary: #b3163c;
|
||||
--el-color-primary-light-3: #c94f6d;
|
||||
--el-color-primary-light-5: #d98899;
|
||||
--el-color-primary-light-7: #e8b4bf;
|
||||
--el-color-primary-light-8: #f0cad2;
|
||||
--el-color-primary-light-9: #f8e7ea;
|
||||
--el-color-primary-dark-2: #8f1230;
|
||||
|
||||
--el-bg-color: #fffdfc;
|
||||
--el-bg-color-page: #f5f1ee;
|
||||
--el-bg-color-overlay: #fffdfc;
|
||||
--el-text-color-primary: #2b2028;
|
||||
--el-text-color-regular: #4d3f47;
|
||||
--el-text-color-secondary: #8a7880;
|
||||
--el-text-color-placeholder: #a8969e;
|
||||
--el-border-color: rgb(179 22 60 / 0.2);
|
||||
--el-border-color-light: rgb(179 22 60 / 0.14);
|
||||
--el-border-color-lighter: rgb(179 22 60 / 0.1);
|
||||
--el-border-color-extra-light: rgb(179 22 60 / 0.08);
|
||||
--el-fill-color: rgb(179 22 60 / 0.06);
|
||||
--el-fill-color-light: rgb(179 22 60 / 0.04);
|
||||
--el-fill-color-lighter: rgb(179 22 60 / 0.025);
|
||||
--el-fill-color-blank: #fffdfc;
|
||||
--el-mask-color: rgb(43 20 32 / 0.42);
|
||||
}
|
||||
|
||||
/* ---------- 妄想:黑之章 ---------- */
|
||||
|
||||
html.paranoia.dark {
|
||||
--gb-primary: #e0245e;
|
||||
/* 深底之上需要更亮的强调色才读得清 */
|
||||
--gb-primary-deep: #f2568a;
|
||||
--gb-ink: #100a12;
|
||||
--gb-ink-soft: #1d0f1b;
|
||||
--gb-bg: #08070c;
|
||||
--gb-card: #150f19;
|
||||
--gb-shadow-card: 0 1px 2px rgb(0 0 0 / 0.5);
|
||||
--gb-shadow-hover: 0 10px 30px rgb(0 0 0 / 0.55), 0 0 0 1px rgb(224 36 94 / 0.14);
|
||||
|
||||
--pa-line: rgb(224 36 94 / 0.24);
|
||||
--pa-line-soft: rgb(224 36 94 / 0.12);
|
||||
--pa-line-strong: rgb(224 36 94 / 0.5);
|
||||
--pa-glow: rgb(224 36 94 / 0.34);
|
||||
--pa-ghost: rgb(236 230 239 / 0.045);
|
||||
--pa-grain-opacity: 0.075;
|
||||
--pa-veil: rgb(10 8 14 / 0.72);
|
||||
|
||||
--el-color-primary: #e0245e;
|
||||
--el-color-primary-light-3: #e6507e;
|
||||
--el-color-primary-light-5: #ec7c9f;
|
||||
--el-color-primary-light-7: #f2a8bf;
|
||||
--el-color-primary-light-8: #f6bfd0;
|
||||
--el-color-primary-light-9: #2a1622;
|
||||
--el-color-primary-dark-2: #b31d4b;
|
||||
|
||||
--el-bg-color: #150f19;
|
||||
--el-bg-color-page: #08070c;
|
||||
--el-bg-color-overlay: #1b1220;
|
||||
--el-text-color-primary: #ece6ef;
|
||||
--el-text-color-regular: #cabfcb;
|
||||
--el-text-color-secondary: #9a8b98;
|
||||
--el-text-color-placeholder: #6f616d;
|
||||
--el-border-color: rgb(224 36 94 / 0.26);
|
||||
--el-border-color-light: rgb(224 36 94 / 0.18);
|
||||
--el-border-color-lighter: rgb(224 36 94 / 0.14);
|
||||
--el-border-color-extra-light: rgb(224 36 94 / 0.1);
|
||||
--el-fill-color: rgb(224 36 94 / 0.12);
|
||||
--el-fill-color-light: rgb(224 36 94 / 0.08);
|
||||
--el-fill-color-lighter: rgb(224 36 94 / 0.05);
|
||||
--el-fill-color-blank: #150f19;
|
||||
--el-mask-color: rgb(4 3 6 / 0.66);
|
||||
}
|
||||
|
||||
/* ---------- 底色:纸纹与深渊雾气 ---------- */
|
||||
|
||||
html.paranoia,
|
||||
html.paranoia body,
|
||||
html.paranoia #app {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html.paranoia body {
|
||||
background-color: var(--gb-bg);
|
||||
background-image:
|
||||
radial-gradient(1100px 620px at 8% -12%, rgb(224 36 94 / 0.1), transparent 62%),
|
||||
radial-gradient(900px 520px at 102% 4%, rgb(102 204 255 / 0.07), transparent 58%),
|
||||
radial-gradient(760px 760px at 50% 118%, rgb(224 36 94 / 0.08), transparent 60%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* ---------- 排版 ---------- */
|
||||
|
||||
html.paranoia .page-header h2,
|
||||
html.paranoia .el-card__header,
|
||||
html.paranoia .el-dialog__title,
|
||||
html.paranoia h1,
|
||||
html.paranoia h2,
|
||||
html.paranoia h3,
|
||||
html.paranoia .pa-display {
|
||||
font-family: var(--pa-serif);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
html.paranoia .page-header h2 {
|
||||
font-weight: 700;
|
||||
/* 标题下压一道极细的红线,像卷宗上的分割线 */
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--pa-line);
|
||||
}
|
||||
|
||||
html.paranoia .mono,
|
||||
html.paranoia .stat-value,
|
||||
html.paranoia .countdown-num,
|
||||
html.paranoia .ring-pct {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------- 卡片:切边的纸片 ---------- */
|
||||
|
||||
html.paranoia .el-card {
|
||||
border: 1px solid var(--pa-line);
|
||||
box-shadow: var(--gb-shadow-card);
|
||||
background: var(--gb-card);
|
||||
}
|
||||
|
||||
html.paranoia .el-card__header {
|
||||
border-bottom: 1px solid var(--pa-line);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
html.paranoia:not(.dark) .el-card {
|
||||
background-image: linear-gradient(180deg, rgb(255 255 255 / 0.6), rgb(255 252 251 / 0.2));
|
||||
}
|
||||
|
||||
html.paranoia.dark .el-card {
|
||||
background-image: linear-gradient(180deg, rgb(224 36 94 / 0.05), transparent 42%);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
/* ---------- 按钮 ---------- */
|
||||
|
||||
html.paranoia .el-button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
html.paranoia .el-button--primary {
|
||||
--el-button-text-color: #fff7fa;
|
||||
--el-button-hover-text-color: #fff;
|
||||
--el-button-active-text-color: #fff;
|
||||
--el-button-bg-color: var(--gb-primary);
|
||||
--el-button-border-color: var(--gb-primary);
|
||||
--el-button-hover-bg-color: var(--pa-crimson-soft);
|
||||
--el-button-hover-border-color: var(--pa-crimson-soft);
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
html.paranoia.dark .el-button--primary {
|
||||
--el-button-text-color: #fff;
|
||||
}
|
||||
|
||||
html.paranoia .el-button--default:not(.is-disabled) {
|
||||
background: transparent;
|
||||
border-color: var(--pa-line);
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
html.paranoia .el-button--default:not(.is-disabled):hover {
|
||||
border-color: var(--pa-line-strong);
|
||||
color: var(--gb-primary-deep);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
/* ---------- 表单与表格 ---------- */
|
||||
|
||||
html.paranoia .el-input__wrapper,
|
||||
html.paranoia .el-textarea__inner,
|
||||
html.paranoia .el-select__wrapper {
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html.paranoia .el-table {
|
||||
--el-table-border-color: var(--pa-line-soft);
|
||||
--el-table-header-bg-color: transparent;
|
||||
--el-table-row-hover-bg-color: var(--el-fill-color-light);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html.paranoia .el-table tr,
|
||||
html.paranoia .el-table th.el-table__cell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html.paranoia .el-table th.el-table__cell {
|
||||
font-family: var(--pa-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
html.paranoia .el-tag {
|
||||
border-radius: 3px;
|
||||
font-family: var(--pa-mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
html.paranoia .el-dialog,
|
||||
html.paranoia .el-message-box {
|
||||
border: 1px solid var(--pa-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
html.paranoia .el-dialog__header {
|
||||
border-bottom: 1px solid var(--pa-line-soft);
|
||||
margin-right: 0;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
html.paranoia .el-alert {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--pa-line);
|
||||
}
|
||||
|
||||
html.paranoia .el-empty__description {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
html.paranoia .el-loading-mask {
|
||||
background-color: var(--pa-veil);
|
||||
}
|
||||
|
||||
/* 键盘焦点环改用石蒜红 */
|
||||
html.paranoia :focus-visible {
|
||||
outline: 2px solid var(--gb-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------- 滚动条 ---------- */
|
||||
|
||||
html.paranoia ::-webkit-scrollbar-thumb {
|
||||
background: rgb(224 36 94 / 0.35);
|
||||
}
|
||||
|
||||
html.paranoia ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(224 36 94 / 0.55);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
主题通用零件
|
||||
============================================================ */
|
||||
|
||||
/* 章节记号:b(坠落之章)/ #(上升之章) */
|
||||
.pa-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--pa-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--pa-line);
|
||||
border-radius: 3px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pa-mark em {
|
||||
font-style: normal;
|
||||
color: var(--gb-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pa-mark--plain {
|
||||
border-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 竖排的幽灵字,用作背景里的「妄想」 */
|
||||
.pa-ghost-text {
|
||||
position: absolute;
|
||||
writing-mode: vertical-rl;
|
||||
font-family: var(--pa-serif);
|
||||
font-weight: 700;
|
||||
color: var(--pa-ghost);
|
||||
letter-spacing: 0.3em;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 细红分割线 */
|
||||
.pa-rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--pa-line-strong);
|
||||
}
|
||||
|
||||
.pa-rule::before,
|
||||
.pa-rule::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--pa-line);
|
||||
}
|
||||
|
||||
/* 引文块 */
|
||||
.pa-quote {
|
||||
font-family: var(--pa-serif);
|
||||
line-height: 2;
|
||||
color: var(--el-text-color-regular);
|
||||
border-left: 2px solid var(--gb-primary);
|
||||
padding: 2px 0 2px 16px;
|
||||
}
|
||||
|
||||
/* 胶片感的图片:统一降饱和,hover 时回血 */
|
||||
.pa-film {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gb-ink);
|
||||
}
|
||||
|
||||
.pa-film img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: saturate(0.72) contrast(1.06) brightness(0.94);
|
||||
transition:
|
||||
transform 0.6s var(--gb-ease),
|
||||
filter 0.6s var(--gb-ease);
|
||||
}
|
||||
|
||||
.pa-film::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, transparent 45%, rgb(8 6 10 / 0.78) 100%),
|
||||
radial-gradient(120% 90% at 50% 10%, transparent 40%, rgb(224 36 94 / 0.16) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pa-film:hover img {
|
||||
transform: scale(1.045);
|
||||
filter: saturate(1) contrast(1.04) brightness(1);
|
||||
}
|
||||
|
||||
/* ---------- 背景层 ---------- */
|
||||
|
||||
.pa-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 噪点:让大面积纯色不至于太平 */
|
||||
.pa-backdrop__grain {
|
||||
position: absolute;
|
||||
inset: -40px;
|
||||
opacity: var(--pa-grain-opacity);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
/* 暗角 */
|
||||
.pa-backdrop__vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(130% 100% at 50% 40%, transparent 42%, rgb(6 4 9 / 0.5) 100%);
|
||||
}
|
||||
|
||||
html.paranoia:not(.dark) .pa-backdrop__vignette {
|
||||
background: radial-gradient(130% 100% at 50% 40%, transparent 55%, rgb(120 60 80 / 0.16) 100%);
|
||||
}
|
||||
|
||||
/* 缓慢下移的扫描光带 */
|
||||
.pa-backdrop__scan {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 34vh;
|
||||
background: linear-gradient(180deg, transparent, rgb(224 36 94 / 0.07) 45%, transparent);
|
||||
animation: pa-scan 26s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pa-scan {
|
||||
0% {
|
||||
transform: translate3d(0, -40vh, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(0, 120vh, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 石蒜花瓣 */
|
||||
.pa-backdrop__petals {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.pa-petal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--pa-x, 50%);
|
||||
width: calc(14px * var(--pa-scale, 1));
|
||||
height: calc(14px * var(--pa-scale, 1));
|
||||
opacity: 0;
|
||||
animation: pa-fall var(--pa-dur, 22s) linear var(--pa-delay, 0s) infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.pa-petal svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
animation: pa-spin calc(var(--pa-dur, 22s) / 5) ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* 深色底上的石蒜红要提亮一点才看得见 */
|
||||
html.paranoia.dark .pa-petal svg {
|
||||
filter: brightness(1.5) saturate(1.15);
|
||||
}
|
||||
|
||||
@keyframes pa-fall {
|
||||
0% {
|
||||
transform: translate3d(0, -14vh, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: var(--pa-opacity, 0.5);
|
||||
}
|
||||
85% {
|
||||
opacity: var(--pa-opacity, 0.5);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(var(--pa-drift, 40px), 112vh, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pa-spin {
|
||||
from {
|
||||
transform: rotate(-24deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(24deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 呼吸的红色光晕,浮在角落 */
|
||||
.pa-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(70px);
|
||||
animation: pa-breathe 16s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pa-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.42;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 皮肤切换时强制让新画面浮到最上层 ---------- */
|
||||
|
||||
html.vt-skin::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
html.vt-skin::view-transition-new(root) {
|
||||
z-index: 2147483646;
|
||||
}
|
||||
|
||||
/* 不支持 View Transitions 的浏览器退化成整页淡变 */
|
||||
html.no-vt.paranoia body,
|
||||
html.no-vt.paranoia .el-card,
|
||||
html.no-vt.paranoia .el-table tr,
|
||||
html.no-vt.paranoia .aside,
|
||||
html.no-vt.paranoia .header,
|
||||
html.no-vt.paranoia .nav-item {
|
||||
transition:
|
||||
background-color 0.36s var(--gb-ease),
|
||||
border-color 0.36s var(--gb-ease),
|
||||
color 0.36s var(--gb-ease);
|
||||
}
|
||||
|
||||
/* ---------- 响应式 ---------- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 小屏只留四片花瓣,省电也省注意力 */
|
||||
.pa-backdrop__petals .pa-petal:nth-child(n + 5) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.paranoia .page-header h2 {
|
||||
font-size: 19px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pa-petal,
|
||||
.pa-backdrop__scan,
|
||||
.pa-orb {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.pa-petal {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 妄想症主题的背景层:噪点、暗角、缓慢下移的扫描光带,
|
||||
* 以及像石蒜花瓣一样飘落的色片。
|
||||
*
|
||||
* 数值全部由下标推出来,刷新页面时不会跳到别的位置;
|
||||
* 小屏与「减少动效」偏好下会由 CSS 自动收敛。
|
||||
*/
|
||||
const PETAL_COUNT = 14
|
||||
|
||||
const petals = Array.from({ length: PETAL_COUNT }, (_, i) => {
|
||||
const r1 = ((i * 97) % 100) / 100
|
||||
const r2 = ((i * 53 + 17) % 100) / 100
|
||||
const r3 = ((i * 37 + 11) % 100) / 100
|
||||
|
||||
return {
|
||||
id: i,
|
||||
x: `${((i * 7.3 + 3.5) % 100).toFixed(2)}%`,
|
||||
drift: `${Math.round((r1 - 0.5) * 170)}px`,
|
||||
dur: `${(18 + r2 * 16).toFixed(1)}s`,
|
||||
delay: `${(-i * 1.9).toFixed(1)}s`,
|
||||
scale: (0.55 + r3 * 1.05).toFixed(2),
|
||||
opacity: (0.32 + r1 * 0.36).toFixed(2),
|
||||
color: i % 5 === 0 ? 'var(--pa-blue)' : i % 7 === 0 ? 'var(--pa-cyan)' : 'var(--pa-crimson)',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-backdrop" aria-hidden="true">
|
||||
<div
|
||||
class="pa-orb"
|
||||
style="
|
||||
left: -6%;
|
||||
top: 12%;
|
||||
width: 42vw;
|
||||
height: 42vw;
|
||||
background: rgb(224 36 94 / 0.22);
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="pa-orb"
|
||||
style="
|
||||
right: -8%;
|
||||
bottom: 4%;
|
||||
width: 38vw;
|
||||
height: 38vw;
|
||||
background: rgb(102 204 255 / 0.14);
|
||||
animation-delay: -6s;
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="pa-backdrop__scan" />
|
||||
<div class="pa-backdrop__grain" />
|
||||
|
||||
<span class="pa-ghost-text" style="right: 3%; top: 8%; font-size: 92px">妄想</span>
|
||||
<span class="pa-ghost-text" style="left: 1%; bottom: 6%; font-size: 58px">现实</span>
|
||||
|
||||
<div class="pa-backdrop__petals">
|
||||
<span
|
||||
v-for="p in petals"
|
||||
:key="p.id"
|
||||
class="pa-petal"
|
||||
:style="{
|
||||
'--pa-x': p.x,
|
||||
'--pa-drift': p.drift,
|
||||
'--pa-dur': p.dur,
|
||||
'--pa-delay': p.delay,
|
||||
'--pa-scale': p.scale,
|
||||
'--pa-opacity': p.opacity,
|
||||
}"
|
||||
>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 1.6c3.4 5.2 6.6 8.6 6.6 13.1 0 3.9-3 7.1-6.6 7.1S5.4 18.6 5.4 14.7C5.4 10.2 8.6 6.8 12 1.6z"
|
||||
:fill="p.color"
|
||||
/>
|
||||
<path d="M12 3.6v16.4" stroke="rgb(0 0 0 / 0.22)" stroke-width="0.6" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pa-backdrop__vignette" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Check, MagicStick } from '@element-plus/icons-vue'
|
||||
import { skinOptions, useTheme, type ThemeSkin } from '@/composables/useTheme'
|
||||
|
||||
/**
|
||||
* 外观主题选择器。
|
||||
*
|
||||
* 用自绘的浮层而不是 el-popover:这样预览小样可以完全跟着皮肤走,
|
||||
* 也不必和 teleport 出来的 popper 抢样式。
|
||||
*/
|
||||
const { skin, isDark, setSkin } = useTheme()
|
||||
|
||||
const open = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const currentName = computed(() => skinOptions.find((o) => o.id === skin.value)?.name ?? '')
|
||||
|
||||
/** 每个皮肤的迷你预览配色,跟随当前明暗模式 */
|
||||
function previewOf(id: ThemeSkin) {
|
||||
const paranoia = id === 'paranoia'
|
||||
if (isDark.value) {
|
||||
return paranoia
|
||||
? { bg: '#0d0a11', side: '#1d0f1b', card: '#150f19', accent: '#e0245e', line: '#3a2130' }
|
||||
: { bg: '#101216', side: '#17191f', card: '#1a1d23', accent: '#66ccff', line: '#2b303a' }
|
||||
}
|
||||
return paranoia
|
||||
? { bg: '#f5f1ee', side: '#241a22', card: '#fffdfc', accent: '#b3163c', line: '#e4d3d8' }
|
||||
: { bg: '#f4f6f8', side: '#232630', card: '#ffffff', accent: '#3fa9e0', line: '#dde3e9' }
|
||||
}
|
||||
|
||||
async function pick(id: ThemeSkin, event: MouseEvent) {
|
||||
open.value = false
|
||||
if (id === skin.value) return
|
||||
await setSkin(id, event)
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (root.value && !root.value.contains(event.target as Node)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') open.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick, true)
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="skin-picker">
|
||||
<button
|
||||
class="icon-btn skin-btn"
|
||||
type="button"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="dialog"
|
||||
:title="`外观主题:${currentName}`"
|
||||
@click.stop="open = !open"
|
||||
>
|
||||
<el-icon :size="17"><MagicStick /></el-icon>
|
||||
<span class="skin-btn__pip" :class="skin" />
|
||||
</button>
|
||||
|
||||
<Transition name="skin-pop">
|
||||
<div v-if="open" class="skin-panel" role="dialog" aria-label="外观主题">
|
||||
<div class="skin-panel__head">
|
||||
<span class="skin-panel__title">外观主题</span>
|
||||
<span class="skin-panel__hint">Appearance</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="option in skinOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="skin-option"
|
||||
:class="{ 'is-active': option.id === skin }"
|
||||
@click.stop="pick(option.id, $event)"
|
||||
>
|
||||
<span
|
||||
class="skin-option__preview"
|
||||
:style="{
|
||||
background: previewOf(option.id).bg,
|
||||
borderColor: previewOf(option.id).line,
|
||||
}"
|
||||
>
|
||||
<i class="skin-option__side" :style="{ background: previewOf(option.id).side }" />
|
||||
<i class="skin-option__bar" :style="{ background: previewOf(option.id).accent }" />
|
||||
<i
|
||||
class="skin-option__card"
|
||||
:style="{
|
||||
background: previewOf(option.id).card,
|
||||
borderColor: previewOf(option.id).line,
|
||||
}"
|
||||
/>
|
||||
<i
|
||||
class="skin-option__card skin-option__card--alt"
|
||||
:style="{
|
||||
background: previewOf(option.id).card,
|
||||
borderColor: previewOf(option.id).line,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="skin-option__meta">
|
||||
<span class="skin-option__name">
|
||||
{{ option.name }}
|
||||
<el-icon v-if="option.id === skin" :size="14" class="skin-option__check">
|
||||
<Check />
|
||||
</el-icon>
|
||||
</span>
|
||||
<span class="skin-option__sub">{{ option.sub }}</span>
|
||||
<span class="skin-option__swatches">
|
||||
<i v-for="color in option.swatches" :key="color" :style="{ background: color }" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="skin-panel__foot">
|
||||
<span>暗色模式由顶栏的月亮按钮切换</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skin-picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.skin-btn {
|
||||
position: relative;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s var(--gb-ease);
|
||||
}
|
||||
|
||||
.skin-btn:hover {
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
.skin-btn__pip {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 2px;
|
||||
background: var(--gb-primary);
|
||||
box-shadow: 0 0 0 2px var(--gb-card);
|
||||
}
|
||||
|
||||
.skin-btn__pip.paranoia {
|
||||
background: #e0245e;
|
||||
}
|
||||
|
||||
/* ---------- 浮层 ---------- */
|
||||
|
||||
.skin-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
z-index: 1600;
|
||||
width: 296px;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 44px rgb(16 12 24 / 0.22);
|
||||
}
|
||||
|
||||
html.paranoia .skin-panel {
|
||||
border-radius: 6px;
|
||||
border-color: var(--pa-line);
|
||||
box-shadow:
|
||||
0 18px 44px rgb(0 0 0 / 0.4),
|
||||
0 0 0 1px rgb(224 36 94 / 0.12);
|
||||
}
|
||||
|
||||
.skin-panel__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 2px 10px;
|
||||
}
|
||||
|
||||
.skin-panel__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
html.paranoia .skin-panel__title {
|
||||
font-family: var(--pa-serif);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.skin-panel__hint {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.skin-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s var(--gb-ease),
|
||||
border-color 0.2s var(--gb-ease);
|
||||
}
|
||||
|
||||
html.paranoia .skin-option {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skin-option:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.skin-option.is-active {
|
||||
border-color: var(--gb-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
/* ---------- 迷你预览 ---------- */
|
||||
|
||||
.skin-option__preview {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 62px;
|
||||
height: 44px;
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.paranoia .skin-option__preview {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.skin-option__side {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 15px;
|
||||
}
|
||||
|
||||
.skin-option__bar {
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 7px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.skin-option__card {
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 13px;
|
||||
width: 32px;
|
||||
height: 11px;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.skin-option__card--alt {
|
||||
top: 28px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.skin-option__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skin-option__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.skin-option__check {
|
||||
color: var(--gb-primary);
|
||||
}
|
||||
|
||||
.skin-option__sub {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.skin-option__swatches {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.skin-option__swatches i {
|
||||
width: 14px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.skin-panel__foot {
|
||||
padding: 6px 2px 0;
|
||||
border-top: 1px solid var(--el-border-color-extra-light);
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
/* ---------- 浮层进出场 ---------- */
|
||||
|
||||
.skin-pop-enter-active,
|
||||
.skin-pop-leave-active {
|
||||
transition:
|
||||
opacity 0.18s var(--gb-ease),
|
||||
transform 0.22s var(--gb-ease);
|
||||
}
|
||||
|
||||
.skin-pop-enter-from,
|
||||
.skin-pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.97);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.skin-panel {
|
||||
width: min(84vw, 296px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +1,85 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'gb-theme'
|
||||
/** 明暗模式的存储键 */
|
||||
const DARK_STORAGE_KEY = 'gb-theme'
|
||||
/** 外观皮肤(主题)的存储键 */
|
||||
const SKIN_STORAGE_KEY = 'gb-skin'
|
||||
|
||||
/**
|
||||
* 可选皮肤:
|
||||
* - default:原有的晴空配色
|
||||
* - paranoia:致敬《妄想症 Paranoia》系列曲的外观主题
|
||||
*/
|
||||
export type ThemeSkin = 'default' | 'paranoia'
|
||||
|
||||
export interface SkinOption {
|
||||
id: ThemeSkin
|
||||
/** 皮肤名 */
|
||||
name: string
|
||||
/** 一句话说明 */
|
||||
sub: string
|
||||
/** 预览用的色板 */
|
||||
swatches: string[]
|
||||
}
|
||||
|
||||
export const skinOptions: SkinOption[] = [
|
||||
{
|
||||
id: 'default',
|
||||
name: '晴空',
|
||||
sub: 'goodBaby 默认外观',
|
||||
swatches: ['#66ccff', '#3fa9e0', '#232630'],
|
||||
},
|
||||
{
|
||||
id: 'paranoia',
|
||||
name: '妄想症',
|
||||
sub: 'Paranoia · 九重档案',
|
||||
swatches: ['#e0245e', '#7ee8e0', '#f2d16b'],
|
||||
},
|
||||
]
|
||||
|
||||
/** 全局共享的主题状态,模块级单例,任何组件读到的都是同一份 */
|
||||
const isDark = ref(false)
|
||||
const skin = ref<ThemeSkin>('default')
|
||||
const isParanoia = computed(() => skin.value === 'paranoia')
|
||||
|
||||
/** 把主题应用到 <html> 上 */
|
||||
function applyTheme(dark: boolean) {
|
||||
function readStorage(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
// 隐私模式 / 禁用存储时静默降级为「本次会话内有效」
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
|
||||
/** 把明暗模式应用到 <html> 上 */
|
||||
function applyDark(dark: boolean) {
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
isDark.value = dark
|
||||
}
|
||||
|
||||
/** 把皮肤应用到 <html> 上,配色覆盖写在 theme-paranoia.css 里 */
|
||||
function applySkin(next: ThemeSkin) {
|
||||
document.documentElement.classList.toggle('paranoia', next === 'paranoia')
|
||||
skin.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 在应用启动时调用一次:恢复上次选择,没有记录时跟随系统。
|
||||
*/
|
||||
export function initTheme() {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
const savedDark = readStorage(DARK_STORAGE_KEY)
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
applyTheme(saved ? saved === 'dark' : prefersDark)
|
||||
applyDark(savedDark ? savedDark === 'dark' : prefersDark)
|
||||
|
||||
applySkin(readStorage(SKIN_STORAGE_KEY) === 'paranoia' ? 'paranoia' : 'default')
|
||||
|
||||
// 标记浏览器是否支持圆形揭示,供 CSS 决定要不要退化成淡变
|
||||
if (typeof document.startViewTransition !== 'function') {
|
||||
@@ -26,66 +88,113 @@ export function initTheme() {
|
||||
|
||||
// 用户没有手动选择过时,跟随系统变化
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem(STORAGE_KEY)) {
|
||||
applyTheme(e.matches)
|
||||
if (!readStorage(DARK_STORAGE_KEY)) {
|
||||
applyDark(e.matches)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 用一次圆形揭示动画包住外观切换。
|
||||
*
|
||||
* 支持 View Transitions 时以点击处为圆心做圆形揭示;不支持(Firefox / Safari 旧版)
|
||||
* 或用户要求减少动效时直接切换。
|
||||
*
|
||||
* @param mutate 真正改变外观状态的函数
|
||||
* @param event 触发切换的点击事件,用来决定圆心
|
||||
* @param mode dark/light 沿用原有的进/退方向;skin 需要额外把新画面提到最上层
|
||||
*/
|
||||
async function reveal(
|
||||
mutate: () => void,
|
||||
event: MouseEvent | undefined,
|
||||
mode: 'dark' | 'light' | 'skin',
|
||||
) {
|
||||
const startViewTransition = document.startViewTransition?.bind(document)
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
if (!startViewTransition || reduceMotion) {
|
||||
mutate()
|
||||
return
|
||||
}
|
||||
|
||||
// 换皮肤时新旧画面可能同为暗色,靠这个类强制让新画面浮到上面
|
||||
if (mode === 'skin') {
|
||||
document.documentElement.classList.add('vt-skin')
|
||||
}
|
||||
|
||||
// 从点击点向外扩散;没有事件对象时从右上角(切换按钮所在位置)扩散
|
||||
const x = event?.clientX ?? window.innerWidth - 60
|
||||
const y = event?.clientY ?? 30
|
||||
// 半径取到最远的那个角,保证能覆盖整个视口
|
||||
const endRadius = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y),
|
||||
)
|
||||
|
||||
const transition = startViewTransition(mutate)
|
||||
|
||||
try {
|
||||
await transition.ready
|
||||
} catch {
|
||||
// 浏览器跳过了这次过渡(页面不可见、连续快速点击等)。
|
||||
// 此时外观已由 mutate 应用好,只是没有揭示动画。
|
||||
document.documentElement.classList.remove('vt-skin')
|
||||
return
|
||||
}
|
||||
|
||||
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`]
|
||||
|
||||
// 切到深色时让新画面扩散进来;切回浅色时让旧画面收缩出去
|
||||
const pseudoElement =
|
||||
mode === 'light' ? '::view-transition-old(root)' : '::view-transition-new(root)'
|
||||
|
||||
document.documentElement.animate(
|
||||
{ clipPath: mode === 'light' ? [...clipPath].reverse() : clipPath },
|
||||
{
|
||||
duration: 480,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
pseudoElement,
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await transition.finished
|
||||
} catch {
|
||||
// finished 在过渡被跳过时也会抛错,忽略即可
|
||||
}
|
||||
document.documentElement.classList.remove('vt-skin')
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
/**
|
||||
* 切换明暗主题。
|
||||
*
|
||||
* 支持 View Transitions 时,以点击处为圆心做一次圆形揭示动画;
|
||||
* 不支持(Firefox / Safari 旧版)或用户要求减少动效时直接切换。
|
||||
*/
|
||||
async function toggleTheme(event?: MouseEvent) {
|
||||
const next = !isDark.value
|
||||
const persist = () => {
|
||||
applyTheme(next)
|
||||
localStorage.setItem(STORAGE_KEY, next ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
// 绑定后再判空,TS 才能正确收窄这个可选 API
|
||||
const startViewTransition = document.startViewTransition?.bind(document)
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
if (!startViewTransition || reduceMotion) {
|
||||
persist()
|
||||
return
|
||||
}
|
||||
|
||||
// 以点击点为圆心;没有事件对象时从右上角(切换按钮所在位置)扩散
|
||||
const x = event?.clientX ?? window.innerWidth - 60
|
||||
const y = event?.clientY ?? 30
|
||||
// 半径取到最远的那个角,保证能覆盖整个视口
|
||||
const endRadius = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y),
|
||||
)
|
||||
|
||||
const transition = startViewTransition(persist)
|
||||
|
||||
try {
|
||||
await transition.ready
|
||||
} catch {
|
||||
// 浏览器跳过了这次过渡(页面不可见、连续快速点击等)。
|
||||
// 此时主题已经通过 persist() 应用好了,只是没有揭示动画,直接返回。
|
||||
return
|
||||
}
|
||||
|
||||
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`]
|
||||
|
||||
document.documentElement.animate(
|
||||
// 切到深色时让新画面扩散进来;切回浅色时让旧画面收缩出去
|
||||
{ clipPath: next ? clipPath : [...clipPath].reverse() },
|
||||
{
|
||||
duration: 480,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
pseudoElement: next ? '::view-transition-new(root)' : '::view-transition-old(root)',
|
||||
await reveal(
|
||||
() => {
|
||||
applyDark(next)
|
||||
writeStorage(DARK_STORAGE_KEY, next ? 'dark' : 'light')
|
||||
},
|
||||
event,
|
||||
next ? 'dark' : 'light',
|
||||
)
|
||||
}
|
||||
|
||||
return { isDark, toggleTheme }
|
||||
/**
|
||||
* 切换外观皮肤。传入点击事件时以点击处为圆心做揭示动画。
|
||||
*/
|
||||
async function setSkin(next: ThemeSkin, event?: MouseEvent) {
|
||||
if (next === skin.value) return
|
||||
await reveal(
|
||||
() => {
|
||||
applySkin(next)
|
||||
writeStorage(SKIN_STORAGE_KEY, next)
|
||||
},
|
||||
event,
|
||||
'skin',
|
||||
)
|
||||
}
|
||||
|
||||
return { isDark, isParanoia, skin, toggleTheme, setSkin }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* 《妄想症 Paranoia》系列资料
|
||||
*
|
||||
* 致敬对象:DELA 作曲 / 雨狸作词 / Lune 等人曲绘的 VOCALOID 中文原创系列曲
|
||||
* (2015.11.28《一重加害》— 2017.03.17《九重现实》,共 475 天;
|
||||
* 2025.11.28 发布十周年纪念曲《十重告别》)。
|
||||
*
|
||||
* 曲绘、歌词与设定的著作权均归原作者所有,此处仅作主题化的介绍与引用。
|
||||
*/
|
||||
|
||||
import ch01 from '@/assets/paranoia/ch01.jpg'
|
||||
import ch02 from '@/assets/paranoia/ch02.jpg'
|
||||
import ch03 from '@/assets/paranoia/ch03.jpg'
|
||||
import ch04 from '@/assets/paranoia/ch04.jpg'
|
||||
import ch05 from '@/assets/paranoia/ch05.jpg'
|
||||
import ch06 from '@/assets/paranoia/ch06.jpg'
|
||||
import ch07 from '@/assets/paranoia/ch07.jpg'
|
||||
import ch08 from '@/assets/paranoia/ch08.jpg'
|
||||
import ch09 from '@/assets/paranoia/ch09.jpg'
|
||||
import ch10 from '@/assets/paranoia/ch10.jpg'
|
||||
|
||||
/** 章:乐曲在系列中的位置 */
|
||||
export type ParanoiaArc = '序曲' | '坠落之章' | '上升之章' | '十周年'
|
||||
|
||||
export interface ParanoiaChapter {
|
||||
/** 重数,0 表示序曲 */
|
||||
no: number
|
||||
/** 中文数字,如「一」 */
|
||||
numeral: string
|
||||
/** 曲名 */
|
||||
title: string
|
||||
arc: ParanoiaArc
|
||||
/** 乐谱记号:坠落之章为 b(降号),上升之章为 #(升号) */
|
||||
sigil: string
|
||||
/** 投稿日期 */
|
||||
date: string
|
||||
/** 演唱 */
|
||||
singers: string
|
||||
/** 视角角色 */
|
||||
voices: string
|
||||
/** 一句话剧情 */
|
||||
summary: string
|
||||
/** 章节简介 */
|
||||
detail: string
|
||||
/** 歌词摘录 */
|
||||
lyric: string
|
||||
/** 对应的妄想症条目 */
|
||||
delusion: string
|
||||
/** 临床描述 */
|
||||
note: string
|
||||
image: string
|
||||
}
|
||||
|
||||
/** 三重加害者、守护者、背叛者与第四面墙外之人 */
|
||||
export interface ParanoiaCharacter {
|
||||
name: string
|
||||
/** 虚拟歌手 */
|
||||
vocal: string
|
||||
role: string
|
||||
/** 主题色 */
|
||||
color: string
|
||||
/** 第二主题色,用于言语这种双重身份的角色 */
|
||||
colorAlt?: string
|
||||
/** 一句属于她的话 */
|
||||
line: string
|
||||
}
|
||||
|
||||
/** 妄想档案:系列中逐章出现的临床条目 */
|
||||
export interface ParanoiaDelusion {
|
||||
index: string
|
||||
term: string
|
||||
alias: string
|
||||
note: string
|
||||
chapter: string
|
||||
}
|
||||
|
||||
export const paranoiaChapters: ParanoiaChapter[] = [
|
||||
{
|
||||
no: 1,
|
||||
numeral: '一',
|
||||
title: '一重加害',
|
||||
arc: '坠落之章',
|
||||
sigil: 'b',
|
||||
date: '2015.11.28',
|
||||
singers: '洛天依 / 言和',
|
||||
voices: '被害者泠珞 · 加害者颜语',
|
||||
summary: '熟悉的街道上多了一个影子,被害妄想从此降临。',
|
||||
detail:
|
||||
'系列的开篇。以被害妄想为主题,由被害者泠珞、加害者颜语与背叛者零羽三人展开,剧情扑朔迷离,在留下巨大悬念的同时,也为最终的结局埋下伏笔。',
|
||||
lyric: '别对我说 你身后是谁在揣度\n别靠近我 你这无锁链的恶魔',
|
||||
delusion: '被害妄想',
|
||||
note: '患者往往处于恐惧状态而胡乱推理和分析,坚持自己受到迫害或伤害,如言语上的针对、嘲弄、跟踪及监听等。',
|
||||
image: ch01,
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
numeral: '二',
|
||||
title: '二重变革',
|
||||
arc: '坠落之章',
|
||||
sigil: 'b',
|
||||
date: '2015.12.12',
|
||||
singers: '洛天依 / 言和',
|
||||
voices: '被害者泠珞 · 加害者颜语',
|
||||
summary: '凶手遵从风声鹤唳者的妄想而来,自虚无中诞生。',
|
||||
detail:
|
||||
'第一作的故事后续与不同视角描述。被害者与加害者之间的斗争正式开始:「我正是接受你『会被杀死』的强烈夙愿而自虚无诞生。」',
|
||||
lyric: '这一重任扭曲泛滥的世界\n它可悲的明天就由我来终结',
|
||||
delusion: '影响妄想 · 逆',
|
||||
note: '患者认为自己的思维、情感与意志行为可以影响并支配外界的现实,即「心想事成」。',
|
||||
image: ch02,
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
numeral: '三',
|
||||
title: '三重爱恋',
|
||||
arc: '坠落之章',
|
||||
sigil: 'b',
|
||||
date: '2016.01.22',
|
||||
singers: '洛天依 / 言和',
|
||||
voices: '被害者泠珞 · 守护者颜语 · 潜意识泠珞',
|
||||
summary: '杀死幻想怪物之后,她被裁定防卫过当,于是开始了一场妄想中的恋爱。',
|
||||
detail:
|
||||
'盲目生活像陀螺一般旋转不停,动荡的视线引发不安。在梦魇深堕之处,她开始相信自己终于遇到了那个人。',
|
||||
lyric: '约会在吊死鬼开的乐园\n看糖果和孤独成就了饕餮',
|
||||
delusion: '情爱妄想',
|
||||
note: '又称 de Clerambault 症候群。病人会以为自己正和某人恋爱,或另一个人深爱着自己;幻想中的恋人甚至只是一个「魅影」。',
|
||||
image: ch03,
|
||||
},
|
||||
{
|
||||
no: 4,
|
||||
numeral: '四',
|
||||
title: '四重罪孽',
|
||||
arc: '坠落之章',
|
||||
sigil: 'b',
|
||||
date: '2016.03.26',
|
||||
singers: '洛天依 / 言和 / 乐正绫',
|
||||
voices: '潜意识泠珞 · 守护者颜语 · 背叛者零羽',
|
||||
summary: '潜意识泠珞觉醒,察觉一切皆妄想,并宣告自己犯下的重重罪孽。',
|
||||
detail:
|
||||
'表现挣扎的一章。加害者的复活、守护者准备为爱牺牲的觉悟、背叛者扑朔迷离的死因,都替之后的剧情留下伏笔。',
|
||||
lyric: '我犯下了重重罪孽 我到底是什么存在\n我是最恐怖的危害 能否从此逃开',
|
||||
delusion: '自罪妄想',
|
||||
note: '又称罪恶妄想。患者毫无根据地认为自己犯了严重错误和罪行,或将一件小事的错误无限夸大,以至于拒食或要求赎罪。',
|
||||
image: ch04,
|
||||
},
|
||||
{
|
||||
no: 5,
|
||||
numeral: '五',
|
||||
title: '五重空洞',
|
||||
arc: '坠落之章',
|
||||
sigil: 'b',
|
||||
date: '2016.04.28',
|
||||
singers: '洛天依 / 乐正绫',
|
||||
voices: '潜意识泠珞 · 背叛者零羽',
|
||||
summary: '妄想世界的夹缝里,彼岸花田中埋葬着背叛者零羽。',
|
||||
detail:
|
||||
'潜意识泠珞在彼岸花田中见到了埋葬在记忆深处的零羽,过去的种种记忆漫上心头。故事的真相揭起一角,过去与未来依旧扑朔迷离。',
|
||||
lyric: '你停留在我无法追逐的记忆彼岸\n永远不会再回来',
|
||||
delusion: '内心被揭露感',
|
||||
note: '又称被洞悉感。患者认为其内心的想法未经表达,别人就知道了;严重时表层意识可能被穿透,导致潜意识暴露在外。',
|
||||
image: ch05,
|
||||
},
|
||||
{
|
||||
no: 6,
|
||||
numeral: '六',
|
||||
title: '六重不忠',
|
||||
arc: '上升之章',
|
||||
sigil: '#',
|
||||
date: '2016.08.05',
|
||||
singers: '洛天依 / 言和 / 乐正绫',
|
||||
voices: '加害者 · 守护者 · 潜意识泠珞',
|
||||
summary: '守护者先被策反,又因誓言死在加害者刀下,系列由此转入上升之章。',
|
||||
detail:
|
||||
'从本曲开始,系列进入 # 之章(上升之章)。她先期待、她先许愿,身为镜像怎能妥协;而誓言与爱,最终以死亡兑现。',
|
||||
lyric: '再见吧终会相见 莫为此放弃明天\n在绝望之前 将我的誓言默念',
|
||||
delusion: '嫉妒妄想综合征',
|
||||
note: '又称奥赛罗综合征。患者坚信所爱之人不忠,即使有确切证据证明其并未背叛,怀疑的依据也捕风捉影。',
|
||||
image: ch06,
|
||||
},
|
||||
{
|
||||
no: 7,
|
||||
numeral: '七',
|
||||
title: '七重痼病',
|
||||
arc: '上升之章',
|
||||
sigil: '#',
|
||||
date: '2016.12.16',
|
||||
singers: '洛天依 / 言和 / 乐正绫',
|
||||
voices: '被害者泠珞 · 加害者颜语 · 零羽',
|
||||
summary: '加害者与潜意识同归于尽,她在白日的中心失去焦距。',
|
||||
detail:
|
||||
'第六作的后续。红石蒜在无人之地盛开,妄议着未来于是溃败。「如果谎言重复了千遍依然无法成为现实,那谎言应该如何生存下去呢?」',
|
||||
lyric: '世界已发出信号 停止临终前无止境祈祷\n我接受她不在的未来',
|
||||
delusion: '疑病妄想',
|
||||
note: '患者毫无根据地坚信自己患了某种严重躯体疾病或不治之症。严重时称为虚无幻想——「本人已经不存在,只剩下一个躯壳空壳了」。',
|
||||
image: ch07,
|
||||
},
|
||||
{
|
||||
no: 8,
|
||||
numeral: '八',
|
||||
title: '八重回归',
|
||||
arc: '上升之章',
|
||||
sigil: '#',
|
||||
date: '2017.03.10',
|
||||
singers: '洛天依 / 乐正绫',
|
||||
voices: '泠珞 · 零羽',
|
||||
summary: '一切妄想破灭之后回归现实,同时与过往和梦想告别。',
|
||||
detail:
|
||||
'睁开眼重新堕入平凡的世界,辗转枯燥的时间一天又一天。所有羁绊终归于平淡,誓言悉数臣服现实的安排——「无意义乃这个世界存在的唯一意义。」',
|
||||
lyric: '再见 再不见\n无比真实过的一切',
|
||||
delusion: '回归空白',
|
||||
note: '现实本身就需要和妄想区分开来。越痛苦,翅膀越充血,它们就越美丽——然后死亡就突然降临,只是轻飘飘地、轻飘飘地。',
|
||||
image: ch08,
|
||||
},
|
||||
{
|
||||
no: 9,
|
||||
numeral: '九',
|
||||
title: '九重现实',
|
||||
arc: '上升之章',
|
||||
sigil: '#',
|
||||
date: '2017.03.17',
|
||||
singers: '洛天依 / 乐正绫 · 言和和声',
|
||||
voices: '泠珞 · 零羽',
|
||||
summary: '完结作。故事的真正脉络,以及带着希望的未来。',
|
||||
detail:
|
||||
'从「与其生离,不如死别」到「面对凶险的今后」。不是自作多情,也不是阳光明媚,仅仅是空白的现实而已。白色,也许是可以着色的,也许是被擦除了色彩之后的。',
|
||||
lyric: '你的声音击破梦魇一重一重\n自最遥远的彼方呼唤我',
|
||||
delusion: '现实',
|
||||
note: '「我所描述的一切或许并非真实,然而我的感受绝无虚假。」绝无虚假。',
|
||||
image: ch09,
|
||||
},
|
||||
{
|
||||
no: 10,
|
||||
numeral: '十',
|
||||
title: '十重告别',
|
||||
arc: '十周年',
|
||||
sigil: '✳',
|
||||
date: '2025.11.28',
|
||||
singers: '洛天依 / 言和 / 乐正绫 / 双狐座',
|
||||
voices: '第四面墙外之人',
|
||||
summary: '第四面墙外之人回望整个系列,把苦痛与欢愉收纳成歌颂明天的力量。',
|
||||
detail:
|
||||
'十周年纪念曲。打破第四面墙的元叙事视角,带领角色、创作者与听众共同回望这一切:告别或是全新的起点,全在他者的一念之间。',
|
||||
lyric: '无论再见或再也不见 我们心声已然相遇见\n比十年更早之前 我便歌颂着明天',
|
||||
delusion: '告别',
|
||||
note: '载体或许会消失,但我们相遇过的回忆不会。无论是十年前还是十年后,都希望你能拥有属于自己的幸福。',
|
||||
image: ch10,
|
||||
},
|
||||
]
|
||||
|
||||
export const paranoiaCharacters: ParanoiaCharacter[] = [
|
||||
{
|
||||
name: '泠珞',
|
||||
vocal: '洛天依',
|
||||
role: '被害者',
|
||||
color: '#66ccff',
|
||||
line: '不承认那是命运,命运是被神抛弃的棋局。',
|
||||
},
|
||||
{
|
||||
name: '颜语',
|
||||
vocal: '言和',
|
||||
role: '加害者 / 守护者',
|
||||
color: '#f2d16b',
|
||||
colorAlt: '#7ee8e0',
|
||||
line: '如果就此离开,还要怎么加害。',
|
||||
},
|
||||
{
|
||||
name: '零羽',
|
||||
vocal: '乐正绫',
|
||||
role: '背叛者',
|
||||
color: '#e0245e',
|
||||
line: '背对着我一跃而下的女孩,在呼唤。',
|
||||
},
|
||||
{
|
||||
name: '双狐座',
|
||||
vocal: '第四面墙外之人',
|
||||
role: '元叙事视角',
|
||||
color: '#b98cff',
|
||||
line: '告别或是全新的起点,全在他者的一念之间。',
|
||||
},
|
||||
]
|
||||
|
||||
export const paranoiaDelusions: ParanoiaDelusion[] = [
|
||||
{
|
||||
index: '01',
|
||||
term: '被害妄想',
|
||||
alias: 'delusion of persecution',
|
||||
note: '处于恐惧状态而胡乱推理和分析,坚持自己受到迫害或伤害,如言语上的针对、嘲弄、跟踪及监听。',
|
||||
chapter: '一重加害',
|
||||
},
|
||||
{
|
||||
index: '02',
|
||||
term: '影响妄想',
|
||||
alias: 'delusion of influence',
|
||||
note: '认为自己的思维、情感、意志行为活动受到外界某种力量的支配、控制、操纵,不能自主。',
|
||||
chapter: '二重变革',
|
||||
},
|
||||
{
|
||||
index: '03',
|
||||
term: '影响妄想 · 逆',
|
||||
alias: 'reverse influence',
|
||||
note: '认为自己的思维与意志不论多么荒谬,都可以影响并支配外界的现实,即「心想事成」。',
|
||||
chapter: '二重变革',
|
||||
},
|
||||
{
|
||||
index: '04',
|
||||
term: '情爱妄想',
|
||||
alias: "de Clerambault's syndrome",
|
||||
note: '以为自己正和某人恋爱,或另一个人深爱着自己;幻想中的恋人甚至只是一个现实中并不存在的「魅影」。',
|
||||
chapter: '三重爱恋',
|
||||
},
|
||||
{
|
||||
index: '05',
|
||||
term: '自罪妄想',
|
||||
alias: 'delusion of guilt',
|
||||
note: '又称罪恶妄想。毫无根据地认为自己犯了严重错误和罪行,罪大恶极、死有余辜,应该受到惩罚。',
|
||||
chapter: '四重罪孽',
|
||||
},
|
||||
{
|
||||
index: '06',
|
||||
term: '内心被揭露感',
|
||||
alias: 'experience of being revealed',
|
||||
note: '又称被洞悉感。认为内心的想法未经表达别人就知道了;严重时表层意识被穿透,潜意识暴露在外。',
|
||||
chapter: '五重空洞',
|
||||
},
|
||||
{
|
||||
index: '07',
|
||||
term: '投射性认同',
|
||||
alias: 'projective identification',
|
||||
note: '诱导他人以一种限定的方式行动或做出反应的人际行为模式,接受者往往被迫成为投射者感受的储存室。',
|
||||
chapter: '五重空洞',
|
||||
},
|
||||
{
|
||||
index: '08',
|
||||
term: '嫉妒妄想',
|
||||
alias: 'Othello syndrome',
|
||||
note: '坚信所爱之人不贞,即使有确切证据也丝毫不动摇;思维推理荒谬,怀疑的依据捕风捉影。',
|
||||
chapter: '六重不忠',
|
||||
},
|
||||
{
|
||||
index: '09',
|
||||
term: '疑病妄想',
|
||||
alias: 'hypochondriacal delusion',
|
||||
note: '毫无根据地坚信自己患了某种严重躯体疾病或不治之症,反复检查也不能纠正其歪曲的信念。',
|
||||
chapter: '七重痼病',
|
||||
},
|
||||
{
|
||||
index: '10',
|
||||
term: '虚无幻想',
|
||||
alias: 'nihilistic delusion',
|
||||
note: '疑病妄想的重度形态:认为「本人已经不存在,只剩下一个躯壳空壳了」。',
|
||||
chapter: '七重痼病',
|
||||
},
|
||||
]
|
||||
|
||||
/** 《九重现实》中对妄想的五条描述 */
|
||||
export const paranoiaAxioms: string[] = [
|
||||
'妄想是一个根本性障碍。',
|
||||
'是慢性和终身频发的。',
|
||||
'妄想有逻辑构造,并且内部成立。',
|
||||
'妄想和普通逻辑推理不冲突,而且没有一般的行为异常。',
|
||||
'自身经历了对自我参考的高度理解——对别人来说不重要的事件,对他们来说极为重要。',
|
||||
]
|
||||
|
||||
/** 主题化引文,用于顶栏滚动字幕与仪表盘签文 */
|
||||
export const paranoiaQuotes: { text: string; from: string }[] = [
|
||||
{ text: '谎言重复一千次就变成真理。所以,现实也是这样诞生的。', from: '《一重加害》' },
|
||||
{ text: '梦魇中是谁背对着我纵身一跃,没说再见,也没有遗言。', from: '《一重加害》' },
|
||||
{ text: '这一重任扭曲泛滥的世界,它可悲的明天就由我来终结。', from: '《二重变革》' },
|
||||
{ text: '约会在吊死鬼开的乐园,看糖果和孤独成就了饕餮。', from: '《三重爱恋》' },
|
||||
{ text: '我犯下了重重罪孽,我到底是什么存在。', from: '《四重罪孽》' },
|
||||
{ text: '你停留在我无法追逐的记忆彼岸,永远不会再回来。', from: '《五重空洞》' },
|
||||
{ text: '再见吧终会相见,莫为此放弃明天。', from: '《六重不忠》' },
|
||||
{ text: '如果谎言重复了千遍依然无法成为现实,那谎言应该如何生存下去呢?', from: '《七重痼病》' },
|
||||
{ text: '无意义乃这个世界存在的唯一意义。', from: '《八重回归》' },
|
||||
{ text: '我所描述的一切或许并非真实,然而我的感受绝无虚假。', from: '《九重现实》' },
|
||||
{ text: '比十年更早之前,我便歌颂着明天。', from: '《十重告别》' },
|
||||
]
|
||||
|
||||
/** 曲目列表:同系列其他作品 */
|
||||
export const paranoiaSeries: { group: string; items: string[] }[] = [
|
||||
{ group: '序曲', items: ['《NOVA》'] },
|
||||
{
|
||||
group: '正篇',
|
||||
items: [
|
||||
'《一重加害》',
|
||||
'《二重变革》',
|
||||
'《三重爱恋》',
|
||||
'《四重罪孽》',
|
||||
'《五重空洞》',
|
||||
'《六重不忠》',
|
||||
'《七重痼病》',
|
||||
'《八重回归》',
|
||||
'《九重现实》',
|
||||
],
|
||||
},
|
||||
{
|
||||
group: '角色歌',
|
||||
items: ['《三重恋爱》', '《泠重乞愿》', '《零重祈愿》'],
|
||||
},
|
||||
{
|
||||
group: '外篇',
|
||||
items: ['《自攻自受》', '《妄想 Paranoia》', '《妄想 Reality》', '《十重告别》'],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 侧边栏章节谱:把应用的每个功能对应到系列的一重上。
|
||||
* 坠落之章用 b(降号),上升之章用 #(升号)——这也是原作分章的方式。
|
||||
*/
|
||||
export interface ParanoiaChapterMark {
|
||||
/** 章节记号 */
|
||||
sigil: string
|
||||
/** 展示用的重数 */
|
||||
label: string
|
||||
/** 曲名 */
|
||||
title: string
|
||||
}
|
||||
|
||||
export const paranoiaNavMarks: Record<string, ParanoiaChapterMark> = {
|
||||
dashboard: { sigil: '✳', label: '序曲', title: 'NOVA' },
|
||||
timers: { sigil: 'b', label: '一重', title: '一重加害' },
|
||||
rules: { sigil: 'b', label: '二重', title: '二重变革' },
|
||||
accounts: { sigil: 'b', label: '三重', title: '三重爱恋' },
|
||||
gateways: { sigil: 'b', label: '四重', title: '四重罪孽' },
|
||||
'gateway-rules': { sigil: 'b', label: '五重', title: '五重空洞' },
|
||||
logs: { sigil: '#', label: '六重', title: '六重不忠' },
|
||||
settings: { sigil: '#', label: '七重', title: '七重痼病' },
|
||||
logout: { sigil: '#', label: '八重', title: '八重回归' },
|
||||
paranoia: { sigil: '#', label: '九重', title: '九重现实' },
|
||||
}
|
||||
|
||||
export const paranoiaCredits = {
|
||||
title: '妄想症 Paranoia',
|
||||
span: '2015.11.28 — 2017.03.17 · 共 475 天',
|
||||
staff: [
|
||||
{ role: '作编曲', name: 'DELA' },
|
||||
{ role: '作词 / 策划', name: '雨狸' },
|
||||
{ role: '曲绘', name: 'Lune、YAL、雨湘雪' },
|
||||
{ role: 'PV', name: '一折起售、owen-z' },
|
||||
{ role: '混音', name: 'POiSON' },
|
||||
{ role: '调校', name: '花儿不哭、Digger、河谐、OQQ、流绪' },
|
||||
],
|
||||
notice:
|
||||
'本主题为界面外观层面的同人致敬,非官方合作。「妄想症 Paranoia」系列曲目、曲绘、歌词与设定的著作权均归原作者所有;此处仅作介绍与引用,不作任何商业用途。',
|
||||
}
|
||||
|
||||
export function chapterByNo(no: number): ParanoiaChapter | undefined {
|
||||
return paranoiaChapters.find((item) => item.no === no)
|
||||
}
|
||||
|
||||
/** 按日期轮换一条引文,让「今日签文」每天都不一样 */
|
||||
export function todaysQuote(date = new Date()) {
|
||||
const start = new Date(date.getFullYear(), 0, 0)
|
||||
const dayOfYear = Math.floor((date.getTime() - start.getTime()) / 86400000)
|
||||
return paranoiaQuotes[dayOfYear % paranoiaQuotes.length]
|
||||
}
|
||||