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
+91 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
@@ -326,24 +327,107 @@ func (s *MediaSupplyService) SetResourcePrice(ctx context.Context, actor *Actor,
}
func (s *MediaSupplyService) SetResourceVisibility(ctx context.Context, actor *Actor, resourceID int64, input SetMediaSupplyVisibilityInput) error {
tag, err := s.pool.Exec(ctx, `
UPDATE supplier_media_resources
SET customer_visible = $3, updated_at = NOW()
WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL
`, resourceID, opsMediaSupplySupplierMeijiequan, input.CustomerVisible)
tx, err := s.pool.Begin(ctx)
if err != nil {
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
if tag.RowsAffected() == 0 {
defer tx.Rollback(ctx)
if !input.CustomerVisible {
ownerRows, err := tx.Query(ctx, `
SELECT favorite_owner.tenant_id, favorite_owner.user_id
FROM media_supply_favorite_owners favorite_owner
WHERE EXISTS (
SELECT 1
FROM media_supply_favorite_resources favorite
WHERE favorite.tenant_id = favorite_owner.tenant_id
AND favorite.user_id = favorite_owner.user_id
AND favorite.resource_id = $1
)
ORDER BY favorite_owner.tenant_id, favorite_owner.user_id
FOR UPDATE OF favorite_owner
`, resourceID)
if err != nil {
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
for ownerRows.Next() {
var tenantID, userID int64
if err := ownerRows.Scan(&tenantID, &userID); err != nil {
ownerRows.Close()
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
}
if err := ownerRows.Err(); err != nil {
ownerRows.Close()
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
ownerRows.Close()
}
var lockedResourceID int64
if err := tx.QueryRow(ctx, `
SELECT id
FROM supplier_media_resources
WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL
FOR UPDATE
`, resourceID, opsMediaSupplySupplierMeijiequan).Scan(&lockedResourceID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架")
}
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
var updated, removedFavorites int
err = tx.QueryRow(ctx, `
WITH updated_resource AS (
UPDATE supplier_media_resources
SET customer_visible = $3, updated_at = NOW()
WHERE id = $1 AND supplier = $2 AND deleted_at IS NULL
RETURNING id
), deleted_favorites AS (
DELETE FROM media_supply_favorite_resources favorite
USING updated_resource resource
WHERE $3 = FALSE AND favorite.resource_id = resource.id
RETURNING favorite.tenant_id, favorite.user_id, favorite.group_key
), touched_groups AS (
UPDATE media_supply_favorite_groups favorite_group
SET updated_at = NOW()
WHERE EXISTS (
SELECT 1 FROM deleted_favorites deleted
WHERE deleted.tenant_id = favorite_group.tenant_id
AND deleted.user_id = favorite_group.user_id
AND deleted.group_key = favorite_group.group_key
)
RETURNING favorite_group.tenant_id, favorite_group.user_id
), touched_owners AS (
UPDATE media_supply_favorite_owners favorite_owner
SET revision = revision + 1, updated_at = NOW()
WHERE EXISTS (
SELECT 1 FROM deleted_favorites deleted
WHERE deleted.tenant_id = favorite_owner.tenant_id
AND deleted.user_id = favorite_owner.user_id
)
RETURNING favorite_owner.tenant_id
)
SELECT (SELECT COUNT(*) FROM updated_resource),
(SELECT COUNT(*) FROM deleted_favorites)
`, resourceID, opsMediaSupplySupplierMeijiequan, input.CustomerVisible).Scan(&updated, &removedFavorites)
if err != nil {
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
if updated == 0 {
return response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架")
}
if err := tx.Commit(ctx); err != nil {
return response.ErrInternal(50087, "media_supply_visibility_update_failed", "媒体展示状态更新失败")
}
if s.audits != nil && actor != nil {
action := "media_supply.resource_show"
if !input.CustomerVisible {
action = "media_supply.resource_hide"
}
_ = s.audits.Append(ctx, actor.audit(action, "supplier_media_resource", resourceID, map[string]any{
"customer_visible": input.CustomerVisible,
"customer_visible": input.CustomerVisible,
"removed_favorite_count": removedFavorites,
}))
}
return nil
+21 -14
View File
@@ -226,20 +226,27 @@ var routeDocs = map[string]routeDoc{
"GET /api/tenant/desktop-client/releases/downloadable": {"可下载桌面客户端版本", "返回当前租户可下载的已启用桌面客户端版本列表。"},
// --- Tenant:媒介供应 ---
"GET /api/tenant/media-supply/resources": {"媒介资源列表", "查询当前 Workspace 可购买的媒介供应资源。"},
"GET /api/tenant/media-supply/resources/by-ids": {"按 ID 查询媒介资源", "按资源 ID 集合查询媒介供应资源详情。"},
"GET /api/tenant/media-supply/resources/search-options": {"媒介资源筛选项", "返回媒介供应资源列表的筛选条件与可选项。"},
"PUT /api/tenant/media-supply/resources/:id/price": {"调整媒介资源售价", "租户侧调整指定媒介资源的销售价格。"},
"POST /api/tenant/media-supply/sync-jobs": {"同步媒介资源", "创建媒介供应资源同步任务。"},
"POST /api/tenant/media-supply/orders": {"创建媒介投放订单", "为选定媒介资源创建投放订单。"},
"GET /api/tenant/media-supply/orders": {"媒介订单列表", "分页查询当前 Workspace 的媒介投放订单。"},
"GET /api/tenant/media-supply/orders/:id": {"媒介订单详情", "读取指定媒介投放订单详情。"},
"GET /api/tenant/media-supply/wallet": {"媒介钱包状态", "读取当前 Workspace 的媒介供应钱包余额与状态。"},
"GET /api/tenant/media-supply/wallet/ledgers": {"媒介钱包流水", "分页查询媒介供应钱包收支流水。"},
"POST /api/tenant/media-supply/wallet/adjustments": {"调整媒介钱包", "创建媒介钱包余额调整记录。"},
"GET /api/tenant/media-supply/supplier-session": {"供应商会话状态", "查询媒介供应商登录会话状态。"},
"PUT /api/tenant/media-supply/supplier-session": {"导入供应商会话", "导入或更新媒介供应商登录会话。"},
"POST /api/tenant/media-supply/supplier-session/login": {"登录供应商账号", "使用已配置账号登录媒介供应商并刷新会话。"},
"GET /api/tenant/media-supply/resources": {"媒介资源列表", "查询当前 Workspace 可购买的媒介供应资源。"},
"GET /api/tenant/media-supply/resources/by-ids": {"按 ID 查询媒介资源", "按资源 ID 集合查询媒介供应资源详情。"},
"GET /api/tenant/media-supply/resources/search-options": {"媒介资源筛选项", "返回媒介供应资源列表的筛选条件与可选项。"},
"GET /api/tenant/media-supply/favorite-groups": {"常发媒体分组", "读取当前租户用户的数据库收藏分组,并静默清理已下架或不可见的媒体。"},
"POST /api/tenant/media-supply/favorite-groups": {"新建常发媒体分组", "创建用户级常发媒体分组;可在同一事务内将指定媒体加入新分组。"},
"DELETE /api/tenant/media-supply/favorite-groups/:group_id": {"删除常发媒体分组", "删除指定用户级常发媒体分组及其收藏关系,默认分组不可删除。"},
"GET /api/tenant/media-supply/favorite-groups/:group_id/resources": {"常发媒体分页", "按分组分页查询当前用户的常发媒体详情,支持媒体名称或 ID 搜索,每页固定 10 条。"},
"PUT /api/tenant/media-supply/favorite-groups/:group_id/resources/:resource_id": {"加入常发媒体分组", "幂等地将指定媒体加入目标分组,不影响该媒体在其他分组中的成员关系。"},
"DELETE /api/tenant/media-supply/favorite-groups/:group_id/resources/:resource_id": {"移出常发媒体分组", "幂等地移除指定媒体在目标分组中的成员关系,保留其他分组中的收藏。"},
"DELETE /api/tenant/media-supply/favorite-resources/:resource_id": {"全局移除常发媒体", "幂等地移除当前租户用户对指定媒体的全部分组成员关系。"},
"PUT /api/tenant/media-supply/resources/:id/price": {"调整媒介资源售价", "租户侧调整指定媒介资源的销售价格。"},
"POST /api/tenant/media-supply/sync-jobs": {"同步媒介资源", "创建媒介供应资源同步任务。"},
"POST /api/tenant/media-supply/orders": {"创建媒介投放订单", "为选定媒介资源创建投放订单。"},
"GET /api/tenant/media-supply/orders": {"媒介订单列表", "分页查询当前 Workspace 的媒介投放订单。"},
"GET /api/tenant/media-supply/orders/:id": {"媒介订单详情", "读取指定媒介投放订单详情。"},
"GET /api/tenant/media-supply/wallet": {"媒介钱包状态", "读取当前 Workspace 的媒介供应钱包余额与状态。"},
"GET /api/tenant/media-supply/wallet/ledgers": {"媒介钱包流水", "分页查询媒介供应钱包收支流水。"},
"POST /api/tenant/media-supply/wallet/adjustments": {"调整媒介钱包", "创建媒介钱包余额调整记录。"},
"GET /api/tenant/media-supply/supplier-session": {"供应商会话状态", "查询媒介供应商登录会话状态。"},
"PUT /api/tenant/media-supply/supplier-session": {"导入供应商会话", "导入或更新媒介供应商登录会话。"},
"POST /api/tenant/media-supply/supplier-session/login": {"登录供应商账号", "使用已配置账号登录媒介供应商并刷新会话。"},
// --- Tenant:内容合规 ---
"GET /api/tenant/compliance/runtime-status": {"合规运行状态", "返回当前租户内容合规模块是否启用、执行模式、词库版本与检测预算。"},
@@ -0,0 +1,919 @@
package app
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
)
const (
mediaSupplyDefaultFavoriteGroupID = "default"
mediaSupplyDefaultFavoriteGroupName = "默认分组"
mediaSupplyFavoriteGroupLimit = 20
mediaSupplyFavoriteResourceLimit = 500
mediaSupplyFavoritePageSize = 10
mediaSupplyFavoriteModelID = 1
)
var (
mediaSupplyFavoriteGroupIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
errMediaSupplyFavoriteGroupExists = errors.New("media supply favorite group already exists")
errMediaSupplyFavoriteGroupLimit = errors.New("media supply favorite group limit reached")
errMediaSupplyFavoriteGroupMissing = errors.New("media supply favorite group not found")
errMediaSupplyFavoriteResourceLimit = errors.New("media supply favorite resource limit reached")
errMediaSupplyFavoriteUnavailable = errors.New("media supply favorite resource unavailable")
)
type MediaSupplyFavoriteGroup struct {
ID string `json:"id"`
Name string `json:"name"`
ResourceIDs []int64 `json:"resource_ids"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type CreateMediaSupplyFavoriteGroupRequest struct {
Name string `json:"name"`
ResourceID *int64 `json:"resource_id,omitempty"`
}
type ListMediaSupplyFavoriteResourcesRequest struct {
GroupID string
Keyword string
Page int
}
type MediaSupplyFavoriteGroupsResponse struct {
Groups []MediaSupplyFavoriteGroup `json:"groups"`
Initialized bool `json:"initialized"`
Revision int64 `json:"revision"`
}
type mediaSupplyFavoriteState struct {
Groups []MediaSupplyFavoriteGroup
Initialized bool
Revision int64
}
type mediaSupplyFavoriteStore interface {
List(ctx context.Context, tenantID, userID int64) (mediaSupplyFavoriteState, error)
CreateGroup(ctx context.Context, tenantID, userID int64, groupID, name string, resourceID int64) (mediaSupplyFavoriteState, error)
DeleteGroup(ctx context.Context, tenantID, userID int64, groupID string) (mediaSupplyFavoriteState, error)
PutResource(ctx context.Context, tenantID, userID int64, groupID string, resourceID int64) (mediaSupplyFavoriteState, error)
DeleteResourceFromGroup(ctx context.Context, tenantID, userID int64, groupID string, resourceID int64) (mediaSupplyFavoriteState, error)
DeleteResource(ctx context.Context, tenantID, userID, resourceID int64) (mediaSupplyFavoriteState, error)
}
type postgresMediaSupplyFavoriteStore struct {
pool *pgxpool.Pool
}
func newPostgresMediaSupplyFavoriteStore(pool *pgxpool.Pool) mediaSupplyFavoriteStore {
return &postgresMediaSupplyFavoriteStore{pool: pool}
}
func (s *MediaSupplyService) ListFavoriteGroups(ctx context.Context) (*MediaSupplyFavoriteGroupsResponse, error) {
if s == nil || s.favoriteStore == nil {
return nil, mediaSupplyFavoriteStoreUnavailable()
}
actor := auth.MustActor(ctx)
state, err := s.favoriteStore.List(ctx, actor.TenantID, actor.UserID)
if err != nil {
s.logFavoriteStoreError("list", actor, err)
return nil, response.ErrInternal(50094, "media_supply_favorite_groups_query_failed", "常发媒体分组读取失败")
}
return favoriteGroupsResponse(state), nil
}
func (s *MediaSupplyService) ListFavoriteResources(
ctx context.Context,
req ListMediaSupplyFavoriteResourcesRequest,
) (*ListCustomerSupplierMediaResourcesResponse, error) {
if s == nil || s.pool == nil || s.favoriteStore == nil {
return nil, mediaSupplyFavoriteStoreUnavailable()
}
req.GroupID = strings.TrimSpace(req.GroupID)
if !validMediaSupplyFavoriteGroupID(req.GroupID) {
return nil, invalidMediaSupplyFavoriteGroup("常发媒体分组标识不正确")
}
if req.Page <= 0 {
req.Page = 1
}
req.Keyword = strings.TrimSpace(req.Keyword)
actor := auth.MustActor(ctx)
state, err := s.favoriteStore.List(ctx, actor.TenantID, actor.UserID)
if err != nil {
s.logFavoriteStoreError("list_resources_state", actor, err)
return nil, response.ErrInternal(50096, "media_supply_favorite_resources_query_failed", "常发媒体读取失败")
}
if !favoriteGroupExists(state, req.GroupID) {
return nil, mapMediaSupplyFavoriteMutationError(errMediaSupplyFavoriteGroupMissing)
}
args := []any{
actor.TenantID,
actor.UserID,
req.GroupID,
mediaSupplySupplierMeijiequan,
mediaSupplyFavoriteModelID,
}
where := []string{
"favorite.tenant_id = $1",
"favorite.user_id = $2",
"favorite.group_key = $3",
"r.supplier = $4",
"r.model_id = $5",
"r.deleted_at IS NULL",
"r.customer_visible = TRUE",
}
if req.Keyword != "" {
args = append(args, "%"+req.Keyword+"%")
placeholder := fmt.Sprintf("$%d", len(args))
where = append(where, "(r.name ILIKE "+placeholder+
" OR r.supplier_resource_id ILIKE "+placeholder+
" OR r.id::TEXT ILIKE "+placeholder+")")
}
whereSQL := strings.Join(where, " AND ")
var total int64
if err := s.pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM media_supply_favorite_resources favorite
JOIN supplier_media_resources r ON r.id = favorite.resource_id
WHERE `+whereSQL, args...).Scan(&total); err != nil {
s.logFavoriteStoreError("count_resources", actor, err)
return nil, response.ErrInternal(50096, "media_supply_favorite_resources_query_failed", "常发媒体读取失败")
}
cfg := s.mediaSupplyConfig()
args = append(args, mediaSupplyFavoritePageSize, (req.Page-1)*mediaSupplyFavoritePageSize)
rows, err := s.pool.Query(ctx, `
SELECT r.id, r.supplier_resource_id, r.model_id,
r.name, r.status, `+mediaSupplySellPriceSQL(cfg)+`,
r.resource_url, r.baidu_weight, r.resource_remark,
r.channel_type, r.region, r.inclusion_effect,
r.link_type, r.publish_rate, r.delivery_speed,
r.last_synced_at
FROM media_supply_favorite_resources favorite
JOIN supplier_media_resources r ON r.id = favorite.resource_id
LEFT JOIN supplier_media_price_overrides o
ON o.resource_id = r.id AND o.price_type = 'price'
WHERE `+whereSQL+`
ORDER BY favorite.created_at DESC, favorite.resource_id DESC
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
if err != nil {
s.logFavoriteStoreError("list_resources", actor, err)
return nil, response.ErrInternal(50096, "media_supply_favorite_resources_query_failed", "常发媒体读取失败")
}
defer rows.Close()
items := make([]CustomerSupplierMediaResource, 0, mediaSupplyFavoritePageSize)
for rows.Next() {
var item CustomerSupplierMediaResource
if err := rows.Scan(
&item.ID,
&item.SupplierResourceID,
&item.ModelID,
&item.Name,
&item.Status,
&item.SellPriceCents,
&item.ResourceURL,
&item.BaiduWeight,
&item.ResourceRemark,
&item.ChannelType,
&item.Region,
&item.InclusionEffect,
&item.LinkType,
&item.PublishRate,
&item.DeliverySpeed,
&item.LastSyncedAt,
); err != nil {
s.logFavoriteStoreError("scan_resources", actor, err)
return nil, response.ErrInternal(50096, "media_supply_favorite_resources_query_failed", "常发媒体读取失败")
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
s.logFavoriteStoreError("iterate_resources", actor, err)
return nil, response.ErrInternal(50096, "media_supply_favorite_resources_query_failed", "常发媒体读取失败")
}
return &ListCustomerSupplierMediaResourcesResponse{
Items: items,
Total: total,
Page: req.Page,
PageSize: mediaSupplyFavoritePageSize,
}, nil
}
func (s *MediaSupplyService) CreateFavoriteGroup(
ctx context.Context,
req CreateMediaSupplyFavoriteGroupRequest,
) (*MediaSupplyFavoriteGroupsResponse, error) {
if s == nil || s.favoriteStore == nil {
return nil, mediaSupplyFavoriteStoreUnavailable()
}
name := strings.TrimSpace(req.Name)
if name == "" || len([]rune(name)) > 64 || strings.EqualFold(name, mediaSupplyDefaultFavoriteGroupName) {
return nil, invalidMediaSupplyFavoriteGroup("常发媒体分组名称不正确")
}
actor := auth.MustActor(ctx)
groupID := "group_" + strings.ReplaceAll(uuid.NewString(), "-", "")
resourceID := int64(0)
if req.ResourceID != nil {
resourceID = *req.ResourceID
if resourceID <= 0 {
return nil, response.ErrBadRequest(40070, "invalid_media_supply_favorite_resource", "常发媒体资源参数不正确")
}
}
state, err := s.favoriteStore.CreateGroup(ctx, actor.TenantID, actor.UserID, groupID, name, resourceID)
if err != nil {
s.logUnexpectedFavoriteMutationError("create_group", actor, err)
return nil, mapMediaSupplyFavoriteMutationError(err)
}
return favoriteGroupsResponse(state), nil
}
func (s *MediaSupplyService) DeleteFavoriteGroup(
ctx context.Context,
groupID string,
) (*MediaSupplyFavoriteGroupsResponse, error) {
if s == nil || s.favoriteStore == nil {
return nil, mediaSupplyFavoriteStoreUnavailable()
}
groupID = strings.TrimSpace(groupID)
if !validMediaSupplyFavoriteGroupID(groupID) || groupID == mediaSupplyDefaultFavoriteGroupID {
return nil, invalidMediaSupplyFavoriteGroup("默认分组不能删除")
}
actor := auth.MustActor(ctx)
state, err := s.favoriteStore.DeleteGroup(ctx, actor.TenantID, actor.UserID, groupID)
if err != nil {
s.logUnexpectedFavoriteMutationError("delete_group", actor, err)
return nil, mapMediaSupplyFavoriteMutationError(err)
}
return favoriteGroupsResponse(state), nil
}
func (s *MediaSupplyService) PutFavoriteResource(
ctx context.Context,
groupID string,
resourceID int64,
) (*MediaSupplyFavoriteGroupsResponse, error) {
if s == nil || s.favoriteStore == nil {
return nil, mediaSupplyFavoriteStoreUnavailable()
}
groupID = strings.TrimSpace(groupID)
if !validMediaSupplyFavoriteGroupID(groupID) {
return nil, invalidMediaSupplyFavoriteGroup("常发媒体分组标识不正确")
}
if resourceID <= 0 {
return nil, response.ErrBadRequest(40070, "invalid_media_supply_favorite_resource", "常发媒体资源参数不正确")
}
actor := auth.MustActor(ctx)
state, err := s.favoriteStore.PutResource(ctx, actor.TenantID, actor.UserID, groupID, resourceID)
if err != nil {
s.logUnexpectedFavoriteMutationError("put_resource", actor, err)
return nil, mapMediaSupplyFavoriteMutationError(err)
}
return favoriteGroupsResponse(state), nil
}
func (s *MediaSupplyService) DeleteFavoriteResource(
ctx context.Context,
resourceID int64,
) (*MediaSupplyFavoriteGroupsResponse, error) {
if s == nil || s.favoriteStore == nil {
return nil, mediaSupplyFavoriteStoreUnavailable()
}
if resourceID <= 0 {
return nil, response.ErrBadRequest(40070, "invalid_media_supply_favorite_resource", "常发媒体资源参数不正确")
}
actor := auth.MustActor(ctx)
state, err := s.favoriteStore.DeleteResource(ctx, actor.TenantID, actor.UserID, resourceID)
if err != nil {
s.logUnexpectedFavoriteMutationError("delete_resource", actor, err)
return nil, mapMediaSupplyFavoriteMutationError(err)
}
return favoriteGroupsResponse(state), nil
}
func (s *MediaSupplyService) DeleteFavoriteResourceFromGroup(
ctx context.Context,
groupID string,
resourceID int64,
) (*MediaSupplyFavoriteGroupsResponse, error) {
if s == nil || s.favoriteStore == nil {
return nil, mediaSupplyFavoriteStoreUnavailable()
}
groupID = strings.TrimSpace(groupID)
if !validMediaSupplyFavoriteGroupID(groupID) {
return nil, invalidMediaSupplyFavoriteGroup("常发媒体分组标识不正确")
}
if resourceID <= 0 {
return nil, response.ErrBadRequest(40070, "invalid_media_supply_favorite_resource", "常发媒体资源参数不正确")
}
actor := auth.MustActor(ctx)
state, err := s.favoriteStore.DeleteResourceFromGroup(
ctx,
actor.TenantID,
actor.UserID,
groupID,
resourceID,
)
if err != nil {
s.logUnexpectedFavoriteMutationError("delete_resource_from_group", actor, err)
return nil, mapMediaSupplyFavoriteMutationError(err)
}
return favoriteGroupsResponse(state), nil
}
func favoriteGroupsResponse(state mediaSupplyFavoriteState) *MediaSupplyFavoriteGroupsResponse {
groups := state.Groups
if !state.Initialized || len(groups) == 0 {
groups = defaultMediaSupplyFavoriteGroups()
}
return &MediaSupplyFavoriteGroupsResponse{
Groups: groups,
Initialized: state.Initialized,
Revision: state.Revision,
}
}
func defaultMediaSupplyFavoriteGroups() []MediaSupplyFavoriteGroup {
return []MediaSupplyFavoriteGroup{{
ID: mediaSupplyDefaultFavoriteGroupID,
Name: mediaSupplyDefaultFavoriteGroupName,
ResourceIDs: []int64{},
UpdatedAt: time.Now().UTC(),
}}
}
func favoriteGroupExists(state mediaSupplyFavoriteState, groupID string) bool {
if !state.Initialized {
return groupID == mediaSupplyDefaultFavoriteGroupID
}
for _, group := range state.Groups {
if group.ID == groupID {
return true
}
}
return false
}
func validMediaSupplyFavoriteGroupID(groupID string) bool {
return groupID != "" && len(groupID) <= 64 && mediaSupplyFavoriteGroupIDPattern.MatchString(groupID)
}
func invalidMediaSupplyFavoriteGroup(message string) error {
return response.ErrBadRequest(40069, "invalid_media_supply_favorite_group", message)
}
func mediaSupplyFavoriteStoreUnavailable() error {
return response.ErrServiceUnavailable(50360, "media_supply_store_unavailable", "媒体投稿服务暂不可用")
}
func mapMediaSupplyFavoriteMutationError(err error) error {
switch {
case errors.Is(err, errMediaSupplyFavoriteGroupExists):
return response.ErrConflict(40966, "media_supply_favorite_group_exists", "常发媒体分组名称已存在")
case errors.Is(err, errMediaSupplyFavoriteGroupLimit):
return response.ErrConflict(40967, "media_supply_favorite_group_limit", "常发媒体分组不能超过 20 个")
case errors.Is(err, errMediaSupplyFavoriteResourceLimit):
return response.ErrConflict(40968, "media_supply_favorite_resource_limit", "常发媒体不能超过 500 个")
case errors.Is(err, errMediaSupplyFavoriteGroupMissing):
return response.ErrNotFound(40464, "media_supply_favorite_group_not_found", "常发媒体分组不存在")
case errors.Is(err, errMediaSupplyFavoriteUnavailable):
return response.ErrNotFound(40460, "media_supply_resource_not_found", "媒体资源不存在或已下架")
default:
return response.ErrInternal(50095, "media_supply_favorite_mutation_failed", "常发媒体保存失败")
}
}
func (s *MediaSupplyService) logFavoriteStoreError(operation string, actor auth.Actor, err error) {
if s == nil || s.logger == nil || err == nil {
return
}
s.logger.Error("media supply favorite store failed",
zap.String("operation", operation),
zap.Int64("tenant_id", actor.TenantID),
zap.Int64("user_id", actor.UserID),
zap.Error(err),
)
}
func (s *MediaSupplyService) logUnexpectedFavoriteMutationError(operation string, actor auth.Actor, err error) {
if errors.Is(err, errMediaSupplyFavoriteGroupExists) ||
errors.Is(err, errMediaSupplyFavoriteGroupLimit) ||
errors.Is(err, errMediaSupplyFavoriteResourceLimit) ||
errors.Is(err, errMediaSupplyFavoriteGroupMissing) ||
errors.Is(err, errMediaSupplyFavoriteUnavailable) {
return
}
s.logFavoriteStoreError(operation, actor, err)
}
func (s *postgresMediaSupplyFavoriteStore) List(
ctx context.Context,
tenantID int64,
userID int64,
) (mediaSupplyFavoriteState, error) {
if s == nil || s.pool == nil {
return mediaSupplyFavoriteState{}, fmt.Errorf("favorite store unavailable")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return mediaSupplyFavoriteState{}, fmt.Errorf("begin favorite list: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
revision, initialized, err := lockMediaSupplyFavoriteOwner(ctx, tx, tenantID, userID)
if err != nil {
return mediaSupplyFavoriteState{}, err
}
if !initialized {
if err := tx.Commit(ctx); err != nil {
return mediaSupplyFavoriteState{}, fmt.Errorf("commit empty favorite list: %w", err)
}
return mediaSupplyFavoriteState{}, nil
}
pruned, err := pruneInvalidMediaSupplyFavorites(ctx, tx, tenantID, userID)
if err != nil {
return mediaSupplyFavoriteState{}, err
}
if pruned > 0 {
revision, err = bumpMediaSupplyFavoriteRevision(ctx, tx, tenantID, userID)
if err != nil {
return mediaSupplyFavoriteState{}, err
}
}
state, err := queryMediaSupplyFavoriteState(ctx, tx, tenantID, userID, revision)
if err != nil {
return mediaSupplyFavoriteState{}, err
}
if err := tx.Commit(ctx); err != nil {
return mediaSupplyFavoriteState{}, fmt.Errorf("commit favorite list: %w", err)
}
return state, nil
}
func (s *postgresMediaSupplyFavoriteStore) CreateGroup(
ctx context.Context,
tenantID int64,
userID int64,
groupID string,
name string,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
return s.mutate(ctx, tenantID, userID, func(tx pgx.Tx) (bool, error) {
var existingGroupID string
err := tx.QueryRow(ctx, `
SELECT group_key
FROM media_supply_favorite_groups
WHERE tenant_id = $1 AND user_id = $2 AND LOWER(name) = LOWER($3)
`, tenantID, userID, name).Scan(&existingGroupID)
if err == nil {
if resourceID > 0 {
return putMediaSupplyFavoriteResource(ctx, tx, tenantID, userID, existingGroupID, resourceID)
}
return false, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return false, fmt.Errorf("lookup favorite group by name: %w", err)
}
tag, err := tx.Exec(ctx, `
INSERT INTO media_supply_favorite_groups (
tenant_id, user_id, group_key, name, sort_order, created_at, updated_at
)
SELECT $1, $2, $3, $4, COALESCE(MAX(sort_order), -1) + 1, NOW(), NOW()
FROM media_supply_favorite_groups
WHERE tenant_id = $1 AND user_id = $2
HAVING COUNT(*) < $5
`, tenantID, userID, groupID, name, mediaSupplyFavoriteGroupLimit)
if err != nil {
if isUniqueViolation(err) {
return false, errMediaSupplyFavoriteGroupExists
}
return false, fmt.Errorf("insert favorite group: %w", err)
}
if tag.RowsAffected() == 0 {
return false, errMediaSupplyFavoriteGroupLimit
}
if resourceID > 0 {
if _, err := putMediaSupplyFavoriteResource(ctx, tx, tenantID, userID, groupID, resourceID); err != nil {
return false, err
}
}
return true, nil
})
}
func (s *postgresMediaSupplyFavoriteStore) DeleteGroup(
ctx context.Context,
tenantID int64,
userID int64,
groupID string,
) (mediaSupplyFavoriteState, error) {
return s.mutate(ctx, tenantID, userID, func(tx pgx.Tx) (bool, error) {
tag, err := tx.Exec(ctx, `
DELETE FROM media_supply_favorite_groups
WHERE tenant_id = $1 AND user_id = $2 AND group_key = $3
`, tenantID, userID, groupID)
if err != nil {
return false, fmt.Errorf("delete favorite group: %w", err)
}
if tag.RowsAffected() == 0 {
return false, nil
}
return true, nil
})
}
func (s *postgresMediaSupplyFavoriteStore) PutResource(
ctx context.Context,
tenantID int64,
userID int64,
groupID string,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
return s.mutate(ctx, tenantID, userID, func(tx pgx.Tx) (bool, error) {
return putMediaSupplyFavoriteResource(ctx, tx, tenantID, userID, groupID, resourceID)
})
}
func putMediaSupplyFavoriteResource(
ctx context.Context,
tx pgx.Tx,
tenantID int64,
userID int64,
groupID string,
resourceID int64,
) (bool, error) {
var groupExists bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM media_supply_favorite_groups
WHERE tenant_id = $1 AND user_id = $2 AND group_key = $3
)
`, tenantID, userID, groupID).Scan(&groupExists); err != nil {
return false, fmt.Errorf("check favorite group: %w", err)
}
if !groupExists {
return false, errMediaSupplyFavoriteGroupMissing
}
var lockedResourceID int64
err := tx.QueryRow(ctx, `
SELECT id
FROM supplier_media_resources
WHERE id = $1
AND supplier = $2
AND model_id = $3
AND deleted_at IS NULL
AND customer_visible = TRUE
FOR SHARE
`, resourceID, mediaSupplySupplierMeijiequan, mediaSupplyFavoriteModelID).Scan(&lockedResourceID)
if errors.Is(err, pgx.ErrNoRows) {
return false, errMediaSupplyFavoriteUnavailable
}
if err != nil {
return false, fmt.Errorf("check favorite resource: %w", err)
}
var membershipExists bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM media_supply_favorite_resources
WHERE tenant_id = $1 AND user_id = $2 AND group_key = $3 AND resource_id = $4
)
`, tenantID, userID, groupID, resourceID).Scan(&membershipExists); err != nil {
return false, fmt.Errorf("lookup favorite membership: %w", err)
}
if membershipExists {
return false, nil
}
var resourceExists bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM media_supply_favorite_resources
WHERE tenant_id = $1 AND user_id = $2 AND resource_id = $3
)
`, tenantID, userID, resourceID).Scan(&resourceExists); err != nil {
return false, fmt.Errorf("lookup existing favorite resource: %w", err)
}
if !resourceExists {
var favoriteCount int
if err := tx.QueryRow(ctx, `
SELECT COUNT(DISTINCT resource_id)
FROM media_supply_favorite_resources
WHERE tenant_id = $1 AND user_id = $2
`, tenantID, userID).Scan(&favoriteCount); err != nil {
return false, fmt.Errorf("count favorite resources: %w", err)
}
if favoriteCount >= mediaSupplyFavoriteResourceLimit {
return false, errMediaSupplyFavoriteResourceLimit
}
}
tag, err := tx.Exec(ctx, `
INSERT INTO media_supply_favorite_resources (
tenant_id, user_id, group_key, resource_id, created_at
) VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (tenant_id, user_id, group_key, resource_id) DO NOTHING
`, tenantID, userID, groupID, resourceID)
if err != nil {
return false, fmt.Errorf("put favorite resource: %w", err)
}
changed := tag.RowsAffected() > 0
if changed {
if _, err := tx.Exec(ctx, `
UPDATE media_supply_favorite_groups
SET updated_at = NOW()
WHERE tenant_id = $1 AND user_id = $2 AND group_key = $3
`, tenantID, userID, groupID); err != nil {
return false, fmt.Errorf("touch favorite groups: %w", err)
}
}
return changed, nil
}
func (s *postgresMediaSupplyFavoriteStore) DeleteResourceFromGroup(
ctx context.Context,
tenantID int64,
userID int64,
groupID string,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
return s.mutate(ctx, tenantID, userID, func(tx pgx.Tx) (bool, error) {
var groupExists bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM media_supply_favorite_groups
WHERE tenant_id = $1 AND user_id = $2 AND group_key = $3
)
`, tenantID, userID, groupID).Scan(&groupExists); err != nil {
return false, fmt.Errorf("check favorite group: %w", err)
}
if !groupExists {
return false, errMediaSupplyFavoriteGroupMissing
}
tag, err := tx.Exec(ctx, `
DELETE FROM media_supply_favorite_resources
WHERE tenant_id = $1 AND user_id = $2 AND group_key = $3 AND resource_id = $4
`, tenantID, userID, groupID, resourceID)
if err != nil {
return false, fmt.Errorf("delete favorite resource from group: %w", err)
}
if tag.RowsAffected() == 0 {
return false, nil
}
if _, err := tx.Exec(ctx, `
UPDATE media_supply_favorite_groups
SET updated_at = NOW()
WHERE tenant_id = $1 AND user_id = $2 AND group_key = $3
`, tenantID, userID, groupID); err != nil {
return false, fmt.Errorf("touch favorite group: %w", err)
}
return true, nil
})
}
func (s *postgresMediaSupplyFavoriteStore) DeleteResource(
ctx context.Context,
tenantID int64,
userID int64,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
return s.mutate(ctx, tenantID, userID, func(tx pgx.Tx) (bool, error) {
tag, err := tx.Exec(ctx, `
WITH deleted AS (
DELETE FROM media_supply_favorite_resources
WHERE tenant_id = $1 AND user_id = $2 AND resource_id = $3
RETURNING group_key
)
UPDATE media_supply_favorite_groups
SET updated_at = NOW()
WHERE tenant_id = $1
AND user_id = $2
AND group_key IN (SELECT DISTINCT group_key FROM deleted)
`, tenantID, userID, resourceID)
if err != nil {
return false, fmt.Errorf("delete favorite resource: %w", err)
}
return tag.RowsAffected() > 0, nil
})
}
func (s *postgresMediaSupplyFavoriteStore) mutate(
ctx context.Context,
tenantID int64,
userID int64,
mutation func(pgx.Tx) (bool, error),
) (mediaSupplyFavoriteState, error) {
if s == nil || s.pool == nil {
return mediaSupplyFavoriteState{}, fmt.Errorf("favorite store unavailable")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return mediaSupplyFavoriteState{}, fmt.Errorf("begin favorite mutation: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
revision, err := ensureAndLockMediaSupplyFavoriteOwner(ctx, tx, tenantID, userID)
if err != nil {
return mediaSupplyFavoriteState{}, err
}
changed, err := mutation(tx)
if err != nil {
return mediaSupplyFavoriteState{}, err
}
if changed {
revision, err = bumpMediaSupplyFavoriteRevision(ctx, tx, tenantID, userID)
if err != nil {
return mediaSupplyFavoriteState{}, err
}
}
state, err := queryMediaSupplyFavoriteState(ctx, tx, tenantID, userID, revision)
if err != nil {
return mediaSupplyFavoriteState{}, err
}
if err := tx.Commit(ctx); err != nil {
return mediaSupplyFavoriteState{}, fmt.Errorf("commit favorite mutation: %w", err)
}
return state, nil
}
func ensureAndLockMediaSupplyFavoriteOwner(
ctx context.Context,
tx pgx.Tx,
tenantID int64,
userID int64,
) (int64, error) {
if _, err := tx.Exec(ctx, `
INSERT INTO media_supply_favorite_owners (tenant_id, user_id, revision, created_at, updated_at)
VALUES ($1, $2, 0, NOW(), NOW())
ON CONFLICT (tenant_id, user_id) DO NOTHING
`, tenantID, userID); err != nil {
return 0, fmt.Errorf("ensure favorite owner: %w", err)
}
revision, initialized, err := lockMediaSupplyFavoriteOwner(ctx, tx, tenantID, userID)
if err != nil {
return 0, err
}
if !initialized {
return 0, fmt.Errorf("favorite owner missing after insert")
}
if _, err := tx.Exec(ctx, `
INSERT INTO media_supply_favorite_groups (
tenant_id, user_id, group_key, name, sort_order, created_at, updated_at
) VALUES ($1, $2, $3, $4, 0, NOW(), NOW())
ON CONFLICT (tenant_id, user_id, group_key) DO NOTHING
`, tenantID, userID, mediaSupplyDefaultFavoriteGroupID, mediaSupplyDefaultFavoriteGroupName); err != nil {
return 0, fmt.Errorf("ensure default favorite group: %w", err)
}
return revision, nil
}
func lockMediaSupplyFavoriteOwner(
ctx context.Context,
tx pgx.Tx,
tenantID int64,
userID int64,
) (int64, bool, error) {
var revision int64
err := tx.QueryRow(ctx, `
SELECT revision
FROM media_supply_favorite_owners
WHERE tenant_id = $1 AND user_id = $2
FOR UPDATE
`, tenantID, userID).Scan(&revision)
if errors.Is(err, pgx.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, fmt.Errorf("lock favorite owner: %w", err)
}
return revision, true, nil
}
func bumpMediaSupplyFavoriteRevision(
ctx context.Context,
tx pgx.Tx,
tenantID int64,
userID int64,
) (int64, error) {
var revision int64
if err := tx.QueryRow(ctx, `
UPDATE media_supply_favorite_owners
SET revision = revision + 1, updated_at = NOW()
WHERE tenant_id = $1 AND user_id = $2
RETURNING revision
`, tenantID, userID).Scan(&revision); err != nil {
return 0, fmt.Errorf("bump favorite revision: %w", err)
}
return revision, nil
}
func pruneInvalidMediaSupplyFavorites(
ctx context.Context,
tx pgx.Tx,
tenantID int64,
userID int64,
) (int64, error) {
tag, err := tx.Exec(ctx, `
WITH deleted AS (
DELETE FROM media_supply_favorite_resources favorite
WHERE favorite.tenant_id = $1
AND favorite.user_id = $2
AND NOT EXISTS (
SELECT 1
FROM supplier_media_resources resource
WHERE resource.id = favorite.resource_id
AND resource.supplier = $3
AND resource.model_id = $4
AND resource.deleted_at IS NULL
AND resource.customer_visible = TRUE
)
RETURNING group_key
)
UPDATE media_supply_favorite_groups group_row
SET updated_at = NOW()
WHERE group_row.tenant_id = $1
AND group_row.user_id = $2
AND group_row.group_key IN (SELECT DISTINCT group_key FROM deleted)
`, tenantID, userID, mediaSupplySupplierMeijiequan, mediaSupplyFavoriteModelID)
if err != nil {
return 0, fmt.Errorf("prune invalid favorites: %w", err)
}
return tag.RowsAffected(), nil
}
func queryMediaSupplyFavoriteState(
ctx context.Context,
tx pgx.Tx,
tenantID int64,
userID int64,
revision int64,
) (mediaSupplyFavoriteState, error) {
rows, err := tx.Query(ctx, `
SELECT group_row.group_key,
group_row.name,
group_row.updated_at,
COALESCE(
array_agg(favorite.resource_id ORDER BY favorite.created_at, favorite.resource_id)
FILTER (WHERE favorite.resource_id IS NOT NULL),
ARRAY[]::BIGINT[]
) AS resource_ids
FROM media_supply_favorite_groups group_row
LEFT JOIN media_supply_favorite_resources favorite
ON favorite.tenant_id = group_row.tenant_id
AND favorite.user_id = group_row.user_id
AND favorite.group_key = group_row.group_key
WHERE group_row.tenant_id = $1 AND group_row.user_id = $2
GROUP BY group_row.tenant_id, group_row.user_id, group_row.group_key,
group_row.name, group_row.sort_order, group_row.created_at, group_row.updated_at
ORDER BY group_row.sort_order, group_row.created_at, group_row.group_key
`, tenantID, userID)
if err != nil {
return mediaSupplyFavoriteState{}, fmt.Errorf("query favorite groups: %w", err)
}
defer rows.Close()
groups := make([]MediaSupplyFavoriteGroup, 0)
for rows.Next() {
var group MediaSupplyFavoriteGroup
if err := rows.Scan(&group.ID, &group.Name, &group.UpdatedAt, &group.ResourceIDs); err != nil {
return mediaSupplyFavoriteState{}, fmt.Errorf("scan favorite group: %w", err)
}
if group.ResourceIDs == nil {
group.ResourceIDs = []int64{}
}
groups = append(groups, group)
}
if err := rows.Err(); err != nil {
return mediaSupplyFavoriteState{}, fmt.Errorf("iterate favorite groups: %w", err)
}
return mediaSupplyFavoriteState{Groups: groups, Initialized: true, Revision: revision}, nil
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
var _ mediaSupplyFavoriteStore = (*postgresMediaSupplyFavoriteStore)(nil)
@@ -0,0 +1,379 @@
package app
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/geo-platform/tenant-api/internal/shared/auth"
)
type favoriteOwner struct {
tenantID int64
userID int64
}
type memoryMediaSupplyFavoriteStore struct {
mu sync.Mutex
states map[favoriteOwner]mediaSupplyFavoriteState
}
func newMemoryMediaSupplyFavoriteStore() *memoryMediaSupplyFavoriteStore {
return &memoryMediaSupplyFavoriteStore{states: make(map[favoriteOwner]mediaSupplyFavoriteState)}
}
func (s *memoryMediaSupplyFavoriteStore) List(
_ context.Context,
tenantID int64,
userID int64,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
state, ok := s.states[favoriteOwner{tenantID: tenantID, userID: userID}]
if !ok {
return mediaSupplyFavoriteState{}, nil
}
return cloneFavoriteState(state), nil
}
func (s *memoryMediaSupplyFavoriteStore) CreateGroup(
_ context.Context,
tenantID int64,
userID int64,
groupID string,
name string,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
owner := favoriteOwner{tenantID: tenantID, userID: userID}
state := ensureMemoryFavoriteState(s.states[owner])
for _, group := range state.Groups {
if group.Name == name {
if resourceID > 0 {
return s.putResourceLocked(owner, state, group.ID, resourceID)
}
return cloneFavoriteState(state), nil
}
}
state.Groups = append(state.Groups, MediaSupplyFavoriteGroup{
ID: groupID,
Name: name,
ResourceIDs: []int64{},
UpdatedAt: favoriteTestTime(),
})
if resourceID > 0 {
for index, group := range state.Groups {
if group.ID == groupID {
group.ResourceIDs = append(group.ResourceIDs, resourceID)
group.UpdatedAt = favoriteTestTime()
}
state.Groups[index] = group
}
}
state.Revision++
s.states[owner] = state
return cloneFavoriteState(state), nil
}
func (s *memoryMediaSupplyFavoriteStore) DeleteGroup(
_ context.Context,
tenantID int64,
userID int64,
groupID string,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
owner := favoriteOwner{tenantID: tenantID, userID: userID}
state := ensureMemoryFavoriteState(s.states[owner])
next := state.Groups[:0]
changed := false
for _, group := range state.Groups {
if group.ID != groupID {
next = append(next, group)
} else {
changed = true
}
}
state.Groups = next
if changed {
state.Revision++
}
s.states[owner] = state
return cloneFavoriteState(state), nil
}
func (s *memoryMediaSupplyFavoriteStore) PutResource(
_ context.Context,
tenantID int64,
userID int64,
groupID string,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
owner := favoriteOwner{tenantID: tenantID, userID: userID}
state := ensureMemoryFavoriteState(s.states[owner])
return s.putResourceLocked(owner, state, groupID, resourceID)
}
func (s *memoryMediaSupplyFavoriteStore) putResourceLocked(
owner favoriteOwner,
state mediaSupplyFavoriteState,
groupID string,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
groupExists := false
resourceIDs := make(map[int64]struct{})
for _, group := range state.Groups {
if group.ID == groupID {
groupExists = true
}
for _, id := range group.ResourceIDs {
resourceIDs[id] = struct{}{}
if id == resourceID {
if group.ID == groupID {
return cloneFavoriteState(state), nil
}
}
}
}
if !groupExists {
return mediaSupplyFavoriteState{}, errMediaSupplyFavoriteGroupMissing
}
_, resourceExists := resourceIDs[resourceID]
if !resourceExists && len(resourceIDs) >= mediaSupplyFavoriteResourceLimit {
return mediaSupplyFavoriteState{}, errMediaSupplyFavoriteResourceLimit
}
for index, group := range state.Groups {
if group.ID == groupID {
group.ResourceIDs = append(group.ResourceIDs, resourceID)
group.UpdatedAt = favoriteTestTime()
}
state.Groups[index] = group
}
state.Revision++
s.states[owner] = state
return cloneFavoriteState(state), nil
}
func (s *memoryMediaSupplyFavoriteStore) DeleteResourceFromGroup(
_ context.Context,
tenantID int64,
userID int64,
groupID string,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
owner := favoriteOwner{tenantID: tenantID, userID: userID}
state := ensureMemoryFavoriteState(s.states[owner])
for index, group := range state.Groups {
if group.ID != groupID {
continue
}
next := removeFavoriteResourceID(group.ResourceIDs, resourceID)
if len(next) != len(group.ResourceIDs) {
group.ResourceIDs = next
group.UpdatedAt = favoriteTestTime()
state.Groups[index] = group
state.Revision++
s.states[owner] = state
return cloneFavoriteState(state), nil
}
s.states[owner] = state
return cloneFavoriteState(state), nil
}
return mediaSupplyFavoriteState{}, errMediaSupplyFavoriteGroupMissing
}
func (s *memoryMediaSupplyFavoriteStore) DeleteResource(
_ context.Context,
tenantID int64,
userID int64,
resourceID int64,
) (mediaSupplyFavoriteState, error) {
s.mu.Lock()
defer s.mu.Unlock()
owner := favoriteOwner{tenantID: tenantID, userID: userID}
state := ensureMemoryFavoriteState(s.states[owner])
changed := false
for index, group := range state.Groups {
if len(group.ResourceIDs) != len(removeFavoriteResourceID(group.ResourceIDs, resourceID)) {
changed = true
}
group.ResourceIDs = removeFavoriteResourceID(group.ResourceIDs, resourceID)
state.Groups[index] = group
}
if changed {
state.Revision++
}
s.states[owner] = state
return cloneFavoriteState(state), nil
}
func TestMediaSupplyFavoriteGroupsPersistAcrossServiceInstances(t *testing.T) {
store := newMemoryMediaSupplyFavoriteStore()
ctx := auth.WithActor(context.Background(), auth.Actor{TenantID: 11, UserID: 101})
writer := &MediaSupplyService{favoriteStore: store}
initial, err := writer.ListFavoriteGroups(ctx)
require.NoError(t, err)
require.False(t, initial.Initialized)
require.Len(t, initial.Groups, 1)
require.Equal(t, "default", initial.Groups[0].ID)
require.Equal(t, "默认分组", initial.Groups[0].Name)
require.Empty(t, initial.Groups[0].ResourceIDs)
require.False(t, initial.Groups[0].UpdatedAt.IsZero())
withDefaultFavorite, err := writer.PutFavoriteResource(ctx, "default", 101)
require.NoError(t, err)
require.True(t, withDefaultFavorite.Initialized)
require.Equal(t, []int64{101}, withDefaultFavorite.Groups[0].ResourceIDs)
resourceID := int64(202)
withGroup, err := writer.CreateFavoriteGroup(ctx, CreateMediaSupplyFavoriteGroupRequest{
Name: "重点媒体",
ResourceID: &resourceID,
})
require.NoError(t, err)
require.Len(t, withGroup.Groups, 2)
groupID := withGroup.Groups[1].ID
require.NotEmpty(t, groupID)
require.NotEqual(t, "default", groupID)
require.Equal(t, []int64{202}, withGroup.Groups[1].ResourceIDs)
idempotentCreate, err := writer.CreateFavoriteGroup(ctx, CreateMediaSupplyFavoriteGroupRequest{
Name: "重点媒体",
})
require.NoError(t, err)
require.Equal(t, withGroup, idempotentCreate)
multiGroup, err := writer.PutFavoriteResource(ctx, groupID, 101)
require.NoError(t, err)
require.Equal(t, []int64{101}, multiGroup.Groups[0].ResourceIDs)
require.Equal(t, []int64{202, 101}, multiGroup.Groups[1].ResourceIDs)
idempotentPut, err := writer.PutFavoriteResource(ctx, groupID, 101)
require.NoError(t, err)
require.Equal(t, multiGroup, idempotentPut)
reader := &MediaSupplyService{favoriteStore: store}
readBack, err := reader.ListFavoriteGroups(ctx)
require.NoError(t, err)
require.Equal(t, multiGroup, readBack)
removedFromGroup, err := writer.DeleteFavoriteResourceFromGroup(ctx, groupID, 101)
require.NoError(t, err)
require.Equal(t, []int64{101}, removedFromGroup.Groups[0].ResourceIDs)
require.Equal(t, []int64{202}, removedFromGroup.Groups[1].ResourceIDs)
restored, err := writer.PutFavoriteResource(ctx, groupID, 101)
require.NoError(t, err)
require.Equal(t, []int64{101}, restored.Groups[0].ResourceIDs)
require.Equal(t, []int64{202, 101}, restored.Groups[1].ResourceIDs)
removed, err := writer.DeleteFavoriteResource(ctx, 101)
require.NoError(t, err)
require.Empty(t, removed.Groups[0].ResourceIDs)
require.Equal(t, []int64{202}, removed.Groups[1].ResourceIDs)
idempotentDelete, err := writer.DeleteFavoriteResource(ctx, 101)
require.NoError(t, err)
require.Equal(t, removed, idempotentDelete)
deletedGroup, err := writer.DeleteFavoriteGroup(ctx, groupID)
require.NoError(t, err)
idempotentGroupDelete, err := writer.DeleteFavoriteGroup(ctx, groupID)
require.NoError(t, err)
require.Equal(t, deletedGroup, idempotentGroupDelete)
otherUserCtx := auth.WithActor(context.Background(), auth.Actor{TenantID: 11, UserID: 102})
otherUser, err := reader.ListFavoriteGroups(otherUserCtx)
require.NoError(t, err)
require.False(t, otherUser.Initialized)
require.Empty(t, otherUser.Groups[0].ResourceIDs)
}
func TestMediaSupplyFavoriteResourceLimit(t *testing.T) {
store := newMemoryMediaSupplyFavoriteStore()
owner := favoriteOwner{tenantID: 11, userID: 101}
resourceIDs := make([]int64, mediaSupplyFavoriteResourceLimit)
for index := range resourceIDs {
resourceIDs[index] = int64(index + 1)
}
store.states[owner] = mediaSupplyFavoriteState{
Groups: []MediaSupplyFavoriteGroup{
{
ID: "default",
Name: "默认分组",
ResourceIDs: resourceIDs,
UpdatedAt: favoriteTestTime(),
},
{
ID: "secondary",
Name: "第二分组",
ResourceIDs: []int64{},
UpdatedAt: favoriteTestTime(),
},
},
Initialized: true,
Revision: 1,
}
service := &MediaSupplyService{favoriteStore: store}
ctx := auth.WithActor(context.Background(), auth.Actor{TenantID: owner.tenantID, UserID: owner.userID})
multiGroup, err := service.PutFavoriteResource(ctx, "secondary", 1)
require.NoError(t, err)
require.Contains(t, multiGroup.Groups[0].ResourceIDs, int64(1))
require.Equal(t, []int64{1}, multiGroup.Groups[1].ResourceIDs)
_, err = service.PutFavoriteResource(ctx, "default", 501)
require.Error(t, err)
}
func ensureMemoryFavoriteState(state mediaSupplyFavoriteState) mediaSupplyFavoriteState {
if state.Initialized {
return state
}
return mediaSupplyFavoriteState{
Groups: []MediaSupplyFavoriteGroup{{
ID: "default",
Name: "默认分组",
ResourceIDs: []int64{},
UpdatedAt: favoriteTestTime(),
}},
Initialized: true,
Revision: 1,
}
}
func cloneFavoriteState(state mediaSupplyFavoriteState) mediaSupplyFavoriteState {
state.Groups = cloneFavoriteGroups(state.Groups)
return state
}
func cloneFavoriteGroups(groups []MediaSupplyFavoriteGroup) []MediaSupplyFavoriteGroup {
cloned := make([]MediaSupplyFavoriteGroup, 0, len(groups))
for _, group := range groups {
group.ResourceIDs = append([]int64(nil), group.ResourceIDs...)
cloned = append(cloned, group)
}
return cloned
}
func removeFavoriteResourceID(ids []int64, resourceID int64) []int64 {
next := make([]int64, 0, len(ids))
for _, id := range ids {
if id != resourceID {
next = append(next, id)
}
}
return next
}
func favoriteTestTime() time.Time {
return time.Date(2026, time.July, 10, 10, 30, 0, 0, time.UTC)
}
@@ -28,6 +28,7 @@ type MediaSupplyService struct {
cfg config.Provider
client *MeijiequanClient
objectStorage objectstorage.Client
favoriteStore mediaSupplyFavoriteStore
}
func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *zap.Logger, cfg config.Provider) *MediaSupplyService {
@@ -37,11 +38,12 @@ func NewMediaSupplyService(pool *pgxpool.Pool, redis *goredis.Client, logger *za
}
client := NewMeijiequanClient(redis, current.MediaSupply.Meijiequan)
return &MediaSupplyService{
pool: pool,
redis: redis,
logger: logger,
cfg: cfg,
client: client,
pool: pool,
redis: redis,
logger: logger,
cfg: cfg,
client: client,
favoriteStore: newPostgresMediaSupplyFavoriteStore(pool),
}
}
@@ -997,6 +999,7 @@ type mediaResourceSyncStats struct {
Synced int
RemovedPriceOverride int
RemovedStale int
RemovedFavorite int
}
func (s *MediaSupplyService) UpsertSyncedResources(ctx context.Context, resources []UpsertSupplierMediaResourceInput) (int, error) {
@@ -1128,6 +1131,7 @@ func (s *MediaSupplyService) RunMediaResourceSyncOnce(ctx context.Context, model
"synced": stats.Synced,
"removed_price_overrides": stats.RemovedPriceOverride,
"removed_stale_resources": stats.RemovedStale,
"removed_stale_favorites": stats.RemovedFavorite,
}, nil
}
@@ -1176,17 +1180,18 @@ func (s *MediaSupplyService) syncMediaResourceModel(ctx context.Context, modelID
if !completed {
return total, fmt.Errorf("meijiequan media resource sync incomplete: reached max pages %d", maxPages)
}
removed, err := s.markMissingSyncedResourcesDeleted(ctx, mediaSupplySupplierMeijiequan, modelID, syncedResourceIDs)
removed, removedFavorites, err := s.markMissingSyncedResourcesDeleted(ctx, mediaSupplySupplierMeijiequan, modelID, syncedResourceIDs)
if err != nil {
return total, err
}
total.RemovedStale = removed
total.RemovedFavorite = removedFavorites
return total, nil
}
func (s *MediaSupplyService) markMissingSyncedResourcesDeleted(ctx context.Context, supplier string, modelID int, syncedResourceIDs map[string]struct{}) (int, error) {
func (s *MediaSupplyService) markMissingSyncedResourcesDeleted(ctx context.Context, supplier string, modelID int, syncedResourceIDs map[string]struct{}) (int, int, error) {
if s == nil || s.pool == nil || strings.TrimSpace(supplier) == "" || modelID <= 0 {
return 0, nil
return 0, 0, nil
}
ids := make([]string, 0, len(syncedResourceIDs))
for id := range syncedResourceIDs {
@@ -1194,18 +1199,115 @@ func (s *MediaSupplyService) markMissingSyncedResourcesDeleted(ctx context.Conte
ids = append(ids, trimmed)
}
}
tag, err := s.pool.Exec(ctx, `
UPDATE supplier_media_resources
SET deleted_at = NOW(), updated_at = NOW()
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, 0, err
}
defer tx.Rollback(ctx)
ownerRows, err := tx.Query(ctx, `
SELECT favorite_owner.tenant_id, favorite_owner.user_id
FROM media_supply_favorite_owners favorite_owner
WHERE EXISTS (
SELECT 1
FROM media_supply_favorite_resources favorite
JOIN supplier_media_resources resource ON resource.id = favorite.resource_id
WHERE favorite.tenant_id = favorite_owner.tenant_id
AND favorite.user_id = favorite_owner.user_id
AND resource.supplier = $1
AND resource.model_id = $2
AND resource.deleted_at IS NULL
AND NOT (resource.supplier_resource_id = ANY($3::text[]))
)
ORDER BY favorite_owner.tenant_id, favorite_owner.user_id
FOR UPDATE OF favorite_owner
`, supplier, modelID, ids)
if err != nil {
return 0, 0, err
}
for ownerRows.Next() {
var tenantID, userID int64
if err := ownerRows.Scan(&tenantID, &userID); err != nil {
ownerRows.Close()
return 0, 0, err
}
}
if err := ownerRows.Err(); err != nil {
ownerRows.Close()
return 0, 0, err
}
ownerRows.Close()
resourceRows, err := tx.Query(ctx, `
SELECT id
FROM supplier_media_resources
WHERE supplier = $1
AND model_id = $2
AND deleted_at IS NULL
AND NOT (supplier_resource_id = ANY($3::text[]))
ORDER BY id
FOR UPDATE
`, supplier, modelID, ids)
if err != nil {
return 0, err
return 0, 0, err
}
return int(tag.RowsAffected()), nil
for resourceRows.Next() {
var resourceID int64
if err := resourceRows.Scan(&resourceID); err != nil {
resourceRows.Close()
return 0, 0, err
}
}
if err := resourceRows.Err(); err != nil {
resourceRows.Close()
return 0, 0, err
}
resourceRows.Close()
var removed, removedFavorites int
err = tx.QueryRow(ctx, `
WITH removed_resources AS (
UPDATE supplier_media_resources
SET deleted_at = NOW(), updated_at = NOW()
WHERE supplier = $1
AND model_id = $2
AND deleted_at IS NULL
AND NOT (supplier_resource_id = ANY($3::text[]))
RETURNING id
), deleted_favorites AS (
DELETE FROM media_supply_favorite_resources favorite
USING removed_resources removed_resource
WHERE favorite.resource_id = removed_resource.id
RETURNING favorite.tenant_id, favorite.user_id, favorite.group_key
), touched_groups AS (
UPDATE media_supply_favorite_groups favorite_group
SET updated_at = NOW()
WHERE EXISTS (
SELECT 1 FROM deleted_favorites deleted
WHERE deleted.tenant_id = favorite_group.tenant_id
AND deleted.user_id = favorite_group.user_id
AND deleted.group_key = favorite_group.group_key
)
RETURNING favorite_group.tenant_id, favorite_group.user_id
), touched_owners AS (
UPDATE media_supply_favorite_owners favorite_owner
SET revision = revision + 1, updated_at = NOW()
WHERE EXISTS (
SELECT 1 FROM deleted_favorites deleted
WHERE deleted.tenant_id = favorite_owner.tenant_id
AND deleted.user_id = favorite_owner.user_id
)
RETURNING favorite_owner.tenant_id
)
SELECT (SELECT COUNT(*) FROM removed_resources),
(SELECT COUNT(*) FROM deleted_favorites)
`, supplier, modelID, ids).Scan(&removed, &removedFavorites)
if err != nil {
return 0, 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, 0, err
}
return removed, removedFavorites, nil
}
func (s *MediaSupplyService) resourceCostForPriceType(ctx context.Context, resourceID int64, priceType string) (int64, error) {
@@ -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)