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)