From b80e7495b995d4db512dc07c89d4aa9515788bce Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Mon, 14 Sep 2026 01:16:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=AE=A1=E7=90=86=E5=90=8E?= =?UTF-8?q?=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- internal/dto/goods.go | 4 ++++ internal/mysqldao/goods.go | 13 +++++++++++ internal/service/goods.go | 46 +++++++++++++++++++++++++++++++++++++- pkg/errcode/admin_code.go | 8 ++++--- 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d00ea5c..8ef64ef 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - **管理员账号**:账号密码登录(bcrypt 存储)、JWT 双 token(access/refresh)签发与刷新、登出、修改密码;超级管理员可增删改普通管理员、禁用/启用账号;初始超级管理员由配置在启动时幂等创建;禁用/删除账号的会话立即失效。 - **仪表盘统计**:用户总数/今日新增/各状态分布、订单总数/各状态分布、累计与今日营收、累计退款、待处理退款数、待审核穿搭数、上架商品数,一次接口聚合返回。 - **用户管理**:分页搜索(昵称/手机号/OpenID/状态)、详情(含永久额度 + 每日额度 + 额度凭证列表)、封禁/解封(临时按天/永久,写原因与期限)、额度发放(有有效期额度凭证)、额度凭证回收、VIP 人工调整(事务 + 人工调整流水)。 -- **商品管理**:分页列表(含下架)、新增、编辑(含 `valid_days` 额度有效期天数)、上下架、软删除;虚拟支付道具 ID 与发布状态只读展示。 +- **商品管理**:分页列表(含下架)、新增、编辑(含 `valid_days` 额度有效期天数)、上下架、软删除;支持登记/修改微信虚拟支付道具 ID 与发布状态(仅「未发布/已发布」两态,已发布必须填道具 ID,道具 ID 全局唯一),运营无需再手工改库。**运营约定**:道具在微信后台手动创建;改价后必须同步微信侧道具价格,否则用户付款后发货金额校验会拒绝(拿不到权益)。 - **订单与退款管理**:订单列表/详情(含虚拟支付 pay_channel=2 与 XPay 平台/环境/道具字段);退款申请审核——通过置「退款中」(实际打款由运营在微信商户平台手动完成)、驳回置「失败」并记录原因。 - **内容审核**:公开穿搭列表/详情(含衣物明细)、审核通过/驳回(写 `outfit_audit_logs`)、封禁/解封;衣物列表/详情;衣柜分享列表、封禁/解封。 - **通知推送**:单用户系统通知、全量广播(分批写入 `user_notifications` + 同步 Redis 未读计数,小程序端实时可见)。 diff --git a/internal/dto/goods.go b/internal/dto/goods.go index 2f3d9be..9293533 100644 --- a/internal/dto/goods.go +++ b/internal/dto/goods.go @@ -51,6 +51,8 @@ type GoodsCreateRequest struct { ValidDays int64 `json:"valid_days" binding:"omitempty,min=0,max=3650"` Description string `json:"description" binding:"omitempty,max=500"` Status int8 `json:"status" binding:"omitempty,oneof=1 2"` + ProductID string `json:"product_id" binding:"omitempty,max=64"` // 虚拟支付道具ID + ProductStatus int8 `json:"product_status" binding:"omitempty,oneof=0 2"` // 虚拟支付道具发布状态 0=未发布 2=已发布 } // GoodsUpdateRequest 更新商品请求(传什么改什么)。 @@ -65,6 +67,8 @@ type GoodsUpdateRequest struct { OutfitShareQuota *int64 `json:"outfit_share_quota" binding:"omitempty,min=0"` ValidDays *int64 `json:"valid_days" binding:"omitempty,min=0,max=3650"` Description *string `json:"description" binding:"omitempty,max=500"` + ProductID *string `json:"product_id" binding:"omitempty,max=64"` // 虚拟支付道具ID(传空串表示解除登记) + ProductStatus *int8 `json:"product_status" binding:"omitempty,oneof=0 2"` // 虚拟支付道具发布状态 0=未发布 2=已发布 } // GoodsStatusRequest 商品上下架请求。 diff --git a/internal/mysqldao/goods.go b/internal/mysqldao/goods.go index afcbd70..b6e117b 100644 --- a/internal/mysqldao/goods.go +++ b/internal/mysqldao/goods.go @@ -82,6 +82,19 @@ func (d *GoodsDao) GoodsGetByCode(ctx context.Context, code string) (*mysqlmodel return &goods, nil } +// GoodsGetByProductID 按虚拟支付道具 ID 查询商品(用于道具 ID 唯一性校验,不含软删) +func (d *GoodsDao) GoodsGetByProductID(ctx context.Context, productID string) (*mysqlmodel.Goods, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsGetByProductID") + defer span.End() + + var goods mysqlmodel.Goods + if err := d.db.WithContext(ctx).Where("product_id = ?", productID).First(&goods).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &goods, nil +} + // GoodsCreate 创建商品 func (d *GoodsDao) GoodsCreate(ctx context.Context, goods *mysqlmodel.Goods) error { ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsCreate") diff --git a/internal/service/goods.go b/internal/service/goods.go index a74793c..69c6500 100644 --- a/internal/service/goods.go +++ b/internal/service/goods.go @@ -69,6 +69,19 @@ func (s *GoodsService) Create(ctx context.Context, adminID int64, request *dto.G return nil, errcode.DatabaseError } + // 虚拟支付道具登记校验:已发布必须有道具 ID;道具 ID 全局唯一(防两个商品指向同一微信道具导致发货金额校验失败)。 + productID := strings.TrimSpace(request.ProductID) + if request.ProductStatus == GoodsProductPublished && productID == "" { + return nil, errcode.GoodsProductInvalid.WithDetails("发布状态为「已发布」时必须填写道具 ID") + } + if productID != "" { + if _, err := s.goodsDao.GoodsGetByProductID(ctx, productID); err == nil { + return nil, errcode.GoodsProductIDExists + } else if !isNotFound(err) { + return nil, errcode.DatabaseError + } + } + status := request.Status if status == 0 { status = GoodsStatusOffShelf // 新建默认下架,运营确认后再上架。 @@ -88,6 +101,8 @@ func (s *GoodsService) Create(ctx context.Context, adminID int64, request *dto.G ValidDays: request.ValidDays, Description: request.Description, Status: status, + ProductID: productID, + ProductStatus: request.ProductStatus, } goods.CreatedBy = adminID goods.UpdatedBy = adminID @@ -104,13 +119,36 @@ func (s *GoodsService) Update(ctx context.Context, adminID int64, id int64, requ ctx, span := global.Tracer.Start(ctx, "svc.AdminGoodsUpdate") defer span.End() - if _, err := s.goodsDao.GoodsGetByID(ctx, id); err != nil { + existing, err := s.goodsDao.GoodsGetByID(ctx, id) + if err != nil { if isNotFound(err) { return errcode.GoodsNotFound } return errcode.DatabaseError } + // 虚拟支付道具登记:以「现有值 + 本次改动」的最终态校验,防止只改其中一个字段绕过校验。 + productID := existing.ProductID + if request.ProductID != nil { + productID = strings.TrimSpace(*request.ProductID) + } + productStatus := existing.ProductStatus + if request.ProductStatus != nil { + productStatus = *request.ProductStatus + } + if productStatus == GoodsProductPublished && productID == "" { + return errcode.GoodsProductInvalid.WithDetails("发布状态为「已发布」时必须填写道具 ID") + } + if request.ProductID != nil && productID != "" { + if other, derr := s.goodsDao.GoodsGetByProductID(ctx, productID); derr == nil { + if other.ID != id { + return errcode.GoodsProductIDExists + } + } else if !isNotFound(derr) { + return errcode.DatabaseError + } + } + updates := map[string]any{"updated_by": adminID} if request.GoodsName != nil { updates["goods_name"] = strings.TrimSpace(*request.GoodsName) @@ -142,6 +180,12 @@ func (s *GoodsService) Update(ctx context.Context, adminID int64, id int64, requ if request.Description != nil { updates["description"] = *request.Description } + if request.ProductID != nil { + updates["product_id"] = productID + } + if request.ProductStatus != nil { + updates["product_status"] = *request.ProductStatus + } if len(updates) == 1 { return nil // 无可更新字段。 } diff --git a/pkg/errcode/admin_code.go b/pkg/errcode/admin_code.go index a762fa0..5677540 100644 --- a/pkg/errcode/admin_code.go +++ b/pkg/errcode/admin_code.go @@ -34,9 +34,11 @@ var ( // ===== 商品域(17000 - 17999) ===== var ( - GoodsNotFound = NewError(17000, "商品不存在") - GoodsCodeExists = NewError(17001, "商品编码已存在") - GoodsPriceInvalid = NewError(17002, "商品价格不合法") + GoodsNotFound = NewError(17000, "商品不存在") + GoodsCodeExists = NewError(17001, "商品编码已存在") + GoodsPriceInvalid = NewError(17002, "商品价格不合法") + GoodsProductInvalid = NewError(17003, "虚拟支付道具信息不合法") + GoodsProductIDExists = NewError(17004, "道具 ID 已被其他商品登记") ) // ===== 订单/退款域(18000 - 18999) =====