Files
martin 4cc6ff307e fix: 修复 P1 事务边界问题
- P1-T1 SetTenantGroups/SetGroupModels 删-插包进事务,避免插入失败清空关联
- P1-T2 ApiKeyUpdate 主表与 scope 更新同事务,缓存失效移到提交后
- P1-T3 InvalidateWallet 移到 FulfillOrder 事务提交后,避免缓存污染窗口
- P1-T4 handleDeletionRequest 跨 4 表删改包进事务,杜绝中间态
- P1-T5 subscribePlanTx 加事务级 advisory lock,串行化同租户订阅
2026-07-26 12:41:28 +08:00

218 lines
7.4 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 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
}