- P1-T1 SetTenantGroups/SetGroupModels 删-插包进事务,避免插入失败清空关联 - P1-T2 ApiKeyUpdate 主表与 scope 更新同事务,缓存失效移到提交后 - P1-T3 InvalidateWallet 移到 FulfillOrder 事务提交后,避免缓存污染窗口 - P1-T4 handleDeletionRequest 跨 4 表删改包进事务,杜绝中间态 - P1-T5 subscribePlanTx 加事务级 advisory lock,串行化同租户订阅
218 lines
7.4 KiB
Go
218 lines
7.4 KiB
Go
package task
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/database/gdb"
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
|
||
"github.com/qianfree/team-api/internal/dao"
|
||
"github.com/qianfree/team-api/internal/logic/common"
|
||
do "github.com/qianfree/team-api/internal/model/do"
|
||
)
|
||
|
||
type ExportPayload struct {
|
||
TenantID int64 `json:"tenant_id"`
|
||
Scopes []string `json:"scopes"`
|
||
RequestedBy int64 `json:"requested_by"`
|
||
}
|
||
|
||
type DeletionPayload struct {
|
||
TenantID int64 `json:"tenant_id"`
|
||
Reason string `json:"reason"`
|
||
RequestedBy int64 `json:"requested_by"`
|
||
}
|
||
|
||
func init() {
|
||
RegisterHandler("data_export", handleDataExport)
|
||
RegisterHandler("data_deletion_request", handleDeletionRequest)
|
||
RegisterHandler("data_export_cleanup", handleExportCleanup)
|
||
}
|
||
|
||
func handleDataExport(ctx context.Context, payload json.RawMessage) (any, error) {
|
||
var p ExportPayload
|
||
if err := json.Unmarshal(payload, &p); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
exportData := make(map[string]any)
|
||
for _, scope := range p.Scopes {
|
||
switch scope {
|
||
case "members":
|
||
var members []map[string]any
|
||
result, err := dao.TntUsers.Ctx(ctx).Where("tenant_id", p.TenantID).
|
||
Fields("id, username, display_name, role, status, created_at").
|
||
All()
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "export members for tenant %d: %v", p.TenantID, err)
|
||
} else {
|
||
for _, rec := range result {
|
||
m := rec.Map()
|
||
delete(m, "email")
|
||
members = append(members, m)
|
||
}
|
||
}
|
||
exportData["members"] = members
|
||
case "usage":
|
||
var logs []map[string]any
|
||
result, err := dao.BilUsageLogs.Ctx(ctx).Where("tenant_id", p.TenantID).
|
||
OrderDesc("created_at").Limit(10000).
|
||
Fields("id, model_name, input_tokens, output_tokens, actual_cost, created_at").
|
||
All()
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "export usage for tenant %d: %v", p.TenantID, err)
|
||
} else {
|
||
for _, rec := range result {
|
||
logs = append(logs, rec.Map())
|
||
}
|
||
}
|
||
exportData["usage"] = logs
|
||
case "billing":
|
||
var records []map[string]any
|
||
result, err := dao.BilRecords.Ctx(ctx).Where("tenant_id", p.TenantID).
|
||
OrderDesc("created_at").Limit(10000).
|
||
Fields("id, relay_mode, model_name, input_tokens, output_tokens, total_cost, currency, status, created_at").
|
||
All()
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "export billing for tenant %d: %v", p.TenantID, err)
|
||
} else {
|
||
for _, rec := range result {
|
||
records = append(records, rec.Map())
|
||
}
|
||
}
|
||
exportData["billing_records"] = records
|
||
case "logs":
|
||
var logs []map[string]any
|
||
result, err := common.AuditModelCtx(ctx, "aud_operation_logs").Where("tenant_id", p.TenantID).
|
||
OrderDesc("created_at").Limit(10000).
|
||
Fields("id, action, resource_type, resource_id, ip_address, detail, created_at").
|
||
All()
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "export logs for tenant %d: %v", p.TenantID, err)
|
||
} else {
|
||
for _, rec := range result {
|
||
logs = append(logs, rec.Map())
|
||
}
|
||
}
|
||
exportData["operation_logs"] = logs
|
||
}
|
||
}
|
||
|
||
jsonData, err := json.MarshalIndent(exportData, "", " ")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("marshal export data: %w", err)
|
||
}
|
||
|
||
// 通过 FileService 上传(未配对象存储时自动降级本地磁盘,保证导出功能基本可用)。
|
||
// storage_path 必须保留 "exports/" 前缀——文件列表的 export 分类与保留期清理
|
||
// (export_file_cleanup cron)均据此识别导出文件,故用 UploadWithKey 而非 Upload
|
||
// (后者生成 "{date}/{uuid}" 路径会破坏该判据)。
|
||
svc, err := common.NewFileServiceFromConfig(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("初始化存储失败: %w", err)
|
||
}
|
||
storagePath := fmt.Sprintf("exports/tenant_%d/%s.json", p.TenantID, time.Now().Format("20060102_150405"))
|
||
rec, err := svc.UploadWithKey(ctx, &common.FileUpload{
|
||
Reader: bytes.NewReader(jsonData),
|
||
Filename: fmt.Sprintf("export_tenant_%d_%s.json", p.TenantID, time.Now().Format("20060102")),
|
||
ContentType: "application/json",
|
||
Size: int64(len(jsonData)),
|
||
TenantID: p.TenantID,
|
||
UserID: p.RequestedBy,
|
||
}, storagePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("upload export: %w", err)
|
||
}
|
||
|
||
// 下载链接:local 返回应用层 serve 相对 URL,OSS 返回预签名 URL(前端按是否 http 分支)。
|
||
downloadURL, err := svc.GetDownloadURL(ctx, rec.ID, p.TenantID)
|
||
if err != nil {
|
||
g.Log().Warningf(ctx, "data export: get download url for tenant %d: %v", p.TenantID, err)
|
||
}
|
||
|
||
g.Log().Infof(ctx, "data export completed for tenant %d, scopes=%v, size=%d bytes, provider=%s",
|
||
p.TenantID, p.Scopes, len(jsonData), svc.ProviderName())
|
||
return map[string]any{
|
||
"file_id": rec.ID,
|
||
"storage_path": storagePath,
|
||
"storage_provider": svc.ProviderName(),
|
||
"download_url": downloadURL,
|
||
"scopes": p.Scopes,
|
||
"size": len(jsonData),
|
||
}, nil
|
||
}
|
||
|
||
func handleDeletionRequest(ctx context.Context, payload json.RawMessage) (any, error) {
|
||
var p DeletionPayload
|
||
if err := json.Unmarshal(payload, &p); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
LogInfo(ctx, 0, fmt.Sprintf("开始处理租户 %d 的数据删除请求", p.TenantID))
|
||
|
||
// 跨 4 张表(tnt_users / api_keys / aud_sensitive_access_logs / tnt_tenants)的删除与更新必须
|
||
// 在同一事务内完成,否则中途失败会留下部分应用的中间态(如 API Key 已禁用但租户未标记 terminated)。
|
||
// 事务采用 ctx 传播式:闭包内统一 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。
|
||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||
_, err := dao.TntUsers.Ctx(ctx).
|
||
Where("tenant_id", p.TenantID).
|
||
Data(do.TntUsers{
|
||
DisplayName: "[deleted]",
|
||
Email: fmt.Sprintf("deleted_%d@deleted.local", p.TenantID),
|
||
}).Update()
|
||
if err != nil {
|
||
LogError(ctx, 0, fmt.Sprintf("匿名化用户数据失败: %v", err))
|
||
return fmt.Errorf("anonymize users: %w", err)
|
||
}
|
||
|
||
if _, err := dao.ApiKeys.Ctx(ctx).
|
||
Where("tenant_id", p.TenantID).
|
||
Data(do.ApiKeys{Status: "disabled"}).Update(); err != nil {
|
||
return fmt.Errorf("disable api keys: %w", err)
|
||
}
|
||
|
||
if _, err := common.AuditModelCtx(ctx, "aud_sensitive_access_logs").Where("tenant_id", p.TenantID).Delete(); err != nil {
|
||
return fmt.Errorf("delete sensitive access logs: %w", err)
|
||
}
|
||
|
||
if _, err := dao.TntTenants.Ctx(ctx).
|
||
Where("id", p.TenantID).
|
||
Data(do.TntTenants{Status: "terminated"}).Update(); err != nil {
|
||
return fmt.Errorf("terminate tenant: %w", err)
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
proof := map[string]any{
|
||
"tenant_id": p.TenantID,
|
||
"reason": p.Reason,
|
||
"requested_by": p.RequestedBy,
|
||
"completed_at": time.Now().Format(time.RFC3339),
|
||
"actions": []string{"用户数据已匿名化", "API Key 已禁用", "敏感日志已删除", "租户已标记 terminated"},
|
||
}
|
||
LogInfo(ctx, 0, fmt.Sprintf("数据删除完成: 租户%d", p.TenantID))
|
||
return proof, nil
|
||
}
|
||
|
||
func handleExportCleanup(ctx context.Context, payload json.RawMessage) (any, error) {
|
||
var p struct {
|
||
FileID int64 `json:"file_id"`
|
||
TenantID int64 `json:"tenant_id"`
|
||
}
|
||
if err := json.Unmarshal(payload, &p); err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := dao.FilFiles.Ctx(ctx).Where("id", p.FileID).Delete(); err != nil {
|
||
return nil, fmt.Errorf("delete export file %d: %w", p.FileID, err)
|
||
}
|
||
g.Log().Infof(ctx, "cleaned up export file %d for tenant %d", p.FileID, p.TenantID)
|
||
return map[string]any{"file_id": p.FileID, "deleted": true}, nil
|
||
}
|