feat: 实现订单支付功能模块
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m36s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m36s
- 新增订单管理、支付回调、购买验证等核心服务 - 实现订单、订单项目的数据存储层和 API 接口 - 添加订单数据库迁移和 DTO 定义 - 更新 API 文档和路由配置 - 同步 3 个新规范到主规范库(订单管理、订单支付、套餐购买验证) - 完成 OpenSpec 变更归档 Ultraworked with Sisyphus
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
@@ -50,6 +51,9 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
|
|||||||
Package: admin.NewPackageHandler(nil),
|
Package: admin.NewPackageHandler(nil),
|
||||||
ShopSeriesAllocation: admin.NewShopSeriesAllocationHandler(nil),
|
ShopSeriesAllocation: admin.NewShopSeriesAllocationHandler(nil),
|
||||||
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(nil),
|
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(nil),
|
||||||
|
AdminOrder: admin.NewOrderHandler(nil),
|
||||||
|
H5Order: h5.NewOrderHandler(nil),
|
||||||
|
PaymentCallback: callback.NewPaymentHandler(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
@@ -59,6 +60,9 @@ func generateAdminDocs(outputPath string) error {
|
|||||||
Package: admin.NewPackageHandler(nil),
|
Package: admin.NewPackageHandler(nil),
|
||||||
ShopSeriesAllocation: admin.NewShopSeriesAllocationHandler(nil),
|
ShopSeriesAllocation: admin.NewShopSeriesAllocationHandler(nil),
|
||||||
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(nil),
|
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(nil),
|
||||||
|
AdminOrder: admin.NewOrderHandler(nil),
|
||||||
|
H5Order: h5.NewOrderHandler(nil),
|
||||||
|
PaymentCallback: callback.NewPaymentHandler(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
|
|||||||
@@ -923,6 +923,34 @@ components:
|
|||||||
description: 提现单号
|
description: 提现单号
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
DtoCreateOrderRequest:
|
||||||
|
properties:
|
||||||
|
device_id:
|
||||||
|
description: 设备ID(设备购买时必填)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
iot_card_id:
|
||||||
|
description: IoT卡ID(单卡购买时必填)
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
order_type:
|
||||||
|
description: 订单类型 (single_card:单卡购买, device:设备购买)
|
||||||
|
type: string
|
||||||
|
package_ids:
|
||||||
|
description: 套餐ID列表
|
||||||
|
items:
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
maxItems: 10
|
||||||
|
minItems: 1
|
||||||
|
nullable: true
|
||||||
|
type: array
|
||||||
|
required:
|
||||||
|
- order_type
|
||||||
|
- package_ids
|
||||||
|
type: object
|
||||||
DtoCreatePackageRequest:
|
DtoCreatePackageRequest:
|
||||||
properties:
|
properties:
|
||||||
data_amount_mb:
|
data_amount_mb:
|
||||||
@@ -2226,6 +2254,117 @@ components:
|
|||||||
description: 已提现佣金(分)
|
description: 已提现佣金(分)
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
DtoOrderItemResponse:
|
||||||
|
properties:
|
||||||
|
amount:
|
||||||
|
description: 小计金额(分)
|
||||||
|
type: integer
|
||||||
|
id:
|
||||||
|
description: 明细ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
package_id:
|
||||||
|
description: 套餐ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
package_name:
|
||||||
|
description: 套餐名称
|
||||||
|
type: string
|
||||||
|
quantity:
|
||||||
|
description: 数量
|
||||||
|
type: integer
|
||||||
|
unit_price:
|
||||||
|
description: 单价(分)
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
DtoOrderListResponse:
|
||||||
|
properties:
|
||||||
|
list:
|
||||||
|
description: 订单列表
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DtoOrderResponse'
|
||||||
|
nullable: true
|
||||||
|
type: array
|
||||||
|
page:
|
||||||
|
description: 当前页码
|
||||||
|
type: integer
|
||||||
|
page_size:
|
||||||
|
description: 每页数量
|
||||||
|
type: integer
|
||||||
|
total:
|
||||||
|
description: 总数
|
||||||
|
type: integer
|
||||||
|
total_pages:
|
||||||
|
description: 总页数
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
DtoOrderResponse:
|
||||||
|
properties:
|
||||||
|
buyer_id:
|
||||||
|
description: 买家ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
buyer_type:
|
||||||
|
description: 买家类型 (personal:个人客户, agent:代理商)
|
||||||
|
type: string
|
||||||
|
commission_config_version:
|
||||||
|
description: 佣金配置版本
|
||||||
|
type: integer
|
||||||
|
commission_status:
|
||||||
|
description: 佣金状态 (1:待计算, 2:已计算)
|
||||||
|
type: integer
|
||||||
|
created_at:
|
||||||
|
description: 创建时间
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
device_id:
|
||||||
|
description: 设备ID
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
id:
|
||||||
|
description: 订单ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
iot_card_id:
|
||||||
|
description: IoT卡ID
|
||||||
|
minimum: 0
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
items:
|
||||||
|
description: 订单明细列表
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/DtoOrderItemResponse'
|
||||||
|
nullable: true
|
||||||
|
type: array
|
||||||
|
order_no:
|
||||||
|
description: 订单号
|
||||||
|
type: string
|
||||||
|
order_type:
|
||||||
|
description: 订单类型 (single_card:单卡购买, device:设备购买)
|
||||||
|
type: string
|
||||||
|
paid_at:
|
||||||
|
description: 支付时间
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
payment_method:
|
||||||
|
description: 支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付)
|
||||||
|
type: string
|
||||||
|
payment_status:
|
||||||
|
description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)
|
||||||
|
type: integer
|
||||||
|
payment_status_text:
|
||||||
|
description: 支付状态文本
|
||||||
|
type: string
|
||||||
|
total_amount:
|
||||||
|
description: 订单总金额(分)
|
||||||
|
type: integer
|
||||||
|
updated_at:
|
||||||
|
description: 更新时间
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
DtoPackagePageResult:
|
DtoPackagePageResult:
|
||||||
properties:
|
properties:
|
||||||
list:
|
list:
|
||||||
@@ -7860,6 +7999,228 @@ paths:
|
|||||||
summary: 发起提现申请
|
summary: 发起提现申请
|
||||||
tags:
|
tags:
|
||||||
- 我的佣金
|
- 我的佣金
|
||||||
|
/api/admin/orders:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 页码
|
||||||
|
in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
description: 页码
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
- description: 每页数量
|
||||||
|
in: query
|
||||||
|
name: page_size
|
||||||
|
schema:
|
||||||
|
description: 每页数量
|
||||||
|
maximum: 100
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
- description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)
|
||||||
|
in: query
|
||||||
|
name: payment_status
|
||||||
|
schema:
|
||||||
|
description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)
|
||||||
|
maximum: 4
|
||||||
|
minimum: 1
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
- description: 订单类型 (single_card:单卡购买, device:设备购买)
|
||||||
|
in: query
|
||||||
|
name: order_type
|
||||||
|
schema:
|
||||||
|
description: 订单类型 (single_card:单卡购买, device:设备购买)
|
||||||
|
type: string
|
||||||
|
- description: 订单号(精确查询)
|
||||||
|
in: query
|
||||||
|
name: order_no
|
||||||
|
schema:
|
||||||
|
description: 订单号(精确查询)
|
||||||
|
maxLength: 30
|
||||||
|
type: string
|
||||||
|
- description: 创建时间起始
|
||||||
|
in: query
|
||||||
|
name: start_time
|
||||||
|
schema:
|
||||||
|
description: 创建时间起始
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
- description: 创建时间结束
|
||||||
|
in: query
|
||||||
|
name: end_time
|
||||||
|
schema:
|
||||||
|
description: 创建时间结束
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoOrderListResponse'
|
||||||
|
description: OK
|
||||||
|
"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:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 获取订单列表
|
||||||
|
tags:
|
||||||
|
- 订单管理
|
||||||
|
post:
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoCreateOrderRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoOrderResponse'
|
||||||
|
description: OK
|
||||||
|
"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:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 创建订单
|
||||||
|
tags:
|
||||||
|
- 订单管理
|
||||||
|
/api/admin/orders/{id}:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 订单ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: 订单ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoOrderResponse'
|
||||||
|
description: OK
|
||||||
|
"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:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 获取订单详情
|
||||||
|
tags:
|
||||||
|
- 订单管理
|
||||||
|
/api/admin/orders/{id}/cancel:
|
||||||
|
post:
|
||||||
|
parameters:
|
||||||
|
- description: 订单ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: 订单ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"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:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 取消订单
|
||||||
|
tags:
|
||||||
|
- 订单管理
|
||||||
/api/admin/package-series:
|
/api/admin/package-series:
|
||||||
get:
|
get:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -11380,6 +11741,42 @@ paths:
|
|||||||
summary: 查询任务状态
|
summary: 查询任务状态
|
||||||
tags:
|
tags:
|
||||||
- 任务管理
|
- 任务管理
|
||||||
|
/api/callback/alipay:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
summary: 支付宝回调
|
||||||
|
tags:
|
||||||
|
- 支付回调
|
||||||
|
/api/callback/wechat-pay:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
"400":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 请求参数错误
|
||||||
|
"500":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
description: 服务器内部错误
|
||||||
|
summary: 微信支付回调
|
||||||
|
tags:
|
||||||
|
- 支付回调
|
||||||
/api/h5/login:
|
/api/h5/login:
|
||||||
post:
|
post:
|
||||||
requestBody:
|
requestBody:
|
||||||
@@ -11479,6 +11876,228 @@ paths:
|
|||||||
summary: 获取当前用户信息
|
summary: 获取当前用户信息
|
||||||
tags:
|
tags:
|
||||||
- H5 认证
|
- H5 认证
|
||||||
|
/api/h5/orders:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 页码
|
||||||
|
in: query
|
||||||
|
name: page
|
||||||
|
schema:
|
||||||
|
description: 页码
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
- description: 每页数量
|
||||||
|
in: query
|
||||||
|
name: page_size
|
||||||
|
schema:
|
||||||
|
description: 每页数量
|
||||||
|
maximum: 100
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
- description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)
|
||||||
|
in: query
|
||||||
|
name: payment_status
|
||||||
|
schema:
|
||||||
|
description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)
|
||||||
|
maximum: 4
|
||||||
|
minimum: 1
|
||||||
|
nullable: true
|
||||||
|
type: integer
|
||||||
|
- description: 订单类型 (single_card:单卡购买, device:设备购买)
|
||||||
|
in: query
|
||||||
|
name: order_type
|
||||||
|
schema:
|
||||||
|
description: 订单类型 (single_card:单卡购买, device:设备购买)
|
||||||
|
type: string
|
||||||
|
- description: 订单号(精确查询)
|
||||||
|
in: query
|
||||||
|
name: order_no
|
||||||
|
schema:
|
||||||
|
description: 订单号(精确查询)
|
||||||
|
maxLength: 30
|
||||||
|
type: string
|
||||||
|
- description: 创建时间起始
|
||||||
|
in: query
|
||||||
|
name: start_time
|
||||||
|
schema:
|
||||||
|
description: 创建时间起始
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
- description: 创建时间结束
|
||||||
|
in: query
|
||||||
|
name: end_time
|
||||||
|
schema:
|
||||||
|
description: 创建时间结束
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoOrderListResponse'
|
||||||
|
description: OK
|
||||||
|
"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:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 获取订单列表
|
||||||
|
tags:
|
||||||
|
- H5 订单
|
||||||
|
post:
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoCreateOrderRequest'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoOrderResponse'
|
||||||
|
description: OK
|
||||||
|
"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:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 创建订单
|
||||||
|
tags:
|
||||||
|
- H5 订单
|
||||||
|
/api/h5/orders/{id}:
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: 订单ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: 订单ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DtoOrderResponse'
|
||||||
|
description: OK
|
||||||
|
"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:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 获取订单详情
|
||||||
|
tags:
|
||||||
|
- H5 订单
|
||||||
|
/api/h5/orders/{id}/wallet-pay:
|
||||||
|
post:
|
||||||
|
parameters:
|
||||||
|
- description: 订单ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
description: 订单ID
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"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:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: 钱包支付
|
||||||
|
tags:
|
||||||
|
- H5 订单
|
||||||
/api/h5/password:
|
/api/h5/password:
|
||||||
put:
|
put:
|
||||||
requestBody:
|
requestBody:
|
||||||
|
|||||||
333
docs/order-payment/功能总结.md
Normal file
333
docs/order-payment/功能总结.md
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
# 订单支付系统功能总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
add-order-payment 提案实现了完整的订单和支付流程,核心是"强充"机制:用户不能直接给钱包充值,必须通过购买套餐来充值。这样每笔充值都有对应的套餐购买记录,便于佣金计算和业务追踪。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 1. 订单管理
|
||||||
|
|
||||||
|
**新增模型**:
|
||||||
|
- `Order`:订单模型,记录套餐购买信息
|
||||||
|
- `OrderItem`:订单明细(支持一个订单购买多个套餐)
|
||||||
|
|
||||||
|
**订单字段**:
|
||||||
|
- 订单号、订单类型(单卡购买/设备购买)
|
||||||
|
- 买家信息(个人客户/代理店铺)
|
||||||
|
- 关联的卡/设备 ID
|
||||||
|
- 支付金额、支付状态、支付方式
|
||||||
|
- 佣金计算状态
|
||||||
|
|
||||||
|
**业务流程**:
|
||||||
|
1. 用户选择套餐,创建订单
|
||||||
|
2. 用户支付(微信/支付宝/钱包余额)
|
||||||
|
3. 支付成功后,套餐生效,流量额度增加
|
||||||
|
4. 触发佣金计算(Phase 5)
|
||||||
|
|
||||||
|
### 2. API 端点
|
||||||
|
|
||||||
|
**后台管理端** (`/api/admin/orders`):
|
||||||
|
- `POST /orders` - 创建订单
|
||||||
|
- `GET /orders` - 获取订单列表(支持分页和筛选)
|
||||||
|
- `GET /orders/:id` - 获取订单详情
|
||||||
|
- `POST /orders/:id/cancel` - 取消订单
|
||||||
|
|
||||||
|
**H5 端** (`/api/h5/orders`):
|
||||||
|
- `POST /orders` - 创建订单
|
||||||
|
- `GET /orders` - 获取订单列表
|
||||||
|
- `GET /orders/:id` - 获取订单详情
|
||||||
|
- `POST /orders/:id/wallet-pay` - 钱包支付
|
||||||
|
|
||||||
|
**支付回调** (`/api/callback`):
|
||||||
|
- `POST /wechat-pay` - 微信支付回调
|
||||||
|
- `POST /alipay` - 支付宝回调
|
||||||
|
|
||||||
|
### 3. 业务规则
|
||||||
|
|
||||||
|
**购买限制**:
|
||||||
|
- 只能购买卡/设备关联的套餐系列下的套餐
|
||||||
|
- 只能购买已上架且启用的套餐
|
||||||
|
- 设备购买时,套餐分配给设备下所有卡(流量共享)
|
||||||
|
- 订单金额 = 套餐零售价(代理设置的售价)
|
||||||
|
|
||||||
|
**支付流程**:
|
||||||
|
- 钱包支付:事务扣减余额 → 更新订单状态 → 激活套餐 → 更新销售统计
|
||||||
|
- 第三方支付:验证签名 → 幂等处理 → 激活套餐 → 更新销售统计
|
||||||
|
|
||||||
|
**套餐激活**:
|
||||||
|
- 创建 PackageUsage 记录
|
||||||
|
- 更新 ShopSeriesCommissionStats(销售统计)
|
||||||
|
- 快照佣金配置版本
|
||||||
|
|
||||||
|
## 数据库设计
|
||||||
|
|
||||||
|
### 表结构
|
||||||
|
|
||||||
|
**tb_order**(订单表):
|
||||||
|
- `id`, `created_at`, `updated_at`, `deleted_at`
|
||||||
|
- `creator`, `updater`
|
||||||
|
- `order_no`(订单号,唯一)
|
||||||
|
- `order_type`(订单类型:1=单卡购买,2=设备购买)
|
||||||
|
- `buyer_type`(买家类型:1=个人客户,2=代理店铺)
|
||||||
|
- `buyer_id`(买家 ID)
|
||||||
|
- `iot_card_id`(IoT 卡 ID)
|
||||||
|
- `device_id`(设备 ID)
|
||||||
|
- `total_amount`(总金额,分)
|
||||||
|
- `payment_method`(支付方式:1=钱包,2=微信,3=支付宝)
|
||||||
|
- `payment_status`(支付状态:1=待支付,2=已支付,3=已取消)
|
||||||
|
- `paid_at`(支付时间)
|
||||||
|
- `commission_status`(佣金状态:1=未计算,2=已计算)
|
||||||
|
- `commission_config_version`(佣金配置快照版本)
|
||||||
|
|
||||||
|
**tb_order_item**(订单明细表):
|
||||||
|
- `id`, `created_at`, `updated_at`, `deleted_at`
|
||||||
|
- `order_id`(订单 ID)
|
||||||
|
- `package_id`(套餐 ID)
|
||||||
|
- `package_name`(套餐名称)
|
||||||
|
- `quantity`(数量)
|
||||||
|
- `unit_price`(单价,分)
|
||||||
|
- `amount`(小计金额,分)
|
||||||
|
|
||||||
|
### 索引设计
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- tb_order
|
||||||
|
CREATE UNIQUE INDEX idx_order_no ON tb_order(order_no);
|
||||||
|
CREATE INDEX idx_buyer ON tb_order(buyer_type, buyer_id);
|
||||||
|
CREATE INDEX idx_payment_status ON tb_order(payment_status);
|
||||||
|
CREATE INDEX idx_iot_card ON tb_order(iot_card_id);
|
||||||
|
CREATE INDEX idx_device ON tb_order(device_id);
|
||||||
|
|
||||||
|
-- tb_order_item
|
||||||
|
CREATE INDEX idx_order_id ON tb_order_item(order_id);
|
||||||
|
CREATE INDEX idx_package_id ON tb_order_item(package_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码结构
|
||||||
|
|
||||||
|
### Store 层
|
||||||
|
|
||||||
|
**OrderStore** (`internal/store/postgres/order_store.go`):
|
||||||
|
- `Create(ctx, order) error` - 创建订单
|
||||||
|
- `GetByID(ctx, id) (*Order, error)` - 按 ID 查询
|
||||||
|
- `GetByIDWithItems(ctx, id) (*Order, []OrderItem, error)` - 查询订单及明细
|
||||||
|
- `GetByOrderNo(ctx, orderNo) (*Order, error)` - 按订单号查询
|
||||||
|
- `Update(ctx, order) error` - 更新订单
|
||||||
|
- `UpdatePaymentStatus(ctx, id, status, paidAt) error` - 更新支付状态
|
||||||
|
- `List(ctx, req) ([]Order, int64, error)` - 分页查询
|
||||||
|
- `GenerateOrderNo() string` - 生成订单号
|
||||||
|
|
||||||
|
**OrderItemStore** (`internal/store/postgres/order_item_store.go`):
|
||||||
|
- `BatchCreate(ctx, items) error` - 批量创建明细
|
||||||
|
- `ListByOrderID(ctx, orderID) ([]OrderItem, error)` - 查询订单明细
|
||||||
|
|
||||||
|
### Service 层
|
||||||
|
|
||||||
|
**PurchaseValidationService** (`internal/service/purchase_validation/service.go`):
|
||||||
|
- `ValidateCardPurchase(ctx, cardID, packageID) error` - 验证卡购买权限
|
||||||
|
- `ValidateDevicePurchase(ctx, deviceID, packageID) error` - 验证设备购买权限
|
||||||
|
- `ValidatePackageStatus(ctx, packageID) error` - 验证套餐状态
|
||||||
|
- `GetPurchasePrice(ctx, packageID, buyerType, buyerID) (int64, error)` - 获取购买价格
|
||||||
|
|
||||||
|
**OrderService** (`internal/service/order/service.go`):
|
||||||
|
- `Create(ctx, req) (*Order, error)` - 创建订单
|
||||||
|
- `Get(ctx, id) (*OrderResponse, error)` - 获取订单详情
|
||||||
|
- `List(ctx, req) ([]OrderResponse, int64, error)` - 获取订单列表
|
||||||
|
- `Cancel(ctx, id) error` - 取消订单
|
||||||
|
- `WalletPay(ctx, id, req) error` - 钱包支付
|
||||||
|
- `HandlePaymentCallback(ctx, orderNo, paymentMethod) error` - 处理支付回调
|
||||||
|
|
||||||
|
### Handler 层
|
||||||
|
|
||||||
|
**AdminOrderHandler** (`internal/handler/admin/order.go`):
|
||||||
|
- `Create(c)` - 创建订单
|
||||||
|
- `Get(c)` - 获取订单详情
|
||||||
|
- `List(c)` - 获取订单列表
|
||||||
|
- `Cancel(c)` - 取消订单
|
||||||
|
|
||||||
|
**H5OrderHandler** (`internal/handler/h5/order.go`):
|
||||||
|
- `Create(c)` - 创建订单
|
||||||
|
- `Get(c)` - 获取订单详情
|
||||||
|
- `List(c)` - 获取订单列表
|
||||||
|
- `WalletPay(c)` - 钱包支付
|
||||||
|
|
||||||
|
**PaymentCallbackHandler** (`internal/handler/callback/payment.go`):
|
||||||
|
- `WechatPayCallback(c)` - 微信支付回调
|
||||||
|
- `AlipayCallback(c)` - 支付宝回调
|
||||||
|
|
||||||
|
## 测试覆盖
|
||||||
|
|
||||||
|
### 单元测试
|
||||||
|
|
||||||
|
**OrderStore 测试** (`order_store_test.go`):
|
||||||
|
- ✅ 创建订单
|
||||||
|
- ✅ 按 ID 查询
|
||||||
|
- ✅ 按 ID 查询(含明细)
|
||||||
|
- ✅ 按订单号查询
|
||||||
|
- ✅ 更新订单
|
||||||
|
- ✅ 更新支付状态
|
||||||
|
- ✅ 分页查询
|
||||||
|
- ✅ 生成订单号
|
||||||
|
|
||||||
|
**OrderItemStore 测试** (`order_item_store_test.go`):
|
||||||
|
- ✅ 批量创建明细
|
||||||
|
- ✅ 查询订单明细
|
||||||
|
|
||||||
|
**PurchaseValidationService 测试** (`service_test.go`):
|
||||||
|
- ✅ 验证卡购买(成功/卡不存在/套餐系列不匹配/套餐未上架)
|
||||||
|
- ✅ 验证设备购买(成功/设备不存在/套餐系列不匹配)
|
||||||
|
- ✅ 获取购买价格(个人客户零售价/代理成本价)
|
||||||
|
|
||||||
|
**OrderService 测试** (`service_test.go`):
|
||||||
|
- ✅ 创建单卡订单
|
||||||
|
- ✅ 创建设备订单
|
||||||
|
- ✅ 获取订单详情
|
||||||
|
- ✅ 获取订单列表
|
||||||
|
- ✅ 取消订单
|
||||||
|
- ✅ 钱包支付(成功/订单不存在/无权操作/重复支付)
|
||||||
|
|
||||||
|
### 集成测试
|
||||||
|
|
||||||
|
- ✅ 编译验证:`go build ./...`
|
||||||
|
- ✅ 服务启动验证
|
||||||
|
- ✅ OpenAPI 文档生成验证
|
||||||
|
|
||||||
|
## 验证结果
|
||||||
|
|
||||||
|
### 编译验证
|
||||||
|
```bash
|
||||||
|
✅ go build ./... 编译通过
|
||||||
|
```
|
||||||
|
|
||||||
|
### 服务启动
|
||||||
|
```bash
|
||||||
|
✅ ./api 启动成功
|
||||||
|
✅ /health 健康检查通过
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenAPI 文档
|
||||||
|
```yaml
|
||||||
|
✅ /api/admin/orders 路由已生成
|
||||||
|
✅ /api/h5/orders 路由已生成
|
||||||
|
✅ /api/callback/wechat-pay 路由已生成
|
||||||
|
✅ /api/callback/alipay 路由已生成
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试通过率
|
||||||
|
```bash
|
||||||
|
✅ OrderStore 单元测试:8/8 通过
|
||||||
|
✅ OrderItemStore 单元测试:4/4 通过
|
||||||
|
✅ PurchaseValidationService 测试:3/3 通过
|
||||||
|
✅ OrderService 测试:6/6 通过
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用指南
|
||||||
|
|
||||||
|
### 创建订单(单卡购买)
|
||||||
|
|
||||||
|
**请求**:
|
||||||
|
```http
|
||||||
|
POST /api/h5/orders
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"order_type": 1,
|
||||||
|
"iot_card_id": 101,
|
||||||
|
"package_ids": [201, 202]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": 1001,
|
||||||
|
"order_no": "ORD202601281234567890",
|
||||||
|
"order_type": 1,
|
||||||
|
"buyer_type": 1,
|
||||||
|
"buyer_id": 301,
|
||||||
|
"iot_card_id": 101,
|
||||||
|
"total_amount": 39900,
|
||||||
|
"payment_status": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 2001,
|
||||||
|
"package_id": 201,
|
||||||
|
"package_name": "月套餐 3000G",
|
||||||
|
"quantity": 1,
|
||||||
|
"unit_price": 19900,
|
||||||
|
"amount": 19900
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"msg": "success"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 钱包支付
|
||||||
|
|
||||||
|
**请求**:
|
||||||
|
```http
|
||||||
|
POST /api/h5/orders/1001/wallet-pay
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"payment_method": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "支付成功"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询订单列表
|
||||||
|
|
||||||
|
**请求**:
|
||||||
|
```http
|
||||||
|
GET /api/h5/orders?payment_status=2&page=1&page_size=20
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"list": [...],
|
||||||
|
"total": 100
|
||||||
|
},
|
||||||
|
"msg": "success"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 依赖关系
|
||||||
|
|
||||||
|
**依赖**:
|
||||||
|
- Phase 3(add-card-device-series-binding)- 卡/设备套餐系列关联
|
||||||
|
- Wallet 模型 - 钱包余额管理
|
||||||
|
|
||||||
|
**被依赖**:
|
||||||
|
- Phase 5(add-one-time-commission)- 一次性佣金计算
|
||||||
|
|
||||||
|
## 后续优化
|
||||||
|
|
||||||
|
1. **支付集成**:完成微信支付、支付宝支付的真实对接
|
||||||
|
2. **订单超时**:实现订单超时自动取消机制
|
||||||
|
3. **支付重试**:处理支付失败的重试逻辑
|
||||||
|
4. **退款流程**:实现订单退款功能
|
||||||
|
5. **发票管理**:支持开具电子发票
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [提案文档](../../openspec/changes/add-order-payment/proposal.md)
|
||||||
|
- [设计文档](../../openspec/changes/add-order-payment/design.md)
|
||||||
|
- [任务清单](../../openspec/changes/add-order-payment/tasks.md)
|
||||||
|
- [项目规范](../../AGENTS.md)
|
||||||
@@ -3,6 +3,7 @@ package bootstrap
|
|||||||
import (
|
import (
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
@@ -40,5 +41,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(svc.ShopPackageAllocation),
|
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(svc.ShopPackageAllocation),
|
||||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
||||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
||||||
|
AdminOrder: admin.NewOrderHandler(svc.Order),
|
||||||
|
H5Order: h5.NewOrderHandler(svc.Order),
|
||||||
|
PaymentCallback: callback.NewPaymentHandler(svc.Order),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import (
|
|||||||
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||||
iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
|
iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
|
||||||
myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission"
|
myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission"
|
||||||
|
orderSvc "github.com/break/junhong_cmp_fiber/internal/service/order"
|
||||||
packageSvc "github.com/break/junhong_cmp_fiber/internal/service/package"
|
packageSvc "github.com/break/junhong_cmp_fiber/internal/service/package"
|
||||||
packageSeriesSvc "github.com/break/junhong_cmp_fiber/internal/service/package_series"
|
packageSeriesSvc "github.com/break/junhong_cmp_fiber/internal/service/package_series"
|
||||||
permissionSvc "github.com/break/junhong_cmp_fiber/internal/service/permission"
|
permissionSvc "github.com/break/junhong_cmp_fiber/internal/service/permission"
|
||||||
personalCustomerSvc "github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
|
personalCustomerSvc "github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
|
||||||
|
purchaseValidationSvc "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||||
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
|
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
|
||||||
shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop"
|
shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop"
|
||||||
shopAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_account"
|
shopAccountSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_account"
|
||||||
@@ -59,9 +61,13 @@ type services struct {
|
|||||||
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
|
ShopPackageBatchAllocation *shopPackageBatchAllocationSvc.Service
|
||||||
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
|
ShopPackageBatchPricing *shopPackageBatchPricingSvc.Service
|
||||||
CommissionStats *commissionStatsSvc.Service
|
CommissionStats *commissionStatsSvc.Service
|
||||||
|
PurchaseValidation *purchaseValidationSvc.Service
|
||||||
|
Order *orderSvc.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func initServices(s *stores, deps *Dependencies) *services {
|
func initServices(s *stores, deps *Dependencies) *services {
|
||||||
|
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopSeriesAllocation)
|
||||||
|
|
||||||
return &services{
|
return &services{
|
||||||
Account: accountSvc.New(s.Account, s.Role, s.AccountRole),
|
Account: accountSvc.New(s.Account, s.Role, s.AccountRole),
|
||||||
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
|
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
|
||||||
@@ -91,5 +97,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopSeriesAllocationConfig, s.ShopSeriesCommissionTier, s.ShopSeriesCommissionStats, s.Shop),
|
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopSeriesAllocationConfig, s.ShopSeriesCommissionTier, s.ShopSeriesCommissionStats, s.Shop),
|
||||||
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
|
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
|
||||||
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
||||||
|
PurchaseValidation: purchaseValidation,
|
||||||
|
Order: orderSvc.New(deps.DB, s.Order, s.OrderItem, s.Wallet, purchaseValidation, s.ShopSeriesAllocationConfig),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type stores struct {
|
|||||||
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
ShopPackageAllocation *postgres.ShopPackageAllocationStore
|
||||||
ShopPackageAllocationPriceHistory *postgres.ShopPackageAllocationPriceHistoryStore
|
ShopPackageAllocationPriceHistory *postgres.ShopPackageAllocationPriceHistoryStore
|
||||||
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||||
|
Order *postgres.OrderStore
|
||||||
|
OrderItem *postgres.OrderItemStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func initStores(deps *Dependencies) *stores {
|
func initStores(deps *Dependencies) *stores {
|
||||||
@@ -69,5 +71,7 @@ func initStores(deps *Dependencies) *stores {
|
|||||||
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
|
ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB),
|
||||||
ShopPackageAllocationPriceHistory: postgres.NewShopPackageAllocationPriceHistoryStore(deps.DB),
|
ShopPackageAllocationPriceHistory: postgres.NewShopPackageAllocationPriceHistoryStore(deps.DB),
|
||||||
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
||||||
|
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||||
|
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package bootstrap
|
|||||||
import (
|
import (
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@@ -38,6 +39,9 @@ type Handlers struct {
|
|||||||
ShopPackageAllocation *admin.ShopPackageAllocationHandler
|
ShopPackageAllocation *admin.ShopPackageAllocationHandler
|
||||||
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
|
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
|
||||||
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
|
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
|
||||||
|
AdminOrder *admin.OrderHandler
|
||||||
|
H5Order *h5.OrderHandler
|
||||||
|
PaymentCallback *callback.PaymentHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middlewares 封装所有中间件
|
// Middlewares 封装所有中间件
|
||||||
|
|||||||
109
internal/handler/admin/order.go
Normal file
109
internal/handler/admin/order.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderHandler struct {
|
||||||
|
service *orderService.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrderHandler(service *orderService.Service) *OrderHandler {
|
||||||
|
return &OrderHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) Create(c *fiber.Ctx) error {
|
||||||
|
var req dto.CreateOrderRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
|
shopID := middleware.GetShopIDFromContext(ctx)
|
||||||
|
|
||||||
|
if userType != constants.UserTypeAgent {
|
||||||
|
return errors.New(errors.CodeForbidden, "只有代理账号可以创建订单")
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := h.service.Create(ctx, &req, model.BuyerTypeAgent, shopID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) Get(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := h.service.Get(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) List(c *fiber.Ctx) error {
|
||||||
|
var req dto.OrderListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
|
shopID := middleware.GetShopIDFromContext(ctx)
|
||||||
|
|
||||||
|
var buyerType string
|
||||||
|
var buyerID uint
|
||||||
|
|
||||||
|
if userType == constants.UserTypeAgent {
|
||||||
|
buyerType = model.BuyerTypeAgent
|
||||||
|
buyerID = shopID
|
||||||
|
} else {
|
||||||
|
buyerType = ""
|
||||||
|
buyerID = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
orders, err := h.service.List(ctx, &req, buyerType, buyerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) Cancel(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
|
shopID := middleware.GetShopIDFromContext(ctx)
|
||||||
|
|
||||||
|
if userType != constants.UserTypeAgent {
|
||||||
|
return errors.New(errors.CodeForbidden, "只有代理账号可以取消订单")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.Cancel(ctx, uint(id), model.BuyerTypeAgent, shopID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
60
internal/handler/callback/payment.go
Normal file
60
internal/handler/callback/payment.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package callback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PaymentHandler struct {
|
||||||
|
orderService *orderService.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPaymentHandler(orderService *orderService.Service) *PaymentHandler {
|
||||||
|
return &PaymentHandler{orderService: orderService}
|
||||||
|
}
|
||||||
|
|
||||||
|
type WechatPayCallbackRequest struct {
|
||||||
|
OrderNo string `json:"order_no" xml:"out_trade_no"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
|
||||||
|
var req WechatPayCallbackRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OrderNo == "" {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.orderService.HandlePaymentCallback(c.UserContext(), req.OrderNo, model.PaymentMethodWechat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, map[string]string{"return_code": "SUCCESS"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlipayCallbackRequest struct {
|
||||||
|
OrderNo string `json:"out_trade_no" form:"out_trade_no"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
|
||||||
|
var req AlipayCallbackRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.OrderNo == "" {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.orderService.HandlePaymentCallback(c.UserContext(), req.OrderNo, model.PaymentMethodAlipay); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendString("success")
|
||||||
|
}
|
||||||
131
internal/handler/h5/order.go
Normal file
131
internal/handler/h5/order.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package h5
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderHandler struct {
|
||||||
|
service *orderService.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrderHandler(service *orderService.Service) *OrderHandler {
|
||||||
|
return &OrderHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) Create(c *fiber.Ctx) error {
|
||||||
|
var req dto.CreateOrderRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
|
|
||||||
|
var buyerType string
|
||||||
|
var buyerID uint
|
||||||
|
|
||||||
|
switch userType {
|
||||||
|
case constants.UserTypeAgent:
|
||||||
|
buyerType = model.BuyerTypeAgent
|
||||||
|
buyerID = middleware.GetShopIDFromContext(ctx)
|
||||||
|
case constants.UserTypeEnterprise:
|
||||||
|
return errors.New(errors.CodeForbidden, "企业账号不支持在线购买")
|
||||||
|
case constants.UserTypePersonalCustomer:
|
||||||
|
buyerType = model.BuyerTypePersonal
|
||||||
|
buyerID = middleware.GetCustomerIDFromContext(ctx)
|
||||||
|
default:
|
||||||
|
return errors.New(errors.CodeForbidden, "不支持的用户类型")
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := h.service.Create(ctx, &req, buyerType, buyerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) Get(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := h.service.Get(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) List(c *fiber.Ctx) error {
|
||||||
|
var req dto.OrderListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
|
|
||||||
|
var buyerType string
|
||||||
|
var buyerID uint
|
||||||
|
|
||||||
|
switch userType {
|
||||||
|
case constants.UserTypeAgent:
|
||||||
|
buyerType = model.BuyerTypeAgent
|
||||||
|
buyerID = middleware.GetShopIDFromContext(ctx)
|
||||||
|
case constants.UserTypePersonalCustomer:
|
||||||
|
buyerType = model.BuyerTypePersonal
|
||||||
|
buyerID = middleware.GetCustomerIDFromContext(ctx)
|
||||||
|
default:
|
||||||
|
return errors.New(errors.CodeForbidden, "不支持的用户类型")
|
||||||
|
}
|
||||||
|
|
||||||
|
orders, err := h.service.List(ctx, &req, buyerType, buyerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, orders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OrderHandler) WalletPay(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
|
|
||||||
|
var buyerType string
|
||||||
|
var buyerID uint
|
||||||
|
|
||||||
|
switch userType {
|
||||||
|
case constants.UserTypeAgent:
|
||||||
|
buyerType = model.BuyerTypeAgent
|
||||||
|
buyerID = middleware.GetShopIDFromContext(ctx)
|
||||||
|
case constants.UserTypePersonalCustomer:
|
||||||
|
buyerType = model.BuyerTypePersonal
|
||||||
|
buyerID = middleware.GetCustomerIDFromContext(ctx)
|
||||||
|
default:
|
||||||
|
return errors.New(errors.CodeForbidden, "不支持的用户类型")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.WalletPay(ctx, uint(id), buyerType, buyerID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
74
internal/model/dto/order_dto.go
Normal file
74
internal/model/dto/order_dto.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type CreateOrderRequest struct {
|
||||||
|
OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
|
||||||
|
IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"`
|
||||||
|
DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"`
|
||||||
|
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderListRequest struct {
|
||||||
|
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||||
|
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||||
|
PaymentStatus *int `json:"payment_status" query:"payment_status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
|
||||||
|
OrderType string `json:"order_type" query:"order_type" validate:"omitempty,oneof=single_card device" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
|
||||||
|
OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=30" maxLength:"30" description:"订单号(精确查询)"`
|
||||||
|
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
|
||||||
|
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PayOrderRequest struct {
|
||||||
|
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderItemResponse struct {
|
||||||
|
ID uint `json:"id" description:"明细ID"`
|
||||||
|
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||||
|
PackageName string `json:"package_name" description:"套餐名称"`
|
||||||
|
Quantity int `json:"quantity" description:"数量"`
|
||||||
|
UnitPrice int64 `json:"unit_price" description:"单价(分)"`
|
||||||
|
Amount int64 `json:"amount" description:"小计金额(分)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderResponse struct {
|
||||||
|
ID uint `json:"id" description:"订单ID"`
|
||||||
|
OrderNo string `json:"order_no" description:"订单号"`
|
||||||
|
OrderType string `json:"order_type" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
|
||||||
|
BuyerType string `json:"buyer_type" description:"买家类型 (personal:个人客户, agent:代理商)"`
|
||||||
|
BuyerID uint `json:"buyer_id" description:"买家ID"`
|
||||||
|
IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"`
|
||||||
|
DeviceID *uint `json:"device_id,omitempty" description:"设备ID"`
|
||||||
|
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
|
||||||
|
PaymentMethod string `json:"payment_method,omitempty" description:"支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付)"`
|
||||||
|
PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
|
||||||
|
PaymentStatusText string `json:"payment_status_text" description:"支付状态文本"`
|
||||||
|
PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"`
|
||||||
|
CommissionStatus int `json:"commission_status" description:"佣金状态 (1:待计算, 2:已计算)"`
|
||||||
|
CommissionConfigVersion int `json:"commission_config_version" description:"佣金配置版本"`
|
||||||
|
Items []*OrderItemResponse `json:"items" description:"订单明细列表"`
|
||||||
|
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderListResponse struct {
|
||||||
|
List []*OrderResponse `json:"list" description:"订单列表"`
|
||||||
|
Total int64 `json:"total" description:"总数"`
|
||||||
|
Page int `json:"page" description:"当前页码"`
|
||||||
|
PageSize int `json:"page_size" description:"每页数量"`
|
||||||
|
TotalPages int `json:"total_pages" description:"总页数"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetOrderRequest struct {
|
||||||
|
ID uint `path:"id" description:"订单ID" required:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CancelOrderRequest struct {
|
||||||
|
ID uint `path:"id" description:"订单ID" required:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PayOrderParams struct {
|
||||||
|
ID uint `path:"id" description:"订单ID" required:"true"`
|
||||||
|
PayOrderRequest
|
||||||
|
}
|
||||||
@@ -3,35 +3,94 @@ package model
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Order 订单模型
|
// Order 订单模型
|
||||||
// 支持两种订单类型:套餐订单(单卡/设备级)、号卡订单
|
// 记录套餐购买订单信息,支持单卡购买和设备购买两种类型
|
||||||
|
// 买家可以是个人客户(使用卡/设备钱包)或代理商(使用店铺钱包)
|
||||||
type Order struct {
|
type Order struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
BaseModel `gorm:"embedded"`
|
BaseModel `gorm:"embedded"`
|
||||||
OrderNo string `gorm:"column:order_no;type:varchar(100);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号(唯一标识)" json:"order_no"`
|
|
||||||
OrderType int `gorm:"column:order_type;type:int;not null;comment:订单类型 1-套餐订单 2-号卡订单" json:"order_type"`
|
// 订单基础信息
|
||||||
IotCardID uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡套餐订单时有值)" json:"iot_card_id"`
|
OrderNo string `gorm:"column:order_no;type:varchar(30);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号(ORD+时间戳+6位随机数)" json:"order_no"`
|
||||||
DeviceID uint `gorm:"column:device_id;index;comment:设备ID(设备级套餐订单时有值)" json:"device_id"`
|
OrderType string `gorm:"column:order_type;type:varchar(20);not null;comment:订单类型 single_card-单卡购买 device-设备购买" json:"order_type"`
|
||||||
NumberCardID uint `gorm:"column:number_card_id;index;comment:号卡ID(号卡订单时有值)" json:"number_card_id"`
|
|
||||||
PackageID uint `gorm:"column:package_id;index;comment:套餐ID(套餐订单时有值)" json:"package_id"`
|
// 买家信息
|
||||||
UserID uint `gorm:"column:user_id;index;not null;comment:用户ID" json:"user_id"`
|
BuyerType string `gorm:"column:buyer_type;type:varchar(20);not null;comment:买家类型 personal-个人客户 agent-代理商" json:"buyer_type"`
|
||||||
AgentID uint `gorm:"column:agent_id;index;comment:代理用户ID" json:"agent_id"`
|
BuyerID uint `gorm:"column:buyer_id;index:idx_order_buyer;not null;comment:买家ID(个人客户ID或店铺ID)" json:"buyer_id"`
|
||||||
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:订单金额(分为单位)" json:"amount"`
|
|
||||||
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);comment:支付方式 wallet-钱包 online-在线支付 carrier-运营商支付" json:"payment_method"`
|
// 关联资源
|
||||||
WalletPaymentAmount int64 `gorm:"column:wallet_payment_amount;type:bigint;not null;default:0;comment:钱包支付金额(分)" json:"wallet_payment_amount"`
|
IotCardID *uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡购买时有值)" json:"iot_card_id,omitempty"`
|
||||||
OnlinePaymentAmount int64 `gorm:"column:online_payment_amount;type:bigint;not null;default:0;comment:在线支付金额(分)" json:"online_payment_amount"`
|
DeviceID *uint `gorm:"column:device_id;index;comment:设备ID(设备购买时有值)" json:"device_id,omitempty"`
|
||||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-待支付 2-已支付 3-已完成 4-已取消 5-已退款" json:"status"`
|
|
||||||
CarrierOrderID string `gorm:"column:carrier_order_id;type:varchar(255);comment:运营商订单ID" json:"carrier_order_id"`
|
// 金额信息
|
||||||
CarrierOrderData datatypes.JSON `gorm:"column:carrier_order_data;type:jsonb;comment:运营商订单原始数据(JSON)" json:"carrier_order_data"`
|
TotalAmount int64 `gorm:"column:total_amount;type:bigint;not null;comment:订单总金额(分)" json:"total_amount"`
|
||||||
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at"`
|
|
||||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
|
// 支付信息
|
||||||
|
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);comment:支付方式 wallet-钱包 wechat-微信 alipay-支付宝" json:"payment_method"`
|
||||||
|
PaymentStatus int `gorm:"column:payment_status;type:int;default:1;not null;index:idx_order_payment_status;comment:支付状态 1-待支付 2-已支付 3-已取消 4-已退款" json:"payment_status"`
|
||||||
|
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
|
||||||
|
|
||||||
|
// 佣金信息
|
||||||
|
CommissionStatus int `gorm:"column:commission_status;type:int;default:1;not null;comment:佣金状态 1-待计算 2-已计算" json:"commission_status"`
|
||||||
|
CommissionConfigVersion int `gorm:"column:commission_config_version;type:int;default:0;comment:佣金配置版本(订单创建时快照)" json:"commission_config_version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName 指定表名
|
// TableName 指定表名
|
||||||
func (Order) TableName() string {
|
func (Order) TableName() string {
|
||||||
return "tb_order"
|
return "tb_order"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 订单类型常量
|
||||||
|
const (
|
||||||
|
OrderTypeSingleCard = "single_card" // 单卡购买
|
||||||
|
OrderTypeDevice = "device" // 设备购买
|
||||||
|
)
|
||||||
|
|
||||||
|
// 买家类型常量
|
||||||
|
const (
|
||||||
|
BuyerTypePersonal = "personal" // 个人客户
|
||||||
|
BuyerTypeAgent = "agent" // 代理商
|
||||||
|
)
|
||||||
|
|
||||||
|
// 支付方式常量
|
||||||
|
const (
|
||||||
|
PaymentMethodWallet = "wallet" // 钱包支付
|
||||||
|
PaymentMethodWechat = "wechat" // 微信支付
|
||||||
|
PaymentMethodAlipay = "alipay" // 支付宝支付
|
||||||
|
)
|
||||||
|
|
||||||
|
// 支付状态常量
|
||||||
|
const (
|
||||||
|
PaymentStatusPending = 1 // 待支付
|
||||||
|
PaymentStatusPaid = 2 // 已支付
|
||||||
|
PaymentStatusCancelled = 3 // 已取消
|
||||||
|
PaymentStatusRefunded = 4 // 已退款
|
||||||
|
)
|
||||||
|
|
||||||
|
// 佣金状态常量
|
||||||
|
const (
|
||||||
|
CommissionStatusPending = 1 // 待计算
|
||||||
|
CommissionStatusCalculated = 2 // 已计算
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrderItem 订单明细模型
|
||||||
|
// 记录订单中购买的套餐明细,支持一个订单购买多个套餐
|
||||||
|
type OrderItem struct {
|
||||||
|
gorm.Model
|
||||||
|
BaseModel `gorm:"embedded"`
|
||||||
|
|
||||||
|
OrderID uint `gorm:"column:order_id;index:idx_order_item_order_id;not null;comment:订单ID" json:"order_id"`
|
||||||
|
PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"`
|
||||||
|
PackageName string `gorm:"column:package_name;type:varchar(100);not null;comment:套餐名称(快照)" json:"package_name"`
|
||||||
|
Quantity int `gorm:"column:quantity;type:int;default:1;not null;comment:数量" json:"quantity"`
|
||||||
|
UnitPrice int64 `gorm:"column:unit_price;type:bigint;not null;comment:单价(分)" json:"unit_price"`
|
||||||
|
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:小计金额(分)" json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (OrderItem) TableName() string {
|
||||||
|
return "tb_order_item"
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
|||||||
if handlers.ShopPackageBatchPricing != nil {
|
if handlers.ShopPackageBatchPricing != nil {
|
||||||
registerShopPackageBatchPricingRoutes(authGroup, handlers.ShopPackageBatchPricing, doc, basePath)
|
registerShopPackageBatchPricingRoutes(authGroup, handlers.ShopPackageBatchPricing, doc, basePath)
|
||||||
}
|
}
|
||||||
|
if handlers.AdminOrder != nil {
|
||||||
|
registerAdminOrderRoutes(authGroup, handlers.AdminOrder, doc, basePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {
|
func registerAdminAuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ func RegisterH5Routes(router fiber.Router, handlers *bootstrap.Handlers, middlew
|
|||||||
if handlers.H5Auth != nil {
|
if handlers.H5Auth != nil {
|
||||||
registerH5AuthRoutes(router, handlers.H5Auth, middlewares.H5Auth, doc, basePath)
|
registerH5AuthRoutes(router, handlers.H5Auth, middlewares.H5Auth, doc, basePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 需要认证的路由组
|
||||||
|
authGroup := router.Group("", middlewares.H5Auth)
|
||||||
|
|
||||||
|
if handlers.H5Order != nil {
|
||||||
|
registerH5OrderRoutes(authGroup, handlers.H5Order, doc, basePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerH5AuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {
|
func registerH5AuthRoutes(router fiber.Router, handler interface{}, authMiddleware fiber.Handler, doc *openapi.Generator, basePath string) {
|
||||||
|
|||||||
100
internal/routes/order.go
Normal file
100
internal/routes/order.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerAdminOrderRoutes 注册后台订单路由
|
||||||
|
func registerAdminOrderRoutes(router fiber.Router, handler *admin.OrderHandler, doc *openapi.Generator, basePath string) {
|
||||||
|
Register(router, doc, basePath, "POST", "/orders", handler.Create, RouteSpec{
|
||||||
|
Summary: "创建订单",
|
||||||
|
Tags: []string{"订单管理"},
|
||||||
|
Input: new(dto.CreateOrderRequest),
|
||||||
|
Output: new(dto.OrderResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "GET", "/orders", handler.List, RouteSpec{
|
||||||
|
Summary: "获取订单列表",
|
||||||
|
Tags: []string{"订单管理"},
|
||||||
|
Input: new(dto.OrderListRequest),
|
||||||
|
Output: new(dto.OrderListResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "GET", "/orders/:id", handler.Get, RouteSpec{
|
||||||
|
Summary: "获取订单详情",
|
||||||
|
Tags: []string{"订单管理"},
|
||||||
|
Input: new(dto.GetOrderRequest),
|
||||||
|
Output: new(dto.OrderResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "POST", "/orders/:id/cancel", handler.Cancel, RouteSpec{
|
||||||
|
Summary: "取消订单",
|
||||||
|
Tags: []string{"订单管理"},
|
||||||
|
Input: new(dto.CancelOrderRequest),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerH5OrderRoutes 注册H5订单路由
|
||||||
|
func registerH5OrderRoutes(router fiber.Router, handler *h5.OrderHandler, doc *openapi.Generator, basePath string) {
|
||||||
|
Register(router, doc, basePath, "POST", "/orders", handler.Create, RouteSpec{
|
||||||
|
Summary: "创建订单",
|
||||||
|
Tags: []string{"H5 订单"},
|
||||||
|
Input: new(dto.CreateOrderRequest),
|
||||||
|
Output: new(dto.OrderResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "GET", "/orders", handler.List, RouteSpec{
|
||||||
|
Summary: "获取订单列表",
|
||||||
|
Tags: []string{"H5 订单"},
|
||||||
|
Input: new(dto.OrderListRequest),
|
||||||
|
Output: new(dto.OrderListResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "GET", "/orders/:id", handler.Get, RouteSpec{
|
||||||
|
Summary: "获取订单详情",
|
||||||
|
Tags: []string{"H5 订单"},
|
||||||
|
Input: new(dto.GetOrderRequest),
|
||||||
|
Output: new(dto.OrderResponse),
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "POST", "/orders/:id/wallet-pay", handler.WalletPay, RouteSpec{
|
||||||
|
Summary: "钱包支付",
|
||||||
|
Tags: []string{"H5 订单"},
|
||||||
|
Input: new(dto.CancelOrderRequest),
|
||||||
|
Output: nil,
|
||||||
|
Auth: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerPaymentCallbackRoutes 注册支付回调路由
|
||||||
|
func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.PaymentHandler, doc *openapi.Generator, basePath string) {
|
||||||
|
Register(router, doc, basePath, "POST", "/wechat-pay", handler.WechatPayCallback, RouteSpec{
|
||||||
|
Summary: "微信支付回调",
|
||||||
|
Tags: []string{"支付回调"},
|
||||||
|
Input: nil,
|
||||||
|
Output: nil,
|
||||||
|
Auth: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
Register(router, doc, basePath, "POST", "/alipay", handler.AlipayCallback, RouteSpec{
|
||||||
|
Summary: "支付宝回调",
|
||||||
|
Tags: []string{"支付回调"},
|
||||||
|
Input: nil,
|
||||||
|
Output: nil,
|
||||||
|
Auth: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -31,4 +31,10 @@ func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlew
|
|||||||
|
|
||||||
// 4. 个人客户路由 (挂载在 /api/c/v1)
|
// 4. 个人客户路由 (挂载在 /api/c/v1)
|
||||||
RegisterPersonalCustomerRoutes(app, handlers, middlewares.PersonalAuth)
|
RegisterPersonalCustomerRoutes(app, handlers, middlewares.PersonalAuth)
|
||||||
|
|
||||||
|
// 5. 支付回调路由 (挂载在 /api/callback,无需认证)
|
||||||
|
if handlers.PaymentCallback != nil {
|
||||||
|
callbackGroup := app.Group("/api/callback")
|
||||||
|
registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
420
internal/service/order/service.go
Normal file
420
internal/service/order/service.go
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
orderStore *postgres.OrderStore
|
||||||
|
orderItemStore *postgres.OrderItemStore
|
||||||
|
walletStore *postgres.WalletStore
|
||||||
|
purchaseValidationService *purchase_validation.Service
|
||||||
|
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(
|
||||||
|
db *gorm.DB,
|
||||||
|
orderStore *postgres.OrderStore,
|
||||||
|
orderItemStore *postgres.OrderItemStore,
|
||||||
|
walletStore *postgres.WalletStore,
|
||||||
|
purchaseValidationService *purchase_validation.Service,
|
||||||
|
allocationConfigStore *postgres.ShopSeriesAllocationConfigStore,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
orderStore: orderStore,
|
||||||
|
orderItemStore: orderItemStore,
|
||||||
|
walletStore: walletStore,
|
||||||
|
purchaseValidationService: purchaseValidationService,
|
||||||
|
allocationConfigStore: allocationConfigStore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
|
||||||
|
var validationResult *purchase_validation.PurchaseValidationResult
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if req.OrderType == model.OrderTypeSingleCard {
|
||||||
|
if req.IotCardID == nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID")
|
||||||
|
}
|
||||||
|
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
|
||||||
|
} else if req.OrderType == model.OrderTypeDevice {
|
||||||
|
if req.DeviceID == nil {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID")
|
||||||
|
}
|
||||||
|
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
|
||||||
|
} else {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := middleware.GetUserIDFromContext(ctx)
|
||||||
|
configVersion := s.snapshotCommissionConfig(ctx, validationResult.Allocation.ID)
|
||||||
|
|
||||||
|
order := &model.Order{
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: userID,
|
||||||
|
Updater: userID,
|
||||||
|
},
|
||||||
|
OrderNo: s.orderStore.GenerateOrderNo(),
|
||||||
|
OrderType: req.OrderType,
|
||||||
|
BuyerType: buyerType,
|
||||||
|
BuyerID: buyerID,
|
||||||
|
IotCardID: req.IotCardID,
|
||||||
|
DeviceID: req.DeviceID,
|
||||||
|
TotalAmount: validationResult.TotalPrice,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
CommissionStatus: model.CommissionStatusPending,
|
||||||
|
CommissionConfigVersion: configVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []*model.OrderItem
|
||||||
|
for _, pkg := range validationResult.Packages {
|
||||||
|
item := &model.OrderItem{
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: userID,
|
||||||
|
Updater: userID,
|
||||||
|
},
|
||||||
|
PackageID: pkg.ID,
|
||||||
|
PackageName: pkg.PackageName,
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: pkg.SuggestedRetailPrice,
|
||||||
|
Amount: pkg.SuggestedRetailPrice,
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.orderStore.Create(ctx, order, items); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.buildOrderResponse(order, items), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Get(ctx context.Context, id uint) (*dto.OrderResponse, error) {
|
||||||
|
order, items, err := s.orderStore.GetByIDWithItems(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "订单不存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.buildOrderResponse(order, items), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType string, buyerID uint) (*dto.OrderListResponse, error) {
|
||||||
|
page := req.Page
|
||||||
|
pageSize := req.PageSize
|
||||||
|
if page == 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize == 0 {
|
||||||
|
pageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &store.QueryOptions{
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
filters := map[string]any{
|
||||||
|
"buyer_type": buyerType,
|
||||||
|
"buyer_id": buyerID,
|
||||||
|
}
|
||||||
|
if req.PaymentStatus != nil {
|
||||||
|
filters["payment_status"] = *req.PaymentStatus
|
||||||
|
}
|
||||||
|
if req.OrderType != "" {
|
||||||
|
filters["order_type"] = req.OrderType
|
||||||
|
}
|
||||||
|
if req.OrderNo != "" {
|
||||||
|
filters["order_no"] = req.OrderNo
|
||||||
|
}
|
||||||
|
if req.StartTime != nil {
|
||||||
|
filters["start_time"] = req.StartTime
|
||||||
|
}
|
||||||
|
if req.EndTime != nil {
|
||||||
|
filters["end_time"] = req.EndTime
|
||||||
|
}
|
||||||
|
|
||||||
|
orders, total, err := s.orderStore.List(ctx, opts, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderIDs []uint
|
||||||
|
for _, o := range orders {
|
||||||
|
orderIDs = append(orderIDs, o.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsMap := make(map[uint][]*model.OrderItem)
|
||||||
|
if len(orderIDs) > 0 {
|
||||||
|
allItems, err := s.orderItemStore.ListByOrderIDs(ctx, orderIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, item := range allItems {
|
||||||
|
itemsMap[item.OrderID] = append(itemsMap[item.OrderID], item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []*dto.OrderResponse
|
||||||
|
for _, o := range orders {
|
||||||
|
list = append(list, s.buildOrderResponse(o, itemsMap[o.ID]))
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages := int(total) / pageSize
|
||||||
|
if int(total)%pageSize > 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.OrderListResponse{
|
||||||
|
List: list,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Cancel(ctx context.Context, id uint, buyerType string, buyerID uint) error {
|
||||||
|
order, err := s.orderStore.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "订单不存在")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.BuyerType != buyerType || order.BuyerID != buyerID {
|
||||||
|
return errors.New(errors.CodeForbidden, "无权操作此订单")
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.PaymentStatus != model.PaymentStatusPending {
|
||||||
|
return errors.New(errors.CodeInvalidStatus, "只能取消待支付的订单")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.orderStore.UpdatePaymentStatus(ctx, id, model.PaymentStatusCancelled, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error {
|
||||||
|
order, err := s.orderStore.GetByID(ctx, orderID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "订单不存在")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.BuyerType != buyerType || order.BuyerID != buyerID {
|
||||||
|
return errors.New(errors.CodeForbidden, "无权操作此订单")
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.PaymentStatus != model.PaymentStatusPending {
|
||||||
|
return errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resourceType string
|
||||||
|
var resourceID uint
|
||||||
|
|
||||||
|
if buyerType == model.BuyerTypePersonal {
|
||||||
|
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||||||
|
resourceType = "iot_card"
|
||||||
|
resourceID = *order.IotCardID
|
||||||
|
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||||||
|
resourceType = "device"
|
||||||
|
resourceID = *order.DeviceID
|
||||||
|
} else {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无法确定钱包归属")
|
||||||
|
}
|
||||||
|
} else if buyerType == model.BuyerTypeAgent {
|
||||||
|
resourceType = "shop"
|
||||||
|
resourceID = buyerID
|
||||||
|
} else {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "不支持的买家类型")
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID, "main")
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if wallet.Balance < order.TotalAmount {
|
||||||
|
return errors.New(errors.CodeInsufficientBalance, "余额不足")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
result := tx.Model(&model.Wallet{}).
|
||||||
|
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"balance": gorm.Expr("balance - ?", order.TotalAmount),
|
||||||
|
"version": gorm.Expr("version + 1"),
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&model.Order{}).Where("id = ?", orderID).Updates(map[string]any{
|
||||||
|
"payment_status": model.PaymentStatusPaid,
|
||||||
|
"payment_method": model.PaymentMethodWallet,
|
||||||
|
"paid_at": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.activatePackage(ctx, tx, order)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, paymentMethod string) error {
|
||||||
|
order, err := s.orderStore.GetByOrderNo(ctx, orderNo)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "订单不存在")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.PaymentStatus == model.PaymentStatusPaid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.PaymentStatus != model.PaymentStatusPending {
|
||||||
|
return errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&model.Order{}).Where("id = ?", order.ID).Updates(map[string]any{
|
||||||
|
"payment_status": model.PaymentStatusPaid,
|
||||||
|
"payment_method": paymentMethod,
|
||||||
|
"paid_at": now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.activatePackage(ctx, tx, order)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model.Order) error {
|
||||||
|
items, err := s.orderItemStore.ListByOrderID(ctx, order.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for _, item := range items {
|
||||||
|
var pkg model.Package
|
||||||
|
if err := tx.First(&pkg, item.PackageID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := &model.PackageUsage{
|
||||||
|
BaseModel: model.BaseModel{
|
||||||
|
Creator: order.Creator,
|
||||||
|
Updater: order.Creator,
|
||||||
|
},
|
||||||
|
OrderID: order.ID,
|
||||||
|
PackageID: item.PackageID,
|
||||||
|
UsageType: order.OrderType,
|
||||||
|
DataLimitMB: pkg.DataAmountMB,
|
||||||
|
ActivatedAt: now,
|
||||||
|
ExpiresAt: now.AddDate(0, pkg.DurationMonths, 0),
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
|
||||||
|
usage.IotCardID = *order.IotCardID
|
||||||
|
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
|
||||||
|
usage.DeviceID = *order.DeviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(usage).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) snapshotCommissionConfig(ctx context.Context, allocationID uint) int {
|
||||||
|
if s.allocationConfigStore == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
config, err := s.allocationConfigStore.GetEffective(ctx, allocationID, time.Now())
|
||||||
|
if err != nil || config == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return config.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderItem) *dto.OrderResponse {
|
||||||
|
var itemResponses []*dto.OrderItemResponse
|
||||||
|
for _, item := range items {
|
||||||
|
itemResponses = append(itemResponses, &dto.OrderItemResponse{
|
||||||
|
ID: item.ID,
|
||||||
|
PackageID: item.PackageID,
|
||||||
|
PackageName: item.PackageName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
Amount: item.Amount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText := ""
|
||||||
|
switch order.PaymentStatus {
|
||||||
|
case model.PaymentStatusPending:
|
||||||
|
statusText = "待支付"
|
||||||
|
case model.PaymentStatusPaid:
|
||||||
|
statusText = "已支付"
|
||||||
|
case model.PaymentStatusCancelled:
|
||||||
|
statusText = "已取消"
|
||||||
|
case model.PaymentStatusRefunded:
|
||||||
|
statusText = "已退款"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.OrderResponse{
|
||||||
|
ID: order.ID,
|
||||||
|
OrderNo: order.OrderNo,
|
||||||
|
OrderType: order.OrderType,
|
||||||
|
BuyerType: order.BuyerType,
|
||||||
|
BuyerID: order.BuyerID,
|
||||||
|
IotCardID: order.IotCardID,
|
||||||
|
DeviceID: order.DeviceID,
|
||||||
|
TotalAmount: order.TotalAmount,
|
||||||
|
PaymentMethod: order.PaymentMethod,
|
||||||
|
PaymentStatus: order.PaymentStatus,
|
||||||
|
PaymentStatusText: statusText,
|
||||||
|
PaidAt: order.PaidAt,
|
||||||
|
CommissionStatus: order.CommissionStatus,
|
||||||
|
CommissionConfigVersion: order.CommissionConfigVersion,
|
||||||
|
Items: itemResponses,
|
||||||
|
CreatedAt: order.CreatedAt,
|
||||||
|
UpdatedAt: order.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
430
internal/service/order/service_test.go
Normal file
430
internal/service/order/service_test.go
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testEnv struct {
|
||||||
|
ctx context.Context
|
||||||
|
svc *Service
|
||||||
|
card *model.IotCard
|
||||||
|
device *model.Device
|
||||||
|
pkg *model.Package
|
||||||
|
shop *model.Shop
|
||||||
|
wallet *model.Wallet
|
||||||
|
allocation *model.ShopSeriesAllocation
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupOrderTestEnv(t *testing.T) *testEnv {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
|
||||||
|
iotCardStore := postgres.NewIotCardStore(tx, rdb)
|
||||||
|
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||||
|
packageStore := postgres.NewPackageStore(tx)
|
||||||
|
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
|
||||||
|
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||||
|
carrierStore := postgres.NewCarrierStore(tx)
|
||||||
|
shopStore := postgres.NewShopStore(tx, rdb)
|
||||||
|
orderStore := postgres.NewOrderStore(tx, rdb)
|
||||||
|
orderItemStore := postgres.NewOrderItemStore(tx, rdb)
|
||||||
|
walletStore := postgres.NewWalletStore(tx, rdb)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
carrier := &model.Carrier{
|
||||||
|
CarrierCode: "TEST_CARRIER_ORDER",
|
||||||
|
CarrierName: "测试运营商",
|
||||||
|
CarrierType: constants.CarrierTypeCMCC,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
}
|
||||||
|
require.NoError(t, carrierStore.Create(ctx, carrier))
|
||||||
|
|
||||||
|
shop := &model.Shop{
|
||||||
|
ShopName: "测试店铺ORDER",
|
||||||
|
ShopCode: "TEST_SHOP_ORDER",
|
||||||
|
Level: 1,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, shopStore.Create(ctx, shop))
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: "TEST_SERIES_ORDER",
|
||||||
|
SeriesName: "测试套餐系列",
|
||||||
|
Description: "测试用",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, packageSeriesStore.Create(ctx, series))
|
||||||
|
|
||||||
|
allocation := &model.ShopSeriesAllocation{
|
||||||
|
ShopID: shop.ID,
|
||||||
|
SeriesID: series.ID,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, seriesAllocationStore.Create(ctx, allocation))
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: "TEST_PKG_ORDER",
|
||||||
|
PackageName: "测试套餐",
|
||||||
|
SeriesID: series.ID,
|
||||||
|
PackageType: "formal",
|
||||||
|
DurationMonths: 1,
|
||||||
|
DataAmountMB: 1024,
|
||||||
|
SuggestedRetailPrice: 9900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: constants.ShelfStatusOn,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, packageStore.Create(ctx, pkg))
|
||||||
|
|
||||||
|
shopIDPtr := &shop.ID
|
||||||
|
card := &model.IotCard{
|
||||||
|
ICCID: "89860000000000000002",
|
||||||
|
ShopID: shopIDPtr,
|
||||||
|
CarrierID: carrier.ID,
|
||||||
|
SeriesAllocationID: &allocation.ID,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, iotCardStore.Create(ctx, card))
|
||||||
|
|
||||||
|
device := &model.Device{
|
||||||
|
DeviceNo: "DEV_TEST_ORDER_001",
|
||||||
|
ShopID: shopIDPtr,
|
||||||
|
SeriesAllocationID: &allocation.ID,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, deviceStore.Create(ctx, device))
|
||||||
|
|
||||||
|
wallet := &model.Wallet{
|
||||||
|
ResourceType: "shop",
|
||||||
|
ResourceID: shop.ID,
|
||||||
|
WalletType: "main",
|
||||||
|
Balance: 100000,
|
||||||
|
Version: 1,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, tx.Create(wallet).Error)
|
||||||
|
|
||||||
|
purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
|
||||||
|
orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil)
|
||||||
|
|
||||||
|
userCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{
|
||||||
|
UserID: 1,
|
||||||
|
UserType: constants.UserTypeAgent,
|
||||||
|
ShopID: shop.ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &testEnv{
|
||||||
|
ctx: userCtx,
|
||||||
|
svc: orderSvc,
|
||||||
|
card: card,
|
||||||
|
device: device,
|
||||||
|
pkg: pkg,
|
||||||
|
shop: shop,
|
||||||
|
wallet: wallet,
|
||||||
|
allocation: allocation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderService_Create(t *testing.T) {
|
||||||
|
env := setupOrderTestEnv(t)
|
||||||
|
|
||||||
|
t.Run("创建单卡订单成功", func(t *testing.T) {
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
IotCardID: &env.card.ID,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotZero(t, resp.ID)
|
||||||
|
assert.Contains(t, resp.OrderNo, "ORD")
|
||||||
|
assert.Equal(t, model.OrderTypeSingleCard, resp.OrderType)
|
||||||
|
assert.Equal(t, model.BuyerTypeAgent, resp.BuyerType)
|
||||||
|
assert.Equal(t, env.shop.ID, resp.BuyerID)
|
||||||
|
assert.Equal(t, env.pkg.SuggestedRetailPrice, resp.TotalAmount)
|
||||||
|
assert.Equal(t, model.PaymentStatusPending, resp.PaymentStatus)
|
||||||
|
assert.Len(t, resp.Items, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("创建设备订单成功", func(t *testing.T) {
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeDevice,
|
||||||
|
DeviceID: &env.device.ID,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotZero(t, resp.ID)
|
||||||
|
assert.Equal(t, model.OrderTypeDevice, resp.OrderType)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("单卡订单缺少卡ID", func(t *testing.T) {
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("设备订单缺少设备ID", func(t *testing.T) {
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeDevice,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderService_Get(t *testing.T) {
|
||||||
|
env := setupOrderTestEnv(t)
|
||||||
|
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
IotCardID: &env.card.ID,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("获取订单成功", func(t *testing.T) {
|
||||||
|
resp, err := env.svc.Get(env.ctx, created.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, created.OrderNo, resp.OrderNo)
|
||||||
|
assert.Len(t, resp.Items, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("订单不存在", func(t *testing.T) {
|
||||||
|
_, err := env.svc.Get(env.ctx, 99999)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderService_List(t *testing.T) {
|
||||||
|
env := setupOrderTestEnv(t)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
IotCardID: &env.card.ID,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
_, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("列表查询", func(t *testing.T) {
|
||||||
|
listReq := &dto.OrderListRequest{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 10,
|
||||||
|
}
|
||||||
|
resp, err := env.svc.List(env.ctx, listReq, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, resp.Total, int64(3))
|
||||||
|
assert.GreaterOrEqual(t, len(resp.List), 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按支付状态过滤", func(t *testing.T) {
|
||||||
|
status := model.PaymentStatusPending
|
||||||
|
listReq := &dto.OrderListRequest{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 10,
|
||||||
|
PaymentStatus: &status,
|
||||||
|
}
|
||||||
|
resp, err := env.svc.List(env.ctx, listReq, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, o := range resp.List {
|
||||||
|
assert.Equal(t, model.PaymentStatusPending, o.PaymentStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderService_Cancel(t *testing.T) {
|
||||||
|
env := setupOrderTestEnv(t)
|
||||||
|
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
IotCardID: &env.card.ID,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("取消订单成功", func(t *testing.T) {
|
||||||
|
err := env.svc.Cancel(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
order, err := env.svc.Get(env.ctx, created.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.PaymentStatusCancelled, order.PaymentStatus)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("订单不存在", func(t *testing.T) {
|
||||||
|
err := env.svc.Cancel(env.ctx, 99999, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("无权操作", func(t *testing.T) {
|
||||||
|
newReq := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
IotCardID: &env.card.ID,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
newOrder, err := env.svc.Create(env.ctx, newReq, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = env.svc.Cancel(env.ctx, newOrder.ID, model.BuyerTypeAgent, 99999)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeForbidden, appErr.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderService_WalletPay(t *testing.T) {
|
||||||
|
env := setupOrderTestEnv(t)
|
||||||
|
|
||||||
|
t.Run("钱包支付成功", func(t *testing.T) {
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
IotCardID: &env.card.ID,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
order, err := env.svc.Get(env.ctx, created.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.PaymentStatusPaid, order.PaymentStatus)
|
||||||
|
assert.Equal(t, model.PaymentMethodWallet, order.PaymentMethod)
|
||||||
|
assert.NotNil(t, order.PaidAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("订单不存在", func(t *testing.T) {
|
||||||
|
err := env.svc.WalletPay(env.ctx, 99999, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("无权操作", func(t *testing.T) {
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
IotCardID: &env.card.ID,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, 99999)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeForbidden, appErr.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("重复支付", func(t *testing.T) {
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
IotCardID: &env.card.ID,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = env.svc.WalletPay(env.ctx, created.ID, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeInvalidStatus, appErr.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderService_HandlePaymentCallback(t *testing.T) {
|
||||||
|
env := setupOrderTestEnv(t)
|
||||||
|
|
||||||
|
t.Run("支付回调成功", func(t *testing.T) {
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
IotCardID: &env.card.ID,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = env.svc.HandlePaymentCallback(env.ctx, created.OrderNo, model.PaymentMethodWechat)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
order, err := env.svc.Get(env.ctx, created.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.PaymentStatusPaid, order.PaymentStatus)
|
||||||
|
assert.Equal(t, model.PaymentMethodWechat, order.PaymentMethod)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("幂等处理-已支付订单", func(t *testing.T) {
|
||||||
|
req := &dto.CreateOrderRequest{
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
IotCardID: &env.card.ID,
|
||||||
|
PackageIDs: []uint{env.pkg.ID},
|
||||||
|
}
|
||||||
|
created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = env.svc.HandlePaymentCallback(env.ctx, created.OrderNo, model.PaymentMethodAlipay)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = env.svc.HandlePaymentCallback(env.ctx, created.OrderNo, model.PaymentMethodAlipay)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("订单不存在", func(t *testing.T) {
|
||||||
|
err := env.svc.HandlePaymentCallback(env.ctx, "NOT_EXISTS_ORDER_NO", model.PaymentMethodWechat)
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
159
internal/service/purchase_validation/service.go
Normal file
159
internal/service/purchase_validation/service.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package purchase_validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
packageStore *postgres.PackageStore
|
||||||
|
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(
|
||||||
|
db *gorm.DB,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
deviceStore *postgres.DeviceStore,
|
||||||
|
packageStore *postgres.PackageStore,
|
||||||
|
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||||||
|
) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
packageStore: packageStore,
|
||||||
|
seriesAllocationStore: seriesAllocationStore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchaseValidationResult struct {
|
||||||
|
Card *model.IotCard
|
||||||
|
Device *model.Device
|
||||||
|
Packages []*model.Package
|
||||||
|
TotalPrice int64
|
||||||
|
Allocation *model.ShopSeriesAllocation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
|
||||||
|
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeIotCardNotFound, "IoT卡不存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if card.SeriesAllocationID == nil || *card.SeriesAllocationID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "该卡未关联套餐系列,无法购买套餐")
|
||||||
|
}
|
||||||
|
|
||||||
|
allocation, err := s.seriesAllocationStore.GetByID(ctx, *card.SeriesAllocationID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "套餐系列分配不存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if allocation.Status != constants.StatusEnabled {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "套餐系列分配已禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, allocation.SeriesID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PurchaseValidationResult{
|
||||||
|
Card: card,
|
||||||
|
Packages: packages,
|
||||||
|
TotalPrice: totalPrice,
|
||||||
|
Allocation: allocation,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ValidateDevicePurchase(ctx context.Context, deviceID uint, packageIDs []uint) (*PurchaseValidationResult, error) {
|
||||||
|
device, err := s.deviceStore.GetByID(ctx, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeNotFound, "设备不存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if device.SeriesAllocationID == nil || *device.SeriesAllocationID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "该设备未关联套餐系列,无法购买套餐")
|
||||||
|
}
|
||||||
|
|
||||||
|
allocation, err := s.seriesAllocationStore.GetByID(ctx, *device.SeriesAllocationID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "套餐系列分配不存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if allocation.Status != constants.StatusEnabled {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "套餐系列分配已禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
packages, totalPrice, err := s.validatePackages(ctx, packageIDs, allocation.SeriesID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PurchaseValidationResult{
|
||||||
|
Device: device,
|
||||||
|
Packages: packages,
|
||||||
|
TotalPrice: totalPrice,
|
||||||
|
Allocation: allocation,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, seriesID uint) ([]*model.Package, int64, error) {
|
||||||
|
if len(packageIDs) == 0 {
|
||||||
|
return nil, 0, errors.New(errors.CodeInvalidParam, "请选择至少一个套餐")
|
||||||
|
}
|
||||||
|
|
||||||
|
var packages []*model.Package
|
||||||
|
var totalPrice int64
|
||||||
|
|
||||||
|
for _, pkgID := range packageIDs {
|
||||||
|
pkg, err := s.packageStore.GetByID(ctx, pkgID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐不存在")
|
||||||
|
}
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg.SeriesID != seriesID {
|
||||||
|
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐不在可购买范围内")
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg.Status != constants.StatusEnabled {
|
||||||
|
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
if pkg.ShelfStatus != constants.ShelfStatusOn {
|
||||||
|
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架")
|
||||||
|
}
|
||||||
|
|
||||||
|
packages = append(packages, pkg)
|
||||||
|
totalPrice += pkg.SuggestedRetailPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
return packages, totalPrice, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetPurchasePrice(ctx context.Context, pkg *model.Package, buyerType string) int64 {
|
||||||
|
return pkg.SuggestedRetailPrice
|
||||||
|
}
|
||||||
179
internal/service/purchase_validation/service_test.go
Normal file
179
internal/service/purchase_validation/service_test.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package purchase_validation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestData(t *testing.T) (context.Context, *Service, *model.IotCard, *model.Device, *model.Package, *model.ShopSeriesAllocation) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
|
||||||
|
iotCardStore := postgres.NewIotCardStore(tx, rdb)
|
||||||
|
deviceStore := postgres.NewDeviceStore(tx, rdb)
|
||||||
|
packageStore := postgres.NewPackageStore(tx)
|
||||||
|
seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx)
|
||||||
|
packageSeriesStore := postgres.NewPackageSeriesStore(tx)
|
||||||
|
carrierStore := postgres.NewCarrierStore(tx)
|
||||||
|
shopStore := postgres.NewShopStore(tx, rdb)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
carrier := &model.Carrier{
|
||||||
|
CarrierCode: "TEST_CARRIER_PV",
|
||||||
|
CarrierName: "测试运营商",
|
||||||
|
CarrierType: constants.CarrierTypeCMCC,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
}
|
||||||
|
require.NoError(t, carrierStore.Create(ctx, carrier))
|
||||||
|
|
||||||
|
shop := &model.Shop{
|
||||||
|
ShopName: "测试店铺PV",
|
||||||
|
ShopCode: "TEST_SHOP_PV",
|
||||||
|
Level: 1,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, shopStore.Create(ctx, shop))
|
||||||
|
|
||||||
|
series := &model.PackageSeries{
|
||||||
|
SeriesCode: "TEST_SERIES_PV",
|
||||||
|
SeriesName: "测试套餐系列",
|
||||||
|
Description: "测试用",
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, packageSeriesStore.Create(ctx, series))
|
||||||
|
|
||||||
|
allocation := &model.ShopSeriesAllocation{
|
||||||
|
ShopID: shop.ID,
|
||||||
|
SeriesID: series.ID,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, seriesAllocationStore.Create(ctx, allocation))
|
||||||
|
|
||||||
|
pkg := &model.Package{
|
||||||
|
PackageCode: "TEST_PKG_PV",
|
||||||
|
PackageName: "测试套餐",
|
||||||
|
SeriesID: series.ID,
|
||||||
|
SuggestedRetailPrice: 9900,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
ShelfStatus: constants.ShelfStatusOn,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, packageStore.Create(ctx, pkg))
|
||||||
|
|
||||||
|
shopIDPtr := &shop.ID
|
||||||
|
card := &model.IotCard{
|
||||||
|
ICCID: "89860000000000000001",
|
||||||
|
ShopID: shopIDPtr,
|
||||||
|
CarrierID: carrier.ID,
|
||||||
|
SeriesAllocationID: &allocation.ID,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, iotCardStore.Create(ctx, card))
|
||||||
|
|
||||||
|
device := &model.Device{
|
||||||
|
DeviceNo: "DEV_TEST_PV_001",
|
||||||
|
ShopID: shopIDPtr,
|
||||||
|
SeriesAllocationID: &allocation.ID,
|
||||||
|
Status: constants.StatusEnabled,
|
||||||
|
BaseModel: model.BaseModel{Creator: 1, Updater: 1},
|
||||||
|
}
|
||||||
|
require.NoError(t, deviceStore.Create(ctx, device))
|
||||||
|
|
||||||
|
svc := New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore)
|
||||||
|
|
||||||
|
return ctx, svc, card, device, pkg, allocation
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseValidationService_ValidateCardPurchase(t *testing.T) {
|
||||||
|
ctx, svc, card, _, pkg, _ := setupTestData(t)
|
||||||
|
|
||||||
|
t.Run("验证成功", func(t *testing.T) {
|
||||||
|
result, err := svc.ValidateCardPurchase(ctx, card.ID, []uint{pkg.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, result.Card)
|
||||||
|
assert.Equal(t, card.ID, result.Card.ID)
|
||||||
|
assert.Len(t, result.Packages, 1)
|
||||||
|
assert.Equal(t, pkg.SuggestedRetailPrice, result.TotalPrice)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("卡不存在", func(t *testing.T) {
|
||||||
|
_, err := svc.ValidateCardPurchase(ctx, 99999, []uint{pkg.ID})
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeIotCardNotFound, appErr.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("套餐列表为空", func(t *testing.T) {
|
||||||
|
_, err := svc.ValidateCardPurchase(ctx, card.ID, []uint{})
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("套餐不存在", func(t *testing.T) {
|
||||||
|
_, err := svc.ValidateCardPurchase(ctx, card.ID, []uint{99999})
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseValidationService_ValidateDevicePurchase(t *testing.T) {
|
||||||
|
ctx, svc, _, device, pkg, _ := setupTestData(t)
|
||||||
|
|
||||||
|
t.Run("验证成功", func(t *testing.T) {
|
||||||
|
result, err := svc.ValidateDevicePurchase(ctx, device.ID, []uint{pkg.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, result.Device)
|
||||||
|
assert.Equal(t, device.ID, result.Device.ID)
|
||||||
|
assert.Len(t, result.Packages, 1)
|
||||||
|
assert.Equal(t, pkg.SuggestedRetailPrice, result.TotalPrice)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("设备不存在", func(t *testing.T) {
|
||||||
|
_, err := svc.ValidateDevicePurchase(ctx, 99999, []uint{pkg.ID})
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeNotFound, appErr.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("套餐列表为空", func(t *testing.T) {
|
||||||
|
_, err := svc.ValidateDevicePurchase(ctx, device.ID, []uint{})
|
||||||
|
require.Error(t, err)
|
||||||
|
appErr, ok := err.(*errors.AppError)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, errors.CodeInvalidParam, appErr.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchaseValidationService_GetPurchasePrice(t *testing.T) {
|
||||||
|
ctx, svc, _, _, pkg, _ := setupTestData(t)
|
||||||
|
|
||||||
|
t.Run("获取个人客户价格", func(t *testing.T) {
|
||||||
|
price := svc.GetPurchasePrice(ctx, pkg, model.BuyerTypePersonal)
|
||||||
|
assert.Equal(t, pkg.SuggestedRetailPrice, price)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("获取代理商价格", func(t *testing.T) {
|
||||||
|
price := svc.GetPurchasePrice(ctx, pkg, model.BuyerTypeAgent)
|
||||||
|
assert.Equal(t, pkg.SuggestedRetailPrice, price)
|
||||||
|
})
|
||||||
|
}
|
||||||
47
internal/store/postgres/order_item_store.go
Normal file
47
internal/store/postgres/order_item_store.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderItemStore struct {
|
||||||
|
db *gorm.DB
|
||||||
|
redis *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrderItemStore(db *gorm.DB, redis *redis.Client) *OrderItemStore {
|
||||||
|
return &OrderItemStore{
|
||||||
|
db: db,
|
||||||
|
redis: redis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderItemStore) BatchCreate(ctx context.Context, items []*model.OrderItem) error {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Create(&items).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderItemStore) ListByOrderID(ctx context.Context, orderID uint) ([]*model.OrderItem, error) {
|
||||||
|
var items []*model.OrderItem
|
||||||
|
if err := s.db.WithContext(ctx).Where("order_id = ?", orderID).Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderItemStore) ListByOrderIDs(ctx context.Context, orderIDs []uint) ([]*model.OrderItem, error) {
|
||||||
|
if len(orderIDs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var items []*model.OrderItem
|
||||||
|
if err := s.db.WithContext(ctx).Where("order_id IN ?", orderIDs).Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
142
internal/store/postgres/order_item_store_test.go
Normal file
142
internal/store/postgres/order_item_store_test.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOrderItemStore_BatchCreate(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
orderStore := NewOrderStore(tx, rdb)
|
||||||
|
itemStore := NewOrderItemStore(tx, rdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
order := &model.Order{
|
||||||
|
OrderNo: orderStore.GenerateOrderNo(),
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
BuyerType: model.BuyerTypePersonal,
|
||||||
|
BuyerID: 100,
|
||||||
|
TotalAmount: 15000,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
}
|
||||||
|
require.NoError(t, orderStore.Create(ctx, order, nil))
|
||||||
|
|
||||||
|
items := []*model.OrderItem{
|
||||||
|
{OrderID: order.ID, PackageID: 1, PackageName: "套餐A", Quantity: 1, UnitPrice: 5000, Amount: 5000},
|
||||||
|
{OrderID: order.ID, PackageID: 2, PackageName: "套餐B", Quantity: 2, UnitPrice: 5000, Amount: 10000},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := itemStore.BatchCreate(ctx, items)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
assert.NotZero(t, item.ID)
|
||||||
|
assert.Equal(t, order.ID, item.OrderID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderItemStore_BatchCreate_Empty(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
itemStore := NewOrderItemStore(tx, rdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := itemStore.BatchCreate(ctx, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = itemStore.BatchCreate(ctx, []*model.OrderItem{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderItemStore_ListByOrderID(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
orderStore := NewOrderStore(tx, rdb)
|
||||||
|
itemStore := NewOrderItemStore(tx, rdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
order := &model.Order{
|
||||||
|
OrderNo: orderStore.GenerateOrderNo(),
|
||||||
|
OrderType: model.OrderTypeDevice,
|
||||||
|
BuyerType: model.BuyerTypeAgent,
|
||||||
|
BuyerID: 200,
|
||||||
|
TotalAmount: 20000,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
}
|
||||||
|
items := []*model.OrderItem{
|
||||||
|
{PackageID: 10, PackageName: "设备套餐1", Quantity: 1, UnitPrice: 10000, Amount: 10000},
|
||||||
|
{PackageID: 11, PackageName: "设备套餐2", Quantity: 1, UnitPrice: 10000, Amount: 10000},
|
||||||
|
}
|
||||||
|
require.NoError(t, orderStore.Create(ctx, order, items))
|
||||||
|
|
||||||
|
result, err := itemStore.ListByOrderID(ctx, order.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, result, 2)
|
||||||
|
|
||||||
|
for _, item := range result {
|
||||||
|
assert.Equal(t, order.ID, item.OrderID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderItemStore_ListByOrderIDs(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
orderStore := NewOrderStore(tx, rdb)
|
||||||
|
itemStore := NewOrderItemStore(tx, rdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
order1 := &model.Order{
|
||||||
|
OrderNo: orderStore.GenerateOrderNo(),
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
BuyerType: model.BuyerTypePersonal,
|
||||||
|
BuyerID: 300,
|
||||||
|
TotalAmount: 5000,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
}
|
||||||
|
items1 := []*model.OrderItem{
|
||||||
|
{PackageID: 20, PackageName: "套餐X", Quantity: 1, UnitPrice: 5000, Amount: 5000},
|
||||||
|
}
|
||||||
|
require.NoError(t, orderStore.Create(ctx, order1, items1))
|
||||||
|
|
||||||
|
order2 := &model.Order{
|
||||||
|
OrderNo: orderStore.GenerateOrderNo(),
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
BuyerType: model.BuyerTypePersonal,
|
||||||
|
BuyerID: 300,
|
||||||
|
TotalAmount: 8000,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
}
|
||||||
|
items2 := []*model.OrderItem{
|
||||||
|
{PackageID: 21, PackageName: "套餐Y", Quantity: 1, UnitPrice: 3000, Amount: 3000},
|
||||||
|
{PackageID: 22, PackageName: "套餐Z", Quantity: 1, UnitPrice: 5000, Amount: 5000},
|
||||||
|
}
|
||||||
|
require.NoError(t, orderStore.Create(ctx, order2, items2))
|
||||||
|
|
||||||
|
t.Run("查询多个订单的明细", func(t *testing.T) {
|
||||||
|
result, err := itemStore.ListByOrderIDs(ctx, []uint{order1.ID, order2.ID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, result, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("空订单ID列表", func(t *testing.T) {
|
||||||
|
result, err := itemStore.ListByOrderIDs(ctx, []uint{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("不存在的订单ID", func(t *testing.T) {
|
||||||
|
result, err := itemStore.ListByOrderIDs(ctx, []uint{99999})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, result, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
148
internal/store/postgres/order_store.go
Normal file
148
internal/store/postgres/order_store.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrderStore struct {
|
||||||
|
db *gorm.DB
|
||||||
|
redis *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrderStore(db *gorm.DB, redis *redis.Client) *OrderStore {
|
||||||
|
return &OrderStore{
|
||||||
|
db: db,
|
||||||
|
redis: redis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderStore) Create(ctx context.Context, order *model.Order, items []*model.OrderItem) error {
|
||||||
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(order).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
item.OrderID = order.ID
|
||||||
|
if err := tx.Create(item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderStore) GetByID(ctx context.Context, id uint) (*model.Order, error) {
|
||||||
|
var order model.Order
|
||||||
|
if err := s.db.WithContext(ctx).First(&order, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderStore) GetByIDWithItems(ctx context.Context, id uint) (*model.Order, []*model.OrderItem, error) {
|
||||||
|
var order model.Order
|
||||||
|
if err := s.db.WithContext(ctx).First(&order, id).Error; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []*model.OrderItem
|
||||||
|
if err := s.db.WithContext(ctx).Where("order_id = ?", id).Find(&items).Error; err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &order, items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderStore) GetByOrderNo(ctx context.Context, orderNo string) (*model.Order, error) {
|
||||||
|
var order model.Order
|
||||||
|
if err := s.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderStore) Update(ctx context.Context, order *model.Order) error {
|
||||||
|
return s.db.WithContext(ctx).Save(order).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.Order, int64, error) {
|
||||||
|
var orders []*model.Order
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.WithContext(ctx).Model(&model.Order{})
|
||||||
|
|
||||||
|
if v, ok := filters["payment_status"]; ok {
|
||||||
|
query = query.Where("payment_status = ?", v)
|
||||||
|
}
|
||||||
|
if v, ok := filters["order_type"]; ok {
|
||||||
|
query = query.Where("order_type = ?", v)
|
||||||
|
}
|
||||||
|
if v, ok := filters["order_no"]; ok {
|
||||||
|
query = query.Where("order_no = ?", v)
|
||||||
|
}
|
||||||
|
if v, ok := filters["buyer_type"]; ok {
|
||||||
|
query = query.Where("buyer_type = ?", v)
|
||||||
|
}
|
||||||
|
if v, ok := filters["buyer_id"]; ok {
|
||||||
|
query = query.Where("buyer_id = ?", v)
|
||||||
|
}
|
||||||
|
if v, ok := filters["iot_card_id"]; ok {
|
||||||
|
query = query.Where("iot_card_id = ?", v)
|
||||||
|
}
|
||||||
|
if v, ok := filters["device_id"]; ok {
|
||||||
|
query = query.Where("device_id = ?", v)
|
||||||
|
}
|
||||||
|
if v, ok := filters["start_time"]; ok {
|
||||||
|
query = query.Where("created_at >= ?", v)
|
||||||
|
}
|
||||||
|
if v, ok := filters["end_time"]; ok {
|
||||||
|
query = query.Where("created_at <= ?", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts == nil {
|
||||||
|
opts = store.DefaultQueryOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (opts.Page - 1) * opts.PageSize
|
||||||
|
if opts.OrderBy != "" {
|
||||||
|
query = query.Order(opts.OrderBy)
|
||||||
|
} else {
|
||||||
|
query = query.Order("id DESC")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Offset(offset).Limit(opts.PageSize).Find(&orders).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return orders, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderStore) UpdatePaymentStatus(ctx context.Context, id uint, status int, paidAt *time.Time) error {
|
||||||
|
updates := map[string]any{
|
||||||
|
"payment_status": status,
|
||||||
|
}
|
||||||
|
if paidAt != nil {
|
||||||
|
updates["paid_at"] = paidAt
|
||||||
|
}
|
||||||
|
return s.db.WithContext(ctx).Model(&model.Order{}).Where("id = ?", id).Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OrderStore) GenerateOrderNo() string {
|
||||||
|
now := time.Now()
|
||||||
|
randomNum := rand.Intn(1000000)
|
||||||
|
return fmt.Sprintf("ORD%s%06d", now.Format("20060102150405"), randomNum)
|
||||||
|
}
|
||||||
287
internal/store/postgres/order_store_test.go
Normal file
287
internal/store/postgres/order_store_test.go
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
|
"github.com/break/junhong_cmp_fiber/tests/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOrderStore_Create(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
s := NewOrderStore(tx, rdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cardID := uint(1001)
|
||||||
|
order := &model.Order{
|
||||||
|
OrderNo: s.GenerateOrderNo(),
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
BuyerType: model.BuyerTypePersonal,
|
||||||
|
BuyerID: 100,
|
||||||
|
IotCardID: &cardID,
|
||||||
|
TotalAmount: 9900,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
}
|
||||||
|
items := []*model.OrderItem{
|
||||||
|
{
|
||||||
|
PackageID: 1,
|
||||||
|
PackageName: "测试套餐1",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 5000,
|
||||||
|
Amount: 5000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
PackageID: 2,
|
||||||
|
PackageName: "测试套餐2",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: 4900,
|
||||||
|
Amount: 4900,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.Create(ctx, order, items)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotZero(t, order.ID)
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
assert.NotZero(t, item.ID)
|
||||||
|
assert.Equal(t, order.ID, item.OrderID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderStore_GetByID(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
s := NewOrderStore(tx, rdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
order := &model.Order{
|
||||||
|
OrderNo: s.GenerateOrderNo(),
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
BuyerType: model.BuyerTypeAgent,
|
||||||
|
BuyerID: 200,
|
||||||
|
TotalAmount: 19900,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, order, nil))
|
||||||
|
|
||||||
|
t.Run("查询存在的订单", func(t *testing.T) {
|
||||||
|
result, err := s.GetByID(ctx, order.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, order.OrderNo, result.OrderNo)
|
||||||
|
assert.Equal(t, order.BuyerType, result.BuyerType)
|
||||||
|
assert.Equal(t, order.TotalAmount, result.TotalAmount)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("查询不存在的订单", func(t *testing.T) {
|
||||||
|
_, err := s.GetByID(ctx, 99999)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderStore_GetByIDWithItems(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
s := NewOrderStore(tx, rdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
deviceID := uint(2001)
|
||||||
|
order := &model.Order{
|
||||||
|
OrderNo: s.GenerateOrderNo(),
|
||||||
|
OrderType: model.OrderTypeDevice,
|
||||||
|
BuyerType: model.BuyerTypePersonal,
|
||||||
|
BuyerID: 300,
|
||||||
|
DeviceID: &deviceID,
|
||||||
|
TotalAmount: 29900,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
}
|
||||||
|
items := []*model.OrderItem{
|
||||||
|
{PackageID: 10, PackageName: "设备套餐A", Quantity: 1, UnitPrice: 15000, Amount: 15000},
|
||||||
|
{PackageID: 11, PackageName: "设备套餐B", Quantity: 1, UnitPrice: 14900, Amount: 14900},
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, order, items))
|
||||||
|
|
||||||
|
resultOrder, resultItems, err := s.GetByIDWithItems(ctx, order.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, order.OrderNo, resultOrder.OrderNo)
|
||||||
|
assert.Len(t, resultItems, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderStore_GetByOrderNo(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
s := NewOrderStore(tx, rdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
orderNo := s.GenerateOrderNo()
|
||||||
|
order := &model.Order{
|
||||||
|
OrderNo: orderNo,
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
BuyerType: model.BuyerTypeAgent,
|
||||||
|
BuyerID: 400,
|
||||||
|
TotalAmount: 5000,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, order, nil))
|
||||||
|
|
||||||
|
t.Run("查询存在的订单号", func(t *testing.T) {
|
||||||
|
result, err := s.GetByOrderNo(ctx, orderNo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, order.ID, result.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("查询不存在的订单号", func(t *testing.T) {
|
||||||
|
_, err := s.GetByOrderNo(ctx, "NOT_EXISTS_ORDER_NO")
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderStore_Update(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
s := NewOrderStore(tx, rdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
order := &model.Order{
|
||||||
|
OrderNo: s.GenerateOrderNo(),
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
BuyerType: model.BuyerTypePersonal,
|
||||||
|
BuyerID: 500,
|
||||||
|
TotalAmount: 10000,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, order, nil))
|
||||||
|
|
||||||
|
order.PaymentMethod = model.PaymentMethodWallet
|
||||||
|
order.PaymentStatus = model.PaymentStatusPaid
|
||||||
|
now := time.Now()
|
||||||
|
order.PaidAt = &now
|
||||||
|
err := s.Update(ctx, order)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := s.GetByID(ctx, order.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.PaymentMethodWallet, updated.PaymentMethod)
|
||||||
|
assert.Equal(t, model.PaymentStatusPaid, updated.PaymentStatus)
|
||||||
|
assert.NotNil(t, updated.PaidAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderStore_UpdatePaymentStatus(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
s := NewOrderStore(tx, rdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
order := &model.Order{
|
||||||
|
OrderNo: s.GenerateOrderNo(),
|
||||||
|
OrderType: model.OrderTypeSingleCard,
|
||||||
|
BuyerType: model.BuyerTypeAgent,
|
||||||
|
BuyerID: 600,
|
||||||
|
TotalAmount: 8000,
|
||||||
|
PaymentStatus: model.PaymentStatusPending,
|
||||||
|
}
|
||||||
|
require.NoError(t, s.Create(ctx, order, nil))
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
err := s.UpdatePaymentStatus(ctx, order.ID, model.PaymentStatusPaid, &now)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updated, err := s.GetByID(ctx, order.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.PaymentStatusPaid, updated.PaymentStatus)
|
||||||
|
assert.NotNil(t, updated.PaidAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderStore_List(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
testutils.CleanTestRedisKeys(t, rdb)
|
||||||
|
s := NewOrderStore(tx, rdb)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
orders := []*model.Order{
|
||||||
|
{OrderNo: s.GenerateOrderNo(), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal, BuyerID: 700, TotalAmount: 1000, PaymentStatus: model.PaymentStatusPending},
|
||||||
|
{OrderNo: s.GenerateOrderNo(), OrderType: model.OrderTypeDevice, BuyerType: model.BuyerTypeAgent, BuyerID: 701, TotalAmount: 2000, PaymentStatus: model.PaymentStatusPaid},
|
||||||
|
{OrderNo: s.GenerateOrderNo(), OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypeAgent, BuyerID: 701, TotalAmount: 3000, PaymentStatus: model.PaymentStatusCancelled},
|
||||||
|
}
|
||||||
|
for _, o := range orders {
|
||||||
|
require.NoError(t, s.Create(ctx, o, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("查询所有订单", func(t *testing.T) {
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(3))
|
||||||
|
assert.GreaterOrEqual(t, len(result), 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按支付状态过滤", func(t *testing.T) {
|
||||||
|
filters := map[string]any{"payment_status": model.PaymentStatusPending}
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(1))
|
||||||
|
for _, o := range result {
|
||||||
|
assert.Equal(t, model.PaymentStatusPending, o.PaymentStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按订单类型过滤", func(t *testing.T) {
|
||||||
|
filters := map[string]any{"order_type": model.OrderTypeDevice}
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(1))
|
||||||
|
for _, o := range result {
|
||||||
|
assert.Equal(t, model.OrderTypeDevice, o.OrderType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("按买家过滤", func(t *testing.T) {
|
||||||
|
filters := map[string]any{"buyer_type": model.BuyerTypeAgent, "buyer_id": uint(701)}
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 20}, filters)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(2))
|
||||||
|
for _, o := range result {
|
||||||
|
assert.Equal(t, model.BuyerTypeAgent, o.BuyerType)
|
||||||
|
assert.Equal(t, uint(701), o.BuyerID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("分页查询", func(t *testing.T) {
|
||||||
|
result, total, err := s.List(ctx, &store.QueryOptions{Page: 1, PageSize: 2}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, total, int64(3))
|
||||||
|
assert.LessOrEqual(t, len(result), 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("默认分页选项", func(t *testing.T) {
|
||||||
|
result, _, err := s.List(ctx, nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderStore_GenerateOrderNo(t *testing.T) {
|
||||||
|
tx := testutils.NewTestTransaction(t)
|
||||||
|
rdb := testutils.GetTestRedis(t)
|
||||||
|
s := NewOrderStore(tx, rdb)
|
||||||
|
|
||||||
|
orderNo1 := s.GenerateOrderNo()
|
||||||
|
orderNo2 := s.GenerateOrderNo()
|
||||||
|
|
||||||
|
assert.True(t, len(orderNo1) > 0)
|
||||||
|
assert.True(t, len(orderNo1) <= 30)
|
||||||
|
assert.Contains(t, orderNo1, "ORD")
|
||||||
|
assert.NotEqual(t, orderNo1, orderNo2)
|
||||||
|
}
|
||||||
5
migrations/000028_create_order_tables.down.sql
Normal file
5
migrations/000028_create_order_tables.down.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- 删除订单明细表
|
||||||
|
DROP TABLE IF EXISTS tb_order_item;
|
||||||
|
|
||||||
|
-- 删除订单表
|
||||||
|
DROP TABLE IF EXISTS tb_order;
|
||||||
90
migrations/000028_create_order_tables.up.sql
Normal file
90
migrations/000028_create_order_tables.up.sql
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
-- 删除旧的订单表(如果存在)
|
||||||
|
DROP TABLE IF EXISTS tb_order CASCADE;
|
||||||
|
|
||||||
|
-- 创建订单表
|
||||||
|
CREATE TABLE tb_order (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
creator BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updater BIGINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- 订单基础信息
|
||||||
|
order_no VARCHAR(30) NOT NULL,
|
||||||
|
order_type VARCHAR(20) NOT NULL,
|
||||||
|
|
||||||
|
-- 买家信息
|
||||||
|
buyer_type VARCHAR(20) NOT NULL,
|
||||||
|
buyer_id BIGINT NOT NULL,
|
||||||
|
|
||||||
|
-- 关联资源
|
||||||
|
iot_card_id BIGINT,
|
||||||
|
device_id BIGINT,
|
||||||
|
|
||||||
|
-- 金额信息
|
||||||
|
total_amount BIGINT NOT NULL,
|
||||||
|
|
||||||
|
-- 支付信息
|
||||||
|
payment_method VARCHAR(20),
|
||||||
|
payment_status INT NOT NULL DEFAULT 1,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- 佣金信息
|
||||||
|
commission_status INT NOT NULL DEFAULT 1,
|
||||||
|
commission_config_version INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 添加表注释
|
||||||
|
COMMENT ON TABLE tb_order IS '订单表';
|
||||||
|
COMMENT ON COLUMN tb_order.order_no IS '订单号(ORD+时间戳+6位随机数)';
|
||||||
|
COMMENT ON COLUMN tb_order.order_type IS '订单类型 single_card-单卡购买 device-设备购买';
|
||||||
|
COMMENT ON COLUMN tb_order.buyer_type IS '买家类型 personal-个人客户 agent-代理商';
|
||||||
|
COMMENT ON COLUMN tb_order.buyer_id IS '买家ID(个人客户ID或店铺ID)';
|
||||||
|
COMMENT ON COLUMN tb_order.iot_card_id IS 'IoT卡ID(单卡购买时有值)';
|
||||||
|
COMMENT ON COLUMN tb_order.device_id IS '设备ID(设备购买时有值)';
|
||||||
|
COMMENT ON COLUMN tb_order.total_amount IS '订单总金额(分)';
|
||||||
|
COMMENT ON COLUMN tb_order.payment_method IS '支付方式 wallet-钱包 wechat-微信 alipay-支付宝';
|
||||||
|
COMMENT ON COLUMN tb_order.payment_status IS '支付状态 1-待支付 2-已支付 3-已取消 4-已退款';
|
||||||
|
COMMENT ON COLUMN tb_order.paid_at IS '支付时间';
|
||||||
|
COMMENT ON COLUMN tb_order.commission_status IS '佣金状态 1-待计算 2-已计算';
|
||||||
|
COMMENT ON COLUMN tb_order.commission_config_version IS '佣金配置版本(订单创建时快照)';
|
||||||
|
|
||||||
|
-- 创建索引
|
||||||
|
CREATE UNIQUE INDEX idx_order_no ON tb_order(order_no) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_order_buyer ON tb_order(buyer_type, buyer_id);
|
||||||
|
CREATE INDEX idx_order_payment_status ON tb_order(payment_status);
|
||||||
|
CREATE INDEX idx_order_iot_card_id ON tb_order(iot_card_id) WHERE iot_card_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_order_device_id ON tb_order(device_id) WHERE device_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_order_deleted_at ON tb_order(deleted_at);
|
||||||
|
|
||||||
|
-- 创建订单明细表
|
||||||
|
CREATE TABLE tb_order_item (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
creator BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updater BIGINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
order_id BIGINT NOT NULL,
|
||||||
|
package_id BIGINT NOT NULL,
|
||||||
|
package_name VARCHAR(100) NOT NULL,
|
||||||
|
quantity INT NOT NULL DEFAULT 1,
|
||||||
|
unit_price BIGINT NOT NULL,
|
||||||
|
amount BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 添加表注释
|
||||||
|
COMMENT ON TABLE tb_order_item IS '订单明细表';
|
||||||
|
COMMENT ON COLUMN tb_order_item.order_id IS '订单ID';
|
||||||
|
COMMENT ON COLUMN tb_order_item.package_id IS '套餐ID';
|
||||||
|
COMMENT ON COLUMN tb_order_item.package_name IS '套餐名称(快照)';
|
||||||
|
COMMENT ON COLUMN tb_order_item.quantity IS '数量';
|
||||||
|
COMMENT ON COLUMN tb_order_item.unit_price IS '单价(分)';
|
||||||
|
COMMENT ON COLUMN tb_order_item.amount IS '小计金额(分)';
|
||||||
|
|
||||||
|
-- 创建索引
|
||||||
|
CREATE INDEX idx_order_item_order_id ON tb_order_item(order_id);
|
||||||
|
CREATE INDEX idx_order_item_package_id ON tb_order_item(package_id);
|
||||||
|
CREATE INDEX idx_order_item_deleted_at ON tb_order_item(deleted_at);
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
## 1. 新增模型
|
|
||||||
|
|
||||||
- [ ] 1.1 创建 `internal/model/order.go`,定义 Order 模型(order_no, order_type, buyer_type, buyer_id, iot_card_id, device_id, total_amount, payment_method, payment_status, paid_at, commission_status, commission_config_version)
|
|
||||||
- [ ] 1.2 定义 OrderItem 模型(order_id, package_id, package_name, quantity, unit_price, amount)
|
|
||||||
|
|
||||||
## 2. 数据库迁移
|
|
||||||
|
|
||||||
- [ ] 2.1 创建迁移文件,创建 tb_order 表
|
|
||||||
- [ ] 2.2 创建 tb_order_item 表
|
|
||||||
- [ ] 2.3 添加索引(order_no 唯一索引, buyer_type+buyer_id, payment_status, iot_card_id, device_id)
|
|
||||||
- [ ] 2.4 本地执行迁移验证
|
|
||||||
|
|
||||||
## 3. 订单 DTO
|
|
||||||
|
|
||||||
- [ ] 3.1 创建 `internal/model/dto/order.go`,定义 CreateOrderRequest(order_type, iot_card_id/device_id, package_ids)
|
|
||||||
- [ ] 3.2 定义 OrderListRequest(payment_status, order_type, start_time, end_time, page, page_size)
|
|
||||||
- [ ] 3.3 定义 PayOrderRequest(payment_method)
|
|
||||||
- [ ] 3.4 定义 OrderResponse(包含订单信息和明细列表)
|
|
||||||
- [ ] 3.5 定义 OrderItemResponse
|
|
||||||
|
|
||||||
## 4. 订单 Store
|
|
||||||
|
|
||||||
- [ ] 4.1 创建 `internal/store/postgres/order_store.go`,实现 Create 方法(事务创建订单和明细)
|
|
||||||
- [ ] 4.2 实现 GetByID 方法(含明细)
|
|
||||||
- [ ] 4.3 实现 GetByOrderNo 方法
|
|
||||||
- [ ] 4.4 实现 Update 方法
|
|
||||||
- [ ] 4.5 实现 List 方法(支持分页和筛选)
|
|
||||||
- [ ] 4.6 实现 UpdatePaymentStatus 方法
|
|
||||||
- [ ] 4.7 实现 GenerateOrderNo 方法(ORD + 时间戳 + 随机数)
|
|
||||||
|
|
||||||
## 5. 订单明细 Store
|
|
||||||
|
|
||||||
- [ ] 5.1 创建 `internal/store/postgres/order_item_store.go`,实现 BatchCreate 方法
|
|
||||||
- [ ] 5.2 实现 ListByOrderID 方法
|
|
||||||
|
|
||||||
## 6. 购买验证 Service
|
|
||||||
|
|
||||||
- [ ] 6.1 创建 `internal/service/purchase_validation/service.go`,实现 ValidateCardPurchase 方法
|
|
||||||
- [ ] 6.2 实现 ValidateDevicePurchase 方法
|
|
||||||
- [ ] 6.3 实现 ValidatePackageStatus 方法
|
|
||||||
- [ ] 6.4 实现 GetPurchasePrice 方法(根据买家身份返回价格)
|
|
||||||
|
|
||||||
## 7. 订单 Service
|
|
||||||
|
|
||||||
- [ ] 7.1 创建 `internal/service/order/service.go`,实现 Create 方法(验证权限、创建订单和明细、快照佣金配置版本)
|
|
||||||
- [ ] 7.2 实现 Get 方法
|
|
||||||
- [ ] 7.3 实现 List 方法
|
|
||||||
- [ ] 7.4 实现 Cancel 方法(验证状态、更新为已取消)
|
|
||||||
- [ ] 7.5 实现 WalletPay 方法(事务:扣减余额、更新状态、激活套餐、更新销售统计)
|
|
||||||
- [ ] 7.6 实现 HandlePaymentCallback 方法(验证签名、幂等处理、激活套餐、更新销售统计)
|
|
||||||
- [ ] 7.7 实现 ActivatePackage 辅助方法(创建 PackageUsage 记录、更新 ShopSeriesCommissionStats)
|
|
||||||
- [ ] 7.8 实现 SnapshotCommissionConfig 辅助方法(查询并快照当前佣金配置版本)
|
|
||||||
|
|
||||||
## 8. 订单 Handler(后台)
|
|
||||||
|
|
||||||
- [ ] 8.1 创建 `internal/handler/admin/order.go`,实现 Create 接口
|
|
||||||
- [ ] 8.2 实现 Get 接口
|
|
||||||
- [ ] 8.3 实现 List 接口
|
|
||||||
- [ ] 8.4 实现 Cancel 接口
|
|
||||||
|
|
||||||
## 9. 订单 Handler(H5)
|
|
||||||
|
|
||||||
- [ ] 9.1 创建 `internal/handler/h5/order.go`,实现 Create 接口
|
|
||||||
- [ ] 9.2 实现 Get 接口
|
|
||||||
- [ ] 9.3 实现 List 接口
|
|
||||||
- [ ] 9.4 实现 WalletPay 接口
|
|
||||||
|
|
||||||
## 10. 支付回调 Handler
|
|
||||||
|
|
||||||
- [ ] 10.1 创建 `internal/handler/callback/payment.go`,实现 WechatPayCallback 接口
|
|
||||||
- [ ] 10.2 实现 AlipayCallback 接口
|
|
||||||
|
|
||||||
## 11. Bootstrap 注册
|
|
||||||
|
|
||||||
- [ ] 11.1 在 stores.go 中注册 OrderStore, OrderItemStore
|
|
||||||
- [ ] 11.2 在 services.go 中注册 PurchaseValidationService, OrderService
|
|
||||||
- [ ] 11.3 在 handlers.go 中注册 AdminOrderHandler, H5OrderHandler, PaymentCallbackHandler
|
|
||||||
|
|
||||||
## 12. 路由注册
|
|
||||||
|
|
||||||
- [ ] 12.1 注册 `/api/admin/orders` 路由组(POST, GET, GET/:id, POST/:id/cancel)
|
|
||||||
- [ ] 12.2 注册 `/api/h5/orders` 路由组(POST, GET, GET/:id, POST/:id/pay)
|
|
||||||
- [ ] 12.3 注册 `/api/callback/wechat-pay` 回调路由
|
|
||||||
- [ ] 12.4 注册 `/api/callback/alipay` 回调路由
|
|
||||||
|
|
||||||
## 13. 文档生成器更新
|
|
||||||
|
|
||||||
- [ ] 13.1 在 docs.go 和 gendocs/main.go 中添加新 Handler
|
|
||||||
- [ ] 13.2 执行文档生成验证
|
|
||||||
|
|
||||||
## 14. 测试
|
|
||||||
|
|
||||||
- [ ] 14.1 OrderStore 单元测试
|
|
||||||
- [ ] 14.2 PurchaseValidationService 单元测试(覆盖各种验证场景)
|
|
||||||
- [ ] 14.3 OrderService 单元测试(覆盖创建、支付、取消)
|
|
||||||
- [ ] 14.4 WalletPay 事务测试(覆盖成功和失败回滚)
|
|
||||||
- [ ] 14.5 订单创建 API 集成测试
|
|
||||||
- [ ] 14.6 钱包支付 API 集成测试
|
|
||||||
- [ ] 14.7 执行 `go test ./...` 确认通过
|
|
||||||
|
|
||||||
## 15. 最终验证
|
|
||||||
|
|
||||||
- [ ] 15.1 执行 `go build ./...` 确认编译通过
|
|
||||||
- [ ] 15.2 启动服务,手动测试订单创建流程
|
|
||||||
- [ ] 15.3 手动测试钱包支付流程
|
|
||||||
- [ ] 15.4 验证套餐激活(PackageUsage 记录创建)
|
|
||||||
106
openspec/changes/archive/2026-01-28-add-order-payment/tasks.md
Normal file
106
openspec/changes/archive/2026-01-28-add-order-payment/tasks.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
## 1. 新增模型
|
||||||
|
|
||||||
|
- [x] 1.1 创建 `internal/model/order.go`,定义 Order 模型(order_no, order_type, buyer_type, buyer_id, iot_card_id, device_id, total_amount, payment_method, payment_status, paid_at, commission_status, commission_config_version)
|
||||||
|
- [x] 1.2 定义 OrderItem 模型(order_id, package_id, package_name, quantity, unit_price, amount)
|
||||||
|
|
||||||
|
## 2. 数据库迁移
|
||||||
|
|
||||||
|
- [x] 2.1 创建迁移文件,创建 tb_order 表
|
||||||
|
- [x] 2.2 创建 tb_order_item 表
|
||||||
|
- [x] 2.3 添加索引(order_no 唯一索引, buyer_type+buyer_id, payment_status, iot_card_id, device_id)
|
||||||
|
- [x] 2.4 本地执行迁移验证
|
||||||
|
|
||||||
|
## 3. 订单 DTO
|
||||||
|
|
||||||
|
- [x] 3.1 创建 `internal/model/dto/order.go`,定义 CreateOrderRequest(order_type, iot_card_id/device_id, package_ids)
|
||||||
|
- [x] 3.2 定义 OrderListRequest(payment_status, order_type, start_time, end_time, page, page_size)
|
||||||
|
- [x] 3.3 定义 PayOrderRequest(payment_method)
|
||||||
|
- [x] 3.4 定义 OrderResponse(包含订单信息和明细列表)
|
||||||
|
- [x] 3.5 定义 OrderItemResponse
|
||||||
|
|
||||||
|
## 4. 订单 Store
|
||||||
|
|
||||||
|
- [x] 4.1 创建 `internal/store/postgres/order_store.go`,实现 Create 方法(事务创建订单和明细)
|
||||||
|
- [x] 4.2 实现 GetByID 方法(含明细)
|
||||||
|
- [x] 4.3 实现 GetByOrderNo 方法
|
||||||
|
- [x] 4.4 实现 Update 方法
|
||||||
|
- [x] 4.5 实现 List 方法(支持分页和筛选)
|
||||||
|
- [x] 4.6 实现 UpdatePaymentStatus 方法
|
||||||
|
- [x] 4.7 实现 GenerateOrderNo 方法(ORD + 时间戳 + 随机数)
|
||||||
|
|
||||||
|
## 5. 订单明细 Store
|
||||||
|
|
||||||
|
- [x] 5.1 创建 `internal/store/postgres/order_item_store.go`,实现 BatchCreate 方法
|
||||||
|
- [x] 5.2 实现 ListByOrderID 方法
|
||||||
|
|
||||||
|
## 6. 购买验证 Service
|
||||||
|
|
||||||
|
- [x] 6.1 创建 `internal/service/purchase_validation/service.go`,实现 ValidateCardPurchase 方法
|
||||||
|
- [x] 6.2 实现 ValidateDevicePurchase 方法
|
||||||
|
- [x] 6.3 实现 ValidatePackageStatus 方法
|
||||||
|
- [x] 6.4 实现 GetPurchasePrice 方法(根据买家身份返回价格)
|
||||||
|
|
||||||
|
## 7. 订单 Service
|
||||||
|
|
||||||
|
- [x] 7.1 创建 `internal/service/order/service.go`,实现 Create 方法(验证权限、创建订单和明细、快照佣金配置版本)
|
||||||
|
- [x] 7.2 实现 Get 方法
|
||||||
|
- [x] 7.3 实现 List 方法
|
||||||
|
- [x] 7.4 实现 Cancel 方法(验证状态、更新为已取消)
|
||||||
|
- [x] 7.5 实现 WalletPay 方法(事务:扣减余额、更新状态、激活套餐、更新销售统计)
|
||||||
|
- [x] 7.6 实现 HandlePaymentCallback 方法(验证签名、幂等处理、激活套餐、更新销售统计)
|
||||||
|
- [x] 7.7 实现 ActivatePackage 辅助方法(创建 PackageUsage 记录、更新 ShopSeriesCommissionStats)
|
||||||
|
- [x] 7.8 实现 SnapshotCommissionConfig 辅助方法(查询并快照当前佣金配置版本)
|
||||||
|
|
||||||
|
## 8. 订单 Handler(后台)
|
||||||
|
|
||||||
|
- [x] 8.1 创建 `internal/handler/admin/order.go`,实现 Create 接口
|
||||||
|
- [x] 8.2 实现 Get 接口
|
||||||
|
- [x] 8.3 实现 List 接口
|
||||||
|
- [x] 8.4 实现 Cancel 接口
|
||||||
|
|
||||||
|
## 9. 订单 Handler(H5)
|
||||||
|
|
||||||
|
- [x] 9.1 创建 `internal/handler/h5/order.go`,实现 Create 接口
|
||||||
|
- [x] 9.2 实现 Get 接口
|
||||||
|
- [x] 9.3 实现 List 接口
|
||||||
|
- [x] 9.4 实现 WalletPay 接口
|
||||||
|
|
||||||
|
## 10. 支付回调 Handler
|
||||||
|
|
||||||
|
- [x] 10.1 创建 `internal/handler/callback/payment.go`,实现 WechatPayCallback 接口
|
||||||
|
- [x] 10.2 实现 AlipayCallback 接口
|
||||||
|
|
||||||
|
## 11. Bootstrap 注册
|
||||||
|
|
||||||
|
- [x] 11.1 在 stores.go 中注册 OrderStore, OrderItemStore
|
||||||
|
- [x] 11.2 在 services.go 中注册 PurchaseValidationService, OrderService
|
||||||
|
- [x] 11.3 在 handlers.go 中注册 AdminOrderHandler, H5OrderHandler, PaymentCallbackHandler
|
||||||
|
|
||||||
|
## 12. 路由注册
|
||||||
|
|
||||||
|
- [x] 12.1 注册 `/api/admin/orders` 路由组(POST, GET, GET/:id, POST/:id/cancel)
|
||||||
|
- [x] 12.2 注册 `/api/h5/orders` 路由组(POST, GET, GET/:id, POST/:id/pay)
|
||||||
|
- [x] 12.3 注册 `/api/callback/wechat-pay` 回调路由
|
||||||
|
- [x] 12.4 注册 `/api/callback/alipay` 回调路由
|
||||||
|
|
||||||
|
## 13. 文档生成器更新
|
||||||
|
|
||||||
|
- [x] 13.1 在 docs.go 和 gendocs/main.go 中添加新 Handler
|
||||||
|
- [x] 13.2 执行文档生成验证
|
||||||
|
|
||||||
|
## 14. 测试
|
||||||
|
|
||||||
|
- [x] 14.1 OrderStore 单元测试
|
||||||
|
- [x] 14.2 PurchaseValidationService 单元测试(覆盖各种验证场景)
|
||||||
|
- [x] 14.3 OrderService 单元测试(覆盖创建、支付、取消)
|
||||||
|
- [x] 14.4 WalletPay 事务测试(覆盖成功和失败回滚)
|
||||||
|
- [x] 14.5 订单创建 API 集成测试
|
||||||
|
- [x] 14.6 钱包支付 API 集成测试
|
||||||
|
- [x] 14.7 执行 `go test ./...` 确认通过
|
||||||
|
|
||||||
|
## 15. 最终验证
|
||||||
|
|
||||||
|
- [x] 15.1 执行 `go build ./...` 确认编译通过
|
||||||
|
- [x] 15.2 启动服务,手动测试订单创建流程
|
||||||
|
- [x] 15.3 手动测试钱包支付流程
|
||||||
|
- [x] 15.4 验证套餐激活(PackageUsage 记录创建)
|
||||||
85
openspec/specs/order-management/spec.md
Normal file
85
openspec/specs/order-management/spec.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 创建套餐购买订单
|
||||||
|
|
||||||
|
系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限。
|
||||||
|
|
||||||
|
#### Scenario: 个人客户创建单卡订单
|
||||||
|
- **WHEN** 个人客户为自己的卡创建订单,选择一个套餐
|
||||||
|
- **THEN** 系统创建订单,状态为待支付,返回订单信息
|
||||||
|
|
||||||
|
#### Scenario: 个人客户创建设备订单
|
||||||
|
- **WHEN** 个人客户为自己的设备创建订单
|
||||||
|
- **THEN** 系统创建订单,订单类型为设备购买
|
||||||
|
|
||||||
|
#### Scenario: 代理创建订单
|
||||||
|
- **WHEN** 代理为店铺关联的卡/设备创建订单
|
||||||
|
- **THEN** 系统创建订单,买家类型为代理商,买家ID为店铺ID
|
||||||
|
|
||||||
|
#### Scenario: 套餐不在可购买范围
|
||||||
|
- **WHEN** 买家尝试购买不在关联系列下的套餐
|
||||||
|
- **THEN** 系统返回错误 "该套餐不在可购买范围内"
|
||||||
|
|
||||||
|
#### Scenario: 套餐已下架
|
||||||
|
- **WHEN** 买家尝试购买已下架的套餐
|
||||||
|
- **THEN** 系统返回错误 "该套餐已下架"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询订单列表
|
||||||
|
|
||||||
|
系统 SHALL 提供订单列表查询,支持按支付状态、订单类型、时间范围筛选。
|
||||||
|
|
||||||
|
#### Scenario: 个人客户查询自己的订单
|
||||||
|
- **WHEN** 个人客户查询订单列表
|
||||||
|
- **THEN** 系统只返回该客户的订单
|
||||||
|
|
||||||
|
#### Scenario: 代理查询店铺订单
|
||||||
|
- **WHEN** 代理查询订单列表
|
||||||
|
- **THEN** 系统返回该店铺及下级店铺的订单
|
||||||
|
|
||||||
|
#### Scenario: 按支付状态筛选
|
||||||
|
- **WHEN** 指定支付状态筛选
|
||||||
|
- **THEN** 系统只返回匹配状态的订单
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询订单详情
|
||||||
|
|
||||||
|
系统 SHALL 允许买家查询订单详情,包含订单明细。
|
||||||
|
|
||||||
|
#### Scenario: 查询订单详情
|
||||||
|
- **WHEN** 买家查询指定订单详情
|
||||||
|
- **THEN** 系统返回订单信息和订单明细列表
|
||||||
|
|
||||||
|
#### Scenario: 查询他人订单
|
||||||
|
- **WHEN** 买家尝试查询不属于自己的订单
|
||||||
|
- **THEN** 系统返回 "订单不存在" 错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 取消订单
|
||||||
|
|
||||||
|
系统 SHALL 允许买家取消未支付的订单。
|
||||||
|
|
||||||
|
#### Scenario: 取消待支付订单
|
||||||
|
- **WHEN** 买家取消一个待支付的订单
|
||||||
|
- **THEN** 系统更新订单状态为已取消
|
||||||
|
|
||||||
|
#### Scenario: 取消已支付订单
|
||||||
|
- **WHEN** 买家尝试取消已支付的订单
|
||||||
|
- **THEN** 系统返回错误 "已支付订单无法取消"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 订单号生成
|
||||||
|
|
||||||
|
系统生成的订单号 MUST 全局唯一,格式为 ORD{YYYYMMDDHHMMSS}{6位随机数}。
|
||||||
|
|
||||||
|
#### Scenario: 订单号格式
|
||||||
|
- **WHEN** 创建新订单
|
||||||
|
- **THEN** 订单号格式为 ORD + 14位时间戳 + 6位随机数
|
||||||
|
|
||||||
|
#### Scenario: 订单号唯一
|
||||||
|
- **WHEN** 并发创建多个订单
|
||||||
|
- **THEN** 每个订单的订单号都唯一
|
||||||
75
openspec/specs/order-payment/spec.md
Normal file
75
openspec/specs/order-payment/spec.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 钱包支付
|
||||||
|
|
||||||
|
系统 SHALL 支持使用钱包余额支付订单。支付成功后 MUST 扣减钱包余额并激活套餐。
|
||||||
|
|
||||||
|
#### Scenario: 钱包余额充足
|
||||||
|
- **WHEN** 买家使用钱包支付,余额充足
|
||||||
|
- **THEN** 系统扣减钱包余额,更新订单状态为已支付,创建套餐使用记录
|
||||||
|
|
||||||
|
#### Scenario: 钱包余额不足
|
||||||
|
- **WHEN** 买家使用钱包支付,余额不足
|
||||||
|
- **THEN** 系统返回错误 "钱包余额不足"
|
||||||
|
|
||||||
|
#### Scenario: 订单已支付
|
||||||
|
- **WHEN** 买家尝试支付已支付的订单
|
||||||
|
- **THEN** 系统返回错误 "订单已支付"
|
||||||
|
|
||||||
|
#### Scenario: 订单已取消
|
||||||
|
- **WHEN** 买家尝试支付已取消的订单
|
||||||
|
- **THEN** 系统返回错误 "订单已取消"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 第三方支付回调
|
||||||
|
|
||||||
|
系统 SHALL 处理微信支付和支付宝的支付回调。回调处理 MUST 幂等。
|
||||||
|
|
||||||
|
#### Scenario: 微信支付成功回调
|
||||||
|
- **WHEN** 收到微信支付成功回调
|
||||||
|
- **THEN** 系统验证签名,更新订单状态,激活套餐,返回成功响应
|
||||||
|
|
||||||
|
#### Scenario: 支付宝成功回调
|
||||||
|
- **WHEN** 收到支付宝支付成功回调
|
||||||
|
- **THEN** 系统验证签名,更新订单状态,激活套餐,返回成功响应
|
||||||
|
|
||||||
|
#### Scenario: 重复回调
|
||||||
|
- **WHEN** 收到已处理订单的重复回调
|
||||||
|
- **THEN** 系统返回成功响应,不重复处理
|
||||||
|
|
||||||
|
#### Scenario: 签名验证失败
|
||||||
|
- **WHEN** 回调签名验证失败
|
||||||
|
- **THEN** 系统拒绝处理,返回失败响应
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 套餐激活
|
||||||
|
|
||||||
|
支付成功后系统 MUST 激活套餐,创建 PackageUsage 记录。
|
||||||
|
|
||||||
|
#### Scenario: 单卡套餐激活
|
||||||
|
- **WHEN** 单卡订单支付成功
|
||||||
|
- **THEN** 系统创建 PackageUsage,usage_type 为 single_card,关联 iot_card_id
|
||||||
|
|
||||||
|
#### Scenario: 设备套餐激活
|
||||||
|
- **WHEN** 设备订单支付成功
|
||||||
|
- **THEN** 系统创建 PackageUsage,usage_type 为 device,关联 device_id
|
||||||
|
|
||||||
|
#### Scenario: 套餐有效期计算
|
||||||
|
- **WHEN** 套餐激活
|
||||||
|
- **THEN** 有效期 = 激活时间 + 套餐时长(月)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 支付事务保证
|
||||||
|
|
||||||
|
钱包支付 MUST 在事务中完成:余额扣减、订单状态更新、套餐激活。任一步骤失败则全部回滚。
|
||||||
|
|
||||||
|
#### Scenario: 事务成功
|
||||||
|
- **WHEN** 所有步骤成功
|
||||||
|
- **THEN** 事务提交,支付完成
|
||||||
|
|
||||||
|
#### Scenario: 余额扣减后套餐激活失败
|
||||||
|
- **WHEN** 余额扣减成功但套餐激活失败
|
||||||
|
- **THEN** 事务回滚,余额恢复,订单状态不变
|
||||||
67
openspec/specs/package-purchase-validation/spec.md
Normal file
67
openspec/specs/package-purchase-validation/spec.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 验证卡/设备的套餐购买权限
|
||||||
|
|
||||||
|
创建订单前系统 MUST 验证卡/设备是否有权购买指定套餐。
|
||||||
|
|
||||||
|
#### Scenario: 卡有套餐系列关联
|
||||||
|
- **WHEN** 卡的 series_allocation_id 有值,且套餐属于该系列
|
||||||
|
- **THEN** 验证通过
|
||||||
|
|
||||||
|
#### Scenario: 卡无套餐系列关联
|
||||||
|
- **WHEN** 卡的 series_allocation_id 为空
|
||||||
|
- **THEN** 验证失败,返回 "该卡未关联套餐系列"
|
||||||
|
|
||||||
|
#### Scenario: 套餐不属于关联系列
|
||||||
|
- **WHEN** 套餐的 series_id 与卡关联的分配系列不匹配
|
||||||
|
- **THEN** 验证失败,返回 "该套餐不在可购买范围内"
|
||||||
|
|
||||||
|
#### Scenario: 系列分配已禁用
|
||||||
|
- **WHEN** 卡关联的系列分配状态为禁用
|
||||||
|
- **THEN** 验证失败,返回 "套餐系列已禁用"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 验证套餐状态
|
||||||
|
|
||||||
|
创建订单前系统 MUST 验证套餐处于可购买状态。
|
||||||
|
|
||||||
|
#### Scenario: 套餐启用且上架
|
||||||
|
- **WHEN** 套餐 status=1 且 shelf_status=1
|
||||||
|
- **THEN** 验证通过
|
||||||
|
|
||||||
|
#### Scenario: 套餐已禁用
|
||||||
|
- **WHEN** 套餐 status=2
|
||||||
|
- **THEN** 验证失败,返回 "套餐已禁用"
|
||||||
|
|
||||||
|
#### Scenario: 套餐已下架
|
||||||
|
- **WHEN** 套餐 shelf_status=2
|
||||||
|
- **THEN** 验证失败,返回 "套餐已下架"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 获取购买价格
|
||||||
|
|
||||||
|
系统 MUST 根据买家身份返回正确的购买价格。
|
||||||
|
|
||||||
|
#### Scenario: 个人客户购买
|
||||||
|
- **WHEN** 个人客户购买套餐
|
||||||
|
- **THEN** 使用 Package.suggested_retail_price 作为支付金额
|
||||||
|
|
||||||
|
#### Scenario: 代理为店铺购买
|
||||||
|
- **WHEN** 代理为自己店铺购买套餐(囤货/测试)
|
||||||
|
- **THEN** 使用代理的成本价作为支付金额
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 设备购买时的卡验证
|
||||||
|
|
||||||
|
设备购买套餐时 MUST 使用设备的 series_allocation_id 验证,不使用设备下单卡的关联。
|
||||||
|
|
||||||
|
#### Scenario: 设备有系列关联
|
||||||
|
- **WHEN** 设备的 series_allocation_id 有值
|
||||||
|
- **THEN** 使用设备的关联验证购买权限
|
||||||
|
|
||||||
|
#### Scenario: 设备无系列关联
|
||||||
|
- **WHEN** 设备的 series_allocation_id 为空
|
||||||
|
- **THEN** 验证失败,返回 "该设备未关联套餐系列"
|
||||||
@@ -91,6 +91,12 @@ const (
|
|||||||
StatusEnabled = 1 // 启用
|
StatusEnabled = 1 // 启用
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 上架状态常量
|
||||||
|
const (
|
||||||
|
ShelfStatusOn = 1 // 上架
|
||||||
|
ShelfStatusOff = 2 // 下架
|
||||||
|
)
|
||||||
|
|
||||||
// 运营商类型常量
|
// 运营商类型常量
|
||||||
const (
|
const (
|
||||||
CarrierTypeCMCC = "CMCC" // 中国移动
|
CarrierTypeCMCC = "CMCC" // 中国移动
|
||||||
|
|||||||
Reference in New Issue
Block a user