feat: 客户端接口数据模型基础准备

- 新增资产状态、订单来源、操作人类型、实名链接类型常量
- 8个模型新增字段(asset_status/generation/source/retail_price等)
- 数据库迁移000082:7张表15+字段,含存量retail_price回填
- BUG-1修复:代理零售价渠道隔离,cost_price分配锁定
- BUG-2修复:一次性佣金仅客户端订单触发
- BUG-4修复:充值回调Store操作纳入事务
- 新增资产手动停用接口(PATCH /iot-cards/:id/deactivate、/devices/:id/deactivate)
- Carrier管理新增实名链接配置
- 后台订单generation写时快照
- BatchUpdatePricing支持retail_price调价目标
- 清理全部H5旧接口和个人客户旧登录方法
This commit is contained in:
2026-03-19 10:56:50 +08:00
parent 817d0d6e04
commit ec86dbf463
70 changed files with 1438 additions and 1188 deletions

View File

@@ -5,7 +5,6 @@ import (
"github.com/break/junhong_cmp_fiber/internal/handler/app"
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/go-playground/validator/v10"
)
@@ -21,14 +20,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Shop: admin.NewShopHandler(svc.Shop),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
H5Auth: h5.NewAuthHandler(svc.Auth, validate),
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal),
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
EnterpriseCard: admin.NewEnterpriseCardHandler(svc.EnterpriseCard),
EnterpriseDevice: admin.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
EnterpriseDeviceH5: h5.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
Authorization: admin.NewAuthorizationHandler(svc.Authorization),
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
IotCard: admin.NewIotCardHandler(svc.IotCard),
@@ -41,13 +38,10 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
H5PackageUsage: h5.NewPackageUsageHandler(deps.DB, svc.PackageCustomerView),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
H5Order: h5.NewOrderHandler(svc.Order),
H5Recharge: h5.NewRechargeHandler(svc.Recharge),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, svc.AgentRecharge, deps.WechatPayment),
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
@@ -56,6 +50,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
Asset: admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService),
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge),

View File

@@ -32,13 +32,9 @@ func initMiddlewares(deps *Dependencies, stores *stores) *Middlewares {
// 创建后台认证中间件(传入 ShopStore 以支持预计算下级店铺 ID
adminAuthMiddleware := createAdminAuthMiddleware(tokenManager, stores.Shop)
// 创建H5认证中间件传入 ShopStore 以支持预计算下级店铺 ID
h5AuthMiddleware := createH5AuthMiddleware(tokenManager, stores.Shop)
return &Middlewares{
PersonalAuth: personalAuthMiddleware,
AdminAuth: adminAuthMiddleware,
H5Auth: h5AuthMiddleware,
}
}
@@ -68,29 +64,3 @@ func createAdminAuthMiddleware(tokenManager *pkgauth.TokenManager, shopStore pkg
ShopStore: shopStore,
})
}
func createH5AuthMiddleware(tokenManager *pkgauth.TokenManager, shopStore pkgmiddleware.AuthShopStoreInterface) fiber.Handler {
return pkgmiddleware.Auth(pkgmiddleware.AuthConfig{
TokenValidator: func(token string) (*pkgmiddleware.UserContextInfo, error) {
tokenInfo, err := tokenManager.ValidateAccessToken(context.Background(), token)
if err != nil {
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
}
// 检查用户类型H5 允许 Agent(3), Enterprise(4)
if tokenInfo.UserType != constants.UserTypeAgent &&
tokenInfo.UserType != constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "权限不足")
}
return &pkgmiddleware.UserContextInfo{
UserID: tokenInfo.UserID,
UserType: tokenInfo.UserType,
ShopID: tokenInfo.ShopID,
EnterpriseID: tokenInfo.EnterpriseID,
}, nil
},
SkipPaths: []string{"/api/h5/login", "/api/h5/refresh-token"},
ShopStore: shopStore,
})
}

View File

@@ -82,6 +82,7 @@ type services struct {
PollingCleanup *pollingSvc.CleanupService
PollingManualTrigger *pollingSvc.ManualTriggerService
Asset *assetSvc.Service
AssetLifecycle *assetSvc.LifecycleService
AssetWallet *assetWalletSvc.Service
StopResumeService *iotCardSvc.StopResumeService
WechatConfig *wechatConfigSvc.Service
@@ -158,6 +159,7 @@ func initServices(s *stores, deps *Dependencies) *services {
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device),
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
StopResumeService: iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger),
WechatConfig: wechatConfig,

View File

@@ -5,7 +5,6 @@ import (
"github.com/break/junhong_cmp_fiber/internal/handler/app"
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
"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/middleware"
"github.com/gofiber/fiber/v2"
)
@@ -19,14 +18,12 @@ type Handlers struct {
Shop *admin.ShopHandler
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler
H5Auth *h5.AuthHandler
ShopCommission *admin.ShopCommissionHandler
CommissionWithdrawal *admin.CommissionWithdrawalHandler
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
Enterprise *admin.EnterpriseHandler
EnterpriseCard *admin.EnterpriseCardHandler
EnterpriseDevice *admin.EnterpriseDeviceHandler
EnterpriseDeviceH5 *h5.EnterpriseDeviceHandler
Authorization *admin.AuthorizationHandler
MyCommission *admin.MyCommissionHandler
IotCard *admin.IotCardHandler
@@ -39,13 +36,10 @@ type Handlers struct {
PackageSeries *admin.PackageSeriesHandler
Package *admin.PackageHandler
PackageUsage *admin.PackageUsageHandler
H5PackageUsage *h5.PackageUsageHandler
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
ShopSeriesGrant *admin.ShopSeriesGrantHandler
AdminOrder *admin.OrderHandler
H5Order *h5.OrderHandler
H5Recharge *h5.RechargeHandler
PaymentCallback *callback.PaymentHandler
PollingConfig *admin.PollingConfigHandler
PollingConcurrency *admin.PollingConcurrencyHandler
@@ -54,6 +48,7 @@ type Handlers struct {
PollingCleanup *admin.PollingCleanupHandler
PollingManualTrigger *admin.PollingManualTriggerHandler
Asset *admin.AssetHandler
AssetLifecycle *admin.AssetLifecycleHandler
AssetWallet *admin.AssetWalletHandler
WechatConfig *admin.WechatConfigHandler
AgentRecharge *admin.AgentRechargeHandler
@@ -64,6 +59,5 @@ type Handlers struct {
type Middlewares struct {
PersonalAuth *middleware.PersonalAuthMiddleware
AdminAuth func(*fiber.Ctx) error
H5Auth func(*fiber.Ctx) error
// TODO: 新增 Middleware 在此添加字段
}

View File

@@ -0,0 +1,59 @@
package admin
import (
"context"
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AssetLifecycleService 资产生命周期服务接口
type AssetLifecycleService interface {
// DeactivateIotCard 停用 IoT 卡
DeactivateIotCard(ctx context.Context, id uint) error
// DeactivateDevice 停用设备
DeactivateDevice(ctx context.Context, id uint) error
}
// AssetLifecycleHandler 资产生命周期处理器
type AssetLifecycleHandler struct {
service AssetLifecycleService
}
// NewAssetLifecycleHandler 创建资产生命周期处理器
func NewAssetLifecycleHandler(service AssetLifecycleService) *AssetLifecycleHandler {
return &AssetLifecycleHandler{service: service}
}
// DeactivateIotCard 手动停用 IoT 卡
// PATCH /api/admin/iot-cards/:id/deactivate
func (h *AssetLifecycleHandler) DeactivateIotCard(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
}
if err := h.service.DeactivateIotCard(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}
// DeactivateDevice 手动停用设备
// PATCH /api/admin/devices/:id/deactivate
func (h *AssetLifecycleHandler) DeactivateDevice(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
}
if err := h.service.DeactivateDevice(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -3,7 +3,6 @@
package app
import (
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
@@ -25,45 +24,6 @@ func NewPersonalCustomerHandler(service *personal_customer.Service, logger *zap.
}
}
// SendCodeRequest 发送验证码请求
type SendCodeRequest struct {
Phone string `json:"phone" validate:"required,len=11"` // 手机号11位
}
// SendCode 发送验证码
// POST /api/c/v1/login/send-code
func (h *PersonalCustomerHandler) SendCode(c *fiber.Ctx) error {
var req SendCodeRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// 发送验证码
if err := h.service.SendVerificationCode(c.Context(), req.Phone); err != nil {
h.logger.Error("发送验证码失败",
zap.String("phone", req.Phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "发送验证码失败")
}
return response.Success(c, fiber.Map{
"message": "验证码已发送",
})
}
// LoginRequest 登录请求
type LoginRequest struct {
Phone string `json:"phone" validate:"required,len=11"` // 手机号11位
Code string `json:"code" validate:"required,len=6"` // 验证码6位
}
// LoginResponse 登录响应
type LoginResponse struct {
Token string `json:"token"` // 访问令牌
Customer *PersonalCustomerDTO `json:"customer"` // 客户信息
}
// PersonalCustomerDTO 个人客户 DTO
type PersonalCustomerDTO struct {
ID uint `json:"id"`
@@ -74,87 +34,6 @@ type PersonalCustomerDTO struct {
Status int `json:"status"`
}
// Login 登录(手机号 + 验证码)
// POST /api/c/v1/login
func (h *PersonalCustomerHandler) Login(c *fiber.Ctx) error {
var req LoginRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// 登录
token, customer, err := h.service.LoginByPhone(c.Context(), req.Phone, req.Code)
if err != nil {
h.logger.Error("登录失败",
zap.String("phone", req.Phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "登录失败")
}
// 构造响应
// 注意Phone 字段已从 PersonalCustomer 模型移除,需要从 PersonalCustomerPhone 表查询
resp := &LoginResponse{
Token: token,
Customer: &PersonalCustomerDTO{
ID: customer.ID,
Phone: req.Phone, // 使用请求中的手机号(临时方案)
Nickname: customer.Nickname,
AvatarURL: customer.AvatarURL,
WxOpenID: customer.WxOpenID,
Status: customer.Status,
},
}
return response.Success(c, resp)
}
// WechatOAuthLogin 微信 OAuth 登录
// POST /api/c/v1/wechat/auth
func (h *PersonalCustomerHandler) WechatOAuthLogin(c *fiber.Ctx) error {
var req dto.WechatOAuthRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.WechatOAuthLogin(c.Context(), req.Code)
if err != nil {
h.logger.Error("微信 OAuth 登录失败",
zap.String("code", req.Code),
zap.Error(err),
)
return err
}
return response.Success(c, result)
}
// BindWechat 绑定微信
// POST /api/c/v1/bind-wechat
func (h *PersonalCustomerHandler) BindWechat(c *fiber.Ctx) error {
var req dto.WechatOAuthRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
customerID, ok := c.Locals("customer_id").(uint)
if !ok {
return errors.New(errors.CodeUnauthorized, "未找到客户信息")
}
if err := h.service.BindWechatWithCode(c.Context(), customerID, req.Code); err != nil {
h.logger.Error("绑定微信失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return err
}
return response.Success(c, fiber.Map{
"message": "绑定成功",
})
}
// UpdateProfileRequest 更新个人资料请求
type UpdateProfileRequest struct {
Nickname string `json:"nickname"` // 昵称

View File

@@ -1,160 +0,0 @@
package h5
import (
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/auth"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
// AuthHandler H5认证处理器
type AuthHandler struct {
authService *auth.Service
validator *validator.Validate
}
// NewAuthHandler 创建H5认证处理器
func NewAuthHandler(authService *auth.Service, validator *validator.Validate) *AuthHandler {
return &AuthHandler{
authService: authService,
validator: validator,
}
}
// Login H5登录
func (h *AuthHandler) Login(c *fiber.Ctx) error {
var req dto.LoginRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("参数验证失败",
zap.String("path", c.Path()),
zap.String("method", c.Method()),
zap.Error(err),
)
return errors.New(errors.CodeInvalidParam)
}
clientIP := c.IP()
ctx := c.UserContext()
resp, err := h.authService.Login(ctx, &req, clientIP)
if err != nil {
return err
}
return response.Success(c, resp)
}
// Logout H5登出
func (h *AuthHandler) Logout(c *fiber.Ctx) error {
auth := c.Get("Authorization")
accessToken := ""
if len(auth) > 7 && auth[:7] == "Bearer " {
accessToken = auth[7:]
}
refreshToken := ""
var req dto.RefreshTokenRequest
if err := c.BodyParser(&req); err == nil {
refreshToken = req.RefreshToken
}
ctx := c.UserContext()
if err := h.authService.Logout(ctx, accessToken, refreshToken); err != nil {
return err
}
return response.Success(c, nil)
}
// RefreshToken 刷新访问令牌
func (h *AuthHandler) RefreshToken(c *fiber.Ctx) error {
var req dto.RefreshTokenRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("参数验证失败",
zap.String("path", c.Path()),
zap.String("method", c.Method()),
zap.Error(err),
)
return errors.New(errors.CodeInvalidParam)
}
ctx := c.UserContext()
newAccessToken, err := h.authService.RefreshToken(ctx, req.RefreshToken)
if err != nil {
return err
}
resp := &dto.RefreshTokenResponse{
AccessToken: newAccessToken,
ExpiresIn: 86400,
}
return response.Success(c, resp)
}
// GetMe 获取当前用户信息
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
ctx := c.UserContext()
userInfo, permissions, err := h.authService.GetCurrentUser(ctx, userID)
if err != nil {
return err
}
data := map[string]interface{}{
"user": userInfo,
"permissions": permissions,
}
return response.Success(c, data)
}
// ChangePassword 修改密码
func (h *AuthHandler) ChangePassword(c *fiber.Ctx) error {
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
var req dto.ChangePasswordRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("参数验证失败",
zap.String("path", c.Path()),
zap.String("method", c.Method()),
zap.Error(err),
)
return errors.New(errors.CodeInvalidParam)
}
ctx := c.UserContext()
if err := h.authService.ChangePassword(ctx, userID, req.OldPassword, req.NewPassword); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -1,55 +0,0 @@
package h5
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
enterpriseDeviceService "github.com/break/junhong_cmp_fiber/internal/service/enterprise_device"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type EnterpriseDeviceHandler struct {
service *enterpriseDeviceService.Service
}
func NewEnterpriseDeviceHandler(service *enterpriseDeviceService.Service) *EnterpriseDeviceHandler {
return &EnterpriseDeviceHandler{service: service}
}
func (h *EnterpriseDeviceHandler) ListDevices(c *fiber.Ctx) error {
var req dto.H5EnterpriseDeviceListReq
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
serviceReq := &dto.EnterpriseDeviceListReq{
Page: req.Page,
PageSize: req.PageSize,
VirtualNo: req.VirtualNo,
}
result, err := h.service.ListDevicesForEnterprise(c.UserContext(), serviceReq)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, req.Page, req.PageSize)
}
func (h *EnterpriseDeviceHandler) GetDeviceDetail(c *fiber.Ctx) error {
deviceIDStr := c.Params("device_id")
deviceID, err := strconv.ParseUint(deviceIDStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "设备ID格式错误")
}
result, err := h.service.GetDeviceDetail(c.UserContext(), uint(deviceID))
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -1,211 +0,0 @@
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, "请求参数解析失败")
}
if req.PaymentMethod != model.PaymentMethodWallet {
return errors.New(errors.CodeInvalidParam, "H5端只支持钱包支付")
}
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.CreateH5Order(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)
}
// WechatPayJSAPI 微信 JSAPI 支付
// POST /api/h5/orders/:id/wechat-pay/jsapi
func (h *OrderHandler) WechatPayJSAPI(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
var req dto.WechatPayJSAPIRequest
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.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
result, err := h.service.WechatPayJSAPI(ctx, uint(id), req.OpenID, buyerType, buyerID)
if err != nil {
return err
}
return response.Success(c, result)
}
// WechatPayH5 微信 H5 支付
// POST /api/h5/orders/:id/wechat-pay/h5
func (h *OrderHandler) WechatPayH5(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
var req dto.WechatPayH5Request
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.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
result, err := h.service.WechatPayH5(ctx, uint(id), &req.SceneInfo, buyerType, buyerID)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -1,93 +0,0 @@
package h5
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
packageService "github.com/break/junhong_cmp_fiber/internal/service/package"
"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"
"gorm.io/gorm"
)
// PackageUsageHandler H5 端套餐使用情况 Handler
type PackageUsageHandler struct {
db *gorm.DB
customerViewService *packageService.CustomerViewService
}
// NewPackageUsageHandler 创建 H5 端套餐使用情况 Handler
func NewPackageUsageHandler(db *gorm.DB, customerViewService *packageService.CustomerViewService) *PackageUsageHandler {
return &PackageUsageHandler{
db: db,
customerViewService: customerViewService,
}
}
// GetMyUsage 任务 15.2-15.5: 获取我的套餐使用情况
// GET /api/h5/packages/my-usage
func (h *PackageUsageHandler) GetMyUsage(c *fiber.Ctx) error {
ctx := c.UserContext()
// 任务 15.3: 从 JWT 上下文中提取用户信息
userType := middleware.GetUserTypeFromContext(ctx)
var carrierType string
var carrierID uint
// 根据用户类型获取载体信息
switch userType {
case constants.UserTypePersonalCustomer:
// 个人客户:查询其订单关联的 IoT 卡或设备
customerID := middleware.GetCustomerIDFromContext(ctx)
if customerID == 0 {
return errors.New(errors.CodeInvalidParam, "未找到客户信息")
}
// 查询该客户的套餐使用记录,获取载体信息
var usage model.PackageUsage
err := h.db.WithContext(ctx).
Joins("JOIN tb_order ON tb_order.id = tb_package_usage.order_id").
Where("tb_order.buyer_type = ? AND tb_order.buyer_id = ?", model.BuyerTypePersonal, customerID).
Where("tb_package_usage.status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Order("tb_package_usage.activated_at DESC").
First(&usage).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "未找到套餐使用记录")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
}
// 确定载体类型和 ID
if usage.IotCardID > 0 {
carrierType = "iot_card"
carrierID = usage.IotCardID
} else if usage.DeviceID > 0 {
carrierType = "device"
carrierID = usage.DeviceID
} else {
return errors.New(errors.CodeInvalidParam, "套餐使用记录未关联卡或设备")
}
case constants.UserTypeAgent, constants.UserTypeEnterprise:
// 代理和企业用户暂不支持通过此接口查询
// 他们应该使用后台管理接口查询指定卡/设备的套餐情况
return errors.New(errors.CodeForbidden, "此接口仅供个人客户使用")
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
// 任务 15.4: 调用 CustomerViewService.GetMyUsage 获取流量数据
usageData, err := h.customerViewService.GetMyUsage(ctx, carrierType, carrierID)
if err != nil {
return err
}
// 任务 15.5: 返回 PackageUsageCustomerViewResponse 响应
return response.Success(c, usageData)
}

View File

@@ -1,169 +0,0 @@
package h5
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// RechargeHandler 充值订单处理器
// 提供充值订单的创建、预检、查询等接口
type RechargeHandler struct {
service *rechargeService.Service
}
// NewRechargeHandler 创建充值订单处理器实例
// 参数:
// - service: 充值服务
//
// 返回:
// - *RechargeHandler: 充值订单处理器实例
func NewRechargeHandler(service *rechargeService.Service) *RechargeHandler {
return &RechargeHandler{service: service}
}
// Create 创建充值订单
// POST /api/h5/wallets/recharge
// 请求参数:
// - resource_type: 资源类型iot_card/device
// - resource_id: 资源ID卡ID或设备ID
// - amount: 充值金额(分)
// - payment_method: 支付方式wechat/alipay
//
// 响应:
// - 成功: 返回充值订单信息订单ID、订单号、金额、状态等
// - 失败: 返回错误信息
func (h *RechargeHandler) Create(c *fiber.Ctx) error {
var req dto.CreateRechargeRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
// 获取个人客户ID作为用户ID
userID := middleware.GetCustomerIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "用户未登录")
}
result, err := h.service.Create(ctx, &req, userID)
if err != nil {
return err
}
return response.Success(c, result)
}
// RechargeCheck 充值预检
// GET /api/h5/wallets/recharge-check
// 请求参数:
// - resource_type: 资源类型iot_card/device
// - resource_id: 资源ID卡ID或设备ID
//
// 响应:
// - 成功: 返回预检信息(是否需要强充、强充金额、最小/最大充值金额等)
// - 失败: 返回错误信息
func (h *RechargeHandler) RechargeCheck(c *fiber.Ctx) error {
var req dto.RechargeCheckRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// 验证必填参数
if req.ResourceType == "" {
return errors.New(errors.CodeInvalidParam, "资源类型不能为空")
}
if req.ResourceID == 0 {
return errors.New(errors.CodeInvalidParam, "资源ID不能为空")
}
ctx := c.UserContext()
result, err := h.service.GetRechargeCheck(ctx, req.ResourceType, req.ResourceID)
if err != nil {
return err
}
// 转换为 DTO 响应
resp := &dto.RechargeCheckResponse{
NeedForceRecharge: result.NeedForceRecharge,
ForceRechargeAmount: result.ForceRechargeAmount,
TriggerType: result.TriggerType,
MinAmount: result.MinAmount,
MaxAmount: result.MaxAmount,
CurrentAccumulated: result.CurrentAccumulated,
Threshold: result.Threshold,
Message: result.Message,
FirstCommissionPaid: result.FirstCommissionPaid,
}
return response.Success(c, resp)
}
// List 查询充值订单列表
// GET /api/h5/wallets/recharges
// 请求参数:
// - page: 页码从1开始默认1
// - page_size: 每页数量默认20最大100
// - wallet_id: 钱包ID筛选可选
// - status: 状态筛选可选1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)
// - start_time: 开始时间筛选(可选)
// - end_time: 结束时间筛选(可选)
//
// 响应:
// - 成功: 返回充值订单列表(分页数据、总记录数、总页数)
// - 失败: 返回错误信息
func (h *RechargeHandler) List(c *fiber.Ctx) error {
var req dto.RechargeListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
// 获取个人客户ID作为用户ID
userID := middleware.GetCustomerIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "用户未登录")
}
result, err := h.service.List(ctx, &req, userID)
if err != nil {
return err
}
return response.Success(c, result)
}
// Get 查询充值订单详情
// GET /api/h5/wallets/recharges/:id
// 路径参数:
// - id: 充值订单ID
//
// 响应:
// - 成功: 返回充值订单详情订单ID、订单号、金额、状态、支付信息等
// - 失败: 返回错误信息
func (h *RechargeHandler) Get(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()
// 获取个人客户ID作为用户ID
userID := middleware.GetCustomerIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "用户未登录")
}
result, err := h.service.GetByID(ctx, uint(id), userID)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -3,6 +3,7 @@ package model
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
@@ -85,6 +86,12 @@ type AssetRechargeRecord struct {
EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
OperatorType string `gorm:"column:operator_type;type:varchar(20);not null;default:'admin_user';comment:操作人类型" json:"operator_type"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
LinkedPackageIDs datatypes.JSON `gorm:"column:linked_package_ids;type:jsonb;default:'[]';comment:强充关联套餐ID列表" json:"linked_package_ids,omitempty"`
LinkedOrderType string `gorm:"column:linked_order_type;type:varchar(20);comment:关联订单类型" json:"linked_order_type,omitempty"`
LinkedCarrierType string `gorm:"column:linked_carrier_type;type:varchar(20);comment:关联载体类型" json:"linked_carrier_type,omitempty"`
LinkedCarrierID *uint `gorm:"column:linked_carrier_id;type:bigint;comment:关联载体ID" json:"linked_carrier_id,omitempty"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
}

View File

@@ -6,13 +6,15 @@ import (
type Carrier struct {
gorm.Model
BaseModel `gorm:"embedded"`
CarrierCode string `gorm:"column:carrier_code;type:varchar(50);uniqueIndex:idx_carrier_code,where:deleted_at IS NULL;not null;comment:运营商编码" json:"carrier_code"`
CarrierName string `gorm:"column:carrier_name;type:varchar(100);not null;comment:运营商名称" json:"carrier_name"`
CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;default:'CMCC';comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"`
Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 0-禁用" json:"status"`
BillingDay int `gorm:"column:billing_day;type:int;default:1;comment:运营商计费日(用于流量查询接口的计费周期计算,联通=27其他=1" json:"billing_day"`
BaseModel `gorm:"embedded"`
CarrierCode string `gorm:"column:carrier_code;type:varchar(50);uniqueIndex:idx_carrier_code,where:deleted_at IS NULL;not null;comment:运营商编码" json:"carrier_code"`
CarrierName string `gorm:"column:carrier_name;type:varchar(100);not null;comment:运营商名称" json:"carrier_name"`
CarrierType string `gorm:"column:carrier_type;type:varchar(20);not null;default:'CMCC';comment:运营商类型(CMCC/CUCC/CTCC/CBN)" json:"carrier_type"`
Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 0-禁用" json:"status"`
BillingDay int `gorm:"column:billing_day;type:int;default:1;comment:运营商计费日(用于流量查询接口的计费周期计算,联通=27其他=1" json:"billing_day"`
RealnameLinkType string `gorm:"column:realname_link_type;type:varchar(20);not null;default:'none';comment:实名链接类型 none-不支持 template-模板URL gateway-Gateway接口" json:"realname_link_type"`
RealnameLinkTemplate string `gorm:"column:realname_link_template;type:varchar(500);default:'';comment:实名链接模板URL" json:"realname_link_template"`
}
// TableName 指定表名

View File

@@ -35,6 +35,8 @@ type Device struct {
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分,废弃,使用按系列追踪)" json:"accumulated_recharge"`
AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的累计充值金额" json:"-"`
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
}
// TableName 指定表名

View File

@@ -1,15 +1,19 @@
package dto
type CreateCarrierRequest struct {
CarrierCode string `json:"carrier_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"运营商编码"`
CarrierName string `json:"carrier_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"运营商名称"`
CarrierType string `json:"carrier_type" validate:"required,oneof=CMCC CUCC CTCC CBN" required:"true" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
CarrierCode string `json:"carrier_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"运营商编码"`
CarrierName string `json:"carrier_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"运营商名称"`
CarrierType string `json:"carrier_type" validate:"required,oneof=CMCC CUCC CTCC CBN" required:"true" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
}
type UpdateCarrierRequest struct {
CarrierName *string `json:"carrier_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"运营商名称"`
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
CarrierName *string `json:"carrier_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"运营商名称"`
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
}
type CarrierListRequest struct {
@@ -25,14 +29,16 @@ type UpdateCarrierStatusRequest struct {
}
type CarrierResponse struct {
ID uint `json:"id" description:"运营商ID"`
CarrierCode string `json:"carrier_code" description:"运营商编码"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
CarrierType string `json:"carrier_type" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
Description string `json:"description" description:"运营商描述"`
Status int `json:"status" description:"状态 (1:启用, 0:禁用)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
ID uint `json:"id" description:"运营商ID"`
CarrierCode string `json:"carrier_code" description:"运营商编码"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
CarrierType string `json:"carrier_type" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
Description string `json:"description" description:"运营商描述"`
RealnameLinkType string `json:"realname_link_type" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate string `json:"realname_link_template" description:"实名链接模板URL"`
Status int `json:"status" description:"状态 (1:启用, 0:禁用)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
type UpdateCarrierParams struct {

View File

@@ -83,6 +83,7 @@ type PackageResponse struct {
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
RetailPrice *int64 `json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`
ProfitMargin *int64 `json:"profit_margin,omitempty" description:"利润空间(分,仅代理用户可见)"`
CurrentCommissionRate string `json:"current_commission_rate,omitempty" description:"当前返佣比例(仅代理用户可见)"`
TierInfo *CommissionTierInfo `json:"tier_info,omitempty" description:"梯度返佣信息(仅代理用户可见)"`

View File

@@ -4,12 +4,20 @@ package dto
type BatchUpdateCostPriceRequest struct {
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"店铺ID"`
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID可选不填则调整所有"`
PricingTarget string `json:"pricing_target" validate:"omitempty,oneof=cost_price retail_price" description:"调价目标 cost_price-成本价(默认) retail_price-零售价"`
PriceAdjustment PriceAdjustment `json:"price_adjustment" validate:"required" required:"true" description:"价格调整配置"`
ChangeReason string `json:"change_reason" validate:"omitempty,max=255" maxLength:"255" description:"变更原因"`
}
// BatchUpdateCostPriceResponse 批量调价响应
type BatchUpdateCostPriceResponse struct {
UpdatedCount int `json:"updated_count" description:"更新数量"`
AffectedIDs []uint `json:"affected_ids" description:"受影响的分配ID列表"`
UpdatedCount int `json:"updated_count" description:"更新数量"`
AffectedIDs []uint `json:"affected_ids" description:"受影响的分配ID列表"`
Skipped []BatchPricingSkipped `json:"skipped,omitempty" description:"跳过的记录"`
}
// BatchPricingSkipped 批量调价跳过记录
type BatchPricingSkipped struct {
AllocationID uint `json:"allocation_id" description:"分配ID"`
Reason string `json:"reason" description:"跳过原因"`
}

View File

@@ -48,6 +48,8 @@ type IotCard struct {
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"`
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"`
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可空,全局唯一)" json:"virtual_no,omitempty"`
}

View File

@@ -40,6 +40,10 @@ type Order struct {
SellerCostPrice int64 `gorm:"column:seller_cost_price;type:bigint;default:0;comment:销售成本价(分,用于计算利润)" json:"seller_cost_price"`
SeriesID *uint `gorm:"column:series_id;index;comment:系列ID用于查询分配配置" json:"series_id,omitempty"`
// 订单来源和世代
Source string `gorm:"column:source;type:varchar(20);not null;default:'admin';comment:订单来源 admin-后台 client-客户端" json:"source"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
// 代购信息
IsPurchaseOnBehalf bool `gorm:"column:is_purchase_on_behalf;type:boolean;default:false;comment:是否为代购订单" json:"is_purchase_on_behalf"`

View File

@@ -79,6 +79,7 @@ type PackageUsage struct {
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);comment:流量重置周期(从Package复制,用于历史记录)" json:"data_reset_cycle"`
LastResetAt *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"`
NextResetAt *time.Time `gorm:"column:next_reset_at;index:idx_package_usage_next_reset_at;comment:下次流量重置时间(用于定时任务查询)" json:"next_reset_at"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
}
// TableName 指定表名

View File

@@ -9,7 +9,7 @@ import (
// 手机号、ICCID、设备号通过关联表存储
type PersonalCustomer struct {
gorm.Model
WxOpenID string `gorm:"column:wx_open_id;type:varchar(100);uniqueIndex:idx_personal_customer_wx_open_id,where:deleted_at IS NULL;not null;comment:微信OpenID唯一标识" json:"wx_open_id"`
WxOpenID string `gorm:"column:wx_open_id;type:varchar(100);index:idx_personal_customer_wx_open_id;not null;comment:微信OpenID唯一标识" json:"wx_open_id"`
WxUnionID string `gorm:"column:wx_union_id;type:varchar(100);index;not null;comment:微信UnionID" json:"wx_union_id"`
Nickname string `gorm:"column:nickname;type:varchar(100);comment:微信昵称" json:"nickname"`
AvatarURL string `gorm:"column:avatar_url;type:varchar(500);comment:微信头像URL" json:"avatar_url"`

View File

@@ -14,6 +14,7 @@ type ShopPackageAllocation struct {
SeriesAllocationID *uint `gorm:"column:series_allocation_id;index;comment:关联的系列分配ID" json:"series_allocation_id"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
ShelfStatus int `gorm:"column:shelf_status;type:int;default:1;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
RetailPrice int64 `gorm:"column:retail_price;type:bigint;not null;default:0;comment:代理面向终端客户的零售价(分)" json:"retail_price"`
}
// TableName 指定表名

View File

@@ -59,6 +59,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.Device != nil {
registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath)
}
if handlers.AssetLifecycle != nil {
registerAssetLifecycleRoutes(authGroup, handlers.AssetLifecycle, doc, basePath)
}
if handlers.AssetAllocationRecord != nil {
registerAssetAllocationRecordRoutes(authGroup, handlers.AssetAllocationRecord, doc, basePath)
}

View File

@@ -0,0 +1,28 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerAssetLifecycleRoutes 注册资产手动停用路由
func registerAssetLifecycleRoutes(router fiber.Router, handler *admin.AssetLifecycleHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "PATCH", "/iot-cards/:id/deactivate", handler.DeactivateIotCard, RouteSpec{
Summary: "手动停用IoT卡",
Tags: []string{"IoT卡管理"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
Register(router, doc, basePath, "PATCH", "/devices/:id/deactivate", handler.DeactivateDevice, RouteSpec{
Summary: "手动停用设备",
Tags: []string{"设备管理"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
}

View File

@@ -1,27 +0,0 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// RegisterH5Routes 注册H5相关路由
func RegisterH5Routes(router fiber.Router, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator, basePath string) {
// 认证路由已迁移到 /api/auth参见 RegisterAuthRoutes
authGroup := router.Group("", middlewares.H5Auth)
if handlers.H5Order != nil {
registerH5OrderRoutes(authGroup, handlers.H5Order, doc, basePath)
}
if handlers.H5Recharge != nil {
registerH5RechargeRoutes(authGroup, handlers.H5Recharge, doc, basePath)
}
if handlers.EnterpriseDeviceH5 != nil {
registerH5EnterpriseDeviceRoutes(authGroup, handlers.EnterpriseDeviceH5, doc, basePath)
}
if handlers.H5PackageUsage != nil {
registerH5PackageUsageRoutes(authGroup, handlers.H5PackageUsage, doc, basePath)
}
}

View File

@@ -1,31 +0,0 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"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"
)
func registerH5EnterpriseDeviceRoutes(router fiber.Router, handler *h5.EnterpriseDeviceHandler, doc *openapi.Generator, basePath string) {
devices := router.Group("/devices")
groupPath := basePath + "/devices"
Register(devices, doc, groupPath, "GET", "", handler.ListDevices, RouteSpec{
Summary: "企业设备列表H5",
Tags: []string{"H5-企业设备"},
Input: new(dto.H5EnterpriseDeviceListReq),
Output: new(dto.EnterpriseDeviceListResp),
Auth: true,
})
Register(devices, doc, groupPath, "GET", "/:device_id", handler.GetDeviceDetail, RouteSpec{
Summary: "获取设备详情H5",
Tags: []string{"H5-企业设备"},
Input: new(dto.DeviceDetailReq),
Output: new(dto.EnterpriseDeviceDetailResp),
Auth: true,
})
}

View File

@@ -1,23 +0,0 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"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"
)
// registerH5PackageUsageRoutes 注册 H5 端套餐使用情况路由
func registerH5PackageUsageRoutes(router fiber.Router, handler *h5.PackageUsageHandler, doc *openapi.Generator, basePath string) {
packages := router.Group("/packages")
groupPath := basePath + "/packages"
Register(packages, doc, groupPath, "GET", "/my-usage", handler.GetMyUsage, RouteSpec{
Summary: "获取我的套餐使用情况",
Tags: []string{"H5-套餐"},
Input: nil,
Output: new(dto.PackageUsageCustomerViewResponse),
Auth: true,
})
}

View File

@@ -5,7 +5,6 @@ import (
"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"
)
@@ -53,57 +52,6 @@ func registerAdminOrderRoutes(router fiber.Router, handler *admin.OrderHandler,
})
}
// 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,
})
Register(router, doc, basePath, "POST", "/orders/:id/wechat-pay/jsapi", handler.WechatPayJSAPI, RouteSpec{
Summary: "微信 JSAPI 支付",
Tags: []string{"H5 订单"},
Input: new(dto.WechatPayJSAPIParams),
Output: new(dto.WechatPayJSAPIResponse),
Auth: true,
})
Register(router, doc, basePath, "POST", "/orders/:id/wechat-pay/h5", handler.WechatPayH5, RouteSpec{
Summary: "微信 H5 支付",
Tags: []string{"H5 订单"},
Input: new(dto.WechatPayH5Params),
Output: new(dto.WechatPayH5Response),
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{

View File

@@ -6,60 +6,16 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// RegisterPersonalCustomerRoutes 注册个人客户路由
// 路由挂载在 /api/c/v1 下
func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers, personalAuthMiddleware *middleware.PersonalAuthMiddleware) {
// 公开路由(不需要认证)
publicGroup := router.Group("")
// 发送验证码
Register(publicGroup, doc, basePath, "POST", "/login/send-code", handlers.PersonalCustomer.SendCode, RouteSpec{
Summary: "发送验证码",
Description: "向指定手机号发送登录验证码",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &apphandler.SendCodeRequest{},
Output: nil,
})
// 登录
Register(publicGroup, doc, basePath, "POST", "/login", handlers.PersonalCustomer.Login, RouteSpec{
Summary: "手机号登录",
Description: "使用手机号和验证码登录",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &apphandler.LoginRequest{},
Output: &apphandler.LoginResponse{},
})
// 微信 OAuth 登录(公开)
Register(publicGroup, doc, basePath, "POST", "/wechat/auth", handlers.PersonalCustomer.WechatOAuthLogin, RouteSpec{
Summary: "微信授权登录",
Description: "使用微信授权码登录,自动创建或关联用户",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &dto.WechatOAuthRequest{},
Output: &dto.WechatOAuthResponse{},
})
// 需要认证的路由
authGroup := router.Group("")
authGroup.Use(personalAuthMiddleware.Authenticate())
// 绑定微信
Register(authGroup, doc, basePath, "POST", "/bind-wechat", handlers.PersonalCustomer.BindWechat, RouteSpec{
Summary: "绑定微信",
Description: "绑定微信账号到当前个人客户",
Tags: []string{"个人客户 - 账户"},
Auth: true,
Input: &dto.WechatOAuthRequest{},
Output: nil,
})
// 获取个人资料
Register(authGroup, doc, basePath, "GET", "/profile", handlers.PersonalCustomer.GetProfile, RouteSpec{
Summary: "获取个人资料",

View File

@@ -1,44 +1 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"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"
)
// registerH5RechargeRoutes 注册H5充值路由
func registerH5RechargeRoutes(router fiber.Router, handler *h5.RechargeHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/wallets/recharge", handler.Create, RouteSpec{
Summary: "创建充值订单",
Tags: []string{"H5 充值"},
Input: new(dto.CreateRechargeRequest),
Output: new(dto.RechargeResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/wallets/recharge-check", handler.RechargeCheck, RouteSpec{
Summary: "充值预检",
Tags: []string{"H5 充值"},
Input: new(dto.RechargeCheckRequest),
Output: new(dto.RechargeCheckResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/wallets/recharges", handler.List, RouteSpec{
Summary: "获取充值订单列表",
Tags: []string{"H5 充值"},
Input: new(dto.RechargeListRequest),
Output: new(dto.RechargeListResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/wallets/recharges/:id", handler.Get, RouteSpec{
Summary: "获取充值订单详情",
Tags: []string{"H5 充值"},
Input: new(dto.GetRechargeRequest),
Output: new(dto.RechargeResponse),
Auth: true,
})
}

View File

@@ -28,15 +28,11 @@ func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlew
adminGroup := app.Group("/api/admin")
RegisterAdminRoutes(adminGroup, handlers, middlewares, doc, "/api/admin")
// 4. H5 域 (挂载在 /api/h5)
h5Group := app.Group("/api/h5")
RegisterH5Routes(h5Group, handlers, middlewares, doc, "/api/h5")
// 5. 个人客户路由 (挂载在 /api/c/v1)
// 4. 个人客户路由 (挂载在 /api/c/v1)
personalGroup := app.Group("/api/c/v1")
RegisterPersonalCustomerRoutes(personalGroup, doc, "/api/c/v1", handlers, middlewares.PersonalAuth)
// 6. 支付回调路由 (挂载在 /api/callback无需认证)
// 5. 支付回调路由 (挂载在 /api/callback无需认证)
if handlers.PaymentCallback != nil {
callbackGroup := app.Group("/api/callback")
registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback")

View File

@@ -0,0 +1,88 @@
package asset
import (
"context"
stderrors "errors"
"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"
)
var deactivatableAssetStatuses = []int{constants.AssetStatusInStock, constants.AssetStatusSold}
// LifecycleService 资产生命周期服务
type LifecycleService struct {
db *gorm.DB
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
}
// NewLifecycleService 创建资产生命周期服务
func NewLifecycleService(db *gorm.DB, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore) *LifecycleService {
return &LifecycleService{
db: db,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
}
}
// DeactivateIotCard 手动停用 IoT 卡
func (s *LifecycleService) DeactivateIotCard(ctx context.Context, id uint) error {
card, err := s.iotCardStore.GetByID(ctx, id)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errors.CodeIotCardNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
if !canDeactivateAsset(card.AssetStatus) {
return errors.New(errors.CodeForbidden, "当前状态不允许停用")
}
result := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).
Update("asset_status", constants.AssetStatusDeactivated)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用IoT卡失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
}
return nil
}
// DeactivateDevice 手动停用设备
func (s *LifecycleService) DeactivateDevice(ctx context.Context, id uint) error {
device, err := s.deviceStore.GetByID(ctx, id)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errors.CodeNotFound, "设备不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
if !canDeactivateAsset(device.AssetStatus) {
return errors.New(errors.CodeForbidden, "当前状态不允许停用")
}
result := s.db.WithContext(ctx).Model(&model.Device{}).
Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).
Update("asset_status", constants.AssetStatusDeactivated)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用设备失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
}
return nil
}
func canDeactivateAsset(assetStatus int) bool {
return assetStatus == constants.AssetStatusInStock || assetStatus == constants.AssetStatusSold
}

View File

@@ -41,6 +41,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
Description: req.Description,
Status: constants.StatusEnabled,
}
if req.RealnameLinkType != nil {
carrier.RealnameLinkType = *req.RealnameLinkType
}
if req.RealnameLinkTemplate != nil {
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
}
carrier.Creator = currentUserID
if err := s.carrierStore.Create(ctx, carrier); err != nil {
@@ -81,6 +87,15 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
if req.Description != nil {
carrier.Description = *req.Description
}
if req.RealnameLinkType != nil {
carrier.RealnameLinkType = *req.RealnameLinkType
}
if req.RealnameLinkTemplate != nil {
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
}
if carrier.RealnameLinkType == "template" && carrier.RealnameLinkTemplate == "" {
return nil, errors.New(errors.CodeInvalidParam, "模板URL类型必须提供实名链接模板")
}
carrier.Updater = currentUserID
if err := s.carrierStore.Update(ctx, carrier); err != nil {
@@ -169,13 +184,15 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
return &dto.CarrierResponse{
ID: c.ID,
CarrierCode: c.CarrierCode,
CarrierName: c.CarrierName,
CarrierType: c.CarrierType,
Description: c.Description,
Status: c.Status,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
ID: c.ID,
CarrierCode: c.CarrierCode,
CarrierName: c.CarrierName,
CarrierType: c.CarrierType,
Description: c.Description,
RealnameLinkType: c.RealnameLinkType,
RealnameLinkTemplate: c.RealnameLinkTemplate,
Status: c.Status,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
"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"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -201,7 +202,7 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
}
func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *gorm.DB, order *model.Order, cardID uint) error {
if order.IsPurchaseOnBehalf {
if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient {
return nil
}
@@ -285,7 +286,7 @@ func (s *Service) TriggerOneTimeCommissionForCard(ctx context.Context, order *mo
}
func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx *gorm.DB, order *model.Order, deviceID uint) error {
if order.IsPurchaseOnBehalf {
if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient {
return nil
}

View File

@@ -564,6 +564,17 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
}
}
// 从资产获取当前 generation用于订单快照
var assetGeneration int
if validationResult.Card != nil {
assetGeneration = validationResult.Card.Generation
} else if validationResult.Device != nil {
assetGeneration = validationResult.Device.Generation
}
if assetGeneration == 0 {
assetGeneration = 1
}
order := &model.Order{
BaseModel: model.BaseModel{
Creator: userID,
@@ -571,6 +582,8 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
},
OrderNo: s.orderStore.GenerateOrderNo(),
OrderType: req.OrderType,
Source: constants.OrderSourceAdmin,
Generation: assetGeneration,
BuyerType: orderBuyerType,
BuyerID: orderBuyerID,
IotCardID: req.IotCardID,

View File

@@ -533,9 +533,9 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, pkg.ID)
if err == nil && allocation != nil {
resp.CostPrice = allocation.CostPrice
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
resp.RetailPrice = &allocation.RetailPrice
profitMargin := allocation.RetailPrice - allocation.CostPrice
resp.ProfitMargin = &profitMargin
// 代理查询时shelf_status 返回自己分配记录的值,而非平台全局值
resp.ShelfStatus = allocation.ShelfStatus
}
}
@@ -595,9 +595,9 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
if allocationMap != nil {
if allocation, ok := allocationMap[pkg.ID]; ok {
resp.CostPrice = allocation.CostPrice
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
resp.RetailPrice = &allocation.RetailPrice
profitMargin := allocation.RetailPrice - allocation.CostPrice
resp.ProfitMargin = &profitMargin
// 代理查询时shelf_status 返回自己分配记录的值,而非平台全局值
resp.ShelfStatus = allocation.ShelfStatus
}
}

View File

@@ -133,44 +133,57 @@ func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, serie
}
if sellerShopID > 0 {
// 代理渠道:检查卖家代理的 allocation.shelf_status不检查 package.shelf_status
if err := s.validateAgentShelfStatus(ctx, sellerShopID, pkgID); err != nil {
return nil, 0, err
// 代理渠道:检查上架状态并获取分配记录,使用零售价
allocation, allocErr := s.validateAgentAllocation(ctx, sellerShopID, pkgID)
if allocErr != nil {
return nil, 0, allocErr
}
// 零售价低于成本价时视为不可购买,防止亏损售卖
if allocation.RetailPrice < allocation.CostPrice {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐价格配置异常,暂不可购买")
}
totalPrice += allocation.RetailPrice
} else {
// 平台自营渠道:检查 package.shelf_status
if pkg.ShelfStatus != constants.ShelfStatusOn {
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
totalPrice += pkg.SuggestedRetailPrice
}
packages = append(packages, pkg)
totalPrice += pkg.SuggestedRetailPrice
}
return packages, totalPrice, nil
}
// validateAgentShelfStatus 校验卖家代理的分配记录上架状态
func (s *Service) validateAgentShelfStatus(ctx context.Context, sellerShopID, packageID uint) error {
// 使用不带数据权限过滤的查询,避免 buyer ctx 的权限限制干扰系统级校验
// validateAgentAllocation 校验卖家代理的分配记录上架状态,并返回分配记录
func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, packageID uint) (*model.ShopPackageAllocation, error) {
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInvalidParam, "套餐已下架")
return nil, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
return errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
}
if allocation.ShelfStatus != constants.ShelfStatusOn {
return errors.New(errors.CodeInvalidParam, "套餐已下架")
return nil, errors.New(errors.CodeInvalidParam, "套餐已下架")
}
return nil
return allocation, nil
}
func (s *Service) GetPurchasePrice(ctx context.Context, pkg *model.Package, buyerType string) int64 {
return pkg.SuggestedRetailPrice
// GetPurchasePrice 获取购买价格
// 代理渠道sellerShopID > 0返回 allocation.RetailPrice平台渠道返回 Package.SuggestedRetailPrice
func (s *Service) GetPurchasePrice(ctx context.Context, pkg *model.Package, sellerShopID uint) (int64, error) {
if sellerShopID > 0 {
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, pkg.ID)
if err != nil {
return 0, errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败")
}
return allocation.RetailPrice, nil
}
return pkg.SuggestedRetailPrice, nil
}
// ValidateAdminOfflineCardPurchase 后台 offline 订单专用卡验证

View File

@@ -306,18 +306,17 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
// 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
// 6.1 更新充值订单状态(带状态检查,实现乐观锁
// 6.1 更新充值订单状态(带状态检查,使用事务内 tx 确保原子性
oldStatus := constants.RechargeStatusPending
if err := s.assetRechargeStore.UpdateStatusWithOptimisticLock(ctx, recharge.ID, &oldStatus, constants.RechargeStatusPaid, &now, nil); err != nil {
if err := s.assetRechargeStore.UpdateStatusWithOptimisticLockDB(ctx, tx, recharge.ID, &oldStatus, constants.RechargeStatusPaid, &now, nil); err != nil {
if err == gorm.ErrRecordNotFound {
// 状态已变更,幂等处理
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单状态失败")
}
// 6.2 更新支付信息
if err := s.assetRechargeStore.UpdatePaymentInfo(ctx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil {
// 6.2 更新支付信息(使用事务内 tx
if err := s.assetRechargeStore.UpdatePaymentInfoWithDB(ctx, tx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败")
}

View File

@@ -95,6 +95,7 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka
PackageID: pkg.ID,
AllocatorShopID: allocatorShopID,
CostPrice: costPrice,
RetailPrice: pkg.SuggestedRetailPrice,
SeriesAllocationID: &seriesAllocation.ID,
Status: constants.StatusEnabled,
}

View File

@@ -65,37 +65,86 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
return nil, errors.New(errors.CodeInvalidParam, "没有找到符合条件的分配记录")
}
pricingTarget := req.PricingTarget
if pricingTarget == "" {
pricingTarget = "cost_price"
}
updatedCount := 0
now := time.Now()
affectedIDs := make([]uint, 0)
skipped := make([]dto.BatchPricingSkipped, 0)
err = s.db.Transaction(func(tx *gorm.DB) error {
for _, allocation := range allocations {
oldPrice := allocation.CostPrice
newPrice := s.calculateAdjustedPrice(oldPrice, &req.PriceAdjustment)
if pricingTarget == "retail_price" {
oldRetailPrice := allocation.RetailPrice
newRetailPrice := s.calculateAdjustedPrice(oldRetailPrice, &req.PriceAdjustment)
if newRetailPrice == oldRetailPrice {
continue
}
if newRetailPrice < allocation.CostPrice {
skipped = append(skipped, dto.BatchPricingSkipped{
AllocationID: allocation.ID,
Reason: "零售价不能低于成本价",
})
continue
}
if newPrice == oldPrice {
continue
}
history := &model.ShopPackageAllocationPriceHistory{
AllocationID: allocation.ID,
OldCostPrice: oldRetailPrice,
NewCostPrice: newRetailPrice,
ChangeReason: req.ChangeReason + "(零售价调整)",
ChangedBy: currentUserID,
EffectiveFrom: now,
}
if err := tx.Create(history).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建价格历史失败")
}
history := &model.ShopPackageAllocationPriceHistory{
AllocationID: allocation.ID,
OldCostPrice: oldPrice,
NewCostPrice: newPrice,
ChangeReason: req.ChangeReason,
ChangedBy: currentUserID,
EffectiveFrom: now,
}
allocation.RetailPrice = newRetailPrice
allocation.Updater = currentUserID
if err := tx.Save(allocation).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
}
} else {
oldPrice := allocation.CostPrice
newPrice := s.calculateAdjustedPrice(oldPrice, &req.PriceAdjustment)
if newPrice == oldPrice {
continue
}
if err := tx.Create(history).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建价格历史失败")
}
// cost_price 锁定检查:存在下级分配记录时跳过
var subCount int64
tx.Model(&model.ShopPackageAllocation{}).
Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, allocation.PackageID).
Count(&subCount)
if subCount > 0 {
skipped = append(skipped, dto.BatchPricingSkipped{
AllocationID: allocation.ID,
Reason: "存在下级分配记录,请先回收后再修改成本价",
})
continue
}
allocation.CostPrice = newPrice
allocation.Updater = currentUserID
if err := tx.Save(allocation).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新成本价失败")
history := &model.ShopPackageAllocationPriceHistory{
AllocationID: allocation.ID,
OldCostPrice: oldPrice,
NewCostPrice: newPrice,
ChangeReason: req.ChangeReason,
ChangedBy: currentUserID,
EffectiveFrom: now,
}
if err := tx.Create(history).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建价格历史失败")
}
allocation.CostPrice = newPrice
allocation.Updater = currentUserID
if err := tx.Save(allocation).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新成本价失败")
}
}
affectedIDs = append(affectedIDs, allocation.ID)
@@ -112,6 +161,7 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
return &dto.BatchUpdateCostPriceResponse{
UpdatedCount: updatedCount,
AffectedIDs: affectedIDs,
Skipped: skipped,
}, nil
}

View File

@@ -159,13 +159,13 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop
// 合并全局 operator 和代理 amount
tiers := make([]dto.GrantCommissionTierItem, 0, len(config.Tiers))
for _, globalTier := range config.Tiers {
tiers = append(tiers, dto.GrantCommissionTierItem{
Operator: globalTier.Operator,
Dimension: globalTier.Dimension,
StatScope: globalTier.StatScope,
Threshold: globalTier.Threshold,
Amount: agentAmountMap[globalTier.Threshold],
})
tiers = append(tiers, dto.GrantCommissionTierItem{
Operator: globalTier.Operator,
Dimension: globalTier.Dimension,
StatScope: globalTier.StatScope,
Threshold: globalTier.Threshold,
Amount: agentAmountMap[globalTier.Threshold],
})
}
resp.CommissionTiers = tiers
}
@@ -218,7 +218,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
return nil, errors.Wrap(errors.CodeDatabaseError, err, "检查授权重复失败")
}
if exists {
return nil, errors.New(errors.CodeConflict, "该代理已存在此系列授权")
return nil, errors.New(errors.CodeConflict, "该代理已存在此系列授权")
}
// 3. 确定 allocatorShopID代理操作者必须自己有授权才能向下分配
@@ -332,6 +332,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
PackageID: item.PackageID,
AllocatorShopID: allocatorShopID,
CostPrice: item.CostPrice,
RetailPrice: pkg.SuggestedRetailPrice,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
ShelfStatus: constants.StatusEnabled,
@@ -341,7 +342,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
}
// 写成本价历史
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID,
OldCostPrice: 0,
@@ -632,6 +632,16 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
if findErr == nil {
// 已有记录:更新成本价并写历史
oldPrice := existing.CostPrice
if oldPrice != item.CostPrice {
// cost_price 锁定检查:存在下级分配记录时禁止修改
var subCount int64
tx.Model(&model.ShopPackageAllocation{}).
Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, item.PackageID).
Count(&subCount)
if subCount > 0 {
return errors.New(errors.CodeForbidden, "存在下级分配记录,请先回收后再修改成本价")
}
}
existing.CostPrice = item.CostPrice
existing.Updater = operatorID
if updateErr := txPkgStore.Update(ctx, existing); updateErr != nil {
@@ -648,24 +658,22 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
})
}
} else {
// W1: 校验套餐归属于该系列,防止跨系列套餐混入
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
if pkgErr != nil || pkg.SeriesID != allocation.SeriesID {
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
}
// W2: 代理操作时,校验分配者已拥有此套餐授权,防止越权分配
if allocation.AllocatorShopID > 0 {
_, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocation.AllocatorShopID, item.PackageID)
if authErr != nil {
return errors.New(errors.CodeForbidden, "无权限分配该套餐")
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
if pkgErr != nil || pkg.SeriesID != allocation.SeriesID {
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
}
if allocation.AllocatorShopID > 0 {
_, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocation.AllocatorShopID, item.PackageID)
if authErr != nil {
return errors.New(errors.CodeForbidden, "无权限分配该套餐")
}
}
}
// 新建分配
pkgAlloc := &model.ShopPackageAllocation{
ShopID: allocation.ShopID,
PackageID: item.PackageID,
AllocatorShopID: allocation.AllocatorShopID,
CostPrice: item.CostPrice,
RetailPrice: pkg.SuggestedRetailPrice,
SeriesAllocationID: &allocation.ID,
Status: constants.StatusEnabled,
ShelfStatus: constants.StatusEnabled,

View File

@@ -189,6 +189,11 @@ func (s *AssetRechargeStore) List(ctx context.Context, params *ListAssetRecharge
// UpdatePaymentInfo 更新支付信息
func (s *AssetRechargeStore) UpdatePaymentInfo(ctx context.Context, id uint, paymentMethod *string, paymentTransactionID *string) error {
return s.UpdatePaymentInfoWithDB(ctx, s.db, id, paymentMethod, paymentTransactionID)
}
// UpdatePaymentInfoWithDB 更新支付信息(支持传入事务 tx
func (s *AssetRechargeStore) UpdatePaymentInfoWithDB(ctx context.Context, db *gorm.DB, id uint, paymentMethod *string, paymentTransactionID *string) error {
updates := map[string]interface{}{}
if paymentMethod != nil {
updates["payment_method"] = paymentMethod
@@ -201,7 +206,7 @@ func (s *AssetRechargeStore) UpdatePaymentInfo(ctx context.Context, id uint, pay
return nil
}
result := s.db.WithContext(ctx).Model(&model.AssetRechargeRecord{}).Where("id = ?", id).Updates(updates)
result := db.WithContext(ctx).Model(&model.AssetRechargeRecord{}).Where("id = ?", id).Updates(updates)
if result.Error != nil {
return result.Error
}
@@ -213,6 +218,11 @@ func (s *AssetRechargeStore) UpdatePaymentInfo(ctx context.Context, id uint, pay
// UpdateStatusWithOptimisticLock 更新充值状态(支持乐观锁)
func (s *AssetRechargeStore) UpdateStatusWithOptimisticLock(ctx context.Context, id uint, oldStatus *int, newStatus int, paidAt interface{}, completedAt interface{}) error {
return s.UpdateStatusWithOptimisticLockDB(ctx, s.db, id, oldStatus, newStatus, paidAt, completedAt)
}
// UpdateStatusWithOptimisticLockDB 更新充值状态(支持传入事务 tx
func (s *AssetRechargeStore) UpdateStatusWithOptimisticLockDB(ctx context.Context, db *gorm.DB, id uint, oldStatus *int, newStatus int, paidAt interface{}, completedAt interface{}) error {
updates := map[string]interface{}{
"status": newStatus,
}
@@ -223,7 +233,7 @@ func (s *AssetRechargeStore) UpdateStatusWithOptimisticLock(ctx context.Context,
updates["completed_at"] = completedAt
}
query := s.db.WithContext(ctx).Model(&model.AssetRechargeRecord{}).Where("id = ?", id)
query := db.WithContext(ctx).Model(&model.AssetRechargeRecord{}).Where("id = ?", id)
if oldStatus != nil {
query = query.Where("status = ?", *oldStatus)