diff --git a/backend/app/http/super/v1/dto/super_user.go b/backend/app/http/super/v1/dto/super_user.go index 64fe932..87c5778 100644 --- a/backend/app/http/super/v1/dto/super_user.go +++ b/backend/app/http/super/v1/dto/super_user.go @@ -151,3 +151,68 @@ type SuperUserContentActionItem struct { // Content 互动对应内容详情(含租户与作者信息)。 Content *AdminContentItem `json:"content"` } + +// SuperUserLibraryListFilter 超管用户内容消费列表过滤条件。 +type SuperUserLibraryListFilter struct { + requests.Pagination + // TenantID 内容所属租户ID,精确匹配。 + TenantID *int64 `query:"tenant_id"` + // TenantCode 租户编码,模糊匹配。 + TenantCode *string `query:"tenant_code"` + // TenantName 租户名称,模糊匹配。 + TenantName *string `query:"tenant_name"` + // ContentID 内容ID,精确匹配。 + ContentID *int64 `query:"content_id"` + // Keyword 内容标题/摘要/描述关键字,模糊匹配。 + Keyword *string `query:"keyword"` + // Status 内容访问状态过滤(active/revoked/expired)。 + Status *consts.ContentAccessStatus `query:"status"` + // OrderID 订单ID,精确匹配。 + OrderID *int64 `query:"order_id"` + // OrderStatus 订单状态过滤。 + OrderStatus *consts.OrderStatus `query:"order_status"` + // PaidAtFrom 支付时间起始(RFC3339)。 + PaidAtFrom *string `query:"paid_at_from"` + // PaidAtTo 支付时间结束(RFC3339)。 + PaidAtTo *string `query:"paid_at_to"` + // AccessedAtFrom 获取访问权限时间起始(RFC3339)。 + AccessedAtFrom *string `query:"accessed_at_from"` + // AccessedAtTo 获取访问权限时间结束(RFC3339)。 + AccessedAtTo *string `query:"accessed_at_to"` + // Asc 升序字段(id/created_at)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/created_at)。 + Desc *string `query:"desc"` +} + +// SuperUserLibraryItem 超管用户内容消费条目。 +type SuperUserLibraryItem struct { + // AccessID 访问记录ID。 + AccessID int64 `json:"access_id"` + // TenantID 内容所属租户ID。 + TenantID int64 `json:"tenant_id"` + // ContentID 内容ID。 + ContentID int64 `json:"content_id"` + // OrderID 订单ID。 + OrderID int64 `json:"order_id"` + // OrderType 订单类型。 + OrderType consts.OrderType `json:"order_type"` + // OrderStatus 订单状态。 + OrderStatus consts.OrderStatus `json:"order_status"` + // OrderStatusDescription 订单状态描述(用于展示)。 + OrderStatusDescription string `json:"order_status_description"` + // AmountPaid 该内容实付金额(分)。 + AmountPaid int64 `json:"amount_paid"` + // PaidAt 支付时间(RFC3339)。 + PaidAt string `json:"paid_at"` + // AccessStatus 访问状态。 + AccessStatus consts.ContentAccessStatus `json:"access_status"` + // AccessStatusDescription 访问状态描述(用于展示)。 + AccessStatusDescription string `json:"access_status_description"` + // AccessedAt 获取访问权限时间(RFC3339)。 + AccessedAt string `json:"accessed_at"` + // Content 内容详情(含租户/作者/价格)。 + Content *AdminContentItem `json:"content"` + // Snapshot 下单快照(内容标题/金额等)。 + Snapshot any `json:"snapshot"` +} diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index 30a9f1b..bb10748 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -328,6 +328,12 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("id"), Query[dto.SuperUserTenantListFilter]("filter"), )) + r.log.Debugf("Registering route: Get /super/v1/users/:id/library -> users.ListLibrary") + router.Get("/super/v1/users/:id/library"[len(r.Path()):], DataFunc2( + r.users.ListLibrary, + PathParam[int64]("id"), + Query[dto.SuperUserLibraryListFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/users/:id/likes -> users.ListLikes") router.Get("/super/v1/users/:id/likes"[len(r.Path()):], DataFunc2( r.users.ListLikes, diff --git a/backend/app/http/super/v1/users.go b/backend/app/http/super/v1/users.go index e915d1d..b1da93c 100644 --- a/backend/app/http/super/v1/users.go +++ b/backend/app/http/super/v1/users.go @@ -127,6 +127,24 @@ func (c *users) ListTenants(ctx fiber.Ctx, id int64, filter *dto.SuperUserTenant return services.Super.ListUserTenants(ctx, id, filter) } +// List user library +// +// @Router /super/v1/users/:id/library [get] +// @Summary List user library +// @Description List purchased contents of a user +// @Tags User +// @Accept json +// @Produce json +// @Param id path int64 true "User ID" +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperUserLibraryItem} +// @Bind id path +// @Bind filter query +func (c *users) ListLibrary(ctx fiber.Ctx, id int64, filter *dto.SuperUserLibraryListFilter) (*requests.Pager, error) { + return services.Super.ListUserLibrary(ctx, id, filter) +} + // List user favorites // // @Router /super/v1/users/:id/favorites [get] diff --git a/backend/app/services/super.go b/backend/app/services/super.go index e8f223e..78dce4d 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -1704,6 +1704,247 @@ func (s *super) ListUserTenants(ctx context.Context, userID int64, filter *super }, nil } +func (s *super) ListUserLibrary(ctx context.Context, userID int64, filter *super_dto.SuperUserLibraryListFilter) (*requests.Pager, error) { + if userID == 0 { + return nil, errorx.ErrBadRequest.WithMsg("用户ID不能为空") + } + if filter == nil { + filter = &super_dto.SuperUserLibraryListFilter{} + } + + tbl, q := models.ContentAccessQuery.QueryContext(ctx) + q = q.Where(tbl.UserID.Eq(userID)) + + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(*filter.Status)) + } + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + if filter.OrderID != nil && *filter.OrderID > 0 { + q = q.Where(tbl.OrderID.Eq(*filter.OrderID)) + } + + contentIDs, contentFilter, err := s.filterContentIDsForUserLibrary(ctx, filter) + if err != nil { + return nil, err + } + if contentFilter { + if len(contentIDs) == 0 { + filter.Pagination.Format() + return &requests.Pager{ + Pagination: filter.Pagination, + Total: 0, + Items: []super_dto.SuperUserLibraryItem{}, + }, nil + } + q = q.Where(tbl.ContentID.In(contentIDs...)) + } + + orderIDs, orderFilter, err := s.filterOrderIDsForUserLibrary(ctx, userID, filter) + if err != nil { + return nil, err + } + if orderFilter { + if len(orderIDs) == 0 { + filter.Pagination.Format() + return &requests.Pager{ + Pagination: filter.Pagination, + Total: 0, + Items: []super_dto.SuperUserLibraryItem{}, + }, nil + } + q = q.Where(tbl.OrderID.In(orderIDs...)) + } + + if filter.AccessedAtFrom != nil { + from, err := s.parseFilterTime(filter.AccessedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.AccessedAtTo != nil { + to, err := s.parseFilterTime(filter.AccessedAtTo) + 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.SuperUserLibraryItem{}, + }, nil + } + + contentIDSet := make(map[int64]struct{}, len(list)) + contentIDs = make([]int64, 0, len(list)) + orderIDSet := make(map[int64]struct{}, len(list)) + orderIDs = make([]int64, 0, len(list)) + for _, access := range list { + if access.ContentID > 0 { + if _, ok := contentIDSet[access.ContentID]; !ok { + contentIDSet[access.ContentID] = struct{}{} + contentIDs = append(contentIDs, access.ContentID) + } + } + if access.OrderID > 0 { + if _, ok := orderIDSet[access.OrderID]; !ok { + orderIDSet[access.OrderID] = struct{}{} + orderIDs = append(orderIDs, access.OrderID) + } + } + } + + var contents []*models.Content + if len(contentIDs) > 0 { + contentTbl, contentQuery := models.ContentQuery.QueryContext(ctx) + err := contentQuery.Where(contentTbl.ID.In(contentIDs...)). + UnderlyingDB(). + Preload("Author"). + Preload("ContentAssets.Asset"). + Find(&contents).Error + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + } + + priceMap, err := s.contentPriceMap(ctx, contents) + if err != nil { + return nil, err + } + tenantMap, err := s.contentTenantMap(ctx, contents) + if err != nil { + return nil, err + } + + contentMap := make(map[int64]*models.Content, len(contents)) + for _, content := range contents { + contentMap[content.ID] = content + } + + orderMap := make(map[int64]*models.Order, len(orderIDs)) + if len(orderIDs) > 0 { + orderTbl, orderQuery := models.OrderQuery.QueryContext(ctx) + orders, err := orderQuery.Where(orderTbl.ID.In(orderIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, order := range orders { + orderMap[order.ID] = order + } + } + + type orderItemKey struct { + orderID int64 + contentID int64 + } + orderItemMap := make(map[orderItemKey]*models.OrderItem, len(list)) + if len(orderIDs) > 0 { + itemTbl, itemQuery := models.OrderItemQuery.QueryContext(ctx) + itemQuery = itemQuery.Where(itemTbl.OrderID.In(orderIDs...), itemTbl.UserID.Eq(userID)) + if len(contentIDs) > 0 { + itemQuery = itemQuery.Where(itemTbl.ContentID.In(contentIDs...)) + } + items, err := itemQuery.Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, item := range items { + orderItemMap[orderItemKey{orderID: item.OrderID, contentID: item.ContentID}] = item + } + } + + items := make([]super_dto.SuperUserLibraryItem, 0, len(list)) + for _, access := range list { + var contentItem *super_dto.AdminContentItem + if content := contentMap[access.ContentID]; content != nil { + item := s.toSuperContentItem(content, priceMap[content.ID], tenantMap[content.TenantID]) + contentItem = &item + } + + order := orderMap[access.OrderID] + orderStatusDesc := "" + orderType := consts.OrderType("") + orderStatus := consts.OrderStatus("") + paidAt := "" + if order != nil { + orderType = order.Type + orderStatus = order.Status + orderStatusDesc = order.Status.Description() + paidAt = s.formatTime(order.PaidAt) + } + + amountPaid := int64(0) + var snapshot any + if orderItem := orderItemMap[orderItemKey{orderID: access.OrderID, contentID: access.ContentID}]; orderItem != nil { + amountPaid = orderItem.AmountPaid + snapshot = orderItem.Snapshot.Data() + } + + items = append(items, super_dto.SuperUserLibraryItem{ + AccessID: access.ID, + TenantID: access.TenantID, + ContentID: access.ContentID, + OrderID: access.OrderID, + OrderType: orderType, + OrderStatus: orderStatus, + OrderStatusDescription: orderStatusDesc, + AmountPaid: amountPaid, + PaidAt: paidAt, + AccessStatus: access.Status, + AccessStatusDescription: access.Status.Description(), + AccessedAt: s.formatTime(access.CreatedAt), + Content: contentItem, + Snapshot: snapshot, + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + func (s *super) ListUserFavorites(ctx context.Context, userID int64, filter *super_dto.SuperUserContentActionListFilter) (*requests.Pager, error) { return s.listUserContentActions(ctx, userID, consts.UserContentActionTypeFavorite, filter) } @@ -3667,6 +3908,117 @@ func (s *super) filterContentIDsForUserActions( return ids, true, nil } +func (s *super) filterContentIDsForUserLibrary( + ctx context.Context, + filter *super_dto.SuperUserLibraryListFilter, +) ([]int64, bool, error) { + if filter == nil { + return nil, false, nil + } + + tbl, q := models.ContentQuery.QueryContext(ctx) + filterApplied := false + + if filter.ContentID != nil && *filter.ContentID > 0 { + q = q.Where(tbl.ID.Eq(*filter.ContentID)) + filterApplied = true + } + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + filterApplied = true + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, false, err + } + if tenantFilter { + filterApplied = true + if len(tenantIDs) == 0 { + return []int64{}, true, nil + } + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + + if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" { + keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%" + q = q.Where(field.Or( + tbl.Title.Like(keyword), + tbl.Description.Like(keyword), + tbl.Summary.Like(keyword), + )) + filterApplied = true + } + + if !filterApplied { + return nil, false, nil + } + + ids, err := q.PluckIDs() + if err != nil { + return nil, true, errorx.ErrDatabaseError.WithCause(err) + } + return ids, true, nil +} + +func (s *super) filterOrderIDsForUserLibrary( + ctx context.Context, + userID int64, + filter *super_dto.SuperUserLibraryListFilter, +) ([]int64, bool, error) { + if filter == nil { + return nil, false, nil + } + + tbl, q := models.OrderQuery.QueryContext(ctx) + q = q.Where(tbl.UserID.Eq(userID)) + filterApplied := false + + if filter.OrderID != nil && *filter.OrderID > 0 { + q = q.Where(tbl.ID.Eq(*filter.OrderID)) + filterApplied = true + } + if filter.OrderStatus != nil && *filter.OrderStatus != "" { + q = q.Where(tbl.Status.Eq(*filter.OrderStatus)) + filterApplied = true + } + if filter.PaidAtFrom != nil { + from, err := s.parseFilterTime(filter.PaidAtFrom) + if err != nil { + return nil, false, err + } + if from != nil { + q = q.Where(tbl.PaidAt.Gte(*from)) + filterApplied = true + } + } + if filter.PaidAtTo != nil { + to, err := s.parseFilterTime(filter.PaidAtTo) + if err != nil { + return nil, false, err + } + if to != nil { + q = q.Where(tbl.PaidAt.Lte(*to)) + filterApplied = true + } + } + + if !filterApplied { + return nil, false, nil + } + + orders, err := q.Select(tbl.ID).Find() + if err != nil { + return nil, true, errorx.ErrDatabaseError.WithCause(err) + } + + ids := make([]int64, 0, len(orders)) + for _, order := range orders { + ids = append(ids, order.ID) + } + return ids, true, nil +} + func (s *super) lookupOrderIDsByContent(ctx context.Context, contentID *int64, contentTitle *string) ([]int64, bool, error) { var id int64 if contentID != nil { diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 051ecba..dcea878 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -2179,6 +2179,66 @@ const docTemplate = `{ } } }, + "/super/v1/users/{id}/library": { + "get": { + "description": "List purchased contents of a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "List user library", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "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.SuperUserLibraryItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/users/{id}/likes": { "get": { "description": "List user's liked contents", @@ -5376,6 +5436,19 @@ const docTemplate = `{ } }, "definitions": { + "consts.ContentAccessStatus": { + "type": "string", + "enum": [ + "active", + "revoked", + "expired" + ], + "x-enum-varnames": [ + "ContentAccessStatusActive", + "ContentAccessStatusRevoked", + "ContentAccessStatusExpired" + ] + }, "consts.ContentStatus": { "type": "string", "enum": [ @@ -7914,6 +7987,82 @@ const docTemplate = `{ } } }, + "dto.SuperUserLibraryItem": { + "type": "object", + "properties": { + "access_id": { + "description": "AccessID 访问记录ID。", + "type": "integer" + }, + "access_status": { + "description": "AccessStatus 访问状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentAccessStatus" + } + ] + }, + "access_status_description": { + "description": "AccessStatusDescription 访问状态描述(用于展示)。", + "type": "string" + }, + "accessed_at": { + "description": "AccessedAt 获取访问权限时间(RFC3339)。", + "type": "string" + }, + "amount_paid": { + "description": "AmountPaid 该内容实付金额(分)。", + "type": "integer" + }, + "content": { + "description": "Content 内容详情(含租户/作者/价格)。", + "allOf": [ + { + "$ref": "#/definitions/dto.AdminContentItem" + } + ] + }, + "content_id": { + "description": "ContentID 内容ID。", + "type": "integer" + }, + "order_id": { + "description": "OrderID 订单ID。", + "type": "integer" + }, + "order_status": { + "description": "OrderStatus 订单状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderStatus" + } + ] + }, + "order_status_description": { + "description": "OrderStatusDescription 订单状态描述(用于展示)。", + "type": "string" + }, + "order_type": { + "description": "OrderType 订单类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderType" + } + ] + }, + "paid_at": { + "description": "PaidAt 支付时间(RFC3339)。", + "type": "string" + }, + "snapshot": { + "description": "Snapshot 下单快照(内容标题/金额等)。" + }, + "tenant_id": { + "description": "TenantID 内容所属租户ID。", + "type": "integer" + } + } + }, "dto.SuperUserLite": { "type": "object", "properties": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 66fced1..6fecc8c 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -2173,6 +2173,66 @@ } } }, + "/super/v1/users/{id}/library": { + "get": { + "description": "List purchased contents of a user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "List user library", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "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.SuperUserLibraryItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/users/{id}/likes": { "get": { "description": "List user's liked contents", @@ -5370,6 +5430,19 @@ } }, "definitions": { + "consts.ContentAccessStatus": { + "type": "string", + "enum": [ + "active", + "revoked", + "expired" + ], + "x-enum-varnames": [ + "ContentAccessStatusActive", + "ContentAccessStatusRevoked", + "ContentAccessStatusExpired" + ] + }, "consts.ContentStatus": { "type": "string", "enum": [ @@ -7908,6 +7981,82 @@ } } }, + "dto.SuperUserLibraryItem": { + "type": "object", + "properties": { + "access_id": { + "description": "AccessID 访问记录ID。", + "type": "integer" + }, + "access_status": { + "description": "AccessStatus 访问状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.ContentAccessStatus" + } + ] + }, + "access_status_description": { + "description": "AccessStatusDescription 访问状态描述(用于展示)。", + "type": "string" + }, + "accessed_at": { + "description": "AccessedAt 获取访问权限时间(RFC3339)。", + "type": "string" + }, + "amount_paid": { + "description": "AmountPaid 该内容实付金额(分)。", + "type": "integer" + }, + "content": { + "description": "Content 内容详情(含租户/作者/价格)。", + "allOf": [ + { + "$ref": "#/definitions/dto.AdminContentItem" + } + ] + }, + "content_id": { + "description": "ContentID 内容ID。", + "type": "integer" + }, + "order_id": { + "description": "OrderID 订单ID。", + "type": "integer" + }, + "order_status": { + "description": "OrderStatus 订单状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderStatus" + } + ] + }, + "order_status_description": { + "description": "OrderStatusDescription 订单状态描述(用于展示)。", + "type": "string" + }, + "order_type": { + "description": "OrderType 订单类型。", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderType" + } + ] + }, + "paid_at": { + "description": "PaidAt 支付时间(RFC3339)。", + "type": "string" + }, + "snapshot": { + "description": "Snapshot 下单快照(内容标题/金额等)。" + }, + "tenant_id": { + "description": "TenantID 内容所属租户ID。", + "type": "integer" + } + } + }, "dto.SuperUserLite": { "type": "object", "properties": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 158c069..a60f4ce 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,5 +1,15 @@ basePath: / definitions: + consts.ContentAccessStatus: + enum: + - active + - revoked + - expired + type: string + x-enum-varnames: + - ContentAccessStatusActive + - ContentAccessStatusRevoked + - ContentAccessStatusExpired consts.ContentStatus: enum: - draft @@ -1778,6 +1788,54 @@ definitions: description: Value 券面值/折扣值。 type: integer type: object + dto.SuperUserLibraryItem: + properties: + access_id: + description: AccessID 访问记录ID。 + type: integer + access_status: + allOf: + - $ref: '#/definitions/consts.ContentAccessStatus' + description: AccessStatus 访问状态。 + access_status_description: + description: AccessStatusDescription 访问状态描述(用于展示)。 + type: string + accessed_at: + description: AccessedAt 获取访问权限时间(RFC3339)。 + type: string + amount_paid: + description: AmountPaid 该内容实付金额(分)。 + type: integer + content: + allOf: + - $ref: '#/definitions/dto.AdminContentItem' + description: Content 内容详情(含租户/作者/价格)。 + content_id: + description: ContentID 内容ID。 + type: integer + order_id: + description: OrderID 订单ID。 + type: integer + order_status: + allOf: + - $ref: '#/definitions/consts.OrderStatus' + description: OrderStatus 订单状态。 + order_status_description: + description: OrderStatusDescription 订单状态描述(用于展示)。 + type: string + order_type: + allOf: + - $ref: '#/definitions/consts.OrderType' + description: OrderType 订单类型。 + paid_at: + description: PaidAt 支付时间(RFC3339)。 + type: string + snapshot: + description: Snapshot 下单快照(内容标题/金额等)。 + tenant_id: + description: TenantID 内容所属租户ID。 + type: integer + type: object dto.SuperUserLite: properties: created_at: @@ -4015,6 +4073,43 @@ paths: summary: List user following tenants tags: - User + /super/v1/users/{id}/library: + get: + consumes: + - application/json + description: List purchased contents of a user + parameters: + - description: User ID + format: int64 + in: path + name: id + required: true + type: integer + - 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.SuperUserLibraryItem' + type: array + type: object + summary: List user library + tags: + - User /super/v1/users/{id}/likes: get: consumes: diff --git a/docs/superadmin_progress.md b/docs/superadmin_progress.md index 383faa1..77189da 100644 --- a/docs/superadmin_progress.md +++ b/docs/superadmin_progress.md @@ -4,7 +4,7 @@ ## 1) 总体结论 -- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录与互动(收藏/点赞/关注)视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)。 +- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录、互动(收藏/点赞/关注)与内容消费明细视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)。 - **部分落地**:租户详情(缺财务/报表聚合)、内容治理(缺评论/举报)、创作者治理(缺提现审核联动与结算账户审批流)、财务(缺钱包流水/异常排查)、报表(缺提现/内容深度指标与钻取)。 - **未落地**:审计与系统配置类能力。 @@ -36,9 +36,9 @@ - 缺口:无显著功能缺口。 ### 2.6 用户详情 `/superadmin/users/:userID` -- 状态:**部分完成** -- 已有:用户资料、租户关系、订单查询、钱包余额与流水、充值记录、通知、优惠券、实名认证详情。 -- 缺口:内容消费明细等用户互动深度视图。 +- 状态:**已完成** +- 已有:用户资料、租户关系、订单查询、钱包余额与流水、充值记录、通知、优惠券、实名认证详情、互动与内容消费明细。 +- 缺口:无显著功能缺口。 ### 2.7 内容治理 `/superadmin/contents` - 状态:**部分完成** @@ -82,12 +82,11 @@ ## 3) `/super/v1` 接口覆盖度概览 -- **已具备**:Auth、Tenants(含成员审核/邀请)、Users(含钱包/通知/优惠券/实名)、Contents、Orders、Withdrawals、Reports、Coupons(列表/创建/编辑/发放/冻结/记录)、Creators(列表/申请/成员审核)、Payout Accounts(列表/删除)、Assets(列表/用量/删除)、Notifications(列表/群发/模板)。 -- **缺失/待补**:内容消费明细、创作者提现审核、优惠券异常核查与风控、审计与系统配置类能力。 +- **已具备**:Auth、Tenants(含成员审核/邀请)、Users(含钱包/通知/优惠券/实名/互动/内容消费)、Contents、Orders、Withdrawals、Reports、Coupons(列表/创建/编辑/发放/冻结/记录)、Creators(列表/申请/成员审核)、Payout Accounts(列表/删除)、Assets(列表/用量/删除)、Notifications(列表/群发/模板)。 +- **缺失/待补**:创作者提现审核、优惠券异常核查与风控、审计与系统配置类能力。 ## 4) 建议的下一步(按优先级) -1. **内容消费明细**:补齐用户内容消费/访问的明细与聚合能力。 -2. **优惠券异常核查**:完善发放/核销异常监测与风控处理流程。 -3. **报表深化**:补齐提现/内容维度指标与多维钻取能力。 -4. **审计与系统配置**:完善全量操作审计与系统级配置能力。 +1. **优惠券异常核查**:完善发放/核销异常监测与风控处理流程。 +2. **报表深化**:补齐提现/内容维度指标与多维钻取能力。 +3. **审计与系统配置**:完善全量操作审计与系统级配置能力。 diff --git a/frontend/superadmin/src/service/UserService.js b/frontend/superadmin/src/service/UserService.js index 014ce5b..afd86a1 100644 --- a/frontend/superadmin/src/service/UserService.js +++ b/frontend/superadmin/src/service/UserService.js @@ -186,6 +186,45 @@ export const UserService = { items: normalizeItems(data?.items) }; }, + async listUserLibrary(userID, { page, limit, tenant_id, tenant_code, tenant_name, content_id, keyword, status, order_id, order_status, paid_at_from, paid_at_to, accessed_at_from, accessed_at_to, sortField, sortOrder } = {}) { + if (!userID) throw new Error('userID is required'); + + 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, + tenant_code, + tenant_name, + content_id, + keyword, + status, + order_id, + order_status, + paid_at_from: iso(paid_at_from), + paid_at_to: iso(paid_at_to), + accessed_at_from: iso(accessed_at_from), + accessed_at_to: iso(accessed_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson(`/super/v1/users/${userID}/library`, { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, async listUserFollowing(userID, { page, limit, tenant_id, code, name, status, created_at_from, created_at_to, sortField, sortOrder } = {}) { if (!userID) throw new Error('userID is required'); diff --git a/frontend/superadmin/src/views/superadmin/UserDetail.vue b/frontend/superadmin/src/views/superadmin/UserDetail.vue index 0232771..79ac33d 100644 --- a/frontend/superadmin/src/views/superadmin/UserDetail.vue +++ b/frontend/superadmin/src/views/superadmin/UserDetail.vue @@ -52,6 +52,12 @@ const followingTenantsTotal = ref(0); const followingTenantsPage = ref(1); const followingTenantsRows = ref(10); +const libraryItems = ref([]); +const libraryLoading = ref(false); +const libraryTotal = ref(0); +const libraryPage = ref(1); +const libraryRows = ref(10); + const rechargeOrders = ref([]); const rechargeOrdersLoading = ref(false); const rechargeOrdersTotal = ref(0); @@ -60,6 +66,23 @@ const rechargeOrdersRows = ref(10); const tabValue = ref('owned'); +const accessStatusOptions = [ + { label: '全部', value: '' }, + { label: 'active', value: 'active' }, + { label: 'revoked', value: 'revoked' }, + { label: 'expired', value: 'expired' } +]; + +const orderStatusOptions = [ + { label: '全部', value: '' }, + { label: 'created', value: 'created' }, + { label: 'paid', value: 'paid' }, + { label: 'refunding', value: 'refunding' }, + { label: 'refunded', value: 'refunded' }, + { label: 'canceled', value: 'canceled' }, + { label: 'failed', value: 'failed' } +]; + function formatDate(value) { if (!value) return '-'; if (String(value).startsWith('0001-01-01')) return '-'; @@ -124,6 +147,19 @@ function getOrderStatusSeverity(value) { } } +function getAccessStatusSeverity(value) { + switch (value) { + case 'active': + return 'success'; + case 'revoked': + return 'danger'; + case 'expired': + return 'warn'; + default: + return 'secondary'; + } +} + function getNotificationReadSeverity(value) { return value ? 'secondary' : 'warn'; } @@ -275,6 +311,36 @@ async function loadLikes() { } } +async function loadLibrary() { + const id = userID.value; + if (!id || Number.isNaN(id)) return; + libraryLoading.value = true; + try { + const result = await UserService.listUserLibrary(id, { + page: libraryPage.value, + limit: libraryRows.value, + tenant_id: libraryTenantID.value || undefined, + tenant_code: libraryTenantCode.value || undefined, + tenant_name: libraryTenantName.value || undefined, + content_id: libraryContentID.value || undefined, + keyword: libraryKeyword.value || undefined, + status: libraryStatus.value || undefined, + order_id: libraryOrderID.value || undefined, + order_status: libraryOrderStatus.value || undefined, + paid_at_from: libraryPaidAtFrom.value || undefined, + paid_at_to: libraryPaidAtTo.value || undefined, + accessed_at_from: libraryAccessedAtFrom.value || undefined, + accessed_at_to: libraryAccessedAtTo.value || undefined + }); + libraryItems.value = result.items; + libraryTotal.value = result.total; + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载内容消费明细', life: 4000 }); + } finally { + libraryLoading.value = false; + } +} + async function loadFollowingTenants() { const id = userID.value; if (!id || Number.isNaN(id)) return; @@ -453,6 +519,19 @@ const likesKeyword = ref(''); const likesCreatedAtFrom = ref(null); const likesCreatedAtTo = ref(null); +const libraryTenantID = ref(null); +const libraryTenantCode = ref(''); +const libraryTenantName = ref(''); +const libraryContentID = ref(null); +const libraryKeyword = ref(''); +const libraryStatus = ref(''); +const libraryOrderID = ref(null); +const libraryOrderStatus = ref(''); +const libraryPaidAtFrom = ref(null); +const libraryPaidAtTo = ref(null); +const libraryAccessedAtFrom = ref(null); +const libraryAccessedAtTo = ref(null); + const followingTenantID = ref(null); const followingCode = ref(''); const followingName = ref(''); @@ -558,6 +637,35 @@ function onLikesPage(event) { loadLikes(); } +function onLibrarySearch() { + libraryPage.value = 1; + loadLibrary(); +} + +function onLibraryReset() { + libraryTenantID.value = null; + libraryTenantCode.value = ''; + libraryTenantName.value = ''; + libraryContentID.value = null; + libraryKeyword.value = ''; + libraryStatus.value = ''; + libraryOrderID.value = null; + libraryOrderStatus.value = ''; + libraryPaidAtFrom.value = null; + libraryPaidAtTo.value = null; + libraryAccessedAtFrom.value = null; + libraryAccessedAtTo.value = null; + libraryPage.value = 1; + libraryRows.value = 10; + loadLibrary(); +} + +function onLibraryPage(event) { + libraryPage.value = (event.page ?? 0) + 1; + libraryRows.value = event.rows ?? libraryRows.value; + loadLibrary(); +} + function onFollowingSearch() { followingTenantsPage.value = 1; loadFollowingTenants(); @@ -609,6 +717,7 @@ watch( couponsPage.value = 1; favoritesPage.value = 1; likesPage.value = 1; + libraryPage.value = 1; rechargeOrdersPage.value = 1; loadUser(); loadWallet(); @@ -620,6 +729,7 @@ watch( loadCoupons(); loadFavorites(); loadLikes(); + loadLibrary(); loadRechargeOrders(); }, { immediate: true } @@ -691,6 +801,7 @@ onMounted(() => { 关注 收藏 点赞 + 内容消费 钱包 充值记录 优惠券 @@ -1100,6 +1211,117 @@ onMounted(() => { + +
+
+ 共 {{ libraryTotal }} 条 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+