682 lines
20 KiB
Go
682 lines
20 KiB
Go
package tenant
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/gogf/gf/v2/database/gdb"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/os/gtime"
|
||
|
||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||
"github.com/qianfree/team-api/internal/dao"
|
||
"github.com/qianfree/team-api/internal/logic/common"
|
||
"github.com/qianfree/team-api/internal/middleware"
|
||
do "github.com/qianfree/team-api/internal/model/do"
|
||
"github.com/qianfree/team-api/internal/model/entity"
|
||
"github.com/qianfree/team-api/internal/utility/crypto"
|
||
)
|
||
|
||
// ============================================================
|
||
// 开放平台应用管理
|
||
// ============================================================
|
||
|
||
// openAppPermissionPoints 是开放平台应用可授予的全部权限点。
|
||
//
|
||
// 与 internal/logic/open 中各 handler 实际校验的字符串一一对应(models:read 目前尚无
|
||
// handler 使用,保留以免存量授权在编辑时被判为非法)。此前创建/更新只校验 required,
|
||
// 任意字符串都能入库:判定是精确匹配且 fail-closed,写错不会越权,但会静默失效 ——
|
||
// 写成 members:wirte 的应用在任何接口上都拿不到权限,且排查时毫无线索。
|
||
var openAppPermissionPoints = map[string]bool{
|
||
"members:read": true,
|
||
"members:write": true,
|
||
"keys:read": true,
|
||
"keys:write": true,
|
||
"projects:read": true,
|
||
"projects:write": true,
|
||
"models:read": true,
|
||
"usage:read": true,
|
||
"billing:read": true,
|
||
}
|
||
|
||
// sanitizeOpenAppPermissions 校验并规整应用权限点:去重去空、拒绝未定义的权限点。
|
||
func sanitizeOpenAppPermissions(perms []string) ([]string, error) {
|
||
seen := make(map[string]bool, len(perms))
|
||
out := make([]string, 0, len(perms))
|
||
for _, p := range perms {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" || seen[p] {
|
||
continue
|
||
}
|
||
if !openAppPermissionPoints[p] {
|
||
return nil, common.NewBadRequestError("无效的权限点:" + p)
|
||
}
|
||
seen[p] = true
|
||
out = append(out, p)
|
||
}
|
||
if len(out) == 0 {
|
||
return nil, common.NewBadRequestError("请至少选择一个权限点")
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
type opnAppEncryptedSecretData struct {
|
||
EncryptedSecret any `json:"encrypted_secret"`
|
||
}
|
||
|
||
type opnAppSecretUpdateData struct {
|
||
AppSecretHash any `json:"app_secret_hash"`
|
||
EncryptedSecret any `json:"encrypted_secret"`
|
||
}
|
||
|
||
func (s *sTenant) OpenAppList(ctx context.Context, req *v1.OpenAppListReq) (*v1.OpenAppListRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||
|
||
m := dao.OpnApps.Ctx(ctx).Where("tenant_id", tenantID)
|
||
if req.Keyword != "" {
|
||
m = m.Where("name LIKE ?", "%"+req.Keyword+"%")
|
||
}
|
||
|
||
total, err := m.Count()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var apps []entity.OpnApps
|
||
err = m.OrderDesc("created_at").Page(page, pageSize).Scan(&apps)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
items := make([]v1.OpenAppItem, len(apps))
|
||
for i, app := range apps {
|
||
var perms []string
|
||
_ = json.Unmarshal([]byte(app.Permissions), &perms)
|
||
items[i] = v1.OpenAppItem{
|
||
ID: app.Id,
|
||
Name: app.Name,
|
||
Description: app.Description,
|
||
AppID: app.AppId,
|
||
Permissions: perms,
|
||
Status: app.Status,
|
||
RateLimit: app.RateLimit,
|
||
}
|
||
if app.LastUsedAt != nil {
|
||
items[i].LastUsedAt = app.LastUsedAt.Format("Y-m-d H:i:s")
|
||
}
|
||
if app.CreatedAt != nil {
|
||
items[i].CreatedAt = app.CreatedAt.Format("Y-m-d H:i:s")
|
||
}
|
||
}
|
||
|
||
return &v1.OpenAppListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||
}
|
||
|
||
func getTenantOpenAppID(ctx context.Context, tenantID, id int64) (string, error) {
|
||
type row struct {
|
||
AppId string `json:"app_id"`
|
||
}
|
||
var app *row
|
||
err := dao.OpnApps.Ctx(ctx).
|
||
Where("id", id).
|
||
Where("tenant_id", tenantID).
|
||
Fields("app_id").
|
||
Scan(&app)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if app == nil {
|
||
return "", common.NewNotFoundError("开放平台应用")
|
||
}
|
||
return app.AppId, nil
|
||
}
|
||
|
||
func (s *sTenant) OpenAppCreate(ctx context.Context, req *v1.OpenAppCreateReq) (*v1.OpenAppCreateRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
|
||
// Generate app_id (opn_xxxxxxxx)
|
||
appIDBytes := make([]byte, 12)
|
||
if _, err := rand.Read(appIDBytes); err != nil {
|
||
return nil, err
|
||
}
|
||
appID := "opn_" + hex.EncodeToString(appIDBytes)
|
||
|
||
// Generate app_secret (sk-opn-xxxxxxxxxxxxxxxx)
|
||
secretBytes := make([]byte, 24)
|
||
if _, err := rand.Read(secretBytes); err != nil {
|
||
return nil, err
|
||
}
|
||
appSecret := "sk-opn-" + hex.EncodeToString(secretBytes)
|
||
|
||
// Hash secret for storage
|
||
secretHash, err := crypto.HashPassword(appSecret)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Encrypt the one-time secret for DB persistence and Redis cache.
|
||
encKey := getEncKey(ctx)
|
||
encryptedSecret, err := crypto.EncryptString(encKey, appSecret)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
perms, err := sanitizeOpenAppPermissions(req.Permissions)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
permsJSON, _ := json.Marshal(perms)
|
||
ipJSON, _ := json.Marshal(req.IPWhitelist)
|
||
if req.IPWhitelist == nil {
|
||
ipJSON = []byte("[]")
|
||
}
|
||
|
||
rateLimit := req.RateLimit
|
||
if rateLimit <= 0 {
|
||
rateLimit = 60
|
||
}
|
||
|
||
var id int64
|
||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||
result, err := dao.OpnApps.Ctx(ctx).Data(do.OpnApps{
|
||
TenantId: tenantID,
|
||
Name: req.Name,
|
||
Description: req.Description,
|
||
AppId: appID,
|
||
AppSecretHash: secretHash,
|
||
Permissions: string(permsJSON),
|
||
IpWhitelist: string(ipJSON),
|
||
RateLimit: rateLimit,
|
||
Status: "active",
|
||
}).Insert()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
id, err = result.LastInsertId()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = dao.OpnApps.Ctx(ctx).
|
||
Where("id", id).
|
||
Data(opnAppEncryptedSecretData{EncryptedSecret: encryptedSecret}).
|
||
Update()
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Redis is a cache; DB encrypted_secret is the source of truth.
|
||
_, _ = g.Redis().Do(ctx, "SET", fmt.Sprintf("open:secret:%d", id), encryptedSecret, "EX", 30*86400)
|
||
|
||
return &v1.OpenAppCreateRes{ID: id, AppID: appID, AppSecret: appSecret}, nil
|
||
}
|
||
|
||
func (s *sTenant) OpenAppUpdate(ctx context.Context, req *v1.OpenAppUpdateReq) (*v1.OpenAppUpdateRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
data := do.OpnApps{}
|
||
hasUpdate := false
|
||
|
||
if req.Name != nil {
|
||
data.Name = *req.Name
|
||
hasUpdate = true
|
||
}
|
||
if req.Description != nil {
|
||
data.Description = *req.Description
|
||
hasUpdate = true
|
||
}
|
||
if req.Permissions != nil {
|
||
perms, err := sanitizeOpenAppPermissions(req.Permissions)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
permsJSON, _ := json.Marshal(perms)
|
||
data.Permissions = string(permsJSON)
|
||
hasUpdate = true
|
||
}
|
||
if req.IPWhitelist != nil {
|
||
ipJSON, _ := json.Marshal(req.IPWhitelist)
|
||
data.IpWhitelist = string(ipJSON)
|
||
hasUpdate = true
|
||
}
|
||
if req.RateLimit != nil {
|
||
data.RateLimit = *req.RateLimit
|
||
hasUpdate = true
|
||
}
|
||
|
||
if !hasUpdate {
|
||
return nil, nil
|
||
}
|
||
|
||
appID, err := getTenantOpenAppID(ctx, tenantID, req.Id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
_, err = dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(data).Update()
|
||
if err == nil {
|
||
middleware.InvalidateOpenAppCache(ctx, appID)
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
func (s *sTenant) OpenAppDelete(ctx context.Context, req *v1.OpenAppDeleteReq) (*v1.OpenAppDeleteRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
appID, err := getTenantOpenAppID(ctx, tenantID, req.Id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
_, err = dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Clean up secret from Redis
|
||
_, _ = g.Redis().Do(ctx, "DEL", fmt.Sprintf("open:secret:%d", req.Id))
|
||
middleware.InvalidateOpenAppCache(ctx, appID)
|
||
return nil, nil
|
||
}
|
||
|
||
func (s *sTenant) OpenAppResetSecret(ctx context.Context, req *v1.OpenAppResetSecretReq) (*v1.OpenAppResetSecretRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
appID, err := getTenantOpenAppID(ctx, tenantID, req.Id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Generate new secret
|
||
secretBytes := make([]byte, 24)
|
||
if _, err := rand.Read(secretBytes); err != nil {
|
||
return nil, err
|
||
}
|
||
appSecret := "sk-opn-" + hex.EncodeToString(secretBytes)
|
||
|
||
secretHash, err := crypto.HashPassword(appSecret)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
encKey := getEncKey(ctx)
|
||
encryptedSecret, err := crypto.EncryptString(encKey, appSecret)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
_, err = dao.OpnApps.Ctx(ctx).
|
||
Where("id", req.Id).
|
||
Where("tenant_id", tenantID).
|
||
Data(opnAppSecretUpdateData{
|
||
AppSecretHash: secretHash,
|
||
EncryptedSecret: encryptedSecret,
|
||
}).
|
||
Update()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
_, _ = g.Redis().Do(ctx, "SET", fmt.Sprintf("open:secret:%d", req.Id), encryptedSecret, "EX", 30*86400)
|
||
middleware.InvalidateOpenAppCache(ctx, appID)
|
||
|
||
return &v1.OpenAppResetSecretRes{AppSecret: appSecret}, nil
|
||
}
|
||
|
||
func (s *sTenant) OpenAppToggleStatus(ctx context.Context, req *v1.OpenAppToggleStatusReq) (*v1.OpenAppToggleStatusRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
appID, err := getTenantOpenAppID(ctx, tenantID, req.Id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
_, err = dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(do.OpnApps{
|
||
Status: req.Status,
|
||
}).Update()
|
||
if err == nil {
|
||
middleware.InvalidateOpenAppCache(ctx, appID)
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
// ============================================================
|
||
// Webhook 配置管理
|
||
// ============================================================
|
||
|
||
func (s *sTenant) WebhookConfigList(ctx context.Context, _ *v1.WebhookConfigListReq) (*v1.WebhookConfigListRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
var configs []entity.OpnWebhookConfigs
|
||
err := dao.OpnWebhookConfigs.Ctx(ctx).Where("tenant_id", tenantID).OrderDesc("created_at").Scan(&configs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
items := make([]v1.WebhookConfigItem, len(configs))
|
||
for i, c := range configs {
|
||
var events []string
|
||
_ = json.Unmarshal([]byte(c.Events), &events)
|
||
items[i] = v1.WebhookConfigItem{
|
||
ID: c.Id,
|
||
Name: c.Name,
|
||
URL: c.Url,
|
||
Events: events,
|
||
IsActive: c.IsActive,
|
||
ConsecutiveFailures: c.ConsecutiveFailures,
|
||
MaxConsecutiveFailures: c.MaxConsecutiveFailures,
|
||
}
|
||
if c.LastDeliveryAt != nil {
|
||
items[i].LastDeliveryAt = c.LastDeliveryAt.Format("Y-m-d H:i:s")
|
||
}
|
||
if c.CreatedAt != nil {
|
||
items[i].CreatedAt = c.CreatedAt.Format("Y-m-d H:i:s")
|
||
}
|
||
}
|
||
|
||
return &v1.WebhookConfigListRes{List: items}, nil
|
||
}
|
||
|
||
func (s *sTenant) WebhookConfigCreate(ctx context.Context, req *v1.WebhookConfigCreateReq) (*v1.WebhookConfigCreateRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
|
||
// Generate signing key
|
||
keyBytes := make([]byte, 24)
|
||
if _, err := rand.Read(keyBytes); err != nil {
|
||
return nil, err
|
||
}
|
||
secretKey := "whk_" + hex.EncodeToString(keyBytes)
|
||
|
||
eventsJSON, _ := json.Marshal(req.Events)
|
||
maxFails := req.MaxConsecutiveFailures
|
||
if maxFails <= 0 {
|
||
maxFails = 10
|
||
}
|
||
|
||
result, err := dao.OpnWebhookConfigs.Ctx(ctx).Data(do.OpnWebhookConfigs{
|
||
TenantId: tenantID,
|
||
Name: req.Name,
|
||
Url: req.URL,
|
||
SecretKey: secretKey,
|
||
Events: string(eventsJSON),
|
||
IsActive: true,
|
||
MaxConsecutiveFailures: maxFails,
|
||
}).Insert()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
id, _ := result.LastInsertId()
|
||
return &v1.WebhookConfigCreateRes{ID: id, SecretKey: secretKey}, nil
|
||
}
|
||
|
||
func (s *sTenant) WebhookConfigUpdate(ctx context.Context, req *v1.WebhookConfigUpdateReq) (*v1.WebhookConfigUpdateRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
data := do.OpnWebhookConfigs{}
|
||
hasUpdate := false
|
||
|
||
if req.Name != nil {
|
||
data.Name = *req.Name
|
||
hasUpdate = true
|
||
}
|
||
if req.URL != nil {
|
||
if !strings.HasPrefix(*req.URL, "https://") && !strings.HasPrefix(*req.URL, "http://") {
|
||
return nil, common.NewBadRequestError("回调 URL 必须以 http:// 或 https:// 开头")
|
||
}
|
||
data.Url = *req.URL
|
||
hasUpdate = true
|
||
}
|
||
if req.Events != nil {
|
||
eventsJSON, _ := json.Marshal(req.Events)
|
||
data.Events = string(eventsJSON)
|
||
hasUpdate = true
|
||
}
|
||
if req.IsActive != nil {
|
||
data.IsActive = *req.IsActive
|
||
hasUpdate = true
|
||
}
|
||
if req.MaxConsecutiveFailures != nil {
|
||
data.MaxConsecutiveFailures = *req.MaxConsecutiveFailures
|
||
hasUpdate = true
|
||
}
|
||
|
||
if !hasUpdate {
|
||
return nil, nil
|
||
}
|
||
|
||
result, err := dao.OpnWebhookConfigs.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(data).Update()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||
return nil, common.NewNotFoundError("Webhook 配置")
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
func (s *sTenant) WebhookConfigDelete(ctx context.Context, req *v1.WebhookConfigDeleteReq) (*v1.WebhookConfigDeleteRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
|
||
// 先校验配置存在且属于当前租户
|
||
count, err := dao.OpnWebhookConfigs.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Count()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if count == 0 {
|
||
return nil, common.NewNotFoundError("Webhook 配置")
|
||
}
|
||
|
||
// 级联删除:投递日志 → 事件 → 配置
|
||
_, err = dao.OpnWebhookDeliveryLogs.Ctx(ctx).Where("webhook_config_id", req.Id).Where("tenant_id", tenantID).Delete()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
_, err = dao.OpnWebhookEvents.Ctx(ctx).Where("webhook_config_id", req.Id).Where("tenant_id", tenantID).Delete()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
_, err = dao.OpnWebhookConfigs.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return nil, nil
|
||
}
|
||
|
||
func (s *sTenant) WebhookDeliveryLogs(ctx context.Context, req *v1.WebhookDeliveryLogsReq) (*v1.WebhookDeliveryLogsRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||
|
||
// 计数查询(不 JOIN)
|
||
countQuery := dao.OpnWebhookDeliveryLogs.Ctx(ctx).Where("tenant_id", tenantID).Where("webhook_config_id", req.Id)
|
||
if req.Status != "" {
|
||
countQuery = countQuery.Where("event_id IN (SELECT id FROM opn_webhook_events WHERE status = ?)", req.Status)
|
||
}
|
||
total, err := countQuery.Count()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 数据查询:LEFT JOIN opn_webhook_events 获取 event_type
|
||
db := g.DB()
|
||
query := db.Ctx(ctx).Raw(
|
||
"SELECT dl.*, e.event_type FROM opn_webhook_delivery_logs dl LEFT JOIN opn_webhook_events e ON dl.event_id = e.id WHERE dl.tenant_id = ? AND dl.webhook_config_id = ?",
|
||
tenantID, req.Id,
|
||
)
|
||
if req.Status != "" {
|
||
query = db.Ctx(ctx).Raw(
|
||
"SELECT dl.*, e.event_type FROM opn_webhook_delivery_logs dl LEFT JOIN opn_webhook_events e ON dl.event_id = e.id WHERE dl.tenant_id = ? AND dl.webhook_config_id = ? AND e.status = ?",
|
||
tenantID, req.Id, req.Status,
|
||
)
|
||
}
|
||
|
||
type deliveryLogWithEventType struct {
|
||
entity.OpnWebhookDeliveryLogs
|
||
EventType string `json:"event_type"`
|
||
}
|
||
var logs []deliveryLogWithEventType
|
||
err = query.OrderDesc("dl.created_at").Page(page, pageSize).Scan(&logs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
items := make([]v1.WebhookDeliveryLogItem, len(logs))
|
||
for i, l := range logs {
|
||
items[i] = v1.WebhookDeliveryLogItem{
|
||
ID: l.Id,
|
||
EventID: l.EventId,
|
||
EventType: l.EventType,
|
||
Attempt: l.Attempt,
|
||
ResponseStatus: l.ResponseStatus,
|
||
ResponseTimeMs: l.ResponseTimeMs,
|
||
ErrorMessage: l.ErrorMessage,
|
||
}
|
||
if l.CreatedAt != nil {
|
||
items[i].CreatedAt = l.CreatedAt.Format("Y-m-d H:i:s")
|
||
}
|
||
}
|
||
|
||
return &v1.WebhookDeliveryLogsRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||
}
|
||
|
||
func (s *sTenant) WebhookRetry(ctx context.Context, req *v1.WebhookRetryReq) (*v1.WebhookRetryRes, error) {
|
||
role := middleware.GetUserRole(ctx)
|
||
if role != "owner" && role != "admin" {
|
||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||
}
|
||
tenantID := middleware.GetTenantID(ctx)
|
||
|
||
result, err := dao.OpnWebhookEvents.Ctx(ctx).
|
||
Where("id", req.EventId).
|
||
Where("tenant_id", tenantID).
|
||
Where("status IN (?)", g.Slice{"failed", "pending"}).
|
||
Data(do.OpnWebhookEvents{
|
||
Status: "pending",
|
||
NextRetryAt: gtime.Now(),
|
||
Attempts: 0,
|
||
}).Update()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
rows, _ := result.RowsAffected()
|
||
if rows > 0 {
|
||
NotifyNewEvent(req.EventId)
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
// ============================================================
|
||
// Webhook 事件投递(后台任务)
|
||
// ============================================================
|
||
|
||
// PublishWebhookEvent publishes a webhook event for a tenant.
|
||
func PublishWebhookEvent(ctx context.Context, tenantID int64, eventType string, payload any) error {
|
||
payloadJSON, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Find all active webhook configs for this tenant, then filter by subscribed events in Go
|
||
var allConfigs []entity.OpnWebhookConfigs
|
||
err = dao.OpnWebhookConfigs.Ctx(ctx).
|
||
Where("tenant_id", tenantID).
|
||
Where("is_active", true).
|
||
Scan(&allConfigs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var configs []entity.OpnWebhookConfigs
|
||
for _, c := range allConfigs {
|
||
var events []string
|
||
if err := json.Unmarshal([]byte(c.Events), &events); err != nil {
|
||
continue
|
||
}
|
||
for _, e := range events {
|
||
if e == eventType {
|
||
configs = append(configs, c)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
for _, config := range configs {
|
||
// Generate event ID
|
||
evtBytes := make([]byte, 12)
|
||
if _, err := rand.Read(evtBytes); err != nil {
|
||
continue
|
||
}
|
||
eventID := "evt_" + hex.EncodeToString(evtBytes)
|
||
|
||
result, insertErr := dao.OpnWebhookEvents.Ctx(ctx).Data(do.OpnWebhookEvents{
|
||
TenantId: tenantID,
|
||
WebhookConfigId: config.Id,
|
||
EventId: eventID,
|
||
EventType: eventType,
|
||
Payload: string(payloadJSON),
|
||
Status: "pending",
|
||
NextRetryAt: gtime.Now(),
|
||
}).Insert()
|
||
if insertErr != nil {
|
||
g.Log().Error(ctx, "publish webhook event failed:", insertErr)
|
||
continue
|
||
}
|
||
|
||
id, _ := result.LastInsertId()
|
||
NotifyNewEvent(id)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ComputeWebhookSignature computes the HMAC-SHA256 signature for a webhook payload.
|
||
func ComputeWebhookSignature(secret string, timestamp string, body []byte) string {
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
mac.Write([]byte(timestamp + "." + string(body)))
|
||
return hex.EncodeToString(mac.Sum(nil))
|
||
}
|