feat(media-supply): persist favorites with multi-group membership

Move media favorites from client state to PostgreSQL with server-side
search and pagination.

- Store favorites keyed by (tenant, user, group, resource) so a resource
  can belong to multiple groups; the 500-resource cap counts distinct
  resources, not memberships.
- Add favorite-group CRUD and group/global removal routes, with silent
  cleanup of delisted or invisible resources on read.
- Serve favorite resource details server-side, 10 per page, with name/ID
  search; favorites-page removal is group-scoped, resources-page removal
  is global.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 20:10:50 +08:00
parent 0bf4b73ebc
commit 67a6cbcd9e
15 changed files with 2378 additions and 518 deletions
@@ -89,6 +89,105 @@ func (h *MediaSupplyHandler) ListResourcesByIDs(c *gin.Context) {
response.Success(c, data)
}
func (h *MediaSupplyHandler) ListFavoriteGroups(c *gin.Context) {
data, err := h.svc.ListFavoriteGroups(c.Request.Context())
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) ListFavoriteResources(c *gin.Context) {
page, err := parseOptionalIntQuery(c, "page")
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_page", "分页参数必须是数字"))
return
}
data, err := h.svc.ListFavoriteResources(
c.Request.Context(),
app.ListMediaSupplyFavoriteResourcesRequest{
GroupID: c.Param("group_id"),
Keyword: c.Query("keyword"),
Page: page,
},
)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) CreateFavoriteGroup(c *gin.Context) {
var req app.CreateMediaSupplyFavoriteGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "常发媒体分组参数不正确"))
return
}
data, err := h.svc.CreateFavoriteGroup(c.Request.Context(), req)
if err != nil {
response.Error(c, err)
return
}
response.SuccessWithStatus(c, 201, data)
}
func (h *MediaSupplyHandler) DeleteFavoriteGroup(c *gin.Context) {
data, err := h.svc.DeleteFavoriteGroup(c.Request.Context(), c.Param("group_id"))
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) PutFavoriteResource(c *gin.Context) {
resourceID, err := strconv.ParseInt(c.Param("resource_id"), 10, 64)
if err != nil || resourceID <= 0 {
response.Error(c, response.ErrBadRequest(40070, "invalid_media_supply_favorite_resource", "常发媒体资源参数不正确"))
return
}
data, err := h.svc.PutFavoriteResource(c.Request.Context(), c.Param("group_id"), resourceID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) DeleteFavoriteResourceFromGroup(c *gin.Context) {
resourceID, err := strconv.ParseInt(c.Param("resource_id"), 10, 64)
if err != nil || resourceID <= 0 {
response.Error(c, response.ErrBadRequest(40070, "invalid_media_supply_favorite_resource", "常发媒体资源参数不正确"))
return
}
data, err := h.svc.DeleteFavoriteResourceFromGroup(
c.Request.Context(),
c.Param("group_id"),
resourceID,
)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) DeleteFavoriteResource(c *gin.Context) {
resourceID, err := strconv.ParseInt(c.Param("resource_id"), 10, 64)
if err != nil || resourceID <= 0 {
response.Error(c, response.ErrBadRequest(40070, "invalid_media_supply_favorite_resource", "常发媒体资源参数不正确"))
return
}
data, err := h.svc.DeleteFavoriteResource(c.Request.Context(), resourceID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *MediaSupplyHandler) QueueSync(c *gin.Context) {
response.Error(c, response.ErrNotFound(40400, "not_found", "接口不存在"))
}
@@ -127,6 +127,13 @@ func RegisterRoutes(app *bootstrap.App) {
mediaSupply.GET("/resources", mediaSupplyHandler.ListResources)
mediaSupply.GET("/resources/by-ids", mediaSupplyHandler.ListResourcesByIDs)
mediaSupply.GET("/resources/search-options", mediaSupplyHandler.SearchOptions)
mediaSupply.GET("/favorite-groups", mediaSupplyHandler.ListFavoriteGroups)
mediaSupply.POST("/favorite-groups", mediaSupplyHandler.CreateFavoriteGroup)
mediaSupply.DELETE("/favorite-groups/:group_id", mediaSupplyHandler.DeleteFavoriteGroup)
mediaSupply.GET("/favorite-groups/:group_id/resources", mediaSupplyHandler.ListFavoriteResources)
mediaSupply.PUT("/favorite-groups/:group_id/resources/:resource_id", mediaSupplyHandler.PutFavoriteResource)
mediaSupply.DELETE("/favorite-groups/:group_id/resources/:resource_id", mediaSupplyHandler.DeleteFavoriteResourceFromGroup)
mediaSupply.DELETE("/favorite-resources/:resource_id", mediaSupplyHandler.DeleteFavoriteResource)
mediaSupply.POST("/sync-jobs", mediaSupplyHandler.QueueSync)
mediaSupply.PUT("/resources/:id/price", mediaSupplyHandler.SetResourcePrice)
mediaSupply.POST("/orders", middleware.RequireCurrentBrand(), mediaSupplyHandler.CreateOrder)