diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index 9f75fd5..200801c 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -52,73 +52,21 @@ func (h *MerchantHandler) ListProducts(c *gin.Context) { response.Page(c, list, total, page, size) } -type merchantProductReq struct { - ProductCode string `json:"product_code"` - ProductName string `json:"product_name"` - Category string `json:"category"` - Description string `json:"description"` - Attributes string `json:"attributes"` - SKU string `json:"sku" binding:"required"` - DisplayName string `json:"display_name"` - PriceAmount int64 `json:"price_amount"` - CostAmount int64 `json:"cost_amount"` - Currency string `json:"currency"` - Stock int64 `json:"stock"` - Status string `json:"status"` - FulfillmentConfig string `json:"fulfillment_config"` +type merchantProductStatusReq struct { + Status string `json:"status" binding:"required"` } -func (h *MerchantHandler) CreateProduct(c *gin.Context) { - var req merchantProductReq +// UpdateProductStatus PATCH /api/merchant/products/:id/status +// 商户仅能上架/下架商品,价格、成本等配置由平台管理员维护。 +func (h *MerchantHandler) UpdateProductStatus(c *gin.Context) { + var req merchantProductStatusReq if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "参数错误:sku 必填") - return - } - product, err := h.merchantSvc.CreateMerchantProduct(middleware.GetMerchantID(c), service.CreateMerchantProductInput{ - ProductCode: req.ProductCode, - ProductName: req.ProductName, - Category: req.Category, - Description: req.Description, - Attributes: req.Attributes, - SKU: req.SKU, - DisplayName: req.DisplayName, - PriceAmount: req.PriceAmount, - CostAmount: req.CostAmount, - Currency: req.Currency, - Stock: req.Stock, - Status: req.Status, - FulfillmentConfig: req.FulfillmentConfig, - }, middleware.GetUserID(c)) - if err != nil { - response.BadRequest(c, err.Error()) - return - } - response.OK(c, product) -} - -type merchantProductUpdateReq struct { - DisplayName *string `json:"display_name"` - PriceAmount *int64 `json:"price_amount"` - CostAmount *int64 `json:"cost_amount"` - Stock *int64 `json:"stock"` - Status *string `json:"status"` - FulfillmentConfig *string `json:"fulfillment_config"` -} - -func (h *MerchantHandler) UpdateProduct(c *gin.Context) { - var req merchantProductUpdateReq - if err := c.ShouldBindJSON(&req); err != nil { - response.BadRequest(c, "参数错误") + response.BadRequest(c, "参数错误:status 必填") return } id, _ := strconv.ParseUint(c.Param("id"), 10, 64) if err := h.merchantSvc.UpdateMerchantProduct(middleware.GetMerchantID(c), uint(id), service.UpdateMerchantProductInput{ - DisplayName: req.DisplayName, - PriceAmount: req.PriceAmount, - CostAmount: req.CostAmount, - Stock: req.Stock, - Status: req.Status, - FulfillmentConfig: req.FulfillmentConfig, + Status: &req.Status, }, middleware.GetUserID(c)); err != nil { response.BadRequest(c, err.Error()) return @@ -453,6 +401,42 @@ func (h *MerchantHandler) ListMerchantProductsByAdmin(c *gin.Context) { response.OK(c, list) } +type merchantProductUpdateReq struct { + DisplayName *string `json:"display_name"` + PriceAmount *int64 `json:"price_amount"` + CostAmount *int64 `json:"cost_amount"` + Stock *int64 `json:"stock"` + Status *string `json:"status"` + FulfillmentConfig *string `json:"fulfillment_config"` +} + +// AdminUpdateMerchantProduct 供平台管理员编辑指定商户的商品(价格、成本、库存、状态等)。 +func (h *MerchantHandler) AdminUpdateMerchantProduct(c *gin.Context) { + merchantID, _ := strconv.ParseUint(c.Param("id"), 10, 64) + productID, _ := strconv.ParseUint(c.Param("pid"), 10, 64) + if merchantID == 0 || productID == 0 { + response.BadRequest(c, "参数错误") + return + } + var req merchantProductUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "参数错误") + return + } + if err := h.merchantSvc.UpdateMerchantProduct(uint(merchantID), uint(productID), service.UpdateMerchantProductInput{ + DisplayName: req.DisplayName, + PriceAmount: req.PriceAmount, + CostAmount: req.CostAmount, + Stock: req.Stock, + Status: req.Status, + FulfillmentConfig: req.FulfillmentConfig, + }, middleware.GetUserID(c)); err != nil { + response.BadRequest(c, err.Error()) + return + } + response.OK(c, nil) +} + type assignProductsReq struct { CatalogIDs []uint `json:"catalog_ids"` } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index d78788a..f2a005e 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -124,8 +124,7 @@ func Setup(h *Handlers) *gin.Engine { { merchant.GET("", h.Merchant.Current) merchant.GET("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), h.Merchant.ListProducts) - merchant.POST("/products", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateProduct) - merchant.PATCH("/products/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProduct) + merchant.PATCH("/products/:id/status", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProductStatus) merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), h.Merchant.ListOrders) merchant.POST("/orders/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateTestOrder) merchant.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.GetDeliveryLink) @@ -165,6 +164,7 @@ func Setup(h *Handlers) *gin.Engine { admin.POST("/platform/merchants/:id/members", h.Merchant.AddPlatformMerchantMember) admin.GET("/platform/product-catalog", h.Merchant.ListProductCatalog) admin.GET("/platform/merchants/:id/products", h.Merchant.ListMerchantProductsByAdmin) + admin.PATCH("/platform/merchants/:id/products/:pid", h.Merchant.AdminUpdateMerchantProduct) admin.POST("/platform/merchants/:id/products/assign", h.Merchant.AssignProducts) admin.GET("/platform/merchants/:id/wallet", h.Merchant.AdminGetWallet) admin.GET("/platform/merchants/:id/wallet/ledger", h.Merchant.AdminListWalletLedger) diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go index bc38b05..00f441c 100644 --- a/backend/internal/service/fulfillment_test.go +++ b/backend/internal/service/fulfillment_test.go @@ -723,6 +723,32 @@ func TestCreateOrderRejectsInsufficientBalance(t *testing.T) { } } +func TestCreateOrderRejectsInactiveProduct(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-inactive-product", 5000, 1, 100) + svc := NewFulfillmentService(db, nil) + + // 商户下架商品后:开放接口下单与商户测试订单都应被拒绝 + if err := db.Model(&model.MerchantProduct{}).Where("id = ?", product.ID).Update("status", model.ProductStatusInactive).Error; err != nil { + t.Fatalf("deactivate product: %v", err) + } + if _, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 16, + ClientOrderNo: "client-inactive-product", + SKU: product.SKU, + }); err == nil || !strings.Contains(err.Error(), "已下架") { + t.Fatalf("inactive product should reject order, got %v", err) + } + if _, err := svc.CreateTestOrder(CreateTestOrderInput{ + MerchantID: merchantID, + ActorUserID: 99, + SKU: product.SKU, + }); err == nil || !strings.Contains(err.Error(), "已下架") { + t.Fatalf("inactive product should reject test order, got %v", err) + } +} + func TestCreateTestOrderCreatesFulfillableOrderWithoutBilling(t *testing.T) { db := newServiceTestDB(t) merchantID, product := seedFulfillmentMerchant(t, db, "merchant-test-order", 0, 0, 100) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 27434f5..4936824 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,7 +40,7 @@ function AppRoutes() { > } /> } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 453aaa9..cabc6c5 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -56,15 +56,8 @@ export const merchantApi = { request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: MerchantMember['role'] }), products: (params?: Record) => request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult), - createProduct: (data: Partial & { - product_code?: string - product_name?: string - category?: string - description?: string - attributes?: string - }) => request.post('/merchant/products', data).then((r) => r.data.data as MerchantProduct), - updateProduct: (id: number, data: Partial) => - request.patch(`/merchant/products/${id}`, data).then((r) => r.data.data), + updateProductStatus: (id: number, status: MerchantProduct['status']) => + request.patch(`/merchant/products/${id}/status`, { status }).then((r) => r.data.data), orders: (params?: Record) => request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult), createTestOrder: (data: { @@ -184,6 +177,8 @@ export const platformApi = { request.get('/platform/product-catalog').then((r) => r.data.data as ProductCatalogItem[]), merchantProducts: (merchantId: number) => request.get(`/platform/merchants/${merchantId}/products`).then((r) => r.data.data as ProductCatalogItem[]), + updateMerchantProduct: (merchantId: number, productId: number, data: Partial) => + request.patch(`/platform/merchants/${merchantId}/products/${productId}`, data).then((r) => r.data.data), assignProducts: (merchantId: number, catalogIds: number[]) => request.post(`/platform/merchants/${merchantId}/products/assign`, { catalog_ids: catalogIds }).then((r) => r.data.data as { assigned: number }), wallet: (merchantId: number) => diff --git a/frontend/src/index.css b/frontend/src/index.css index 496003e..853ba2f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -770,20 +770,18 @@ a:hover { align-items: center; gap: 6px; padding: 4px 8px; - margin-bottom: 4px; + margin-bottom: 6px; font-size: 11.5px; - font-weight: 800; - color: #1e293b; - background: #e2e8f0; - border-radius: 4px; + font-weight: 700; + color: #64748b; letter-spacing: 0.5px; } .api-docs__nav-heading::before { content: ''; display: inline-block; - width: 5px; - height: 5px; + width: 4px; + height: 4px; border-radius: 50%; background: #2563eb; } @@ -791,9 +789,10 @@ a:hover { .api-docs__nav-item { display: block; min-height: 32px; - padding: 6px 10px 6px 12px; + padding: 6px 12px; font-size: 13px; - color: #475569; + font-weight: 500; + color: #334155; cursor: pointer; border-radius: 6px; transition: all 0.15s ease; @@ -805,15 +804,13 @@ a:hover { .api-docs__nav-item:hover { color: #2563eb; - background: #e2e8f0; + background: #eff6ff; } .api-docs__nav-item--active { color: #2563eb !important; - background: #ffffff !important; - font-weight: 700; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); - border-left: 3.5px solid #2563eb; + background: #eff6ff !important; + font-weight: 600; } .api-docs__nav-sub { @@ -821,34 +818,37 @@ a:hover { flex-direction: column; gap: 2px; margin: 4px 0 6px 10px; - padding-left: 10px; - border-left: 2px solid #cbd5e1; + padding-left: 12px; + border-left: 1.5px solid #cbd5e1; } .api-docs__nav-sub-item { display: block; min-height: 28px; - padding: 4px 8px; + padding: 4px 10px; font-size: 12px; color: #64748b; cursor: pointer; - border-radius: 4px; + border-radius: 5px; transition: all 0.15s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + position: relative; } .api-docs__nav-sub-item:hover { color: #2563eb; - background: #e2e8f0; + background: #f1f5f9; } .api-docs__nav-sub-item--active { - color: #1d4ed8 !important; - font-weight: 700; - background: #ffffff !important; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + color: #2563eb !important; + font-weight: 600; + background: #eff6ff !important; + border-left: 2px solid #2563eb; + margin-left: -13.5px; + padding-left: 11.5px; } .api-docs__content { diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 7a12f1e..6f1a3d5 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -113,8 +113,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const [callback, setCallback] = useState(null) const [members, setMembers] = useState([]) const [loading, setLoading] = useState(false) - const [productOpen, setProductOpen] = useState(false) - const [editingProduct, setEditingProduct] = useState(null) const [apiClientOpen, setApiClientOpen] = useState(false) const [apiCredential, setApiCredential] = useState(null) const [callbackCredential, setCallbackCredential] = useState(null) @@ -123,7 +121,6 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer const [testOrderProducts, setTestOrderProducts] = useState([]) const [testOrderProductsLoading, setTestOrderProductsLoading] = useState(false) const [testOrderResult, setTestOrderResult] = useState(null) - const [productForm] = Form.useForm() const [walletFilterForm] = Form.useForm() const [apiClientForm] = Form.useForm() const [callbackForm] = Form.useForm() @@ -295,45 +292,13 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer } }, [activeTab, routeTab]) - const openProductCreate = () => { - setEditingProduct(null) - productForm.resetFields() - productForm.setFieldsValue({ - currency: 'POINT', - stock: -1, - status: 'active', - price_amount: 0, - cost_amount: 0, - }) - setProductOpen(true) - } - - const openProductEdit = (record: MerchantProduct) => { - setEditingProduct(record) - productForm.setFieldsValue({ - ...record, - }) - setProductOpen(true) - } - - const submitProduct = async () => { - const values = await productForm.validateFields() - try { - const payload = { - ...values, - } - if (editingProduct) { - await merchantApi.updateProduct(editingProduct.id, payload) - message.success('商品已更新') - } else { - await merchantApi.createProduct(payload) - message.success('商品已创建') - } - setProductOpen(false) - loadProducts() - } catch (e) { - message.error(e instanceof Error ? e.message : '保存失败') - } + const openProductToggle = (record: MerchantProduct) => { + merchantApi.updateProductStatus(record.id, record.status === 'active' ? 'inactive' : 'active') + .then(() => { + message.success(record.status === 'active' ? '商品已下架,商户无法再下单' : '商品已上架') + loadProducts() + }) + .catch((e) => message.error(e instanceof Error ? e.message : '操作失败')) } const handleWalletFilter = async () => { @@ -542,7 +507,16 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer width: 90, fixed: 'right', render: (_, record) => canManage ? ( - openProductEdit(record)}>编辑 + openProductToggle(record)} + > + + {record.status === 'active' ? '下架' : '上架'} + + ) : '-', }, ] @@ -646,16 +620,11 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer - 商品上架与定额售价管理 + 商品上架与售卖管理 - 通用皮肤与游戏道具通过唯一的商户 SKU 进行对外销售与统一发货。 + 商户可自主上架/下架商品;下架后不可再创建该商品订单。 - {canManage && ( - } onClick={openProductCreate}> - 新增商品 - - )} )} - setProductOpen(false)} destroyOnClose width={620}> - - {!editingProduct && ( - <> - - - - - - - > - )} - - - - - - - - - - - - - - - - - - - - - - - - - - - {!editingProduct && ( - - - - )} - - - - - - - setTestOrderOpen(false)} destroyOnClose width={560}> diff --git a/frontend/src/pages/PlatformMerchants.tsx b/frontend/src/pages/PlatformMerchants.tsx index 4430e54..62adfda 100644 --- a/frontend/src/pages/PlatformMerchants.tsx +++ b/frontend/src/pages/PlatformMerchants.tsx @@ -45,9 +45,13 @@ export default function PlatformMerchants() { const [adjustLoading, setAdjustLoading] = useState(false) const [adjustSubmitting, setAdjustSubmitting] = useState(false) const [adjustWallet, setAdjustWallet] = useState(null) + const [editProduct, setEditProduct] = useState(null) + const [editProductOpen, setEditProductOpen] = useState(false) + const [editProductSubmitting, setEditProductSubmitting] = useState(false) const [createForm] = Form.useForm() const [settingsForm] = Form.useForm() const [adjustForm] = Form.useForm() + const [editProductForm] = Form.useForm() const load = useCallback(async (page = data.page, size = data.size) => { setLoading(true) @@ -184,6 +188,42 @@ export default function PlatformMerchants() { } } + const openProductEdit = (record: ProductCatalogItem) => { + setEditProduct(record) + editProductForm.resetFields() + editProductForm.setFieldsValue({ + display_name: record.display_name, + price_amount: record.price_amount, + cost_amount: record.cost_amount, + stock: record.stock, + status: record.status, + }) + setEditProductOpen(true) + } + + const submitProductEdit = async () => { + if (!selectedMerchant || !editProduct) return + const values = await editProductForm.validateFields() + setEditProductSubmitting(true) + try { + await platformApi.updateMerchantProduct(selectedMerchant.id, editProduct.id, { + display_name: values.display_name, + price_amount: Number(values.price_amount || 0), + cost_amount: Number(values.cost_amount || 0), + stock: Number(values.stock ?? -1), + status: values.status, + }) + message.success('商品已更新') + setEditProductOpen(false) + const productsData = await platformApi.merchantProducts(selectedMerchant.id) + setMerchantProducts(productsData || []) + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败') + } finally { + setEditProductSubmitting(false) + } + } + const columns: ColumnsType = [ { title: 'ID', dataIndex: 'id', width: 70, align: 'center' }, { title: '编码', dataIndex: 'code', width: 160, render: (v) => {v} }, @@ -515,6 +555,14 @@ export default function PlatformMerchants() { width: 80, render: (v) => (v === 'active' ? 上架 : 下架), }, + { + title: '操作', + key: 'action', + width: 60, + render: (_, record: ProductCatalogItem) => ( + openProductEdit(record)}>编辑 + ), + }, ]} /> @@ -524,6 +572,40 @@ export default function PlatformMerchants() { + + setEditProductOpen(false)} + destroyOnClose + width={560} + confirmLoading={editProductSubmitting} + okText="保存" + > + + + + + + + + + + + + + + + + + + + + 下架后商户将无法再创建该商品订单;价格/成本调整立即对后续订单生效。 + + + ) }