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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user