From b896d0fa00c48b380d351d59f204280233b6bb20 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 15 Jan 2026 15:28:41 +0800 Subject: [PATCH] feat: add superadmin assets and notifications --- backend/app/http/super/v1/assets.go | 63 ++ backend/app/http/super/v1/dto/super_asset.go | 119 +++ .../http/super/v1/dto/super_notification.go | 140 ++++ backend/app/http/super/v1/notifications.go | 72 ++ backend/app/http/super/v1/provider.gen.go | 18 + backend/app/http/super/v1/routes.gen.go | 40 + backend/app/services/super.go | 779 ++++++++++++++++++ backend/database/.transform.yaml | 2 + ...15063813_create_notification_templates.sql | 26 + backend/database/models/contents.gen.go | 2 +- backend/database/models/contents.query.gen.go | 186 ++--- .../models/notification_templates.gen.go | 65 ++ .../notification_templates.query.gen.go | 495 +++++++++++ backend/database/models/query.gen.go | 296 +++---- backend/docs/docs.go | 635 ++++++++++++++ backend/docs/swagger.json | 635 ++++++++++++++ backend/docs/swagger.yaml | 422 ++++++++++ docs/superadmin_progress.md | 28 +- .../superadmin/src/service/AssetService.js | 57 ++ .../src/service/NotificationService.js | 102 +++ .../src/views/superadmin/Assets.vue | 368 ++++++++- .../src/views/superadmin/Notifications.vue | 562 ++++++++++++- 22 files changed, 4852 insertions(+), 260 deletions(-) create mode 100644 backend/app/http/super/v1/assets.go create mode 100644 backend/app/http/super/v1/dto/super_asset.go create mode 100644 backend/app/http/super/v1/dto/super_notification.go create mode 100644 backend/app/http/super/v1/notifications.go create mode 100644 backend/database/migrations/20260115063813_create_notification_templates.sql create mode 100644 backend/database/models/notification_templates.gen.go create mode 100644 backend/database/models/notification_templates.query.gen.go create mode 100644 frontend/superadmin/src/service/AssetService.js create mode 100644 frontend/superadmin/src/service/NotificationService.js diff --git a/backend/app/http/super/v1/assets.go b/backend/app/http/super/v1/assets.go new file mode 100644 index 0000000..31039e9 --- /dev/null +++ b/backend/app/http/super/v1/assets.go @@ -0,0 +1,63 @@ +package v1 + +import ( + dto "quyun/v2/app/http/super/v1/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + + "github.com/gofiber/fiber/v3" +) + +// @provider +type assets struct{} + +// List assets +// +// @Router /super/v1/assets [get] +// @Summary List assets +// @Description List assets across tenants +// @Tags Asset +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperAssetItem} +// @Bind filter query +func (c *assets) List(ctx fiber.Ctx, filter *dto.SuperAssetListFilter) (*requests.Pager, error) { + return services.Super.ListAssets(ctx, filter) +} + +// Asset usage +// +// @Router /super/v1/assets/usage [get] +// @Summary Asset usage +// @Description Asset usage statistics +// @Tags Asset +// @Accept json +// @Produce json +// @Success 200 {object} dto.SuperAssetUsageResponse +// @Bind filter query +func (c *assets) Usage(ctx fiber.Ctx, filter *dto.SuperAssetUsageFilter) (*dto.SuperAssetUsageResponse, error) { + return services.Super.AssetUsage(ctx, filter) +} + +// Delete asset +// +// @Router /super/v1/assets/:id [delete] +// @Summary Delete asset +// @Description Delete asset +// @Tags Asset +// @Accept json +// @Produce json +// @Param id path int64 true "Asset ID" +// @Param force query bool false "Force delete" +// @Success 200 {string} string "Deleted" +// @Bind id path +// @Bind query query +func (c *assets) Delete(ctx fiber.Ctx, id int64, query *dto.SuperAssetDeleteQuery) error { + force := false + if query != nil && query.Force != nil { + force = *query.Force + } + return services.Super.DeleteAsset(ctx, id, force) +} diff --git a/backend/app/http/super/v1/dto/super_asset.go b/backend/app/http/super/v1/dto/super_asset.go new file mode 100644 index 0000000..e84eaff --- /dev/null +++ b/backend/app/http/super/v1/dto/super_asset.go @@ -0,0 +1,119 @@ +package dto + +import ( + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" +) + +// SuperAssetListFilter 超管资产列表查询条件。 +type SuperAssetListFilter struct { + requests.Pagination + // ID 资产ID,精确匹配。 + ID *int64 `query:"id"` + // TenantID 租户ID,精确匹配。 + TenantID *int64 `query:"tenant_id"` + // TenantCode 租户编码,模糊匹配。 + TenantCode *string `query:"tenant_code"` + // TenantName 租户名称,模糊匹配。 + TenantName *string `query:"tenant_name"` + // UserID 上传用户ID,精确匹配。 + UserID *int64 `query:"user_id"` + // Username 上传用户名/昵称,模糊匹配。 + Username *string `query:"username"` + // Type 媒体类型过滤。 + Type *consts.MediaAssetType `query:"type"` + // Status 处理状态过滤。 + Status *consts.MediaAssetStatus `query:"status"` + // Provider 存储提供方过滤。 + Provider *string `query:"provider"` + // ObjectKey 对象Key关键字,模糊匹配。 + ObjectKey *string `query:"object_key"` + // CreatedAtFrom 上传时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 上传时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // SizeMin 文件大小下限(字节)。 + SizeMin *int64 `query:"size_min"` + // SizeMax 文件大小上限(字节)。 + SizeMax *int64 `query:"size_max"` + // Asc 升序字段(id/created_at)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/created_at)。 + Desc *string `query:"desc"` +} + +// SuperAssetUsageFilter 超管资产用量统计查询条件。 +type SuperAssetUsageFilter struct { + // TenantID 租户ID(不传代表全平台)。 + TenantID *int64 `query:"tenant_id"` +} + +// SuperAssetDeleteQuery 超管资产删除参数。 +type SuperAssetDeleteQuery struct { + // Force 是否强制删除(忽略被内容引用的限制)。 + Force *bool `query:"force"` +} + +// SuperAssetItem 超管资产条目。 +type SuperAssetItem struct { + // ID 资产ID。 + ID int64 `json:"id"` + // TenantID 租户ID。 + TenantID int64 `json:"tenant_id"` + // TenantCode 租户编码。 + TenantCode string `json:"tenant_code"` + // TenantName 租户名称。 + TenantName string `json:"tenant_name"` + // UserID 上传用户ID。 + UserID int64 `json:"user_id"` + // Username 上传用户名/昵称。 + Username string `json:"username"` + // Type 媒体类型。 + Type consts.MediaAssetType `json:"type"` + // Status 处理状态。 + Status consts.MediaAssetStatus `json:"status"` + // Provider 存储提供方。 + Provider string `json:"provider"` + // Bucket 存储桶名称。 + Bucket string `json:"bucket"` + // ObjectKey 对象Key。 + ObjectKey string `json:"object_key"` + // URL 访问URL(若可用)。 + URL string `json:"url"` + // Filename 原始文件名。 + Filename string `json:"filename"` + // Size 文件大小(字节)。 + Size int64 `json:"size"` + // Hash 文件哈希(MD5)。 + Hash string `json:"hash"` + // Variant 媒体变体(main/preview/cover 等)。 + Variant consts.MediaAssetVariant `json:"variant"` + // SourceAssetID 源资产ID(用于变体关联)。 + SourceAssetID int64 `json:"source_asset_id"` + // UsedCount 被内容引用次数。 + UsedCount int64 `json:"used_count"` + // CreatedAt 创建时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // UpdatedAt 更新时间(RFC3339)。 + UpdatedAt string `json:"updated_at"` +} + +// SuperAssetUsageResponse 超管资产用量统计响应。 +type SuperAssetUsageResponse struct { + // TotalCount 资产总量。 + TotalCount int64 `json:"total_count"` + // TotalSize 资产总大小(字节)。 + TotalSize int64 `json:"total_size"` + // ByType 按媒体类型汇总的用量统计。 + ByType []SuperAssetUsageItem `json:"by_type"` +} + +// SuperAssetUsageItem 资产类型用量统计条目。 +type SuperAssetUsageItem struct { + // Type 媒体类型。 + Type consts.MediaAssetType `json:"type"` + // Count 该类型资产数量。 + Count int64 `json:"count"` + // TotalSize 该类型资产大小总和(字节)。 + TotalSize int64 `json:"total_size"` +} diff --git a/backend/app/http/super/v1/dto/super_notification.go b/backend/app/http/super/v1/dto/super_notification.go new file mode 100644 index 0000000..e506684 --- /dev/null +++ b/backend/app/http/super/v1/dto/super_notification.go @@ -0,0 +1,140 @@ +package dto + +import ( + "quyun/v2/app/requests" + "quyun/v2/pkg/consts" +) + +// SuperNotificationListFilter 超管通知列表查询条件。 +type SuperNotificationListFilter struct { + requests.Pagination + // ID 通知ID,精确匹配。 + ID *int64 `query:"id"` + // TenantID 租户ID,精确匹配。 + TenantID *int64 `query:"tenant_id"` + // TenantCode 租户编码,模糊匹配。 + TenantCode *string `query:"tenant_code"` + // TenantName 租户名称,模糊匹配。 + TenantName *string `query:"tenant_name"` + // UserID 用户ID,精确匹配。 + UserID *int64 `query:"user_id"` + // Username 用户名/昵称,模糊匹配。 + Username *string `query:"username"` + // Type 通知类型过滤。 + Type *consts.NotificationType `query:"type"` + // IsRead 是否已读过滤。 + IsRead *bool `query:"is_read"` + // Keyword 标题或内容关键词,模糊匹配。 + Keyword *string `query:"keyword"` + // CreatedAtFrom 创建时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 创建时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // Asc 升序字段(id/created_at)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/created_at)。 + Desc *string `query:"desc"` +} + +// SuperNotificationBroadcastForm 超管通知群发参数。 +type SuperNotificationBroadcastForm struct { + // TenantID 租户ID(选填,用于指定租户成员)。 + TenantID *int64 `json:"tenant_id"` + // UserIDs 指定接收用户ID列表(优先级高于 TenantID)。 + UserIDs []int64 `json:"user_ids"` + // Type 通知类型(system/order/audit/interaction)。 + Type consts.NotificationType `json:"type"` + // Title 通知标题。 + Title string `json:"title"` + // Content 通知内容。 + Content string `json:"content"` +} + +// SuperNotificationItem 超管通知条目。 +type SuperNotificationItem struct { + // ID 通知ID。 + ID int64 `json:"id"` + // TenantID 租户ID。 + TenantID int64 `json:"tenant_id"` + // TenantCode 租户编码。 + TenantCode string `json:"tenant_code"` + // TenantName 租户名称。 + TenantName string `json:"tenant_name"` + // UserID 用户ID。 + UserID int64 `json:"user_id"` + // Username 用户名/昵称。 + Username string `json:"username"` + // Type 通知类型。 + Type consts.NotificationType `json:"type"` + // Title 通知标题。 + Title string `json:"title"` + // Content 通知内容。 + Content string `json:"content"` + // IsRead 是否已读。 + IsRead bool `json:"is_read"` + // CreatedAt 创建时间(RFC3339)。 + CreatedAt string `json:"created_at"` +} + +// SuperNotificationTemplateListFilter 超管通知模板列表查询条件。 +type SuperNotificationTemplateListFilter struct { + requests.Pagination + // TenantID 租户ID(不传代表全平台模板)。 + TenantID *int64 `query:"tenant_id"` + // Keyword 模板名称或标题关键字,模糊匹配。 + Keyword *string `query:"keyword"` + // Type 通知类型过滤。 + Type *consts.NotificationType `query:"type"` + // IsActive 是否启用过滤。 + IsActive *bool `query:"is_active"` + // CreatedAtFrom 创建时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 创建时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // Asc 升序字段(id/created_at)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/created_at)。 + Desc *string `query:"desc"` +} + +// SuperNotificationTemplateCreateForm 超管通知模板创建参数。 +type SuperNotificationTemplateCreateForm struct { + // TenantID 租户ID(不传代表全平台模板)。 + TenantID *int64 `json:"tenant_id"` + // Name 模板名称(用于识别用途)。 + Name string `json:"name"` + // Type 通知类型(system/order/audit/interaction)。 + Type consts.NotificationType `json:"type"` + // Title 通知标题。 + Title string `json:"title"` + // Content 通知内容。 + Content string `json:"content"` + // IsActive 是否启用(不传默认启用)。 + IsActive *bool `json:"is_active"` +} + +// SuperNotificationTemplateItem 超管通知模板条目。 +type SuperNotificationTemplateItem struct { + // ID 模板ID。 + ID int64 `json:"id"` + // TenantID 租户ID。 + TenantID int64 `json:"tenant_id"` + // TenantCode 租户编码。 + TenantCode string `json:"tenant_code"` + // TenantName 租户名称。 + TenantName string `json:"tenant_name"` + // Name 模板名称。 + Name string `json:"name"` + // Type 通知类型。 + Type consts.NotificationType `json:"type"` + // Title 模板标题。 + Title string `json:"title"` + // Content 模板内容。 + Content string `json:"content"` + // IsActive 是否启用。 + IsActive bool `json:"is_active"` + // CreatedAt 创建时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // UpdatedAt 更新时间(RFC3339)。 + UpdatedAt string `json:"updated_at"` +} diff --git a/backend/app/http/super/v1/notifications.go b/backend/app/http/super/v1/notifications.go new file mode 100644 index 0000000..05f2f4b --- /dev/null +++ b/backend/app/http/super/v1/notifications.go @@ -0,0 +1,72 @@ +package v1 + +import ( + dto "quyun/v2/app/http/super/v1/dto" + "quyun/v2/app/requests" + "quyun/v2/app/services" + + "github.com/gofiber/fiber/v3" +) + +// @provider +type notifications struct{} + +// List notifications +// +// @Router /super/v1/notifications [get] +// @Summary List notifications +// @Description List notifications across tenants +// @Tags Notification +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperNotificationItem} +// @Bind filter query +func (c *notifications) List(ctx fiber.Ctx, filter *dto.SuperNotificationListFilter) (*requests.Pager, error) { + return services.Super.ListNotifications(ctx, filter) +} + +// Broadcast notification +// +// @Router /super/v1/notifications/broadcast [post] +// @Summary Broadcast notification +// @Description Broadcast notification to users or tenant members +// @Tags Notification +// @Accept json +// @Produce json +// @Param form body dto.SuperNotificationBroadcastForm true "Broadcast form" +// @Success 200 {string} string "Sent" +// @Bind form body +func (c *notifications) Broadcast(ctx fiber.Ctx, form *dto.SuperNotificationBroadcastForm) error { + return services.Super.BroadcastNotifications(ctx, form) +} + +// List notification templates +// +// @Router /super/v1/notifications/templates [get] +// @Summary List notification templates +// @Description List notification templates +// @Tags Notification +// @Accept json +// @Produce json +// @Success 200 {object} requests.Pager{items=[]dto.SuperNotificationTemplateItem} +// @Bind filter query +func (c *notifications) ListTemplates(ctx fiber.Ctx, filter *dto.SuperNotificationTemplateListFilter) (*requests.Pager, error) { + return services.Super.ListNotificationTemplates(ctx, filter) +} + +// Create notification template +// +// @Router /super/v1/notifications/templates [post] +// @Summary Create notification template +// @Description Create notification template +// @Tags Notification +// @Accept json +// @Produce json +// @Param form body dto.SuperNotificationTemplateCreateForm true "Template form" +// @Success 200 {object} dto.SuperNotificationTemplateItem +// @Bind form body +func (c *notifications) CreateTemplate(ctx fiber.Ctx, form *dto.SuperNotificationTemplateCreateForm) (*dto.SuperNotificationTemplateItem, error) { + return services.Super.CreateNotificationTemplate(ctx, form) +} diff --git a/backend/app/http/super/v1/provider.gen.go b/backend/app/http/super/v1/provider.gen.go index 3b4e327..ef58589 100755 --- a/backend/app/http/super/v1/provider.gen.go +++ b/backend/app/http/super/v1/provider.gen.go @@ -10,6 +10,13 @@ import ( ) func Provide(opts ...opt.Option) error { + if err := container.Container.Provide(func() (*assets, error) { + obj := &assets{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*contents, error) { obj := &contents{} @@ -38,6 +45,13 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } + if err := container.Container.Provide(func() (*notifications, error) { + obj := ¬ifications{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*orders, error) { obj := &orders{} @@ -60,11 +74,13 @@ func Provide(opts ...opt.Option) error { return err } if err := container.Container.Provide(func( + assets *assets, contents *contents, coupons *coupons, creatorApplications *creatorApplications, creators *creators, middlewares *middlewares.Middlewares, + notifications *notifications, orders *orders, payoutAccounts *payoutAccounts, reports *reports, @@ -73,11 +89,13 @@ func Provide(opts ...opt.Option) error { withdrawals *withdrawals, ) (contracts.HttpRoute, error) { obj := &Routes{ + assets: assets, contents: contents, coupons: coupons, creatorApplications: creatorApplications, creators: creators, middlewares: middlewares, + notifications: notifications, orders: orders, payoutAccounts: payoutAccounts, reports: reports, diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index 2c38d2c..57727ae 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -25,10 +25,12 @@ type Routes struct { log *log.Entry `inject:"false"` middlewares *middlewares.Middlewares // Controller instances + assets *assets contents *contents coupons *coupons creatorApplications *creatorApplications creators *creators + notifications *notifications orders *orders payoutAccounts *payoutAccounts reports *reports @@ -52,6 +54,23 @@ func (r *Routes) Name() string { // Register registers all HTTP routes with the provided fiber router. // Each route is registered with its corresponding controller action and parameter bindings. func (r *Routes) Register(router fiber.Router) { + // Register routes for controller: assets + r.log.Debugf("Registering route: Delete /super/v1/assets/:id -> assets.Delete") + router.Delete("/super/v1/assets/:id"[len(r.Path()):], Func2( + r.assets.Delete, + PathParam[int64]("id"), + Query[dto.SuperAssetDeleteQuery]("query"), + )) + r.log.Debugf("Registering route: Get /super/v1/assets -> assets.List") + router.Get("/super/v1/assets"[len(r.Path()):], DataFunc1( + r.assets.List, + Query[dto.SuperAssetListFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /super/v1/assets/usage -> assets.Usage") + router.Get("/super/v1/assets/usage"[len(r.Path()):], DataFunc1( + r.assets.Usage, + Query[dto.SuperAssetUsageFilter]("filter"), + )) // Register routes for controller: contents r.log.Debugf("Registering route: Get /super/v1/contents -> contents.List") router.Get("/super/v1/contents"[len(r.Path()):], DataFunc1( @@ -154,6 +173,27 @@ func (r *Routes) Register(router fiber.Router) { r.creators.List, Query[dto.TenantListFilter]("filter"), )) + // Register routes for controller: notifications + r.log.Debugf("Registering route: Get /super/v1/notifications -> notifications.List") + router.Get("/super/v1/notifications"[len(r.Path()):], DataFunc1( + r.notifications.List, + Query[dto.SuperNotificationListFilter]("filter"), + )) + r.log.Debugf("Registering route: Get /super/v1/notifications/templates -> notifications.ListTemplates") + router.Get("/super/v1/notifications/templates"[len(r.Path()):], DataFunc1( + r.notifications.ListTemplates, + Query[dto.SuperNotificationTemplateListFilter]("filter"), + )) + r.log.Debugf("Registering route: Post /super/v1/notifications/broadcast -> notifications.Broadcast") + router.Post("/super/v1/notifications/broadcast"[len(r.Path()):], Func1( + r.notifications.Broadcast, + Body[dto.SuperNotificationBroadcastForm]("form"), + )) + r.log.Debugf("Registering route: Post /super/v1/notifications/templates -> notifications.CreateTemplate") + router.Post("/super/v1/notifications/templates"[len(r.Path()):], DataFunc1( + r.notifications.CreateTemplate, + Body[dto.SuperNotificationTemplateCreateForm]("form"), + )) // Register routes for controller: orders r.log.Debugf("Registering route: Get /super/v1/orders -> orders.List") router.Get("/super/v1/orders"[len(r.Path()):], DataFunc1( diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 36bed68..1fcac89 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "path/filepath" "strconv" "strings" "time" @@ -2127,6 +2128,784 @@ func (s *super) ContentStatistics(ctx context.Context, filter *super_dto.SuperCo }, nil } +func (s *super) ListAssets(ctx context.Context, filter *super_dto.SuperAssetListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperAssetListFilter{} + } + + tbl, q := models.MediaAssetQuery.QueryContext(ctx) + + if filter.ID != nil && *filter.ID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ID)) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + q = q.Where(tbl.UserID.Eq(*filter.UserID)) + } + if filter.Type != nil && *filter.Type != "" { + q = q.Where(tbl.Type.Eq(*filter.Type)) + } + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(*filter.Status)) + } + if filter.Provider != nil && strings.TrimSpace(*filter.Provider) != "" { + q = q.Where(tbl.Provider.Eq(strings.TrimSpace(*filter.Provider))) + } + if filter.ObjectKey != nil && strings.TrimSpace(*filter.ObjectKey) != "" { + keyword := "%" + strings.TrimSpace(*filter.ObjectKey) + "%" + q = q.Where(tbl.ObjectKey.Like(keyword)) + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, err + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + } + + userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username) + if err != nil { + return nil, err + } + if userFilter { + if len(userIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.UserID.In(userIDs...)) + } + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + + if filter.SizeMin != nil { + // JSONB 元信息内的 size 需要使用原生表达式过滤。 + q = q.Where(field.NewUnsafeFieldRaw("coalesce((meta->>'size')::bigint,0) >= ?", *filter.SizeMin)) + } + if filter.SizeMax != nil { + // JSONB 元信息内的 size 需要使用原生表达式过滤。 + q = q.Where(field.NewUnsafeFieldRaw("coalesce((meta->>'size')::bigint,0) <= ?", *filter.SizeMax)) + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "created_at": + q = q.Order(tbl.CreatedAt) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.CreatedAt.Desc()) + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + if len(list) == 0 { + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: []super_dto.SuperAssetItem{}, + }, nil + } + + tenantSet := make(map[int64]struct{}) + userSet := make(map[int64]struct{}) + assetIDs := make([]int64, 0, len(list)) + for _, asset := range list { + assetIDs = append(assetIDs, asset.ID) + if asset.TenantID > 0 { + tenantSet[asset.TenantID] = struct{}{} + } + if asset.UserID > 0 { + userSet[asset.UserID] = struct{}{} + } + } + + tenantMap := make(map[int64]*models.Tenant, len(tenantSet)) + if len(tenantSet) > 0 { + ids := make([]int64, 0, len(tenantSet)) + for id := range tenantSet { + ids = append(ids, id) + } + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + tenants, err := tenantQuery.Where(tenantTbl.ID.In(ids...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, tenant := range tenants { + tenantMap[tenant.ID] = tenant + } + } + + userMap := make(map[int64]*models.User, len(userSet)) + if len(userSet) > 0 { + ids := make([]int64, 0, len(userSet)) + for id := range userSet { + ids = append(ids, id) + } + userTbl, userQuery := models.UserQuery.QueryContext(ctx) + users, err := userQuery.Where(userTbl.ID.In(ids...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, user := range users { + userMap[user.ID] = user + } + } + + usedCountMap := make(map[int64]int64) + if len(assetIDs) > 0 { + type assetUseRow struct { + AssetID int64 `gorm:"column:asset_id"` + Count int64 `gorm:"column:count"` + } + rows := make([]assetUseRow, 0) + query := models.ContentAssetQuery.WithContext(ctx). + UnderlyingDB(). + Model(&models.ContentAsset{}). + Select("asset_id, count(*) as count"). + Where("asset_id in ?", assetIDs). + Group("asset_id") + if err := query.Scan(&rows).Error; err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, row := range rows { + usedCountMap[row.AssetID] = row.Count + } + } + + items := make([]super_dto.SuperAssetItem, 0, len(list)) + for _, asset := range list { + meta := asset.Meta.Data() + filename := strings.TrimSpace(meta.Filename) + if filename == "" { + filename = filepath.Base(asset.ObjectKey) + } + url := "" + if Common != nil { + url = Common.GetAssetURL(asset.ObjectKey) + } + item := super_dto.SuperAssetItem{ + ID: asset.ID, + TenantID: asset.TenantID, + UserID: asset.UserID, + Type: asset.Type, + Status: asset.Status, + Provider: asset.Provider, + Bucket: asset.Bucket, + ObjectKey: asset.ObjectKey, + URL: url, + Filename: filename, + Size: meta.Size, + Hash: asset.Hash, + Variant: asset.Variant, + SourceAssetID: asset.SourceAssetID, + UsedCount: usedCountMap[asset.ID], + CreatedAt: s.formatTime(asset.CreatedAt), + UpdatedAt: s.formatTime(asset.UpdatedAt), + } + if tenant := tenantMap[asset.TenantID]; tenant != nil { + item.TenantCode = tenant.Code + item.TenantName = tenant.Name + } + if user := userMap[asset.UserID]; user != nil { + item.Username = user.Username + } else if asset.UserID > 0 { + item.Username = "ID:" + strconv.FormatInt(asset.UserID, 10) + } + items = append(items, item) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *super) AssetUsage(ctx context.Context, filter *super_dto.SuperAssetUsageFilter) (*super_dto.SuperAssetUsageResponse, error) { + tenantID := int64(0) + if filter != nil && filter.TenantID != nil { + tenantID = *filter.TenantID + } + + tbl, q := models.MediaAssetQuery.QueryContext(ctx) + if tenantID > 0 { + q = q.Where(tbl.TenantID.Eq(tenantID)) + } + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + var totalSize int64 + sizeQuery := models.MediaAssetQuery.WithContext(ctx). + UnderlyingDB(). + Model(&models.MediaAsset{}). + Select("coalesce(sum((meta->>'size')::bigint), 0) as size") + if tenantID > 0 { + sizeQuery = sizeQuery.Where("tenant_id = ?", tenantID) + } + if err := sizeQuery.Scan(&totalSize).Error; err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + type usageRow struct { + Type string `gorm:"column:type"` + Count int64 `gorm:"column:count"` + Size int64 `gorm:"column:size"` + } + rows := make([]usageRow, 0) + typeQuery := models.MediaAssetQuery.WithContext(ctx). + UnderlyingDB(). + Model(&models.MediaAsset{}). + Select("type, count(*) as count, coalesce(sum((meta->>'size')::bigint), 0) as size"). + Group("type") + if tenantID > 0 { + typeQuery = typeQuery.Where("tenant_id = ?", tenantID) + } + if err := typeQuery.Scan(&rows).Error; err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + byType := make([]super_dto.SuperAssetUsageItem, 0, len(rows)) + for _, row := range rows { + byType = append(byType, super_dto.SuperAssetUsageItem{ + Type: consts.MediaAssetType(row.Type), + Count: row.Count, + TotalSize: row.Size, + }) + } + + return &super_dto.SuperAssetUsageResponse{ + TotalCount: total, + TotalSize: totalSize, + ByType: byType, + }, nil +} + +func (s *super) DeleteAsset(ctx context.Context, assetID int64, force bool) error { + if assetID <= 0 { + return errorx.ErrBadRequest.WithMsg("资产ID不能为空") + } + + tbl, q := models.MediaAssetQuery.QueryContext(ctx) + asset, err := q.Where(tbl.ID.Eq(assetID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("资产不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + + useTbl, useQuery := models.ContentAssetQuery.QueryContext(ctx) + usedCount, err := useQuery.Where(useTbl.AssetID.Eq(assetID)).Count() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if usedCount > 0 && !force { + return errorx.ErrStatusConflict.WithMsg("资产已被内容引用,无法删除") + } + if usedCount > 0 && force { + // 强制删除时先清理引用关系,避免残留无效关联。 + if _, err := useQuery.Where(useTbl.AssetID.Eq(assetID)).Delete(); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + } + + if _, err := q.Where(tbl.ID.Eq(assetID)).Delete(); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + if Common != nil && asset.ObjectKey != "" { + count, err := models.MediaAssetQuery.WithContext(ctx). + Where(models.MediaAssetQuery.ObjectKey.Eq(asset.ObjectKey)). + Count() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if count == 0 && Common.storage != nil { + _ = Common.storage.Delete(asset.ObjectKey) + } + } + return nil +} + +func (s *super) ListNotifications(ctx context.Context, filter *super_dto.SuperNotificationListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperNotificationListFilter{} + } + + tbl, q := models.NotificationQuery.QueryContext(ctx) + + if filter.ID != nil && *filter.ID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ID)) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + if filter.UserID != nil && *filter.UserID > 0 { + q = q.Where(tbl.UserID.Eq(*filter.UserID)) + } + if filter.Type != nil && *filter.Type != "" { + q = q.Where(tbl.Type.Eq(filter.Type.String())) + } + if filter.IsRead != nil { + q = q.Where(tbl.IsRead.Is(*filter.IsRead)) + } + if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" { + keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%" + q = q.Where(field.Or(tbl.Title.Like(keyword), tbl.Content.Like(keyword))) + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, err + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + } + + userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username) + if err != nil { + return nil, err + } + if userFilter { + if len(userIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.UserID.In(userIDs...)) + } + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "created_at": + q = q.Order(tbl.CreatedAt) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.CreatedAt.Desc()) + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + if len(list) == 0 { + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: []super_dto.SuperNotificationItem{}, + }, nil + } + + tenantSet := make(map[int64]struct{}) + userSet := make(map[int64]struct{}) + for _, n := range list { + if n.TenantID > 0 { + tenantSet[n.TenantID] = struct{}{} + } + if n.UserID > 0 { + userSet[n.UserID] = struct{}{} + } + } + + tenantMap := make(map[int64]*models.Tenant, len(tenantSet)) + if len(tenantSet) > 0 { + ids := make([]int64, 0, len(tenantSet)) + for id := range tenantSet { + ids = append(ids, id) + } + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + tenants, err := tenantQuery.Where(tenantTbl.ID.In(ids...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, tenant := range tenants { + tenantMap[tenant.ID] = tenant + } + } + + userMap := make(map[int64]*models.User, len(userSet)) + if len(userSet) > 0 { + ids := make([]int64, 0, len(userSet)) + for id := range userSet { + ids = append(ids, id) + } + userTbl, userQuery := models.UserQuery.QueryContext(ctx) + users, err := userQuery.Where(userTbl.ID.In(ids...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, user := range users { + userMap[user.ID] = user + } + } + + items := make([]super_dto.SuperNotificationItem, 0, len(list)) + for _, n := range list { + item := super_dto.SuperNotificationItem{ + ID: n.ID, + TenantID: n.TenantID, + UserID: n.UserID, + Type: consts.NotificationType(n.Type), + Title: n.Title, + Content: n.Content, + IsRead: n.IsRead, + CreatedAt: s.formatTime(n.CreatedAt), + } + if tenant := tenantMap[n.TenantID]; tenant != nil { + item.TenantCode = tenant.Code + item.TenantName = tenant.Name + } + if user := userMap[n.UserID]; user != nil { + item.Username = user.Username + } else if n.UserID > 0 { + item.Username = "ID:" + strconv.FormatInt(n.UserID, 10) + } + items = append(items, item) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *super) BroadcastNotifications(ctx context.Context, form *super_dto.SuperNotificationBroadcastForm) error { + if form == nil { + return errorx.ErrBadRequest.WithMsg("群发参数不能为空") + } + + title := strings.TrimSpace(form.Title) + content := strings.TrimSpace(form.Content) + if title == "" || content == "" { + return errorx.ErrBadRequest.WithMsg("通知标题和内容不能为空") + } + if !form.Type.IsValid() { + return errorx.ErrBadRequest.WithMsg("通知类型非法") + } + + tenantID := int64(0) + if form.TenantID != nil { + tenantID = *form.TenantID + } + + userSet := make(map[int64]struct{}) + for _, id := range form.UserIDs { + if id <= 0 { + continue + } + userSet[id] = struct{}{} + } + + if len(userSet) == 0 && tenantID <= 0 { + return errorx.ErrBadRequest.WithMsg("请指定租户或用户") + } + + if len(userSet) == 0 && tenantID > 0 { + // 仅向该租户的已验证成员发送。 + tbl, q := models.TenantUserQuery.QueryContext(ctx) + list, err := q.Where(tbl.TenantID.Eq(tenantID), tbl.Status.Eq(consts.UserStatusVerified)).Find() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + for _, tu := range list { + if tu.UserID > 0 { + userSet[tu.UserID] = struct{}{} + } + } + } + + if len(userSet) == 0 { + return errorx.ErrRecordNotFound.WithMsg("未找到可通知的用户") + } + if Notification == nil { + return errorx.ErrInternalError.WithMsg("通知服务不可用") + } + + typ := form.Type.String() + for userID := range userSet { + if err := Notification.Send(ctx, tenantID, userID, typ, title, content); err != nil { + return err + } + } + return nil +} + +func (s *super) ListNotificationTemplates(ctx context.Context, filter *super_dto.SuperNotificationTemplateListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperNotificationTemplateListFilter{} + } + + tbl, q := models.NotificationTemplateQuery.QueryContext(ctx) + + if filter.TenantID != nil && *filter.TenantID >= 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + if filter.Type != nil && *filter.Type != "" { + q = q.Where(tbl.Type.Eq(*filter.Type)) + } + if filter.IsActive != nil { + q = q.Where(tbl.IsActive.Is(*filter.IsActive)) + } + if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" { + keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%" + q = q.Where(field.Or(tbl.Name.Like(keyword), tbl.Title.Like(keyword))) + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + + orderApplied := false + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + switch strings.TrimSpace(*filter.Desc) { + case "id": + q = q.Order(tbl.ID.Desc()) + case "created_at": + q = q.Order(tbl.CreatedAt.Desc()) + } + orderApplied = true + } else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + switch strings.TrimSpace(*filter.Asc) { + case "id": + q = q.Order(tbl.ID) + case "created_at": + q = q.Order(tbl.CreatedAt) + } + orderApplied = true + } + if !orderApplied { + q = q.Order(tbl.CreatedAt.Desc()) + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + if len(list) == 0 { + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: []super_dto.SuperNotificationTemplateItem{}, + }, nil + } + + tenantSet := make(map[int64]struct{}) + for _, tmpl := range list { + if tmpl.TenantID > 0 { + tenantSet[tmpl.TenantID] = struct{}{} + } + } + tenantMap := make(map[int64]*models.Tenant, len(tenantSet)) + if len(tenantSet) > 0 { + ids := make([]int64, 0, len(tenantSet)) + for id := range tenantSet { + ids = append(ids, id) + } + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + tenants, err := tenantQuery.Where(tenantTbl.ID.In(ids...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, tenant := range tenants { + tenantMap[tenant.ID] = tenant + } + } + + items := make([]super_dto.SuperNotificationTemplateItem, 0, len(list)) + for _, tmpl := range list { + item := super_dto.SuperNotificationTemplateItem{ + ID: tmpl.ID, + TenantID: tmpl.TenantID, + Name: tmpl.Name, + Type: tmpl.Type, + Title: tmpl.Title, + Content: tmpl.Content, + IsActive: tmpl.IsActive, + CreatedAt: s.formatTime(tmpl.CreatedAt), + UpdatedAt: s.formatTime(tmpl.UpdatedAt), + } + if tenant := tenantMap[tmpl.TenantID]; tenant != nil { + item.TenantCode = tenant.Code + item.TenantName = tenant.Name + } + items = append(items, item) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *super) CreateNotificationTemplate(ctx context.Context, form *super_dto.SuperNotificationTemplateCreateForm) (*super_dto.SuperNotificationTemplateItem, error) { + if form == nil { + return nil, errorx.ErrBadRequest.WithMsg("模板参数不能为空") + } + + name := strings.TrimSpace(form.Name) + title := strings.TrimSpace(form.Title) + content := strings.TrimSpace(form.Content) + if name == "" || title == "" || content == "" { + return nil, errorx.ErrBadRequest.WithMsg("模板名称、标题和内容不能为空") + } + if !form.Type.IsValid() { + return nil, errorx.ErrBadRequest.WithMsg("通知类型非法") + } + + tenantID := int64(0) + if form.TenantID != nil { + tenantID = *form.TenantID + } + isActive := true + if form.IsActive != nil { + isActive = *form.IsActive + } + + tmpl := &models.NotificationTemplate{ + TenantID: tenantID, + Name: name, + Type: form.Type, + Title: title, + Content: content, + IsActive: isActive, + } + if err := models.NotificationTemplateQuery.WithContext(ctx).Create(tmpl); err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + item := &super_dto.SuperNotificationTemplateItem{ + ID: tmpl.ID, + TenantID: tmpl.TenantID, + Name: tmpl.Name, + Type: tmpl.Type, + Title: tmpl.Title, + Content: tmpl.Content, + IsActive: tmpl.IsActive, + CreatedAt: s.formatTime(tmpl.CreatedAt), + UpdatedAt: s.formatTime(tmpl.UpdatedAt), + } + if tmpl.TenantID > 0 { + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + tenant, err := tenantQuery.Where(tenantTbl.ID.Eq(tmpl.TenantID)).First() + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + if tenant != nil { + item.TenantCode = tenant.Code + item.TenantName = tenant.Name + } + } + + return item, nil +} + func (s *super) ListOrders(ctx context.Context, filter *super_dto.SuperOrderListFilter) (*requests.Pager, error) { tbl, q := models.OrderQuery.QueryContext(ctx) diff --git a/backend/database/.transform.yaml b/backend/database/.transform.yaml index 149ddc6..c07c6dd 100644 --- a/backend/database/.transform.yaml +++ b/backend/database/.transform.yaml @@ -52,6 +52,8 @@ field_type: type: consts.CouponType user_coupons: status: consts.UserCouponStatus + notification_templates: + type: consts.NotificationType field_relate: contents: Author: diff --git a/backend/database/migrations/20260115063813_create_notification_templates.sql b/backend/database/migrations/20260115063813_create_notification_templates.sql new file mode 100644 index 0000000..c2b78c0 --- /dev/null +++ b/backend/database/migrations/20260115063813_create_notification_templates.sql @@ -0,0 +1,26 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS notification_templates ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL DEFAULT 0, + name VARCHAR(128) NOT NULL, + type VARCHAR(32) NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE notification_templates IS '通知模板:用于超管/运营预置通知内容,便于批量触达与统一管理。'; +COMMENT ON COLUMN notification_templates.id IS '模板主键ID,用于后台检索与引用。'; +COMMENT ON COLUMN notification_templates.tenant_id IS '归属租户ID(0 表示平台级模板);用于限制模板适用范围。'; +COMMENT ON COLUMN notification_templates.name IS '模板名称,用于后台识别用途(如“提现提醒”“内容审核通过”),不直接下发给用户。'; +COMMENT ON COLUMN notification_templates.type IS '通知类型(system/order/audit/interaction),需与前端枚举一致,用于分类与筛选。'; +COMMENT ON COLUMN notification_templates.title IS '通知标题,直接展示给用户的标题文本。'; +COMMENT ON COLUMN notification_templates.content IS '通知内容,直接展示给用户的正文,可包含简要说明与行动提示。'; +COMMENT ON COLUMN notification_templates.is_active IS '是否启用;禁用模板不可用于发送,便于临时下架或停用。'; +COMMENT ON COLUMN notification_templates.created_at IS '创建时间,按时间排序与审计使用。'; +COMMENT ON COLUMN notification_templates.updated_at IS '更新时间,记录最近一次编辑时间。'; + +-- +goose Down +DROP TABLE IF EXISTS notification_templates; diff --git a/backend/database/models/contents.gen.go b/backend/database/models/contents.gen.go index a3de8e8..fc251f6 100644 --- a/backend/database/models/contents.gen.go +++ b/backend/database/models/contents.gen.go @@ -40,9 +40,9 @@ type Content struct { DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp with time zone" json:"deleted_at"` Key string `gorm:"column:key;type:character varying(32);comment:Musical key/tone" json:"key"` // Musical key/tone IsPinned bool `gorm:"column:is_pinned;type:boolean;comment:Whether content is pinned/featured" json:"is_pinned"` // Whether content is pinned/featured - Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"` Author *User `gorm:"foreignKey:UserID;references:ID" json:"author,omitempty"` ContentAssets []*ContentAsset `gorm:"foreignKey:ContentID;references:ID" json:"content_assets,omitempty"` + Comments []*Comment `gorm:"foreignKey:ContentID;references:ID" json:"comments,omitempty"` } // Quick operations without importing query package diff --git a/backend/database/models/contents.query.gen.go b/backend/database/models/contents.query.gen.go index ddbf1b6..d462865 100644 --- a/backend/database/models/contents.query.gen.go +++ b/backend/database/models/contents.query.gen.go @@ -46,12 +46,6 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { _contentQuery.DeletedAt = field.NewField(tableName, "deleted_at") _contentQuery.Key = field.NewString(tableName, "key") _contentQuery.IsPinned = field.NewBool(tableName, "is_pinned") - _contentQuery.Comments = contentQueryHasManyComments{ - db: db.Session(&gorm.Session{}), - - RelationField: field.NewRelation("Comments", "Comment"), - } - _contentQuery.Author = contentQueryBelongsToAuthor{ db: db.Session(&gorm.Session{}), @@ -64,6 +58,12 @@ func newContent(db *gorm.DB, opts ...gen.DOOption) contentQuery { RelationField: field.NewRelation("ContentAssets", "ContentAsset"), } + _contentQuery.Comments = contentQueryHasManyComments{ + db: db.Session(&gorm.Session{}), + + RelationField: field.NewRelation("Comments", "Comment"), + } + _contentQuery.fillFieldMap() return _contentQuery @@ -94,12 +94,12 @@ type contentQuery struct { DeletedAt field.Field Key field.String // Musical key/tone IsPinned field.Bool // Whether content is pinned/featured - Comments contentQueryHasManyComments - - Author contentQueryBelongsToAuthor + Author contentQueryBelongsToAuthor ContentAssets contentQueryHasManyContentAssets + Comments contentQueryHasManyComments + fieldMap map[string]field.Expr } @@ -195,104 +195,23 @@ func (c *contentQuery) fillFieldMap() { func (c contentQuery) clone(db *gorm.DB) contentQuery { c.contentQueryDo.ReplaceConnPool(db.Statement.ConnPool) - c.Comments.db = db.Session(&gorm.Session{Initialized: true}) - c.Comments.db.Statement.ConnPool = db.Statement.ConnPool c.Author.db = db.Session(&gorm.Session{Initialized: true}) c.Author.db.Statement.ConnPool = db.Statement.ConnPool c.ContentAssets.db = db.Session(&gorm.Session{Initialized: true}) c.ContentAssets.db.Statement.ConnPool = db.Statement.ConnPool + c.Comments.db = db.Session(&gorm.Session{Initialized: true}) + c.Comments.db.Statement.ConnPool = db.Statement.ConnPool return c } func (c contentQuery) replaceDB(db *gorm.DB) contentQuery { c.contentQueryDo.ReplaceDB(db) - c.Comments.db = db.Session(&gorm.Session{}) c.Author.db = db.Session(&gorm.Session{}) c.ContentAssets.db = db.Session(&gorm.Session{}) + c.Comments.db = db.Session(&gorm.Session{}) return c } -type contentQueryHasManyComments struct { - db *gorm.DB - - field.RelationField -} - -func (a contentQueryHasManyComments) Where(conds ...field.Expr) *contentQueryHasManyComments { - if len(conds) == 0 { - return &a - } - - exprs := make([]clause.Expression, 0, len(conds)) - for _, cond := range conds { - exprs = append(exprs, cond.BeCond().(clause.Expression)) - } - a.db = a.db.Clauses(clause.Where{Exprs: exprs}) - return &a -} - -func (a contentQueryHasManyComments) WithContext(ctx context.Context) *contentQueryHasManyComments { - a.db = a.db.WithContext(ctx) - return &a -} - -func (a contentQueryHasManyComments) Session(session *gorm.Session) *contentQueryHasManyComments { - a.db = a.db.Session(session) - return &a -} - -func (a contentQueryHasManyComments) Model(m *Content) *contentQueryHasManyCommentsTx { - return &contentQueryHasManyCommentsTx{a.db.Model(m).Association(a.Name())} -} - -func (a contentQueryHasManyComments) Unscoped() *contentQueryHasManyComments { - a.db = a.db.Unscoped() - return &a -} - -type contentQueryHasManyCommentsTx struct{ tx *gorm.Association } - -func (a contentQueryHasManyCommentsTx) Find() (result []*Comment, err error) { - return result, a.tx.Find(&result) -} - -func (a contentQueryHasManyCommentsTx) Append(values ...*Comment) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Append(targetValues...) -} - -func (a contentQueryHasManyCommentsTx) Replace(values ...*Comment) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Replace(targetValues...) -} - -func (a contentQueryHasManyCommentsTx) Delete(values ...*Comment) (err error) { - targetValues := make([]interface{}, len(values)) - for i, v := range values { - targetValues[i] = v - } - return a.tx.Delete(targetValues...) -} - -func (a contentQueryHasManyCommentsTx) Clear() error { - return a.tx.Clear() -} - -func (a contentQueryHasManyCommentsTx) Count() int64 { - return a.tx.Count() -} - -func (a contentQueryHasManyCommentsTx) Unscoped() *contentQueryHasManyCommentsTx { - a.tx = a.tx.Unscoped() - return &a -} - type contentQueryBelongsToAuthor struct { db *gorm.DB @@ -455,6 +374,87 @@ func (a contentQueryHasManyContentAssetsTx) Unscoped() *contentQueryHasManyConte return &a } +type contentQueryHasManyComments struct { + db *gorm.DB + + field.RelationField +} + +func (a contentQueryHasManyComments) Where(conds ...field.Expr) *contentQueryHasManyComments { + if len(conds) == 0 { + return &a + } + + exprs := make([]clause.Expression, 0, len(conds)) + for _, cond := range conds { + exprs = append(exprs, cond.BeCond().(clause.Expression)) + } + a.db = a.db.Clauses(clause.Where{Exprs: exprs}) + return &a +} + +func (a contentQueryHasManyComments) WithContext(ctx context.Context) *contentQueryHasManyComments { + a.db = a.db.WithContext(ctx) + return &a +} + +func (a contentQueryHasManyComments) Session(session *gorm.Session) *contentQueryHasManyComments { + a.db = a.db.Session(session) + return &a +} + +func (a contentQueryHasManyComments) Model(m *Content) *contentQueryHasManyCommentsTx { + return &contentQueryHasManyCommentsTx{a.db.Model(m).Association(a.Name())} +} + +func (a contentQueryHasManyComments) Unscoped() *contentQueryHasManyComments { + a.db = a.db.Unscoped() + return &a +} + +type contentQueryHasManyCommentsTx struct{ tx *gorm.Association } + +func (a contentQueryHasManyCommentsTx) Find() (result []*Comment, err error) { + return result, a.tx.Find(&result) +} + +func (a contentQueryHasManyCommentsTx) Append(values ...*Comment) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Append(targetValues...) +} + +func (a contentQueryHasManyCommentsTx) Replace(values ...*Comment) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Replace(targetValues...) +} + +func (a contentQueryHasManyCommentsTx) Delete(values ...*Comment) (err error) { + targetValues := make([]interface{}, len(values)) + for i, v := range values { + targetValues[i] = v + } + return a.tx.Delete(targetValues...) +} + +func (a contentQueryHasManyCommentsTx) Clear() error { + return a.tx.Clear() +} + +func (a contentQueryHasManyCommentsTx) Count() int64 { + return a.tx.Count() +} + +func (a contentQueryHasManyCommentsTx) Unscoped() *contentQueryHasManyCommentsTx { + a.tx = a.tx.Unscoped() + return &a +} + type contentQueryDo struct{ gen.DO } func (c contentQueryDo) Debug() *contentQueryDo { diff --git a/backend/database/models/notification_templates.gen.go b/backend/database/models/notification_templates.gen.go new file mode 100644 index 0000000..6663c1d --- /dev/null +++ b/backend/database/models/notification_templates.gen.go @@ -0,0 +1,65 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + "time" + + "quyun/v2/pkg/consts" + + "go.ipao.vip/gen" +) + +const TableNameNotificationTemplate = "notification_templates" + +// NotificationTemplate mapped from table +type NotificationTemplate struct { + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:模板主键ID,用于后台检索与引用。" json:"id"` // 模板主键ID,用于后台检索与引用。 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:归属租户ID(0 表示平台级模板);用于限制模板适用范围。" json:"tenant_id"` // 归属租户ID(0 表示平台级模板);用于限制模板适用范围。 + Name string `gorm:"column:name;type:character varying(128);not null;comment:模板名称,用于后台识别用途(如“提现提醒”“内容审核通过”),不直接下发给用户。" json:"name"` // 模板名称,用于后台识别用途(如“提现提醒”“内容审核通过”),不直接下发给用户。 + Type consts.NotificationType `gorm:"column:type;type:character varying(32);not null;comment:通知类型(system/order/audit/interaction),需与前端枚举一致,用于分类与筛选。" json:"type"` // 通知类型(system/order/audit/interaction),需与前端枚举一致,用于分类与筛选。 + Title string `gorm:"column:title;type:character varying(255);not null;comment:通知标题,直接展示给用户的标题文本。" json:"title"` // 通知标题,直接展示给用户的标题文本。 + Content string `gorm:"column:content;type:text;not null;comment:通知内容,直接展示给用户的正文,可包含简要说明与行动提示。" json:"content"` // 通知内容,直接展示给用户的正文,可包含简要说明与行动提示。 + IsActive bool `gorm:"column:is_active;type:boolean;not null;default:true;comment:是否启用;禁用模板不可用于发送,便于临时下架或停用。" json:"is_active"` // 是否启用;禁用模板不可用于发送,便于临时下架或停用。 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间,按时间排序与审计使用。" json:"created_at"` // 创建时间,按时间排序与审计使用。 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间,记录最近一次编辑时间。" json:"updated_at"` // 更新时间,记录最近一次编辑时间。 +} + +// Quick operations without importing query package +// Update applies changed fields to the database using the default DB. +func (m *NotificationTemplate) Update(ctx context.Context) (gen.ResultInfo, error) { + return Q.NotificationTemplate.WithContext(ctx).Updates(m) +} + +// Save upserts the model using the default DB. +func (m *NotificationTemplate) Save(ctx context.Context) error { + return Q.NotificationTemplate.WithContext(ctx).Save(m) +} + +// Create inserts the model using the default DB. +func (m *NotificationTemplate) Create(ctx context.Context) error { + return Q.NotificationTemplate.WithContext(ctx).Create(m) +} + +// Delete removes the row represented by the model using the default DB. +func (m *NotificationTemplate) Delete(ctx context.Context) (gen.ResultInfo, error) { + return Q.NotificationTemplate.WithContext(ctx).Delete(m) +} + +// ForceDelete permanently deletes the row (ignores soft delete) using the default DB. +func (m *NotificationTemplate) ForceDelete(ctx context.Context) (gen.ResultInfo, error) { + return Q.NotificationTemplate.WithContext(ctx).Unscoped().Delete(m) +} + +// Reload reloads the model from database by its primary key and overwrites current fields. +func (m *NotificationTemplate) Reload(ctx context.Context) error { + fresh, err := Q.NotificationTemplate.WithContext(ctx).GetByID(m.ID) + if err != nil { + return err + } + *m = *fresh + return nil +} diff --git a/backend/database/models/notification_templates.query.gen.go b/backend/database/models/notification_templates.query.gen.go new file mode 100644 index 0000000..97d670b --- /dev/null +++ b/backend/database/models/notification_templates.query.gen.go @@ -0,0 +1,495 @@ +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. +// Code generated by go.ipao.vip/gen. DO NOT EDIT. + +package models + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/gorm/schema" + + "go.ipao.vip/gen" + "go.ipao.vip/gen/field" + + "gorm.io/plugin/dbresolver" +) + +func newNotificationTemplate(db *gorm.DB, opts ...gen.DOOption) notificationTemplateQuery { + _notificationTemplateQuery := notificationTemplateQuery{} + + _notificationTemplateQuery.notificationTemplateQueryDo.UseDB(db, opts...) + _notificationTemplateQuery.notificationTemplateQueryDo.UseModel(&NotificationTemplate{}) + + tableName := _notificationTemplateQuery.notificationTemplateQueryDo.TableName() + _notificationTemplateQuery.ALL = field.NewAsterisk(tableName) + _notificationTemplateQuery.ID = field.NewInt64(tableName, "id") + _notificationTemplateQuery.TenantID = field.NewInt64(tableName, "tenant_id") + _notificationTemplateQuery.Name = field.NewString(tableName, "name") + _notificationTemplateQuery.Type = field.NewField(tableName, "type") + _notificationTemplateQuery.Title = field.NewString(tableName, "title") + _notificationTemplateQuery.Content = field.NewString(tableName, "content") + _notificationTemplateQuery.IsActive = field.NewBool(tableName, "is_active") + _notificationTemplateQuery.CreatedAt = field.NewTime(tableName, "created_at") + _notificationTemplateQuery.UpdatedAt = field.NewTime(tableName, "updated_at") + + _notificationTemplateQuery.fillFieldMap() + + return _notificationTemplateQuery +} + +type notificationTemplateQuery struct { + notificationTemplateQueryDo notificationTemplateQueryDo + + ALL field.Asterisk + ID field.Int64 // 模板主键ID,用于后台检索与引用。 + TenantID field.Int64 // 归属租户ID(0 表示平台级模板);用于限制模板适用范围。 + Name field.String // 模板名称,用于后台识别用途(如“提现提醒”“内容审核通过”),不直接下发给用户。 + Type field.Field // 通知类型(system/order/audit/interaction),需与前端枚举一致,用于分类与筛选。 + Title field.String // 通知标题,直接展示给用户的标题文本。 + Content field.String // 通知内容,直接展示给用户的正文,可包含简要说明与行动提示。 + IsActive field.Bool // 是否启用;禁用模板不可用于发送,便于临时下架或停用。 + CreatedAt field.Time // 创建时间,按时间排序与审计使用。 + UpdatedAt field.Time // 更新时间,记录最近一次编辑时间。 + + fieldMap map[string]field.Expr +} + +func (n notificationTemplateQuery) Table(newTableName string) *notificationTemplateQuery { + n.notificationTemplateQueryDo.UseTable(newTableName) + return n.updateTableName(newTableName) +} + +func (n notificationTemplateQuery) As(alias string) *notificationTemplateQuery { + n.notificationTemplateQueryDo.DO = *(n.notificationTemplateQueryDo.As(alias).(*gen.DO)) + return n.updateTableName(alias) +} + +func (n *notificationTemplateQuery) updateTableName(table string) *notificationTemplateQuery { + n.ALL = field.NewAsterisk(table) + n.ID = field.NewInt64(table, "id") + n.TenantID = field.NewInt64(table, "tenant_id") + n.Name = field.NewString(table, "name") + n.Type = field.NewField(table, "type") + n.Title = field.NewString(table, "title") + n.Content = field.NewString(table, "content") + n.IsActive = field.NewBool(table, "is_active") + n.CreatedAt = field.NewTime(table, "created_at") + n.UpdatedAt = field.NewTime(table, "updated_at") + + n.fillFieldMap() + + return n +} + +func (n *notificationTemplateQuery) QueryContext(ctx context.Context) (*notificationTemplateQuery, *notificationTemplateQueryDo) { + return n, n.notificationTemplateQueryDo.WithContext(ctx) +} + +func (n *notificationTemplateQuery) WithContext(ctx context.Context) *notificationTemplateQueryDo { + return n.notificationTemplateQueryDo.WithContext(ctx) +} + +func (n notificationTemplateQuery) TableName() string { + return n.notificationTemplateQueryDo.TableName() +} + +func (n notificationTemplateQuery) Alias() string { return n.notificationTemplateQueryDo.Alias() } + +func (n notificationTemplateQuery) Columns(cols ...field.Expr) gen.Columns { + return n.notificationTemplateQueryDo.Columns(cols...) +} + +func (n *notificationTemplateQuery) GetFieldByName(fieldName string) (field.OrderExpr, bool) { + _f, ok := n.fieldMap[fieldName] + if !ok || _f == nil { + return nil, false + } + _oe, ok := _f.(field.OrderExpr) + return _oe, ok +} + +func (n *notificationTemplateQuery) fillFieldMap() { + n.fieldMap = make(map[string]field.Expr, 9) + n.fieldMap["id"] = n.ID + n.fieldMap["tenant_id"] = n.TenantID + n.fieldMap["name"] = n.Name + n.fieldMap["type"] = n.Type + n.fieldMap["title"] = n.Title + n.fieldMap["content"] = n.Content + n.fieldMap["is_active"] = n.IsActive + n.fieldMap["created_at"] = n.CreatedAt + n.fieldMap["updated_at"] = n.UpdatedAt +} + +func (n notificationTemplateQuery) clone(db *gorm.DB) notificationTemplateQuery { + n.notificationTemplateQueryDo.ReplaceConnPool(db.Statement.ConnPool) + return n +} + +func (n notificationTemplateQuery) replaceDB(db *gorm.DB) notificationTemplateQuery { + n.notificationTemplateQueryDo.ReplaceDB(db) + return n +} + +type notificationTemplateQueryDo struct{ gen.DO } + +func (n notificationTemplateQueryDo) Debug() *notificationTemplateQueryDo { + return n.withDO(n.DO.Debug()) +} + +func (n notificationTemplateQueryDo) WithContext(ctx context.Context) *notificationTemplateQueryDo { + return n.withDO(n.DO.WithContext(ctx)) +} + +func (n notificationTemplateQueryDo) ReadDB() *notificationTemplateQueryDo { + return n.Clauses(dbresolver.Read) +} + +func (n notificationTemplateQueryDo) WriteDB() *notificationTemplateQueryDo { + return n.Clauses(dbresolver.Write) +} + +func (n notificationTemplateQueryDo) Session(config *gorm.Session) *notificationTemplateQueryDo { + return n.withDO(n.DO.Session(config)) +} + +func (n notificationTemplateQueryDo) Clauses(conds ...clause.Expression) *notificationTemplateQueryDo { + return n.withDO(n.DO.Clauses(conds...)) +} + +func (n notificationTemplateQueryDo) Returning(value interface{}, columns ...string) *notificationTemplateQueryDo { + return n.withDO(n.DO.Returning(value, columns...)) +} + +func (n notificationTemplateQueryDo) Not(conds ...gen.Condition) *notificationTemplateQueryDo { + return n.withDO(n.DO.Not(conds...)) +} + +func (n notificationTemplateQueryDo) Or(conds ...gen.Condition) *notificationTemplateQueryDo { + return n.withDO(n.DO.Or(conds...)) +} + +func (n notificationTemplateQueryDo) Select(conds ...field.Expr) *notificationTemplateQueryDo { + return n.withDO(n.DO.Select(conds...)) +} + +func (n notificationTemplateQueryDo) Where(conds ...gen.Condition) *notificationTemplateQueryDo { + return n.withDO(n.DO.Where(conds...)) +} + +func (n notificationTemplateQueryDo) Order(conds ...field.Expr) *notificationTemplateQueryDo { + return n.withDO(n.DO.Order(conds...)) +} + +func (n notificationTemplateQueryDo) Distinct(cols ...field.Expr) *notificationTemplateQueryDo { + return n.withDO(n.DO.Distinct(cols...)) +} + +func (n notificationTemplateQueryDo) Omit(cols ...field.Expr) *notificationTemplateQueryDo { + return n.withDO(n.DO.Omit(cols...)) +} + +func (n notificationTemplateQueryDo) Join(table schema.Tabler, on ...field.Expr) *notificationTemplateQueryDo { + return n.withDO(n.DO.Join(table, on...)) +} + +func (n notificationTemplateQueryDo) LeftJoin(table schema.Tabler, on ...field.Expr) *notificationTemplateQueryDo { + return n.withDO(n.DO.LeftJoin(table, on...)) +} + +func (n notificationTemplateQueryDo) RightJoin(table schema.Tabler, on ...field.Expr) *notificationTemplateQueryDo { + return n.withDO(n.DO.RightJoin(table, on...)) +} + +func (n notificationTemplateQueryDo) Group(cols ...field.Expr) *notificationTemplateQueryDo { + return n.withDO(n.DO.Group(cols...)) +} + +func (n notificationTemplateQueryDo) Having(conds ...gen.Condition) *notificationTemplateQueryDo { + return n.withDO(n.DO.Having(conds...)) +} + +func (n notificationTemplateQueryDo) Limit(limit int) *notificationTemplateQueryDo { + return n.withDO(n.DO.Limit(limit)) +} + +func (n notificationTemplateQueryDo) Offset(offset int) *notificationTemplateQueryDo { + return n.withDO(n.DO.Offset(offset)) +} + +func (n notificationTemplateQueryDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *notificationTemplateQueryDo { + return n.withDO(n.DO.Scopes(funcs...)) +} + +func (n notificationTemplateQueryDo) Unscoped() *notificationTemplateQueryDo { + return n.withDO(n.DO.Unscoped()) +} + +func (n notificationTemplateQueryDo) Create(values ...*NotificationTemplate) error { + if len(values) == 0 { + return nil + } + return n.DO.Create(values) +} + +func (n notificationTemplateQueryDo) CreateInBatches(values []*NotificationTemplate, batchSize int) error { + return n.DO.CreateInBatches(values, batchSize) +} + +// Save : !!! underlying implementation is different with GORM +// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values) +func (n notificationTemplateQueryDo) Save(values ...*NotificationTemplate) error { + if len(values) == 0 { + return nil + } + return n.DO.Save(values) +} + +func (n notificationTemplateQueryDo) First() (*NotificationTemplate, error) { + if result, err := n.DO.First(); err != nil { + return nil, err + } else { + return result.(*NotificationTemplate), nil + } +} + +func (n notificationTemplateQueryDo) Take() (*NotificationTemplate, error) { + if result, err := n.DO.Take(); err != nil { + return nil, err + } else { + return result.(*NotificationTemplate), nil + } +} + +func (n notificationTemplateQueryDo) Last() (*NotificationTemplate, error) { + if result, err := n.DO.Last(); err != nil { + return nil, err + } else { + return result.(*NotificationTemplate), nil + } +} + +func (n notificationTemplateQueryDo) Find() ([]*NotificationTemplate, error) { + result, err := n.DO.Find() + return result.([]*NotificationTemplate), err +} + +func (n notificationTemplateQueryDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*NotificationTemplate, err error) { + buf := make([]*NotificationTemplate, 0, batchSize) + err = n.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error { + defer func() { results = append(results, buf...) }() + return fc(tx, batch) + }) + return results, err +} + +func (n notificationTemplateQueryDo) FindInBatches(result *[]*NotificationTemplate, batchSize int, fc func(tx gen.Dao, batch int) error) error { + return n.DO.FindInBatches(result, batchSize, fc) +} + +func (n notificationTemplateQueryDo) Attrs(attrs ...field.AssignExpr) *notificationTemplateQueryDo { + return n.withDO(n.DO.Attrs(attrs...)) +} + +func (n notificationTemplateQueryDo) Assign(attrs ...field.AssignExpr) *notificationTemplateQueryDo { + return n.withDO(n.DO.Assign(attrs...)) +} + +func (n notificationTemplateQueryDo) Joins(fields ...field.RelationField) *notificationTemplateQueryDo { + for _, _f := range fields { + n = *n.withDO(n.DO.Joins(_f)) + } + return &n +} + +func (n notificationTemplateQueryDo) Preload(fields ...field.RelationField) *notificationTemplateQueryDo { + for _, _f := range fields { + n = *n.withDO(n.DO.Preload(_f)) + } + return &n +} + +func (n notificationTemplateQueryDo) FirstOrInit() (*NotificationTemplate, error) { + if result, err := n.DO.FirstOrInit(); err != nil { + return nil, err + } else { + return result.(*NotificationTemplate), nil + } +} + +func (n notificationTemplateQueryDo) FirstOrCreate() (*NotificationTemplate, error) { + if result, err := n.DO.FirstOrCreate(); err != nil { + return nil, err + } else { + return result.(*NotificationTemplate), nil + } +} + +func (n notificationTemplateQueryDo) FindByPage(offset int, limit int) (result []*NotificationTemplate, count int64, err error) { + result, err = n.Offset(offset).Limit(limit).Find() + if err != nil { + return + } + + if size := len(result); 0 < limit && 0 < size && size < limit { + count = int64(size + offset) + return + } + + count, err = n.Offset(-1).Limit(-1).Count() + return +} + +func (n notificationTemplateQueryDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) { + count, err = n.Count() + if err != nil { + return + } + + err = n.Offset(offset).Limit(limit).Scan(result) + return +} + +func (n notificationTemplateQueryDo) Scan(result interface{}) (err error) { + return n.DO.Scan(result) +} + +func (n notificationTemplateQueryDo) Delete(models ...*NotificationTemplate) (result gen.ResultInfo, err error) { + return n.DO.Delete(models) +} + +// ForceDelete performs a permanent delete (ignores soft-delete) for current scope. +func (n notificationTemplateQueryDo) ForceDelete() (gen.ResultInfo, error) { + return n.Unscoped().Delete() +} + +// Inc increases the given column by step for current scope. +func (n notificationTemplateQueryDo) Inc(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column + step + e := field.NewUnsafeFieldRaw("?+?", column.RawExpr(), step) + return n.DO.UpdateColumn(column, e) +} + +// Dec decreases the given column by step for current scope. +func (n notificationTemplateQueryDo) Dec(column field.Expr, step int64) (gen.ResultInfo, error) { + // column = column - step + e := field.NewUnsafeFieldRaw("?-?", column.RawExpr(), step) + return n.DO.UpdateColumn(column, e) +} + +// Sum returns SUM(column) for current scope. +func (n notificationTemplateQueryDo) Sum(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("SUM(?)", column.RawExpr()) + if err := n.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Avg returns AVG(column) for current scope. +func (n notificationTemplateQueryDo) Avg(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("AVG(?)", column.RawExpr()) + if err := n.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Min returns MIN(column) for current scope. +func (n notificationTemplateQueryDo) Min(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MIN(?)", column.RawExpr()) + if err := n.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// Max returns MAX(column) for current scope. +func (n notificationTemplateQueryDo) Max(column field.Expr) (float64, error) { + var _v float64 + agg := field.NewUnsafeFieldRaw("MAX(?)", column.RawExpr()) + if err := n.Select(agg).Scan(&_v); err != nil { + return 0, err + } + return _v, nil +} + +// PluckMap returns a map[key]value for selected key/value expressions within current scope. +func (n notificationTemplateQueryDo) PluckMap(key, val field.Expr) (map[interface{}]interface{}, error) { + do := n.Select(key, val) + rows, err := do.DO.Rows() + if err != nil { + return nil, err + } + defer rows.Close() + mm := make(map[interface{}]interface{}) + for rows.Next() { + var k interface{} + var v interface{} + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + mm[k] = v + } + return mm, rows.Err() +} + +// Exists returns true if any record matches the given conditions. +func (n notificationTemplateQueryDo) Exists(conds ...gen.Condition) (bool, error) { + cnt, err := n.Where(conds...).Count() + if err != nil { + return false, err + } + return cnt > 0, nil +} + +// PluckIDs returns all primary key values under current scope. +func (n notificationTemplateQueryDo) PluckIDs() ([]int64, error) { + ids := make([]int64, 0, 16) + pk := field.NewInt64(n.TableName(), "id") + if err := n.DO.Pluck(pk, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// GetByID finds a single record by primary key. +func (n notificationTemplateQueryDo) GetByID(id int64) (*NotificationTemplate, error) { + pk := field.NewInt64(n.TableName(), "id") + return n.Where(pk.Eq(id)).First() +} + +// GetByIDs finds records by primary key list. +func (n notificationTemplateQueryDo) GetByIDs(ids ...int64) ([]*NotificationTemplate, error) { + if len(ids) == 0 { + return []*NotificationTemplate{}, nil + } + pk := field.NewInt64(n.TableName(), "id") + return n.Where(pk.In(ids...)).Find() +} + +// DeleteByID deletes records by primary key. +func (n notificationTemplateQueryDo) DeleteByID(id int64) (gen.ResultInfo, error) { + pk := field.NewInt64(n.TableName(), "id") + return n.Where(pk.Eq(id)).Delete() +} + +// DeleteByIDs deletes records by a list of primary keys. +func (n notificationTemplateQueryDo) DeleteByIDs(ids ...int64) (gen.ResultInfo, error) { + if len(ids) == 0 { + return gen.ResultInfo{RowsAffected: 0, Error: nil}, nil + } + pk := field.NewInt64(n.TableName(), "id") + return n.Where(pk.In(ids...)).Delete() +} + +func (n *notificationTemplateQueryDo) withDO(do gen.Dao) *notificationTemplateQueryDo { + n.DO = *do.(*gen.DO) + return n +} diff --git a/backend/database/models/query.gen.go b/backend/database/models/query.gen.go index ea2ab64..75d5203 100644 --- a/backend/database/models/query.gen.go +++ b/backend/database/models/query.gen.go @@ -16,27 +16,28 @@ import ( ) var ( - Q = new(Query) - CommentQuery *commentQuery - ContentQuery *contentQuery - ContentAccessQuery *contentAccessQuery - ContentAssetQuery *contentAssetQuery - ContentPriceQuery *contentPriceQuery - CouponQuery *couponQuery - MediaAssetQuery *mediaAssetQuery - NotificationQuery *notificationQuery - OrderQuery *orderQuery - OrderItemQuery *orderItemQuery - PayoutAccountQuery *payoutAccountQuery - TenantQuery *tenantQuery - TenantInviteQuery *tenantInviteQuery - TenantJoinRequestQuery *tenantJoinRequestQuery - TenantLedgerQuery *tenantLedgerQuery - TenantUserQuery *tenantUserQuery - UserQuery *userQuery - UserCommentActionQuery *userCommentActionQuery - UserContentActionQuery *userContentActionQuery - UserCouponQuery *userCouponQuery + Q = new(Query) + CommentQuery *commentQuery + ContentQuery *contentQuery + ContentAccessQuery *contentAccessQuery + ContentAssetQuery *contentAssetQuery + ContentPriceQuery *contentPriceQuery + CouponQuery *couponQuery + MediaAssetQuery *mediaAssetQuery + NotificationQuery *notificationQuery + NotificationTemplateQuery *notificationTemplateQuery + OrderQuery *orderQuery + OrderItemQuery *orderItemQuery + PayoutAccountQuery *payoutAccountQuery + TenantQuery *tenantQuery + TenantInviteQuery *tenantInviteQuery + TenantJoinRequestQuery *tenantJoinRequestQuery + TenantLedgerQuery *tenantLedgerQuery + TenantUserQuery *tenantUserQuery + UserQuery *userQuery + UserCommentActionQuery *userCommentActionQuery + UserContentActionQuery *userContentActionQuery + UserCouponQuery *userCouponQuery ) func SetDefault(db *gorm.DB, opts ...gen.DOOption) { @@ -49,6 +50,7 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { CouponQuery = &Q.Coupon MediaAssetQuery = &Q.MediaAsset NotificationQuery = &Q.Notification + NotificationTemplateQuery = &Q.NotificationTemplate OrderQuery = &Q.Order OrderItemQuery = &Q.OrderItem PayoutAccountQuery = &Q.PayoutAccount @@ -65,80 +67,83 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) { func Use(db *gorm.DB, opts ...gen.DOOption) *Query { return &Query{ - db: db, - Comment: newComment(db, opts...), - Content: newContent(db, opts...), - ContentAccess: newContentAccess(db, opts...), - ContentAsset: newContentAsset(db, opts...), - ContentPrice: newContentPrice(db, opts...), - Coupon: newCoupon(db, opts...), - MediaAsset: newMediaAsset(db, opts...), - Notification: newNotification(db, opts...), - Order: newOrder(db, opts...), - OrderItem: newOrderItem(db, opts...), - PayoutAccount: newPayoutAccount(db, opts...), - Tenant: newTenant(db, opts...), - TenantInvite: newTenantInvite(db, opts...), - TenantJoinRequest: newTenantJoinRequest(db, opts...), - TenantLedger: newTenantLedger(db, opts...), - TenantUser: newTenantUser(db, opts...), - User: newUser(db, opts...), - UserCommentAction: newUserCommentAction(db, opts...), - UserContentAction: newUserContentAction(db, opts...), - UserCoupon: newUserCoupon(db, opts...), + db: db, + Comment: newComment(db, opts...), + Content: newContent(db, opts...), + ContentAccess: newContentAccess(db, opts...), + ContentAsset: newContentAsset(db, opts...), + ContentPrice: newContentPrice(db, opts...), + Coupon: newCoupon(db, opts...), + MediaAsset: newMediaAsset(db, opts...), + Notification: newNotification(db, opts...), + NotificationTemplate: newNotificationTemplate(db, opts...), + Order: newOrder(db, opts...), + OrderItem: newOrderItem(db, opts...), + PayoutAccount: newPayoutAccount(db, opts...), + Tenant: newTenant(db, opts...), + TenantInvite: newTenantInvite(db, opts...), + TenantJoinRequest: newTenantJoinRequest(db, opts...), + TenantLedger: newTenantLedger(db, opts...), + TenantUser: newTenantUser(db, opts...), + User: newUser(db, opts...), + UserCommentAction: newUserCommentAction(db, opts...), + UserContentAction: newUserContentAction(db, opts...), + UserCoupon: newUserCoupon(db, opts...), } } type Query struct { db *gorm.DB - Comment commentQuery - Content contentQuery - ContentAccess contentAccessQuery - ContentAsset contentAssetQuery - ContentPrice contentPriceQuery - Coupon couponQuery - MediaAsset mediaAssetQuery - Notification notificationQuery - Order orderQuery - OrderItem orderItemQuery - PayoutAccount payoutAccountQuery - Tenant tenantQuery - TenantInvite tenantInviteQuery - TenantJoinRequest tenantJoinRequestQuery - TenantLedger tenantLedgerQuery - TenantUser tenantUserQuery - User userQuery - UserCommentAction userCommentActionQuery - UserContentAction userContentActionQuery - UserCoupon userCouponQuery + Comment commentQuery + Content contentQuery + ContentAccess contentAccessQuery + ContentAsset contentAssetQuery + ContentPrice contentPriceQuery + Coupon couponQuery + MediaAsset mediaAssetQuery + Notification notificationQuery + NotificationTemplate notificationTemplateQuery + Order orderQuery + OrderItem orderItemQuery + PayoutAccount payoutAccountQuery + Tenant tenantQuery + TenantInvite tenantInviteQuery + TenantJoinRequest tenantJoinRequestQuery + TenantLedger tenantLedgerQuery + TenantUser tenantUserQuery + User userQuery + UserCommentAction userCommentActionQuery + UserContentAction userContentActionQuery + UserCoupon userCouponQuery } func (q *Query) Available() bool { return q.db != nil } func (q *Query) clone(db *gorm.DB) *Query { return &Query{ - db: db, - Comment: q.Comment.clone(db), - Content: q.Content.clone(db), - ContentAccess: q.ContentAccess.clone(db), - ContentAsset: q.ContentAsset.clone(db), - ContentPrice: q.ContentPrice.clone(db), - Coupon: q.Coupon.clone(db), - MediaAsset: q.MediaAsset.clone(db), - Notification: q.Notification.clone(db), - Order: q.Order.clone(db), - OrderItem: q.OrderItem.clone(db), - PayoutAccount: q.PayoutAccount.clone(db), - Tenant: q.Tenant.clone(db), - TenantInvite: q.TenantInvite.clone(db), - TenantJoinRequest: q.TenantJoinRequest.clone(db), - TenantLedger: q.TenantLedger.clone(db), - TenantUser: q.TenantUser.clone(db), - User: q.User.clone(db), - UserCommentAction: q.UserCommentAction.clone(db), - UserContentAction: q.UserContentAction.clone(db), - UserCoupon: q.UserCoupon.clone(db), + db: db, + Comment: q.Comment.clone(db), + Content: q.Content.clone(db), + ContentAccess: q.ContentAccess.clone(db), + ContentAsset: q.ContentAsset.clone(db), + ContentPrice: q.ContentPrice.clone(db), + Coupon: q.Coupon.clone(db), + MediaAsset: q.MediaAsset.clone(db), + Notification: q.Notification.clone(db), + NotificationTemplate: q.NotificationTemplate.clone(db), + Order: q.Order.clone(db), + OrderItem: q.OrderItem.clone(db), + PayoutAccount: q.PayoutAccount.clone(db), + Tenant: q.Tenant.clone(db), + TenantInvite: q.TenantInvite.clone(db), + TenantJoinRequest: q.TenantJoinRequest.clone(db), + TenantLedger: q.TenantLedger.clone(db), + TenantUser: q.TenantUser.clone(db), + User: q.User.clone(db), + UserCommentAction: q.UserCommentAction.clone(db), + UserContentAction: q.UserContentAction.clone(db), + UserCoupon: q.UserCoupon.clone(db), } } @@ -152,75 +157,78 @@ func (q *Query) WriteDB() *Query { func (q *Query) ReplaceDB(db *gorm.DB) *Query { return &Query{ - db: db, - Comment: q.Comment.replaceDB(db), - Content: q.Content.replaceDB(db), - ContentAccess: q.ContentAccess.replaceDB(db), - ContentAsset: q.ContentAsset.replaceDB(db), - ContentPrice: q.ContentPrice.replaceDB(db), - Coupon: q.Coupon.replaceDB(db), - MediaAsset: q.MediaAsset.replaceDB(db), - Notification: q.Notification.replaceDB(db), - Order: q.Order.replaceDB(db), - OrderItem: q.OrderItem.replaceDB(db), - PayoutAccount: q.PayoutAccount.replaceDB(db), - Tenant: q.Tenant.replaceDB(db), - TenantInvite: q.TenantInvite.replaceDB(db), - TenantJoinRequest: q.TenantJoinRequest.replaceDB(db), - TenantLedger: q.TenantLedger.replaceDB(db), - TenantUser: q.TenantUser.replaceDB(db), - User: q.User.replaceDB(db), - UserCommentAction: q.UserCommentAction.replaceDB(db), - UserContentAction: q.UserContentAction.replaceDB(db), - UserCoupon: q.UserCoupon.replaceDB(db), + db: db, + Comment: q.Comment.replaceDB(db), + Content: q.Content.replaceDB(db), + ContentAccess: q.ContentAccess.replaceDB(db), + ContentAsset: q.ContentAsset.replaceDB(db), + ContentPrice: q.ContentPrice.replaceDB(db), + Coupon: q.Coupon.replaceDB(db), + MediaAsset: q.MediaAsset.replaceDB(db), + Notification: q.Notification.replaceDB(db), + NotificationTemplate: q.NotificationTemplate.replaceDB(db), + Order: q.Order.replaceDB(db), + OrderItem: q.OrderItem.replaceDB(db), + PayoutAccount: q.PayoutAccount.replaceDB(db), + Tenant: q.Tenant.replaceDB(db), + TenantInvite: q.TenantInvite.replaceDB(db), + TenantJoinRequest: q.TenantJoinRequest.replaceDB(db), + TenantLedger: q.TenantLedger.replaceDB(db), + TenantUser: q.TenantUser.replaceDB(db), + User: q.User.replaceDB(db), + UserCommentAction: q.UserCommentAction.replaceDB(db), + UserContentAction: q.UserContentAction.replaceDB(db), + UserCoupon: q.UserCoupon.replaceDB(db), } } type queryCtx struct { - Comment *commentQueryDo - Content *contentQueryDo - ContentAccess *contentAccessQueryDo - ContentAsset *contentAssetQueryDo - ContentPrice *contentPriceQueryDo - Coupon *couponQueryDo - MediaAsset *mediaAssetQueryDo - Notification *notificationQueryDo - Order *orderQueryDo - OrderItem *orderItemQueryDo - PayoutAccount *payoutAccountQueryDo - Tenant *tenantQueryDo - TenantInvite *tenantInviteQueryDo - TenantJoinRequest *tenantJoinRequestQueryDo - TenantLedger *tenantLedgerQueryDo - TenantUser *tenantUserQueryDo - User *userQueryDo - UserCommentAction *userCommentActionQueryDo - UserContentAction *userContentActionQueryDo - UserCoupon *userCouponQueryDo + Comment *commentQueryDo + Content *contentQueryDo + ContentAccess *contentAccessQueryDo + ContentAsset *contentAssetQueryDo + ContentPrice *contentPriceQueryDo + Coupon *couponQueryDo + MediaAsset *mediaAssetQueryDo + Notification *notificationQueryDo + NotificationTemplate *notificationTemplateQueryDo + Order *orderQueryDo + OrderItem *orderItemQueryDo + PayoutAccount *payoutAccountQueryDo + Tenant *tenantQueryDo + TenantInvite *tenantInviteQueryDo + TenantJoinRequest *tenantJoinRequestQueryDo + TenantLedger *tenantLedgerQueryDo + TenantUser *tenantUserQueryDo + User *userQueryDo + UserCommentAction *userCommentActionQueryDo + UserContentAction *userContentActionQueryDo + UserCoupon *userCouponQueryDo } func (q *Query) WithContext(ctx context.Context) *queryCtx { return &queryCtx{ - Comment: q.Comment.WithContext(ctx), - Content: q.Content.WithContext(ctx), - ContentAccess: q.ContentAccess.WithContext(ctx), - ContentAsset: q.ContentAsset.WithContext(ctx), - ContentPrice: q.ContentPrice.WithContext(ctx), - Coupon: q.Coupon.WithContext(ctx), - MediaAsset: q.MediaAsset.WithContext(ctx), - Notification: q.Notification.WithContext(ctx), - Order: q.Order.WithContext(ctx), - OrderItem: q.OrderItem.WithContext(ctx), - PayoutAccount: q.PayoutAccount.WithContext(ctx), - Tenant: q.Tenant.WithContext(ctx), - TenantInvite: q.TenantInvite.WithContext(ctx), - TenantJoinRequest: q.TenantJoinRequest.WithContext(ctx), - TenantLedger: q.TenantLedger.WithContext(ctx), - TenantUser: q.TenantUser.WithContext(ctx), - User: q.User.WithContext(ctx), - UserCommentAction: q.UserCommentAction.WithContext(ctx), - UserContentAction: q.UserContentAction.WithContext(ctx), - UserCoupon: q.UserCoupon.WithContext(ctx), + Comment: q.Comment.WithContext(ctx), + Content: q.Content.WithContext(ctx), + ContentAccess: q.ContentAccess.WithContext(ctx), + ContentAsset: q.ContentAsset.WithContext(ctx), + ContentPrice: q.ContentPrice.WithContext(ctx), + Coupon: q.Coupon.WithContext(ctx), + MediaAsset: q.MediaAsset.WithContext(ctx), + Notification: q.Notification.WithContext(ctx), + NotificationTemplate: q.NotificationTemplate.WithContext(ctx), + Order: q.Order.WithContext(ctx), + OrderItem: q.OrderItem.WithContext(ctx), + PayoutAccount: q.PayoutAccount.WithContext(ctx), + Tenant: q.Tenant.WithContext(ctx), + TenantInvite: q.TenantInvite.WithContext(ctx), + TenantJoinRequest: q.TenantJoinRequest.WithContext(ctx), + TenantLedger: q.TenantLedger.WithContext(ctx), + TenantUser: q.TenantUser.WithContext(ctx), + User: q.User.WithContext(ctx), + UserCommentAction: q.UserCommentAction.WithContext(ctx), + UserContentAction: q.UserContentAction.WithContext(ctx), + UserCoupon: q.UserCoupon.WithContext(ctx), } } diff --git a/backend/docs/docs.go b/backend/docs/docs.go index d89c2e9..e8e5a47 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -24,6 +24,120 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/super/v1/assets": { + "get": { + "description": "List assets across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Asset" + ], + "summary": "List assets", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperAssetItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/assets/usage": { + "get": { + "description": "Asset usage statistics", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Asset" + ], + "summary": "Asset usage", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.SuperAssetUsageResponse" + } + } + } + } + }, + "/super/v1/assets/{id}": { + "delete": { + "description": "Delete asset", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Asset" + ], + "summary": "Delete asset", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Asset ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Force delete", + "name": "force", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Deleted", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/auth/login": { "post": { "description": "Login", @@ -524,6 +638,162 @@ const docTemplate = `{ } } }, + "/super/v1/notifications": { + "get": { + "description": "List notifications across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "List notifications", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperNotificationItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/notifications/broadcast": { + "post": { + "description": "Broadcast notification to users or tenant members", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "Broadcast notification", + "parameters": [ + { + "description": "Broadcast form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperNotificationBroadcastForm" + } + } + ], + "responses": { + "200": { + "description": "Sent", + "schema": { + "type": "string" + } + } + } + } + }, + "/super/v1/notifications/templates": { + "get": { + "description": "List notification templates", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "List notification templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperNotificationTemplateItem" + } + } + } + } + ] + } + } + } + }, + "post": { + "description": "Create notification template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "Create notification template", + "parameters": [ + { + "description": "Template form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperNotificationTemplateCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.SuperNotificationTemplateItem" + } + } + } + } + }, "/super/v1/orders": { "get": { "description": "List orders", @@ -4976,6 +5246,62 @@ const docTemplate = `{ "GenderSecret" ] }, + "consts.MediaAssetStatus": { + "type": "string", + "enum": [ + "uploaded", + "processing", + "ready", + "failed", + "deleted" + ], + "x-enum-varnames": [ + "MediaAssetStatusUploaded", + "MediaAssetStatusProcessing", + "MediaAssetStatusReady", + "MediaAssetStatusFailed", + "MediaAssetStatusDeleted" + ] + }, + "consts.MediaAssetType": { + "type": "string", + "enum": [ + "video", + "audio", + "image" + ], + "x-enum-varnames": [ + "MediaAssetTypeVideo", + "MediaAssetTypeAudio", + "MediaAssetTypeImage" + ] + }, + "consts.MediaAssetVariant": { + "type": "string", + "enum": [ + "main", + "preview" + ], + "x-enum-varnames": [ + "MediaAssetVariantMain", + "MediaAssetVariantPreview" + ] + }, + "consts.NotificationType": { + "type": "string", + "enum": [ + "system", + "order", + "audit", + "interaction" + ], + "x-enum-varnames": [ + "NotificationTypeSystem", + "NotificationTypeOrder", + "NotificationTypeAudit", + "NotificationTypeInteraction" + ] + }, "consts.OrderStatus": { "type": "string", "enum": [ @@ -6348,6 +6674,144 @@ const docTemplate = `{ } } }, + "dto.SuperAssetItem": { + "type": "object", + "properties": { + "bucket": { + "description": "Bucket 存储桶名称。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "filename": { + "description": "Filename 原始文件名。", + "type": "string" + }, + "hash": { + "description": "Hash 文件哈希(MD5)。", + "type": "string" + }, + "id": { + "description": "ID 资产ID。", + "type": "integer" + }, + "object_key": { + "description": "ObjectKey 对象Key。", + "type": "string" + }, + "provider": { + "description": "Provider 存储提供方。", + "type": "string" + }, + "size": { + "description": "Size 文件大小(字节)。", + "type": "integer" + }, + "source_asset_id": { + "description": "SourceAssetID 源资产ID(用于变体关联)。", + "type": "integer" + }, + "status": { + "description": "Status 处理状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetStatus" + } + ] + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "type": { + "description": "Type 媒体类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetType" + } + ] + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "url": { + "description": "URL 访问URL(若可用)。", + "type": "string" + }, + "used_count": { + "description": "UsedCount 被内容引用次数。", + "type": "integer" + }, + "user_id": { + "description": "UserID 上传用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 上传用户名/昵称。", + "type": "string" + }, + "variant": { + "description": "Variant 媒体变体(main/preview/cover 等)。", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetVariant" + } + ] + } + } + }, + "dto.SuperAssetUsageItem": { + "type": "object", + "properties": { + "count": { + "description": "Count 该类型资产数量。", + "type": "integer" + }, + "total_size": { + "description": "TotalSize 该类型资产大小总和(字节)。", + "type": "integer" + }, + "type": { + "description": "Type 媒体类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetType" + } + ] + } + } + }, + "dto.SuperAssetUsageResponse": { + "type": "object", + "properties": { + "by_type": { + "description": "ByType 按媒体类型汇总的用量统计。", + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperAssetUsageItem" + } + }, + "total_count": { + "description": "TotalCount 资产总量。", + "type": "integer" + }, + "total_size": { + "description": "TotalSize 资产总大小(字节)。", + "type": "integer" + } + } + }, "dto.SuperContentBatchReviewForm": { "type": "object", "required": [ @@ -6633,6 +7097,177 @@ const docTemplate = `{ } } }, + "dto.SuperNotificationBroadcastForm": { + "type": "object", + "properties": { + "content": { + "description": "Content 通知内容。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID(选填,用于指定租户成员)。", + "type": "integer" + }, + "title": { + "description": "Title 通知标题。", + "type": "string" + }, + "type": { + "description": "Type 通知类型(system/order/audit/interaction)。", + "allOf": [ + { + "$ref": "#/definitions/consts.NotificationType" + } + ] + }, + "user_ids": { + "description": "UserIDs 指定接收用户ID列表(优先级高于 TenantID)。", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "dto.SuperNotificationItem": { + "type": "object", + "properties": { + "content": { + "description": "Content 通知内容。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 通知ID。", + "type": "integer" + }, + "is_read": { + "description": "IsRead 是否已读。", + "type": "boolean" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "title": { + "description": "Title 通知标题。", + "type": "string" + }, + "type": { + "description": "Type 通知类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.NotificationType" + } + ] + }, + "user_id": { + "description": "UserID 用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 用户名/昵称。", + "type": "string" + } + } + }, + "dto.SuperNotificationTemplateCreateForm": { + "type": "object", + "properties": { + "content": { + "description": "Content 通知内容。", + "type": "string" + }, + "is_active": { + "description": "IsActive 是否启用(不传默认启用)。", + "type": "boolean" + }, + "name": { + "description": "Name 模板名称(用于识别用途)。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID(不传代表全平台模板)。", + "type": "integer" + }, + "title": { + "description": "Title 通知标题。", + "type": "string" + }, + "type": { + "description": "Type 通知类型(system/order/audit/interaction)。", + "allOf": [ + { + "$ref": "#/definitions/consts.NotificationType" + } + ] + } + } + }, + "dto.SuperNotificationTemplateItem": { + "type": "object", + "properties": { + "content": { + "description": "Content 模板内容。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 模板ID。", + "type": "integer" + }, + "is_active": { + "description": "IsActive 是否启用。", + "type": "boolean" + }, + "name": { + "description": "Name 模板名称。", + "type": "string" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "title": { + "description": "Title 模板标题。", + "type": "string" + }, + "type": { + "description": "Type 通知类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.NotificationType" + } + ] + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + } + } + }, "dto.SuperOrderDetail": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 38d8fc9..b7b4a50 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -18,6 +18,120 @@ "host": "localhost:8080", "basePath": "/", "paths": { + "/super/v1/assets": { + "get": { + "description": "List assets across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Asset" + ], + "summary": "List assets", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperAssetItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/assets/usage": { + "get": { + "description": "Asset usage statistics", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Asset" + ], + "summary": "Asset usage", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.SuperAssetUsageResponse" + } + } + } + } + }, + "/super/v1/assets/{id}": { + "delete": { + "description": "Delete asset", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Asset" + ], + "summary": "Delete asset", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "Asset ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "boolean", + "description": "Force delete", + "name": "force", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Deleted", + "schema": { + "type": "string" + } + } + } + } + }, "/super/v1/auth/login": { "post": { "description": "Login", @@ -518,6 +632,162 @@ } } }, + "/super/v1/notifications": { + "get": { + "description": "List notifications across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "List notifications", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperNotificationItem" + } + } + } + } + ] + } + } + } + } + }, + "/super/v1/notifications/broadcast": { + "post": { + "description": "Broadcast notification to users or tenant members", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "Broadcast notification", + "parameters": [ + { + "description": "Broadcast form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperNotificationBroadcastForm" + } + } + ], + "responses": { + "200": { + "description": "Sent", + "schema": { + "type": "string" + } + } + } + } + }, + "/super/v1/notifications/templates": { + "get": { + "description": "List notification templates", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "List notification templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperNotificationTemplateItem" + } + } + } + } + ] + } + } + } + }, + "post": { + "description": "Create notification template", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "Create notification template", + "parameters": [ + { + "description": "Template form", + "name": "form", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/dto.SuperNotificationTemplateCreateForm" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/dto.SuperNotificationTemplateItem" + } + } + } + } + }, "/super/v1/orders": { "get": { "description": "List orders", @@ -4970,6 +5240,62 @@ "GenderSecret" ] }, + "consts.MediaAssetStatus": { + "type": "string", + "enum": [ + "uploaded", + "processing", + "ready", + "failed", + "deleted" + ], + "x-enum-varnames": [ + "MediaAssetStatusUploaded", + "MediaAssetStatusProcessing", + "MediaAssetStatusReady", + "MediaAssetStatusFailed", + "MediaAssetStatusDeleted" + ] + }, + "consts.MediaAssetType": { + "type": "string", + "enum": [ + "video", + "audio", + "image" + ], + "x-enum-varnames": [ + "MediaAssetTypeVideo", + "MediaAssetTypeAudio", + "MediaAssetTypeImage" + ] + }, + "consts.MediaAssetVariant": { + "type": "string", + "enum": [ + "main", + "preview" + ], + "x-enum-varnames": [ + "MediaAssetVariantMain", + "MediaAssetVariantPreview" + ] + }, + "consts.NotificationType": { + "type": "string", + "enum": [ + "system", + "order", + "audit", + "interaction" + ], + "x-enum-varnames": [ + "NotificationTypeSystem", + "NotificationTypeOrder", + "NotificationTypeAudit", + "NotificationTypeInteraction" + ] + }, "consts.OrderStatus": { "type": "string", "enum": [ @@ -6342,6 +6668,144 @@ } } }, + "dto.SuperAssetItem": { + "type": "object", + "properties": { + "bucket": { + "description": "Bucket 存储桶名称。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "filename": { + "description": "Filename 原始文件名。", + "type": "string" + }, + "hash": { + "description": "Hash 文件哈希(MD5)。", + "type": "string" + }, + "id": { + "description": "ID 资产ID。", + "type": "integer" + }, + "object_key": { + "description": "ObjectKey 对象Key。", + "type": "string" + }, + "provider": { + "description": "Provider 存储提供方。", + "type": "string" + }, + "size": { + "description": "Size 文件大小(字节)。", + "type": "integer" + }, + "source_asset_id": { + "description": "SourceAssetID 源资产ID(用于变体关联)。", + "type": "integer" + }, + "status": { + "description": "Status 处理状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetStatus" + } + ] + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "type": { + "description": "Type 媒体类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetType" + } + ] + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + }, + "url": { + "description": "URL 访问URL(若可用)。", + "type": "string" + }, + "used_count": { + "description": "UsedCount 被内容引用次数。", + "type": "integer" + }, + "user_id": { + "description": "UserID 上传用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 上传用户名/昵称。", + "type": "string" + }, + "variant": { + "description": "Variant 媒体变体(main/preview/cover 等)。", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetVariant" + } + ] + } + } + }, + "dto.SuperAssetUsageItem": { + "type": "object", + "properties": { + "count": { + "description": "Count 该类型资产数量。", + "type": "integer" + }, + "total_size": { + "description": "TotalSize 该类型资产大小总和(字节)。", + "type": "integer" + }, + "type": { + "description": "Type 媒体类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.MediaAssetType" + } + ] + } + } + }, + "dto.SuperAssetUsageResponse": { + "type": "object", + "properties": { + "by_type": { + "description": "ByType 按媒体类型汇总的用量统计。", + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperAssetUsageItem" + } + }, + "total_count": { + "description": "TotalCount 资产总量。", + "type": "integer" + }, + "total_size": { + "description": "TotalSize 资产总大小(字节)。", + "type": "integer" + } + } + }, "dto.SuperContentBatchReviewForm": { "type": "object", "required": [ @@ -6627,6 +7091,177 @@ } } }, + "dto.SuperNotificationBroadcastForm": { + "type": "object", + "properties": { + "content": { + "description": "Content 通知内容。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID(选填,用于指定租户成员)。", + "type": "integer" + }, + "title": { + "description": "Title 通知标题。", + "type": "string" + }, + "type": { + "description": "Type 通知类型(system/order/audit/interaction)。", + "allOf": [ + { + "$ref": "#/definitions/consts.NotificationType" + } + ] + }, + "user_ids": { + "description": "UserIDs 指定接收用户ID列表(优先级高于 TenantID)。", + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "dto.SuperNotificationItem": { + "type": "object", + "properties": { + "content": { + "description": "Content 通知内容。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 通知ID。", + "type": "integer" + }, + "is_read": { + "description": "IsRead 是否已读。", + "type": "boolean" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "title": { + "description": "Title 通知标题。", + "type": "string" + }, + "type": { + "description": "Type 通知类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.NotificationType" + } + ] + }, + "user_id": { + "description": "UserID 用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 用户名/昵称。", + "type": "string" + } + } + }, + "dto.SuperNotificationTemplateCreateForm": { + "type": "object", + "properties": { + "content": { + "description": "Content 通知内容。", + "type": "string" + }, + "is_active": { + "description": "IsActive 是否启用(不传默认启用)。", + "type": "boolean" + }, + "name": { + "description": "Name 模板名称(用于识别用途)。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID(不传代表全平台模板)。", + "type": "integer" + }, + "title": { + "description": "Title 通知标题。", + "type": "string" + }, + "type": { + "description": "Type 通知类型(system/order/audit/interaction)。", + "allOf": [ + { + "$ref": "#/definitions/consts.NotificationType" + } + ] + } + } + }, + "dto.SuperNotificationTemplateItem": { + "type": "object", + "properties": { + "content": { + "description": "Content 模板内容。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 创建时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 模板ID。", + "type": "integer" + }, + "is_active": { + "description": "IsActive 是否启用。", + "type": "boolean" + }, + "name": { + "description": "Name 模板名称。", + "type": "string" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "title": { + "description": "Title 模板标题。", + "type": "string" + }, + "type": { + "description": "Type 通知类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.NotificationType" + } + ] + }, + "updated_at": { + "description": "UpdatedAt 更新时间(RFC3339)。", + "type": "string" + } + } + }, "dto.SuperOrderDetail": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index cd9d6f2..5549a7b 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -38,6 +38,50 @@ definitions: - GenderMale - GenderFemale - GenderSecret + consts.MediaAssetStatus: + enum: + - uploaded + - processing + - ready + - failed + - deleted + type: string + x-enum-varnames: + - MediaAssetStatusUploaded + - MediaAssetStatusProcessing + - MediaAssetStatusReady + - MediaAssetStatusFailed + - MediaAssetStatusDeleted + consts.MediaAssetType: + enum: + - video + - audio + - image + type: string + x-enum-varnames: + - MediaAssetTypeVideo + - MediaAssetTypeAudio + - MediaAssetTypeImage + consts.MediaAssetVariant: + enum: + - main + - preview + type: string + x-enum-varnames: + - MediaAssetVariantMain + - MediaAssetVariantPreview + consts.NotificationType: + enum: + - system + - order + - audit + - interaction + type: string + x-enum-varnames: + - NotificationTypeSystem + - NotificationTypeOrder + - NotificationTypeAudit + - NotificationTypeInteraction consts.OrderStatus: enum: - created @@ -1013,6 +1057,99 @@ definitions: description: Likes 累计点赞数。 type: integer type: object + dto.SuperAssetItem: + properties: + bucket: + description: Bucket 存储桶名称。 + type: string + created_at: + description: CreatedAt 创建时间(RFC3339)。 + type: string + filename: + description: Filename 原始文件名。 + type: string + hash: + description: Hash 文件哈希(MD5)。 + type: string + id: + description: ID 资产ID。 + type: integer + object_key: + description: ObjectKey 对象Key。 + type: string + provider: + description: Provider 存储提供方。 + type: string + size: + description: Size 文件大小(字节)。 + type: integer + source_asset_id: + description: SourceAssetID 源资产ID(用于变体关联)。 + type: integer + status: + allOf: + - $ref: '#/definitions/consts.MediaAssetStatus' + description: Status 处理状态。 + tenant_code: + description: TenantCode 租户编码。 + type: string + tenant_id: + description: TenantID 租户ID。 + type: integer + tenant_name: + description: TenantName 租户名称。 + type: string + type: + allOf: + - $ref: '#/definitions/consts.MediaAssetType' + description: Type 媒体类型。 + updated_at: + description: UpdatedAt 更新时间(RFC3339)。 + type: string + url: + description: URL 访问URL(若可用)。 + type: string + used_count: + description: UsedCount 被内容引用次数。 + type: integer + user_id: + description: UserID 上传用户ID。 + type: integer + username: + description: Username 上传用户名/昵称。 + type: string + variant: + allOf: + - $ref: '#/definitions/consts.MediaAssetVariant' + description: Variant 媒体变体(main/preview/cover 等)。 + type: object + dto.SuperAssetUsageItem: + properties: + count: + description: Count 该类型资产数量。 + type: integer + total_size: + description: TotalSize 该类型资产大小总和(字节)。 + type: integer + type: + allOf: + - $ref: '#/definitions/consts.MediaAssetType' + description: Type 媒体类型。 + type: object + dto.SuperAssetUsageResponse: + properties: + by_type: + description: ByType 按媒体类型汇总的用量统计。 + items: + $ref: '#/definitions/dto.SuperAssetUsageItem' + type: array + total_count: + description: TotalCount 资产总量。 + type: integer + total_size: + description: TotalSize 资产总大小(字节)。 + type: integer + type: object dto.SuperContentBatchReviewForm: properties: action: @@ -1214,6 +1351,123 @@ definitions: required: - action type: object + dto.SuperNotificationBroadcastForm: + properties: + content: + description: Content 通知内容。 + type: string + tenant_id: + description: TenantID 租户ID(选填,用于指定租户成员)。 + type: integer + title: + description: Title 通知标题。 + type: string + type: + allOf: + - $ref: '#/definitions/consts.NotificationType' + description: Type 通知类型(system/order/audit/interaction)。 + user_ids: + description: UserIDs 指定接收用户ID列表(优先级高于 TenantID)。 + items: + type: integer + type: array + type: object + dto.SuperNotificationItem: + properties: + content: + description: Content 通知内容。 + type: string + created_at: + description: CreatedAt 创建时间(RFC3339)。 + type: string + id: + description: ID 通知ID。 + type: integer + is_read: + description: IsRead 是否已读。 + type: boolean + tenant_code: + description: TenantCode 租户编码。 + type: string + tenant_id: + description: TenantID 租户ID。 + type: integer + tenant_name: + description: TenantName 租户名称。 + type: string + title: + description: Title 通知标题。 + type: string + type: + allOf: + - $ref: '#/definitions/consts.NotificationType' + description: Type 通知类型。 + user_id: + description: UserID 用户ID。 + type: integer + username: + description: Username 用户名/昵称。 + type: string + type: object + dto.SuperNotificationTemplateCreateForm: + properties: + content: + description: Content 通知内容。 + type: string + is_active: + description: IsActive 是否启用(不传默认启用)。 + type: boolean + name: + description: Name 模板名称(用于识别用途)。 + type: string + tenant_id: + description: TenantID 租户ID(不传代表全平台模板)。 + type: integer + title: + description: Title 通知标题。 + type: string + type: + allOf: + - $ref: '#/definitions/consts.NotificationType' + description: Type 通知类型(system/order/audit/interaction)。 + type: object + dto.SuperNotificationTemplateItem: + properties: + content: + description: Content 模板内容。 + type: string + created_at: + description: CreatedAt 创建时间(RFC3339)。 + type: string + id: + description: ID 模板ID。 + type: integer + is_active: + description: IsActive 是否启用。 + type: boolean + name: + description: Name 模板名称。 + type: string + tenant_code: + description: TenantCode 租户编码。 + type: string + tenant_id: + description: TenantID 租户ID。 + type: integer + tenant_name: + description: TenantName 租户名称。 + type: string + title: + description: Title 模板标题。 + type: string + type: + allOf: + - $ref: '#/definitions/consts.NotificationType' + description: Type 通知类型。 + updated_at: + description: UpdatedAt 更新时间(RFC3339)。 + type: string + type: object dto.SuperOrderDetail: properties: buyer: @@ -2402,6 +2656,78 @@ info: title: ApiDoc version: "1.0" paths: + /super/v1/assets: + get: + consumes: + - application/json + description: List assets across tenants + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperAssetItem' + type: array + type: object + summary: List assets + tags: + - Asset + /super/v1/assets/{id}: + delete: + consumes: + - application/json + description: Delete asset + parameters: + - description: Asset ID + format: int64 + in: path + name: id + required: true + type: integer + - description: Force delete + in: query + name: force + type: boolean + produces: + - application/json + responses: + "200": + description: Deleted + schema: + type: string + summary: Delete asset + tags: + - Asset + /super/v1/assets/usage: + get: + consumes: + - application/json + description: Asset usage statistics + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.SuperAssetUsageResponse' + summary: Asset usage + tags: + - Asset /super/v1/auth/login: post: consumes: @@ -2715,6 +3041,102 @@ paths: summary: List creators tags: - Creator + /super/v1/notifications: + get: + consumes: + - application/json + description: List notifications across tenants + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperNotificationItem' + type: array + type: object + summary: List notifications + tags: + - Notification + /super/v1/notifications/broadcast: + post: + consumes: + - application/json + description: Broadcast notification to users or tenant members + parameters: + - description: Broadcast form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperNotificationBroadcastForm' + produces: + - application/json + responses: + "200": + description: Sent + schema: + type: string + summary: Broadcast notification + tags: + - Notification + /super/v1/notifications/templates: + get: + consumes: + - application/json + description: List notification templates + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperNotificationTemplateItem' + type: array + type: object + summary: List notification templates + tags: + - Notification + post: + consumes: + - application/json + description: Create notification template + parameters: + - description: Template form + in: body + name: form + required: true + schema: + $ref: '#/definitions/dto.SuperNotificationTemplateCreateForm' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/dto.SuperNotificationTemplateItem' + summary: Create notification template + tags: + - Notification /super/v1/orders: get: consumes: diff --git a/docs/superadmin_progress.md b/docs/superadmin_progress.md index f216a28..a3acb76 100644 --- a/docs/superadmin_progress.md +++ b/docs/superadmin_progress.md @@ -4,9 +4,9 @@ ## 1) 总体结论 -- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录。 +- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)。 - **部分落地**:租户详情(缺财务/报表聚合)、内容治理(缺评论/举报)、创作者治理(缺提现审核联动与结算账户审批流)、财务(缺钱包流水/异常排查)、报表(缺提现/内容深度指标与钻取)。 -- **未落地**:资产治理、通知中心、审计与系统配置类能力。 +- **未落地**:审计与系统配置类能力。 ## 2) 按页面完成度(对照 2.x) @@ -71,23 +71,23 @@ - 缺口:提现维度报表、内容深度指标与多维钻取能力。 ### 2.13 资产与上传 `/superadmin/assets` -- 状态:**未完成** -- 现状:页面为占位提示。 -- 缺口:资产列表、用量统计、跨租户查询与清理接口。 +- 状态:**已完成** +- 已有:资产列表、用量统计、跨租户查询与清理操作。 +- 缺口:无显著功能缺口。 ### 2.14 通知与消息 `/superadmin/notifications` -- 状态:**未完成** -- 现状:页面为占位提示。 -- 缺口:批量发送、模板管理、通知中心聚合与审计。 +- 状态:**已完成** +- 已有:通知列表、批量发送、模板管理。 +- 缺口:无显著功能缺口。 ## 3) `/super/v1` 接口覆盖度概览 -- **已具备**:Auth、Tenants(含成员审核/邀请)、Users(含钱包/通知/优惠券/实名)、Contents、Orders、Withdrawals、Reports、Coupons(列表/创建/编辑/发放/冻结/记录)、Creators(列表/申请/成员审核)、Payout Accounts(列表/删除)。 -- **缺失/待补**:资产治理、通知中心、用户互动明细(收藏/点赞/关注)、创作者提现审核、优惠券异常核查与风控。 +- **已具备**:Auth、Tenants(含成员审核/邀请)、Users(含钱包/通知/优惠券/实名)、Contents、Orders、Withdrawals、Reports、Coupons(列表/创建/编辑/发放/冻结/记录)、Creators(列表/申请/成员审核)、Payout Accounts(列表/删除)、Assets(列表/用量/删除)、Notifications(列表/群发/模板)。 +- **缺失/待补**:用户互动明细(收藏/点赞/关注)、创作者提现审核、优惠券异常核查与风控、审计与系统配置类能力。 ## 4) 建议的下一步(按优先级) -1. **资产与通知中心**:补齐资产治理与通知中心接口/页面,形成治理闭环。 -2. **用户互动明细**:补齐收藏/点赞/关注等互动明细视图与聚合能力。 -3. **优惠券异常核查**:完善发放/核销异常监测与风控处理流程。 -4. **报表深化**:补齐提现/内容维度指标与多维钻取能力。 +1. **用户互动明细**:补齐收藏/点赞/关注等互动明细视图与聚合能力。 +2. **优惠券异常核查**:完善发放/核销异常监测与风控处理流程。 +3. **报表深化**:补齐提现/内容维度指标与多维钻取能力。 +4. **审计与系统配置**:完善全量操作审计与系统级配置能力。 diff --git a/frontend/superadmin/src/service/AssetService.js b/frontend/superadmin/src/service/AssetService.js new file mode 100644 index 0000000..219d37f --- /dev/null +++ b/frontend/superadmin/src/service/AssetService.js @@ -0,0 +1,57 @@ +import { requestJson } from './apiClient'; + +function normalizeItems(items) { + if (Array.isArray(items)) return items; + if (items && typeof items === 'object') return [items]; + return []; +} + +export const AssetService = { + async listAssets({ page, limit, id, tenant_id, tenant_code, tenant_name, user_id, username, type, status, provider, object_key, created_at_from, created_at_to, size_min, size_max, sortField, sortOrder } = {}) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + id, + tenant_id, + tenant_code, + tenant_name, + user_id, + username, + type, + status, + provider, + object_key, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to), + size_min, + size_max + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/assets', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async getUsage({ tenant_id } = {}) { + const query = { tenant_id }; + return requestJson('/super/v1/assets/usage', { query }); + }, + async deleteAsset(assetID, { force } = {}) { + if (!assetID) throw new Error('assetID is required'); + return requestJson(`/super/v1/assets/${assetID}`, { method: 'DELETE', query: { force } }); + } +}; diff --git a/frontend/superadmin/src/service/NotificationService.js b/frontend/superadmin/src/service/NotificationService.js new file mode 100644 index 0000000..bf12df3 --- /dev/null +++ b/frontend/superadmin/src/service/NotificationService.js @@ -0,0 +1,102 @@ +import { requestJson } from './apiClient'; + +function normalizeItems(items) { + if (Array.isArray(items)) return items; + if (items && typeof items === 'object') return [items]; + return []; +} + +export const NotificationService = { + async listNotifications({ page, limit, id, tenant_id, tenant_code, tenant_name, user_id, username, type, is_read, keyword, created_at_from, created_at_to, sortField, sortOrder } = {}) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + id, + tenant_id, + tenant_code, + tenant_name, + user_id, + username, + type, + is_read, + keyword, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/notifications', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async broadcast({ tenant_id, user_ids, type, title, content } = {}) { + return requestJson('/super/v1/notifications/broadcast', { + method: 'POST', + body: { + tenant_id, + user_ids, + type, + title, + content + } + }); + }, + async listTemplates({ page, limit, tenant_id, keyword, type, is_active, created_at_from, created_at_to, sortField, sortOrder } = {}) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + tenant_id, + keyword, + type, + is_active, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/notifications/templates', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async createTemplate({ tenant_id, name, type, title, content, is_active } = {}) { + return requestJson('/super/v1/notifications/templates', { + method: 'POST', + body: { + tenant_id, + name, + type, + title, + content, + is_active + } + }); + } +}; diff --git a/frontend/superadmin/src/views/superadmin/Assets.vue b/frontend/superadmin/src/views/superadmin/Assets.vue index 2e84ced..5ccdd20 100644 --- a/frontend/superadmin/src/views/superadmin/Assets.vue +++ b/frontend/superadmin/src/views/superadmin/Assets.vue @@ -1,11 +1,371 @@ diff --git a/frontend/superadmin/src/views/superadmin/Notifications.vue b/frontend/superadmin/src/views/superadmin/Notifications.vue index 46af60b..be1d48d 100644 --- a/frontend/superadmin/src/views/superadmin/Notifications.vue +++ b/frontend/superadmin/src/views/superadmin/Notifications.vue @@ -1,11 +1,565 @@