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
@@ -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)