Files

682 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}