67a6cbcd9e
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>
920 lines
30 KiB
Go
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)
|