feat(media-supply): add media resource supply marketplace
Desktop Client Build / Resolve Build Metadata (push) Successful in 43s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Failing after 7m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m4s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 28s
Desktop Client Build / Resolve Build Metadata (push) Successful in 43s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Failing after 7m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m4s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 28s
Introduce an end-to-end media-supply feature: tenant-side resource sync service/worker backed by a Meijiequan supplier client, ops-side management APIs, and admin/ops web views for resources, orders, favorites and submission. Adds a shared digitocr helper, MediaSupply config blocks for tenant and ops, shared types, and migrations for supplier media resources, price overrides, customer visibility and order refunds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/bootstrap"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/app"
|
||||
)
|
||||
|
||||
type MediaSupplyHandler struct {
|
||||
svc *app.MediaSupplyService
|
||||
}
|
||||
|
||||
func NewMediaSupplyHandler(a *bootstrap.App) *MediaSupplyHandler {
|
||||
svc := a.MediaSupplyService
|
||||
if svc == nil {
|
||||
svc = app.NewMediaSupplyService(a.DB, a.Redis, a.Logger, a.ConfigStore)
|
||||
}
|
||||
return &MediaSupplyHandler{
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) ListResources(c *gin.Context) {
|
||||
modelID, err := parseOptionalIntQuery(c, "model_id")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_model_id", "媒体资源类型参数必须是数字"))
|
||||
return
|
||||
}
|
||||
page, _ := parseOptionalIntQuery(c, "page")
|
||||
pageSize, _ := parseOptionalIntQuery(c, "page_size")
|
||||
minPriceCents, err := parseOptionalInt64Query(c, "min_price_cents")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_min_price_cents", "最低价格参数必须是数字"))
|
||||
return
|
||||
}
|
||||
maxPriceCents, err := parseOptionalInt64Query(c, "max_price_cents")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_max_price_cents", "最高价格参数必须是数字"))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.ListCustomerResources(c.Request.Context(), app.ListSupplierMediaResourcesRequest{
|
||||
ModelID: modelID,
|
||||
Keyword: c.Query("keyword"),
|
||||
RemarkKeyword: c.Query("remark_keyword"),
|
||||
ChannelType: c.Query("channel_type"),
|
||||
Portal: c.Query("portal"),
|
||||
Region: c.Query("region"),
|
||||
InclusionEffect: c.Query("inclusion_effect"),
|
||||
SpecialIndustry: c.Query("special_industry"),
|
||||
LinkType: c.Query("link_type"),
|
||||
DeliverySpeed: c.Query("delivery_speed"),
|
||||
AIInclude: c.Query("ai_include"),
|
||||
OtherOption: c.Query("other_option"),
|
||||
MinPriceCents: minPriceCents,
|
||||
MaxPriceCents: maxPriceCents,
|
||||
Sort: c.Query("sort"),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) ListResourcesByIDs(c *gin.Context) {
|
||||
ids, err := parseInt64ListQuery(c, "ids")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_resource_ids", "媒体资源参数必须是数字"))
|
||||
return
|
||||
}
|
||||
modelID, err := parseOptionalIntQuery(c, "model_id")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_model_id", "媒体资源类型参数必须是数字"))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.ListCustomerResourcesByIDs(c.Request.Context(), ids, modelID)
|
||||
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", "接口不存在"))
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) SearchOptions(c *gin.Context) {
|
||||
modelID, err := parseOptionalIntQuery(c, "model_id")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_model_id", "媒体资源类型参数必须是数字"))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.SearchOptions(c.Request.Context(), modelID)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) SetResourcePrice(c *gin.Context) {
|
||||
response.Error(c, response.ErrNotFound(40400, "not_found", "接口不存在"))
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) CreateOrder(c *gin.Context) {
|
||||
var req app.CreateMediaSupplyOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "投稿参数不正确"))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.CreateOrder(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.SuccessWithStatus(c, 201, data)
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) WalletStatus(c *gin.Context) {
|
||||
data, err := h.svc.WalletStatus(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) ListWalletLedgers(c *gin.Context) {
|
||||
page, _ := parseOptionalIntQuery(c, "page")
|
||||
pageSize, _ := parseOptionalIntQuery(c, "page_size")
|
||||
userID, err := parseOptionalInt64Query(c, "user_id")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_user_id", "用户参数必须是数字"))
|
||||
return
|
||||
}
|
||||
createdFrom, err := parseOptionalTimeQuery(c, "created_from")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_created_from", "开始时间格式不正确"))
|
||||
return
|
||||
}
|
||||
createdTo, err := parseOptionalTimeQuery(c, "created_to")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_created_to", "结束时间格式不正确"))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.ListWalletLedgers(c.Request.Context(), app.ListMediaSupplyWalletLedgersRequest{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
UserID: userID,
|
||||
CreatedFrom: createdFrom,
|
||||
CreatedTo: createdTo,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) AdjustWallet(c *gin.Context) {
|
||||
response.Error(c, response.ErrNotFound(40400, "not_found", "接口不存在"))
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) ListOrders(c *gin.Context) {
|
||||
page, _ := parseOptionalIntQuery(c, "page")
|
||||
pageSize, _ := parseOptionalIntQuery(c, "page_size")
|
||||
var articleID *int64
|
||||
if raw := c.Query("article_id"); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_article_id", "文章参数必须是数字"))
|
||||
return
|
||||
}
|
||||
articleID = &parsed
|
||||
}
|
||||
data, err := h.svc.ListOrders(c.Request.Context(), app.ListMediaSupplyOrdersRequest{
|
||||
Status: c.Query("status"),
|
||||
ArticleID: articleID,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) GetOrder(c *gin.Context) {
|
||||
orderID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || orderID <= 0 {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "订单参数必须是数字"))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.GetOrder(c.Request.Context(), orderID)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) ImportSession(c *gin.Context) {
|
||||
var req app.ImportMeijiequanSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", "登录状态参数不正确"))
|
||||
return
|
||||
}
|
||||
data, err := h.svc.ImportSession(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) LoginConfiguredAccount(c *gin.Context) {
|
||||
data, err := h.svc.LoginConfiguredAccount(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) SessionStatus(c *gin.Context) {
|
||||
data, err := h.svc.SessionStatus(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func (h *MediaSupplyHandler) SyncBacklinks(c *gin.Context) {
|
||||
data, err := h.svc.SyncBacklinks(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, data)
|
||||
}
|
||||
|
||||
func parseOptionalIntQuery(c *gin.Context, key string) (int, error) {
|
||||
raw := c.Query(key)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseOptionalInt64Query(c *gin.Context, key string) (int64, error) {
|
||||
raw := c.Query(key)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseOptionalTimeQuery(c *gin.Context, key string) (*time.Time, error) {
|
||||
raw := c.Query(key)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
value, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func parseInt64ListQuery(c *gin.Context, key string) ([]int64, error) {
|
||||
rawValues := c.QueryArray(key)
|
||||
if len(rawValues) == 0 {
|
||||
rawValues = []string{c.Query(key)}
|
||||
}
|
||||
values := make([]int64, 0, len(rawValues))
|
||||
for _, raw := range rawValues {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseInt(part, 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return nil, strconv.ErrSyntax
|
||||
}
|
||||
values = append(values, value)
|
||||
if len(values) >= 100 {
|
||||
return values, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
@@ -97,6 +97,24 @@ func RegisterRoutes(app *bootstrap.App) {
|
||||
media := tenantProtected.Group("/media")
|
||||
media.GET("/platforms", mediaHandler.ListPlatforms)
|
||||
|
||||
mediaSupplyHandler := NewMediaSupplyHandler(app)
|
||||
mediaSupply := tenantProtected.Group("/media-supply")
|
||||
mediaSupply.GET("/resources", mediaSupplyHandler.ListResources)
|
||||
mediaSupply.GET("/resources/by-ids", mediaSupplyHandler.ListResourcesByIDs)
|
||||
mediaSupply.GET("/resources/search-options", mediaSupplyHandler.SearchOptions)
|
||||
mediaSupply.POST("/sync-jobs", mediaSupplyHandler.QueueSync)
|
||||
mediaSupply.PUT("/resources/:id/price", mediaSupplyHandler.SetResourcePrice)
|
||||
mediaSupply.POST("/orders", middleware.RequireCurrentBrand(), mediaSupplyHandler.CreateOrder)
|
||||
mediaSupply.GET("/orders", mediaSupplyHandler.ListOrders)
|
||||
mediaSupply.POST("/orders/sync-backlinks", mediaSupplyHandler.SyncBacklinks)
|
||||
mediaSupply.GET("/orders/:id", mediaSupplyHandler.GetOrder)
|
||||
mediaSupply.GET("/wallet", mediaSupplyHandler.WalletStatus)
|
||||
mediaSupply.GET("/wallet/ledgers", mediaSupplyHandler.ListWalletLedgers)
|
||||
mediaSupply.POST("/wallet/adjustments", mediaSupplyHandler.AdjustWallet)
|
||||
mediaSupply.GET("/supplier-session", mediaSupplyHandler.SessionStatus)
|
||||
mediaSupply.PUT("/supplier-session", mediaSupplyHandler.ImportSession)
|
||||
mediaSupply.POST("/supplier-session/login", mediaSupplyHandler.LoginConfiguredAccount)
|
||||
|
||||
workspace := tenantProtected.Group("/workspace")
|
||||
wsHandler := NewWorkspaceHandler(app)
|
||||
workspace.GET("/overview", middleware.RequireCurrentBrand(), wsHandler.Overview)
|
||||
|
||||
Reference in New Issue
Block a user