Files

371 lines
13 KiB
Go

package admin
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/common"
do "github.com/qianfree/team-api/internal/model/do"
)
func (s *sAdmin) DataGovernanceSettingsGet(ctx context.Context, _ *v1.DataGovernanceSettingsGetReq) (*v1.DataGovernanceSettingsGetRes, error) {
items := common.Config().GetCategoryWithValues(ctx, "data_governance")
result := make(map[string]any, len(items))
for _, item := range items {
result[item.Key] = item.Value
}
return &v1.DataGovernanceSettingsGetRes{Data: result}, nil
}
// UpdateDataGovernanceSettings 更新数据治理设置
func (s *sAdmin) DataGovernanceSettingsUpdate(ctx context.Context, req *v1.DataGovernanceSettingsUpdateReq) (*v1.DataGovernanceSettingsUpdateRes, error) {
for key, value := range req.Settings {
if !common.IsRegisteredKey(key) {
return nil, gerror.NewCodef(gcode.New(consts.CodeBadRequest, consts.MsgBadRequest, nil),
"未知配置项: %s", key)
}
if err := common.Config().SetOption(ctx, key, value); err != nil {
return nil, err
}
}
return &v1.DataGovernanceSettingsUpdateRes{}, nil
}
// RequestDataExport 请求数据导出
func (s *sAdmin) DataGovernanceExport(ctx context.Context, req *v1.DataGovernanceExportReq) (*v1.DataGovernanceExportRes, error) {
count, err := dao.TskTasks.Ctx(ctx).
Where("handler", "data_export").
Where("status", "pending").
Where("(payload->>'tenant_id')::bigint = ?", req.TenantID).
Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, gerror.NewCode(gcode.New(consts.CodeExportAlreadyPending, consts.MsgExportAlreadyPending, nil),
consts.MsgExportAlreadyPending)
}
payload, _ := json.Marshal(map[string]any{
"tenant_id": req.TenantID,
"scopes": req.Scopes,
"requested_by": common.GetCtxUserID(ctx),
})
result, err := dao.TskTasks.Ctx(ctx).Data(do.TskTasks{
Name: fmt.Sprintf("数据导出 [租户%d]", req.TenantID),
Handler: "data_export",
Payload: payload,
Status: "pending",
MaxRetries: 1,
}).Insert()
if err != nil {
return nil, err
}
taskID, _ := result.LastInsertId()
return &v1.DataGovernanceExportRes{TaskID: taskID}, nil
}
// RequestDataDeletion 请求数据删除
func (s *sAdmin) DataGovernanceDeletion(ctx context.Context, req *v1.DataGovernanceDeletionReq) (*v1.DataGovernanceDeletionRes, error) {
count, err := dao.TskTasks.Ctx(ctx).
Where("handler", "data_deletion_request").
Where("status IN (?)", []string{"pending", "running"}).
Where("(payload->>'tenant_id')::bigint = ?", req.TenantID).
Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, gerror.NewCode(gcode.New(consts.CodeDeletionRequestExists, consts.MsgDeletionRequestExists, nil),
consts.MsgDeletionRequestExists)
}
payload, _ := json.Marshal(map[string]any{
"tenant_id": req.TenantID,
"reason": req.Reason,
"requested_by": common.GetCtxUserID(ctx),
})
result, err := dao.TskTasks.Ctx(ctx).Data(do.TskTasks{
Name: fmt.Sprintf("数据删除 [租户%d]", req.TenantID),
Handler: "data_deletion_request",
Payload: payload,
Status: "pending",
MaxRetries: 0,
}).Insert()
if err != nil {
return nil, err
}
taskID, _ := result.LastInsertId()
return &v1.DataGovernanceDeletionRes{TaskID: taskID}, nil
}
// TriggerDataCleanup 手动触发数据清理
func (s *sAdmin) DataGovernanceCleanup(ctx context.Context, _ *v1.DataGovernanceCleanupReq) (*v1.DataGovernanceCleanupRes, error) {
if err := CleanupExpiredData(ctx); err != nil {
return nil, err
}
return &v1.DataGovernanceCleanupRes{Message: "数据清理已触发"}, nil
}
// CleanupExpiredData 清理过期数据(定时任务入口)
func CleanupExpiredData(ctx context.Context) error {
apiLogsDays := common.Config().GetInt(ctx, "data_retention_api_logs_days")
opLogsDays := common.Config().GetInt(ctx, "data_retention_operation_logs_days")
tempDays := common.Config().GetInt(ctx, "data_retention_temp_data_days")
if apiLogsDays > 0 {
if err := cleanupTableByDate(ctx, "aud_request_logs", "created_at", apiLogsDays); err != nil {
g.Log().Errorf(ctx, "cleanup aud_request_logs: %v", err)
}
}
if opLogsDays > 0 {
if err := cleanupTableByDate(ctx, "aud_operation_logs", "created_at", opLogsDays); err != nil {
g.Log().Errorf(ctx, "cleanup aud_operation_logs: %v", err)
}
}
if tempDays > 0 {
if err := cleanupTableByDate(ctx, "aud_sensitive_access_logs", "created_at", tempDays); err != nil {
g.Log().Errorf(ctx, "cleanup aud_sensitive_access_logs: %v", err)
}
}
if err := cleanupDeactivatedTenants(ctx); err != nil {
g.Log().Errorf(ctx, "cleanup deactivated tenants: %v", err)
}
return nil
}
// FileCleanupResult 报告一次文件保留期清理删除的文件数量。
type FileCleanupResult struct {
ExportsDeleted int `json:"exports_deleted"`
ImagesDeleted int `json:"images_deleted"`
}
// deleteExpiredFiles 删除 created_at 早于 cutoff 的 fil_files 行及其存储对象。
// exports=true 匹配导出文件(storage_path LIKE 'exports/%');exports=false 只匹配
// **图片**文件(mime_type LIKE 'image/%' 且非导出)——file_image_retention_days 的
// 语义即「图片保留期」,绝不能连带删除非图片的 provider 上传文件(附件/头像等),否则
// 会误删用户数据。删除经 FileService.Delete 完成,会一并删除桶中对象(而非仅删库行,
// 避免存储泄漏)。fileSvc 为 nil 时(对象存储未配置)退化为仅删库行。分批处理以限制
// 内存与单次事务规模。
func deleteExpiredFiles(ctx context.Context, fileSvc *common.FileService, cutoff time.Time, exports bool) (int, error) {
const batchSize = 500
total := 0
for {
m := dao.FilFiles.Ctx(ctx).Where("created_at < ?", cutoff)
if exports {
m = m.Where("storage_path LIKE ?", "exports/%")
} else {
// 只清理图片:按 mime_type 精确限定,不再用「非导出」笼统匹配所有上传文件。
m = m.Where("storage_path NOT LIKE ?", "exports/%").
Where("mime_type LIKE ?", "image/%")
}
var batch []struct {
ID int64 `json:"id"`
}
if err := m.Fields("id").OrderAsc("id").Limit(batchSize).Scan(&batch); err != nil {
return total, err
}
if len(batch) == 0 {
break
}
deletedInBatch := 0
for _, f := range batch {
if fileSvc != nil {
// FileService.Delete 删对象(失败仅告警)后删行;返回硬错误表示行未删。
if err := fileSvc.Delete(ctx, f.ID, 0); err != nil {
g.Log().Warningf(ctx, "file retention: delete file %d failed: %v", f.ID, err)
continue
}
} else if _, err := dao.FilFiles.Ctx(ctx).Where("id", f.ID).Delete(); err != nil {
g.Log().Warningf(ctx, "file retention: delete row %d failed: %v", f.ID, err)
continue
}
total++
deletedInBatch++
}
// 整批未能删除任何一行(删除持续失败)——停止,避免同一批被反复重查形成热循环。
if deletedInBatch == 0 {
g.Log().Warningf(ctx, "file retention: no progress in batch, stopping (check storage/db)")
break
}
if len(batch) < batchSize {
break
}
}
if total > 0 {
g.Log().Infof(ctx, "file retention: removed %d files (exports=%v, before %s)", total, exports, cutoff.Format("2006-01-02"))
}
return total, nil
}
// buildFileService 从数据库配置构造 FileService;对象存储未配置时返回 nil(清理退化为仅删库行)。
func buildFileService(ctx context.Context) *common.FileService {
fileSvc, err := common.NewFileServiceFromConfig(ctx)
if err != nil {
g.Log().Warningf(ctx, "file retention: object storage unavailable, pruning db rows only: %v", err)
return nil
}
return fileSvc
}
// CleanupExpiredExportFiles 清理过期导出文件(含存储对象)。由 export_file_cleanup cron 调用。
func CleanupExpiredExportFiles(ctx context.Context) error {
expiryDays := common.Config().GetInt(ctx, "data_export_expiry_days")
if expiryDays <= 0 {
return nil
}
cutoff := time.Now().AddDate(0, 0, -expiryDays)
_, err := deleteExpiredFiles(ctx, buildFileService(ctx), cutoff, true)
return err
}
// CleanupExpiredImages 清理过期的图片文件(含存储对象)。仅删除 mime_type 为 image/* 的文件,
// 不影响导出文件与其它非图片上传。file_image_retention_days<=0 表示关闭(默认),直接跳过。
func CleanupExpiredImages(ctx context.Context) (int, error) {
retentionDays := common.Config().GetInt(ctx, "file_image_retention_days")
if retentionDays <= 0 {
return 0, nil
}
cutoff := time.Now().AddDate(0, 0, -retentionDays)
return deleteExpiredFiles(ctx, buildFileService(ctx), cutoff, false)
}
// CheckFileRetention 由 file_retention_check cron 调用:受 file_retention_enabled 开关控制,
// 清理过期图片文件(导出文件由独立的 export_file_cleanup cron 负责)。
func CheckFileRetention(ctx context.Context) error {
if !common.Config().GetBool(ctx, "file_retention_enabled") {
return nil
}
_, err := CleanupExpiredImages(ctx)
return err
}
// RunFileRetentionNow 立即执行一次完整保留期清理(导出 + 图片),返回各自删除数量。
// 供管理后台「手动触发清理」端点调用;不受 file_retention_enabled 开关限制。
func RunFileRetentionNow(ctx context.Context) (*FileCleanupResult, error) {
res := &FileCleanupResult{}
fileSvc := buildFileService(ctx)
if days := common.Config().GetInt(ctx, "data_export_expiry_days"); days > 0 {
n, err := deleteExpiredFiles(ctx, fileSvc, time.Now().AddDate(0, 0, -days), true)
if err != nil {
return res, err
}
res.ExportsDeleted = n
}
if days := common.Config().GetInt(ctx, "file_image_retention_days"); days > 0 {
n, err := deleteExpiredFiles(ctx, fileSvc, time.Now().AddDate(0, 0, -days), false)
if err != nil {
return res, err
}
res.ImagesDeleted = n
}
return res, nil
}
func cleanupTableByDate(ctx context.Context, table, dateColumn string, days int) error {
allowedCleanupTables := map[string]map[string]bool{
"aud_request_logs": {"created_at": true},
"aud_operation_logs": {"created_at": true},
"aud_sensitive_access_logs": {"created_at": true},
}
cols, ok := allowedCleanupTables[table]
if !ok || !cols[dateColumn] {
return fmt.Errorf("invalid table/column for cleanup: %s.%s", table, dateColumn)
}
cutoff := time.Now().AddDate(0, 0, -days)
batchSize := 5000
// Build safe DELETE SQL via switch/case to avoid fmt.Sprintf on table/column names.
// The whitelist above guarantees table and dateColumn are from a trusted set.
var deleteSQL string
switch table {
case "aud_request_logs":
deleteSQL = "DELETE FROM aud_request_logs WHERE id IN (SELECT id FROM aud_request_logs WHERE created_at < ? LIMIT ?)"
case "aud_operation_logs":
deleteSQL = "DELETE FROM aud_operation_logs WHERE id IN (SELECT id FROM aud_operation_logs WHERE created_at < ? LIMIT ?)"
case "aud_sensitive_access_logs":
deleteSQL = "DELETE FROM aud_sensitive_access_logs WHERE id IN (SELECT id FROM aud_sensitive_access_logs WHERE created_at < ? LIMIT ?)"
default:
return fmt.Errorf("invalid table for cleanup: %s", table)
}
// aud_request_logs 走独立库,其余审计表走主库
db := g.DB()
if table == "aud_request_logs" {
db = common.GetAuditDB()
}
for {
result, err := db.Ctx(ctx).Exec(ctx, deleteSQL, cutoff, batchSize)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
break
}
g.Log().Infof(ctx, "cleaned %d rows from %s (older than %d days)", rowsAffected, table, days)
}
return nil
}
func cleanupDeactivatedTenants(ctx context.Context) error {
var tenants []struct {
ID int64 `json:"id"`
StatusCode string `json:"status"`
}
err := dao.TntTenants.Ctx(ctx).
WhereIn("status", []string{"frozen", "terminated", "closed"}).
Where("data_removal_at < ?", gtime.Now()).
Where("data_removal_at IS NOT NULL").
Fields("id, status").
Scan(&tenants)
if err != nil {
return err
}
for _, t := range tenants {
g.Log().Infof(ctx, "cleaning data for deactivated tenant %d (status=%s)", t.ID, t.StatusCode)
// Use transaction to ensure atomicity: if any step fails, the tenant's data
// remains consistent (no partial anonymization/deletion).
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
tenantID := t.ID
if _, err := dao.TntUsers.Ctx(ctx).Where("tenant_id", tenantID).
Data(do.TntUsers{DisplayName: "[deleted]", Email: fmt.Sprintf("deleted_%d@deleted.local", tenantID)}).Update(); err != nil {
return gerror.Wrapf(err, "anonymize users for tenant %d", tenantID)
}
if _, err := dao.ApiKeys.Ctx(ctx).Where("tenant_id", tenantID).Delete(); err != nil {
return gerror.Wrapf(err, "delete api keys for tenant %d", tenantID)
}
if _, err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).
Data(do.TntTenants{DataRemovalAt: gtime.Now()}).Update(); err != nil {
return gerror.Wrapf(err, "update data removal for tenant %d", tenantID)
}
return nil
})
if err != nil {
return err
}
}
return nil
}