Files
geo/server/internal/tenant/app/media_supply_favorites.go
T
root 67a6cbcd9e 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>
2026-07-10 20:21:59 +08:00

920 lines
30 KiB
Go

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)