diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index db6db29..0b79d75 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -135,6 +135,31 @@ components: required: - operation_password type: object + DtoAgentOpenAPICardResumeRequest: + properties: + card_no: + description: 卡标识(支持 ICCID、虚拟号、MSISDN) + maxLength: 100 + minLength: 1 + type: string + required: + - card_no + type: object + DtoAgentOpenAPICardResumeResponse: + properties: + card_no: + description: 卡标识 + type: string + card_status: + description: 卡状态 (normal:正常, stopped:停机) + type: string + card_status_name: + description: 卡状态名称(中文) + type: string + gateway_extend: + description: Gateway 卡状态扩展字段,复机成功后本地清空 + type: string + type: object DtoAgentOpenAPICardStatusResponse: properties: card_no: @@ -829,7 +854,7 @@ components: nullable: true type: string activation_status: - description: 激活状态(0:未激活, 1:已激活) + description: 激活状态(0:未激活, 1:已激活;设备需有生效中主套餐且任意绑定卡已实名) type: integer activation_status_name: description: 激活状态名称(中文) @@ -963,7 +988,7 @@ components: description: 设备序列号 type: string status: - description: 状态(1:在库, 2:已分销, 3:已激活, 4:已停用) + description: 资产归属状态(1:在库, 2:已分销;卡仍沿用原卡状态枚举) type: integer status_name: description: 状态名称(中文) @@ -1214,6 +1239,9 @@ components: format: date-time nullable: true type: string + expiry_base: + description: 到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算) + type: string master_usage_id: description: 主套餐ID(加油包时有值) minimum: 0 @@ -1371,8 +1399,11 @@ components: nullable: true type: string activation_status: - description: 激活状态 + description: 激活状态:0未激活 1已激活;设备需有生效中主套餐且任意绑定卡已实名 type: integer + activation_status_name: + description: 激活状态名称(中文) + type: string asset_id: description: 资产数据库ID minimum: 0 @@ -1538,7 +1569,7 @@ components: description: 固件版本号(设备类型时有效) type: string status: - description: 资产状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) + description: 资产归属状态 (1:在库, 2:已分销;卡仍沿用原卡状态枚举) type: integer status_name: description: 资产状态名称(中文) @@ -1933,6 +1964,12 @@ components: type: object DtoBatchSetDeviceSeriesBindngRequest: properties: + activation_status: + description: 激活状态 (0:未激活, 1:已激活;selection_type=filter时可选) + maximum: 1 + minimum: 0 + nullable: true + type: integer batch_no: description: 批次号(selection_type=filter时可选) maxLength: 100 @@ -1996,8 +2033,8 @@ components: nullable: true type: array status: - description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)(selection_type=filter时可选) - maximum: 4 + description: 归属状态 (1:在库, 2:已分销)(selection_type=filter时可选) + maximum: 2 minimum: 1 nullable: true type: integer @@ -4392,6 +4429,12 @@ components: format: date-time nullable: true type: string + activation_status: + description: 激活状态 (0:未激活, 1:已激活;需有生效中主套餐且任意绑定卡已实名) + type: integer + activation_status_name: + description: 激活状态名称(中文) + type: string batch_no: description: 批次号 type: string @@ -4463,10 +4506,10 @@ components: description: 固件版本号 type: string status: - description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) + description: 归属状态 (1:在库, 2:已分销) type: integer status_name: - description: 状态名称 + description: 归属状态名称(中文) type: string switch_mode: description: 切卡模式:0=自动,1=手动 @@ -13091,15 +13134,24 @@ paths: description: 设备名称(模糊查询) maxLength: 255 type: string - - description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) + - description: 归属状态 (1:在库, 2:已分销) in: query name: status schema: - description: 状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) - maximum: 4 + description: 归属状态 (1:在库, 2:已分销) + maximum: 2 minimum: 1 nullable: true type: integer + - description: 激活状态 (0:未激活, 1:已激活;需有生效中主套餐且任意绑定卡已实名) + in: query + name: activation_status + schema: + description: 激活状态 (0:未激活, 1:已激活;需有生效中主套餐且任意绑定卡已实名) + maximum: 1 + minimum: 0 + nullable: true + type: integer - description: 店铺ID(兼容旧参数,单选;NULL表示平台库存) in: query name: shop_id @@ -26220,6 +26272,138 @@ paths: summary: 查询单卡实名状态 tags: - 代理开放接口 + /api/open/v1/cards/resume: + post: + description: |- + 认证方式: + 1. 每个请求必须携带 Header:X-Agent-Account、X-Agent-Password、X-Agent-Timestamp、X-Agent-Nonce、X-Agent-Sign。 + 2. Header 字段说明: + - X-Agent-Account:代理账号标识,填写代理登录平台使用的用户名或手机号,例如 agent001;该值由平台开通代理账号时提供,必须与签名原文 account 行完全一致。 + - X-Agent-Password:代理账号当前登录密码,由代理账号持有人提供;该值同时作为 HMAC-SHA256 签名密钥,密码变更后必须使用新密码签名。 + - X-Agent-Timestamp:请求发起时间,由调用方生成,支持 Unix 秒、Unix 毫秒或 RFC3339 时间,例如 1715400000、1715400000000 或 2024-05-11T10:00:00+08:00;必须与签名原文 timestamp 行完全一致。 + - X-Agent-Nonce:请求随机串,由调用方每次请求自行生成,例如 UUID、雪花 ID 或“时间戳+随机数”;不需要平台提前分配,同一账号在 5 分钟内不可重复。 + - X-Agent-Sign:请求签名,由调用方按下方规则实时计算得到,不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化。 + 3. X-Agent-Timestamp 超出允许时间窗口会认证失败;客户端服务器时间应保持同步。 + 4. X-Agent-Nonce 重复会被判定为重放请求。 + 5. 签名原文按 7 行拼接,行尾使用 \n,最后一行后不追加换行: + METHOD + PATH + canonical_query + body_sha256 + timestamp + nonce + account + 6. METHOD 使用大写 HTTP 方法;PATH 只取请求路径,例如 /api/open/v1/cards/status,不包含域名和 query。 + 7. canonical_query 为 query 参数规范化结果:排除 sign 字段,参数名升序;同名多值按值升序;key 和 value 使用 URL QueryEscape 编码后用 key=value 拼接,多个参数用 & 连接;无 query 时为空字符串。 + 8. body_sha256 为原始请求 body 字节的 SHA256 小写十六进制值;GET 或空 body 使用空字符串的 SHA256;POST JSON 请求必须用最终发送的 body 字符串计算,签名和请求发送的 body 必须完全一致。 + 9. X-Agent-Sign = hex(HMAC-SHA256(password, sign_payload)),password 为 X-Agent-Password 的原始值,结果使用小写十六进制。 + + Node.js 签名示例: + + ```javascript + const crypto = require("crypto"); + + function queryEscape(value) { + return new URLSearchParams({ v: String(value) }).toString().slice(2); + } + + function canonicalQuery(params) { + return Object.entries(params || {}) + .filter(([key]) => key.toLowerCase() !== "sign") + .flatMap(([key, value]) => { + const values = Array.isArray(value) ? value : [value]; + return values.sort().map((item) => [key, item]); + }) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) return String(aValue).localeCompare(String(bValue)); + return aKey.localeCompare(bKey); + }) + .map(([key, value]) => queryEscape(key) + "=" + queryEscape(value)) + .join("&"); + } + + function buildAgentSign({ method, path, query, body, timestamp, nonce, account, password }) { + const bodyText = body || ""; + const bodyHash = crypto.createHash("sha256").update(bodyText).digest("hex"); + const signPayload = [ + method.toUpperCase(), + path, + canonicalQuery(query), + bodyHash, + timestamp, + nonce, + account, + ].join("\n"); + return crypto.createHmac("sha256", password).update(signPayload).digest("hex"); + } + ``` + + 按 card_no 调用上游复机接口。调用前检查本地落库的 Gateway 卡状态扩展字段,仅当 gateway_extend 等于“机卡分离停机”时允许复机,否则返回“仅允许机卡分离状态下的卡复机”。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoAgentOpenAPICardResumeRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentOpenAPICardResumeResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - AgentOpenAPIAccount: [] + AgentOpenAPINonce: [] + AgentOpenAPIPassword: [] + AgentOpenAPISign: [] + AgentOpenAPITimestamp: [] + summary: 机卡分离卡复机 + tags: + - 代理开放接口 /api/open/v1/cards/status: get: description: |- diff --git a/docs/device-status-split/功能总结.md b/docs/device-status-split/功能总结.md new file mode 100644 index 0000000..9d4bf5c --- /dev/null +++ b/docs/device-status-split/功能总结.md @@ -0,0 +1,31 @@ +# 设备状态拆分功能总结 + +## 背景 + +设备原 `status` 同时表达在库、分销、激活和停用,导致“已分销且已激活”的设备只能展示一个状态,语义混淆。 + +## 调整内容 + +- `status` 收敛为设备归属状态:`1=在库`、`2=已分销` +- 设备响应新增 `activation_status` 和 `activation_status_name` +- 设备列表和批量筛选支持按 `activation_status` 查询 +- 设备激活状态不落库,按查询实时计算 +- 历史 `status=3/4` 迁移为归属状态:有 `shop_id` 归为已分销,无 `shop_id` 归为在库 + +## 激活判定 + +设备激活必须同时满足: + +1. 存在生效中的主套餐(`tb_package_usage.status=1` 且 `master_usage_id IS NULL`) +2. 设备任意绑定卡已实名(`tb_iot_card.real_name_status=1`) + +## 响应示例 + +```json +{ + "status": 2, + "status_name": "已分销", + "activation_status": 1, + "activation_status_name": "已激活" +} +``` diff --git a/docs/iot-sim-management/表结构详细说明.md b/docs/iot-sim-management/表结构详细说明.md index bce7efc..187a511 100644 --- a/docs/iot-sim-management/表结构详细说明.md +++ b/docs/iot-sim-management/表结构详细说明.md @@ -153,7 +153,7 @@ INSERT INTO carriers (carrier_code, carrier_name, status) VALUES | sim_slots | INT | DEFAULT 1 | SIM卡槽数量(1-4) | | owner_type | VARCHAR(20) | NOT NULL, DEFAULT 'platform' | 所有者类型 platform-平台 agent-代理 user-用户 | | owner_id | BIGINT | NOT NULL, DEFAULT 0 | 所有者ID | -| status | INT | NOT NULL, DEFAULT 1 | 状态 1-在库 2-已激活 3-已停用 | +| status | INT | NOT NULL, DEFAULT 1 | 归属状态 1-在库 2-已分销 | | activated_at | TIMESTAMPTZ | | 激活时间 | | created_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | 创建时间 | | updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | 更新时间 | diff --git a/internal/model/dto/agent_recharge_dto.go b/internal/model/dto/agent_recharge_dto.go index dd297c7..21fd635 100644 --- a/internal/model/dto/agent_recharge_dto.go +++ b/internal/model/dto/agent_recharge_dto.go @@ -3,7 +3,7 @@ package dto // CreateAgentRechargeRequest 创建代理充值请求 type CreateAgentRechargeRequest struct { ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID,代理只能填自己店铺"` - Amount int64 `json:"amount" validate:"required,min=10000,max=100000000" required:"true" minimum:"10000" maximum:"100000000" description:"充值金额(分),范围100元~100万元"` + Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额(分),范围1分~100万元"` PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"` PaymentVoucherKey string `json:"payment_voucher_key" validate:"omitempty,max=500" description:"支付凭证对象存储Key(payment_method=offline 时必填,微信支付时忽略)"` } diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index fcfb7f2..234f725 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -210,8 +211,15 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe if req.ApprovedRefundAmount != nil { approvedAmount = *req.ApprovedRefundAmount } + order, err := s.orderStore.GetByID(ctx, refund.OrderID) + if err != nil { + return errors.New(errors.CodeNotFound, "订单不存在") + } + if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, order); err != nil { + return err + } - // 事务内同步更新退款状态和订单支付状态,确保审批成功后订单立即变为已退款 + // 事务内同步更新退款状态、订单支付状态和钱包回款,避免订单已退款但资金未退回。 if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.RefundRequest{}). Where("id = ? AND status = ?", id, model.RefundStatusPending). @@ -244,6 +252,10 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe return errors.New(errors.CodeInvalidStatus, "订单状态已变更,无法执行退款审批") } + if err := s.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil { + return err + } + return nil }); err != nil { return err @@ -263,6 +275,244 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe return nil } +// refundWalletPayment 处理钱包支付订单的退款回款。 +func (s *Service) refundWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error { + if order.PaymentMethod != model.PaymentMethodWallet { + return nil + } + + switch order.BuyerType { + case model.BuyerTypeAgent: + return s.refundAgentWalletPayment(ctx, tx, refund, order, amount, operatorID) + case model.BuyerTypePersonal: + return s.refundAssetWalletPayment(ctx, tx, refund, order, amount, operatorID) + default: + return errors.New(errors.CodeInvalidParam, "不支持的钱包退款买家类型") + } +} + +// refundAgentWalletPayment 将代理钱包支付的订单退款退回原扣款主钱包。 +func (s *Service) refundAgentWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error { + wallet, relatedShopID, subtype, err := s.resolveAgentRefundWallet(tx, order) + if err != nil { + return err + } + + balanceBefore := wallet.Balance + if err := s.agentWalletStore.AddBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "退回代理预充值钱包失败") + } + + refType := constants.ReferenceTypeRefund + refID := refund.ID + remark := fmt.Sprintf("订单%s退款退回预充值钱包", order.OrderNo) + transaction := &model.AgentWalletTransaction{ + AgentWalletID: wallet.ID, + ShopID: wallet.ShopID, + UserID: operatorID, + TransactionType: constants.AgentTransactionTypeRefund, + TransactionSubtype: subtype, + Amount: amount, + BalanceBefore: balanceBefore, + BalanceAfter: balanceBefore + amount, + Status: constants.TransactionStatusSuccess, + ReferenceType: &refType, + ReferenceID: &refID, + RelatedShopID: relatedShopID, + Remark: &remark, + Creator: operatorID, + ShopIDTag: wallet.ShopIDTag, + EnterpriseIDTag: wallet.EnterpriseIDTag, + } + if err := s.agentWalletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建代理钱包退款流水失败") + } + + return nil +} + +// resolveAgentRefundWallet 优先按原扣款流水定位回款钱包,兼容历史缺失扣款流水的订单。 +func (s *Service) resolveAgentRefundWallet(tx *gorm.DB, order *model.Order) (*model.AgentWallet, *uint, *string, error) { + var deductTx model.AgentWalletTransaction + err := tx.Where("reference_type = ? AND reference_id = ? AND transaction_type = ?", + constants.ReferenceTypeOrder, order.ID, constants.AgentTransactionTypeDeduct). + Order("id ASC"). + First(&deductTx).Error + if err == nil { + wallet, err := lockAgentMainWalletByID(tx, deductTx.AgentWalletID) + if err != nil { + return nil, nil, nil, err + } + return wallet, deductTx.RelatedShopID, deductTx.TransactionSubtype, nil + } + if err != gorm.ErrRecordNotFound { + return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原代理钱包扣款流水失败") + } + + payerShopID, relatedShopID, err := resolveAgentWalletRefundShopID(order) + if err != nil { + return nil, nil, nil, err + } + wallet, err := lockAgentMainWalletByShopID(tx, payerShopID) + if err != nil { + return nil, nil, nil, err + } + return wallet, relatedShopID, nil, nil +} + +// resolveAgentWalletRefundShopID 兼容旧订单:没有扣款流水时按订单角色推导原扣款店铺。 +func resolveAgentWalletRefundShopID(order *model.Order) (uint, *uint, error) { + if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate { + if order.OperatorID == nil || *order.OperatorID == 0 { + return 0, nil, errors.New(errors.CodeInternalError, "代理代购订单缺少原扣款代理") + } + relatedShopID := order.BuyerID + return *order.OperatorID, &relatedShopID, nil + } + + if order.OperatorID != nil && *order.OperatorID > 0 && order.OperatorType == model.OperatorAccountTypeAgent { + return *order.OperatorID, nil, nil + } + if order.BuyerID == 0 { + return 0, nil, errors.New(errors.CodeInternalError, "订单买家店铺为空") + } + return order.BuyerID, nil, nil +} + +// lockAgentMainWalletByID 锁定代理主钱包,保证余额快照与后续入账一致。 +func lockAgentMainWalletByID(tx *gorm.DB, walletID uint) (*model.AgentWallet, error) { + var wallet model.AgentWallet + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND wallet_type = ?", walletID, constants.AgentWalletTypeMain). + First(&wallet).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWalletNotFound, "代理预充值钱包不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理预充值钱包失败") + } + return &wallet, nil +} + +// lockAgentMainWalletByShopID 按店铺锁定代理主钱包。 +func lockAgentMainWalletByShopID(tx *gorm.DB, shopID uint) (*model.AgentWallet, error) { + var wallet model.AgentWallet + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain). + First(&wallet).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWalletNotFound, "代理预充值钱包不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理预充值钱包失败") + } + return &wallet, nil +} + +// refundAssetWalletPayment 将个人资产钱包支付的订单退款退回原资产钱包。 +func (s *Service) refundAssetWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error { + wallet, err := s.resolveAssetRefundWallet(tx, order) + if err != nil { + return err + } + + balanceBefore := wallet.Balance + if err := s.assetWalletStore.AddBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "退回资产钱包失败") + } + + refType := constants.ReferenceTypeRefund + refNo := refund.RefundNo + remark := fmt.Sprintf("订单%s退款退回资产钱包", order.OrderNo) + transaction := &model.AssetWalletTransaction{ + AssetWalletID: wallet.ID, + ResourceType: wallet.ResourceType, + ResourceID: wallet.ResourceID, + UserID: operatorID, + TransactionType: constants.AssetTransactionTypeRefund, + Amount: amount, + BalanceBefore: balanceBefore, + BalanceAfter: balanceBefore + amount, + Status: constants.TransactionStatusSuccess, + ReferenceType: &refType, + ReferenceNo: &refNo, + Remark: &remark, + Creator: operatorID, + ShopIDTag: wallet.ShopIDTag, + EnterpriseIDTag: wallet.EnterpriseIDTag, + } + if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建资产钱包退款流水失败") + } + + return nil +} + +// resolveAssetRefundWallet 优先按原扣款流水定位回款资产钱包。 +func (s *Service) resolveAssetRefundWallet(tx *gorm.DB, order *model.Order) (*model.AssetWallet, error) { + var deductTx model.AssetWalletTransaction + err := tx.Where("reference_type = ? AND reference_no = ? AND transaction_type = ?", + constants.ReferenceTypeOrder, order.OrderNo, constants.AssetTransactionTypeDeduct). + Order("id ASC"). + First(&deductTx).Error + if err == nil { + return lockAssetWalletByID(tx, deductTx.AssetWalletID) + } + if err != gorm.ErrRecordNotFound { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原资产钱包扣款流水失败") + } + + resourceType, resourceID, err := resolveOrderAssetWalletResource(order) + if err != nil { + return nil, err + } + return lockAssetWalletByResource(tx, resourceType, resourceID) +} + +// resolveOrderAssetWalletResource 从订单载体推导资产钱包归属。 +func resolveOrderAssetWalletResource(order *model.Order) (string, uint, error) { + switch order.OrderType { + case model.OrderTypeSingleCard: + if order.IotCardID == nil || *order.IotCardID == 0 { + return "", 0, errors.New(errors.CodeInternalError, "单卡订单缺少卡ID") + } + return constants.AssetWalletResourceTypeIotCard, *order.IotCardID, nil + case model.OrderTypeDevice: + if order.DeviceID == nil || *order.DeviceID == 0 { + return "", 0, errors.New(errors.CodeInternalError, "设备订单缺少设备ID") + } + return constants.AssetWalletResourceTypeDevice, *order.DeviceID, nil + default: + return "", 0, errors.New(errors.CodeInvalidParam, "未知订单类型") + } +} + +// lockAssetWalletByID 锁定资产钱包。 +func lockAssetWalletByID(tx *gorm.DB, walletID uint) (*model.AssetWallet, error) { + var wallet model.AssetWallet + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", walletID). + First(&wallet).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWalletNotFound, "资产钱包不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定资产钱包失败") + } + return &wallet, nil +} + +// lockAssetWalletByResource 按资产归属锁定资产钱包。 +func lockAssetWalletByResource(tx *gorm.DB, resourceType string, resourceID uint) (*model.AssetWallet, error) { + var wallet model.AssetWallet + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("resource_type = ? AND resource_id = ?", resourceType, resourceID). + First(&wallet).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWalletNotFound, "资产钱包不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定资产钱包失败") + } + return &wallet, nil +} + // Reject 审批拒绝退款申请 // 条件更新 WHERE status=1 func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequest) error { @@ -631,6 +881,17 @@ func validateRequestedRefundAmountByOrder(requestedRefundAmount int64, order *mo return nil } +// validateApprovedRefundAmount 校验审批退款金额不能超过申请金额和订单实收金额。 +func validateApprovedRefundAmount(approvedAmount int64, requestedRefundAmount int64, order *model.Order) error { + if approvedAmount <= 0 { + return errors.New(errors.CodeInvalidParam, "审批退款金额必须大于0") + } + if approvedAmount > requestedRefundAmount { + return errors.New(errors.CodeInvalidParam, "审批退款金额不能大于申请退款金额") + } + return validateRequestedRefundAmountByOrder(approvedAmount, order) +} + // buildRefundResponse 将退款 Model 转换为 DTO 响应 func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse { assetType := "" diff --git a/internal/store/postgres/agent_wallet_store.go b/internal/store/postgres/agent_wallet_store.go index 2e0d353..3bb54f2 100644 --- a/internal/store/postgres/agent_wallet_store.go +++ b/internal/store/postgres/agent_wallet_store.go @@ -164,6 +164,7 @@ func (s *AgentWalletStore) AddBalanceWithTx(ctx context.Context, tx *gorm.DB, wa Where("id = ?", walletID). Updates(map[string]interface{}{ "balance": gorm.Expr("balance + ?", amount), + "version": gorm.Expr("version + 1"), "updated_at": time.Now(), }) diff --git a/internal/store/postgres/asset_wallet_store.go b/internal/store/postgres/asset_wallet_store.go index 6d36bf5..137b9e5 100644 --- a/internal/store/postgres/asset_wallet_store.go +++ b/internal/store/postgres/asset_wallet_store.go @@ -91,6 +91,7 @@ func (s *AssetWalletStore) AddBalanceWithTx(ctx context.Context, tx *gorm.DB, wa Where("id = ?", walletID). Updates(map[string]interface{}{ "balance": gorm.Expr("balance + ?", amount), + "version": gorm.Expr("version + 1"), "updated_at": time.Now(), }) diff --git a/pkg/constants/wallet.go b/pkg/constants/wallet.go index f211caf..10d78a3 100644 --- a/pkg/constants/wallet.go +++ b/pkg/constants/wallet.go @@ -44,7 +44,7 @@ const ( // 代理充值金额限制(单位:分) const ( - AgentRechargeMinAmount = 10000 // 最小充值金额(100元) + AgentRechargeMinAmount = 1 // 最小充值金额(1分) AgentRechargeMaxAmount = 100000000 // 最大充值金额(1000000元) )