Files
team-api/internal/logic/tenant/open_platform.go
T

510 lines
15 KiB
Go

package tenant
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"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"
)
// ============================================================
// 开放平台应用管理
// ============================================================
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 := req.Page
pageSize := req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
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,
IsSandbox: app.IsSandbox,
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 (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
}
// Store raw secret in Redis (encrypted) for HMAC verification
encKey := getEncKey(ctx)
encryptedSecret, err := crypto.EncryptString(encKey, appSecret)
if err != nil {
return nil, err
}
permsJSON, _ := json.Marshal(req.Permissions)
ipJSON, _ := json.Marshal(req.IPWhitelist)
if req.IPWhitelist == nil {
ipJSON = []byte("[]")
}
rateLimit := req.RateLimit
if rateLimit <= 0 {
rateLimit = 60
}
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 nil, err
}
id, _ := result.LastInsertId()
// Store encrypted secret in Redis
_, err = g.Redis().Do(ctx, "SET", fmt.Sprintf("open:secret:%d", id), encryptedSecret)
if err != nil {
return nil, err
}
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 := g.Map{}
if req.Name != nil {
data["name"] = *req.Name
}
if req.Description != nil {
data["description"] = *req.Description
}
if req.Permissions != nil {
permsJSON, _ := json.Marshal(req.Permissions)
data["permissions"] = string(permsJSON)
}
if req.IPWhitelist != nil {
ipJSON, _ := json.Marshal(req.IPWhitelist)
data["ip_whitelist"] = string(ipJSON)
}
if req.RateLimit != nil {
data["rate_limit"] = *req.RateLimit
}
if len(data) == 0 {
return nil, nil
}
_, err := dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(data).Update()
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)
_, 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))
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)
// 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
}
_, err = dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(g.Map{
"app_secret_hash": secretHash,
}).Update()
if err != nil {
return nil, err
}
// Update Redis
encKey := getEncKey(ctx)
encryptedSecret, err := crypto.EncryptString(encKey, appSecret)
if err != nil {
return nil, err
}
_, err = g.Redis().Do(ctx, "SET", fmt.Sprintf("open:secret:%d", req.Id), encryptedSecret)
if err != nil {
return nil, err
}
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)
_, err := dao.OpnApps.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(g.Map{
"status": req.Status,
}).Update()
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 := g.Map{}
if req.Name != nil {
data["name"] = *req.Name
}
if req.URL != nil {
data["url"] = *req.URL
}
if req.Events != nil {
eventsJSON, _ := json.Marshal(req.Events)
data["events"] = string(eventsJSON)
}
if req.IsActive != nil {
data["is_active"] = *req.IsActive
}
if req.MaxConsecutiveFailures != nil {
data["max_consecutive_failures"] = *req.MaxConsecutiveFailures
}
if len(data) == 0 {
return nil, nil
}
_, err := dao.OpnWebhookConfigs.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Data(data).Update()
return nil, err
}
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)
_, err := dao.OpnWebhookConfigs.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).Delete()
return nil, err
}
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 := req.Page
pageSize := req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
m := dao.OpnWebhookDeliveryLogs.Ctx(ctx).Where("tenant_id", tenantID).Where("webhook_config_id", req.Id)
if req.Status != "" {
// Filter by event status through join
m = m.LeftJoin("opn_webhook_events e", "e.id = opn_webhook_delivery_logs.event_id").Where("e.status", req.Status)
}
total, err := m.Count()
if err != nil {
return nil, err
}
var logs []entity.OpnWebhookDeliveryLogs
err = m.OrderDesc("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,
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(g.Map{
"status": "pending",
"next_retry_at": 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 that subscribe to this event type
var configs []entity.OpnWebhookConfigs
err = dao.OpnWebhookConfigs.Ctx(ctx).
Where("tenant_id", tenantID).
Where("is_active", true).
Where("events::jsonb @> ?", fmt.Sprintf(`["%s"]`, eventType)).
Scan(&configs)
if err != nil {
return err
}
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))
}