钱包订单退款时应当正确退回,充值时允许最低充值1分
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m7s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m7s
This commit is contained in:
@@ -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: |-
|
||||
|
||||
31
docs/device-status-split/功能总结.md
Normal file
31
docs/device-status-split/功能总结.md
Normal file
@@ -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": "已激活"
|
||||
}
|
||||
```
|
||||
@@ -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() | 更新时间 |
|
||||
|
||||
@@ -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 时必填,微信支付时忽略)"`
|
||||
}
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ const (
|
||||
|
||||
// 代理充值金额限制(单位:分)
|
||||
const (
|
||||
AgentRechargeMinAmount = 10000 // 最小充值金额(100元)
|
||||
AgentRechargeMinAmount = 1 // 最小充值金额(1分)
|
||||
AgentRechargeMaxAmount = 100000000 // 最大充值金额(1000000元)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user