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:
@@ -5,6 +5,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"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/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"
|
||||||
)
|
)
|
||||||
@@ -22,6 +23,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
|
|||||||
|
|
||||||
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
||||||
handlers := openapi.BuildDocHandlers()
|
handlers := openapi.BuildDocHandlers()
|
||||||
|
handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil)
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
||||||
|
|||||||
@@ -247,14 +247,11 @@ func applyRateLimiterToBusinessRoutes(app *fiber.App, rateLimitMiddleware fiber.
|
|||||||
adminGroup := app.Group("/api/admin")
|
adminGroup := app.Group("/api/admin")
|
||||||
adminGroup.Use(rateLimitMiddleware)
|
adminGroup.Use(rateLimitMiddleware)
|
||||||
|
|
||||||
h5Group := app.Group("/api/h5")
|
|
||||||
h5Group.Use(rateLimitMiddleware)
|
|
||||||
|
|
||||||
personalGroup := app.Group("/api/c/v1")
|
personalGroup := app.Group("/api/c/v1")
|
||||||
personalGroup.Use(rateLimitMiddleware)
|
personalGroup.Use(rateLimitMiddleware)
|
||||||
|
|
||||||
appLogger.Info("限流器已应用到业务路由组",
|
appLogger.Info("限流器已应用到业务路由组",
|
||||||
zap.Strings("paths", []string{"/api/admin", "/api/h5", "/api/c/v1"}),
|
zap.Strings("paths", []string{"/api/admin", "/api/c/v1"}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
"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/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"
|
||||||
)
|
)
|
||||||
@@ -31,6 +32,7 @@ func generateAdminDocs(outputPath string) error {
|
|||||||
|
|
||||||
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
||||||
handlers := openapi.BuildDocHandlers()
|
handlers := openapi.BuildDocHandlers()
|
||||||
|
handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil)
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
||||||
|
|||||||
127
docs/client-api-data-model-fixes/功能总结.md
Normal file
127
docs/client-api-data-model-fixes/功能总结.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# 客户端接口数据模型基础准备 - 功能总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本提案作为客户端接口系列的前置基础,完成三类工作:BUG 修复、基础字段准备、旧接口清理。
|
||||||
|
|
||||||
|
## 一、BUG 修复
|
||||||
|
|
||||||
|
### BUG-1:代理零售价修复
|
||||||
|
|
||||||
|
**问题**:`ShopPackageAllocation` 缺少 `retail_price` 字段,所有渠道统一使用 `Package.SuggestedRetailPrice`,代理无法设定自己的零售价。
|
||||||
|
|
||||||
|
**修复内容**:
|
||||||
|
|
||||||
|
- `ShopPackageAllocation` 新增 `retail_price` 字段(迁移中存量数据批量回填为 `SuggestedRetailPrice`)
|
||||||
|
- `GetPurchasePrice()` 改为按渠道取价:代理渠道返回 `allocation.RetailPrice`,平台渠道返回 `SuggestedRetailPrice`
|
||||||
|
- `validatePackages()` 价格累加同步修正,代理渠道额外校验 `RetailPrice >= CostPrice`
|
||||||
|
- 分配创建(`shop_package_batch_allocation`、`shop_series_grant`)时自动设置 `RetailPrice = SuggestedRetailPrice`
|
||||||
|
- 新增 cost_price 分配锁定:存在下级分配记录时禁止修改 `cost_price`
|
||||||
|
- `BatchUpdatePricing` 接口扩展支持 `pricing_target` 参数(`cost_price`/`retail_price`),默认 `cost_price` 保持向后兼容
|
||||||
|
- `PackageResponse` 新增 `retail_price` 字段,利润计算修正为 `RetailPrice - CostPrice`
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `internal/model/shop_package_allocation.go`
|
||||||
|
- `internal/model/dto/shop_package_batch_pricing_dto.go`
|
||||||
|
- `internal/model/dto/package_dto.go`
|
||||||
|
- `internal/service/purchase_validation/service.go`
|
||||||
|
- `internal/service/shop_package_batch_allocation/service.go`
|
||||||
|
- `internal/service/shop_series_grant/service.go`
|
||||||
|
- `internal/service/shop_package_batch_pricing/service.go`
|
||||||
|
- `internal/service/package/service.go`
|
||||||
|
|
||||||
|
### BUG-2:一次性佣金触发条件修复
|
||||||
|
|
||||||
|
**问题**:后台所有订单(包括代理自购)都可能触发一次性佣金。
|
||||||
|
|
||||||
|
**修复内容**:
|
||||||
|
|
||||||
|
- `Order` 新增 `source` 字段(`admin`/`client`),默认 `admin`
|
||||||
|
- 佣金触发条件从 `!order.IsPurchaseOnBehalf` 改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`
|
||||||
|
- `CreateAdminOrder()` 设置 `Source: constants.OrderSourceAdmin`
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `internal/model/order.go`
|
||||||
|
- `internal/service/commission_calculation/service.go`(两个方法)
|
||||||
|
- `internal/service/order/service.go`
|
||||||
|
|
||||||
|
### BUG-4:充值回调事务一致性修复
|
||||||
|
|
||||||
|
**问题**:`HandlePaymentCallback` 中 `UpdateStatusWithOptimisticLock` 和 `UpdatePaymentInfo` 使用 `s.db` 而非事务内 `tx`。
|
||||||
|
|
||||||
|
**修复内容**:
|
||||||
|
|
||||||
|
- `AssetRechargeStore` 新增 `UpdateStatusWithOptimisticLockDB` 和 `UpdatePaymentInfoWithDB` 方法(支持传入 `tx`)
|
||||||
|
- 原方法保留(委托调用新方法),确保向后兼容
|
||||||
|
- `HandlePaymentCallback` 改用事务内 `tx` 调用
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `internal/store/postgres/asset_recharge_store.go`
|
||||||
|
- `internal/service/recharge/service.go`
|
||||||
|
|
||||||
|
## 二、基础字段准备
|
||||||
|
|
||||||
|
### 新增常量文件
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| `pkg/constants/asset_status.go` | 资产业务状态(在库/已销售/已换货/已停用) |
|
||||||
|
| `pkg/constants/order_source.go` | 订单来源(admin/client) |
|
||||||
|
| `pkg/constants/operator_type.go` | 操作人类型(admin_user/personal_customer) |
|
||||||
|
| `pkg/constants/realname_link.go` | 实名链接类型(none/template/gateway) |
|
||||||
|
|
||||||
|
### 模型字段变更
|
||||||
|
|
||||||
|
| 模型 | 新增字段 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `IotCard` | `asset_status`, `generation` | 业务生命周期状态、资产世代编号 |
|
||||||
|
| `Device` | `asset_status`, `generation` | 同上 |
|
||||||
|
| `Order` | `source`, `generation` | 订单来源、资产世代快照 |
|
||||||
|
| `PackageUsage` | `generation` | 资产世代快照 |
|
||||||
|
| `AssetRechargeRecord` | `operator_type`, `generation`, `linked_package_ids`, `linked_order_type`, `linked_carrier_type`, `linked_carrier_id` | 操作人类型、世代、强充关联字段 |
|
||||||
|
| `Carrier` | `realname_link_type`, `realname_link_template` | 实名链接配置 |
|
||||||
|
| `ShopPackageAllocation` | `retail_price` | 代理零售价 |
|
||||||
|
| `PersonalCustomer` | `wx_open_id` 索引变更 | 唯一索引改为普通索引 |
|
||||||
|
|
||||||
|
### Carrier 管理 DTO 更新
|
||||||
|
|
||||||
|
- `CarrierCreateRequest`、`CarrierUpdateRequest` 新增 `realname_link_type` 和 `realname_link_template` 字段
|
||||||
|
- `CarrierResponse` 新增对应展示字段
|
||||||
|
- Carrier Service 的 Create/Update 方法同步处理,Update 时 `template` 类型强制校验模板非空
|
||||||
|
|
||||||
|
### 资产手动停用
|
||||||
|
|
||||||
|
- 新增 `PATCH /api/admin/iot-cards/:id/deactivate` 和 `PATCH /api/admin/devices/:id/deactivate`
|
||||||
|
- 仅 `asset_status` 为 1(在库)或 2(已销售)时允许停用
|
||||||
|
- 使用条件更新确保幂等
|
||||||
|
|
||||||
|
## 三、旧接口清理
|
||||||
|
|
||||||
|
### H5 接口删除
|
||||||
|
|
||||||
|
- 删除 `internal/handler/h5/` 全部文件(5 个)
|
||||||
|
- 删除 `internal/routes/h5*.go`(3 个文件)
|
||||||
|
- 清理 `routes.go`、`order.go`、`recharge.go` 中的 H5 路由注册
|
||||||
|
- 清理 `bootstrap/` 中 H5 Handler 构造和字段
|
||||||
|
- 清理 `middlewares.go` 中 H5 认证中间件
|
||||||
|
- 清理 `pkg/openapi/handlers.go` 中 H5 文档生成引用
|
||||||
|
- 清理 `cmd/api/main.go` 中 H5 限流挂载
|
||||||
|
|
||||||
|
### 个人客户旧登录方法删除
|
||||||
|
|
||||||
|
- 删除 `internal/handler/app/personal_customer.go` 中 Login、SendCode、WechatOAuthLogin、BindWechat 方法
|
||||||
|
- 清理对应路由注册
|
||||||
|
- 保留 UpdateProfile 和 GetProfile
|
||||||
|
|
||||||
|
## 四、数据库迁移
|
||||||
|
|
||||||
|
- 迁移编号:000082
|
||||||
|
- 涉及 7 张表、15+ 个字段变更
|
||||||
|
- 包含存量 `retail_price` 批量回填
|
||||||
|
- 包含 `wx_open_id` 索引从唯一改为普通
|
||||||
|
- 所有字段使用 `NOT NULL DEFAULT` 确保存量兼容
|
||||||
|
|
||||||
|
## 五、后台订单 generation 快照
|
||||||
|
|
||||||
|
- `CreateAdminOrder()` 创建订单时从资产(IotCard/Device)获取当前 `Generation` 值写入订单
|
||||||
|
- 不再依赖数据库默认值 1
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
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/callback"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,14 +20,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
Shop: admin.NewShopHandler(svc.Shop),
|
Shop: admin.NewShopHandler(svc.Shop),
|
||||||
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
||||||
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
||||||
H5Auth: h5.NewAuthHandler(svc.Auth, validate),
|
|
||||||
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
|
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
|
||||||
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal),
|
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal),
|
||||||
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
|
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
|
||||||
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
|
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
|
||||||
EnterpriseCard: admin.NewEnterpriseCardHandler(svc.EnterpriseCard),
|
EnterpriseCard: admin.NewEnterpriseCardHandler(svc.EnterpriseCard),
|
||||||
EnterpriseDevice: admin.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
|
EnterpriseDevice: admin.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
|
||||||
EnterpriseDeviceH5: h5.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
|
|
||||||
Authorization: admin.NewAuthorizationHandler(svc.Authorization),
|
Authorization: admin.NewAuthorizationHandler(svc.Authorization),
|
||||||
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
|
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
|
||||||
IotCard: admin.NewIotCardHandler(svc.IotCard),
|
IotCard: admin.NewIotCardHandler(svc.IotCard),
|
||||||
@@ -41,13 +38,10 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
||||||
Package: admin.NewPackageHandler(svc.Package),
|
Package: admin.NewPackageHandler(svc.Package),
|
||||||
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
||||||
H5PackageUsage: h5.NewPackageUsageHandler(deps.DB, svc.PackageCustomerView),
|
|
||||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
||||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
||||||
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
||||||
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
|
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),
|
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, svc.AgentRecharge, deps.WechatPayment),
|
||||||
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
|
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
|
||||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
|
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
|
||||||
@@ -56,6 +50,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
||||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
||||||
Asset: admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService),
|
Asset: admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService),
|
||||||
|
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
|
||||||
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
|
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
|
||||||
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
||||||
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge),
|
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge),
|
||||||
|
|||||||
@@ -32,13 +32,9 @@ func initMiddlewares(deps *Dependencies, stores *stores) *Middlewares {
|
|||||||
// 创建后台认证中间件(传入 ShopStore 以支持预计算下级店铺 ID)
|
// 创建后台认证中间件(传入 ShopStore 以支持预计算下级店铺 ID)
|
||||||
adminAuthMiddleware := createAdminAuthMiddleware(tokenManager, stores.Shop)
|
adminAuthMiddleware := createAdminAuthMiddleware(tokenManager, stores.Shop)
|
||||||
|
|
||||||
// 创建H5认证中间件(传入 ShopStore 以支持预计算下级店铺 ID)
|
|
||||||
h5AuthMiddleware := createH5AuthMiddleware(tokenManager, stores.Shop)
|
|
||||||
|
|
||||||
return &Middlewares{
|
return &Middlewares{
|
||||||
PersonalAuth: personalAuthMiddleware,
|
PersonalAuth: personalAuthMiddleware,
|
||||||
AdminAuth: adminAuthMiddleware,
|
AdminAuth: adminAuthMiddleware,
|
||||||
H5Auth: h5AuthMiddleware,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,29 +64,3 @@ func createAdminAuthMiddleware(tokenManager *pkgauth.TokenManager, shopStore pkg
|
|||||||
ShopStore: shopStore,
|
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ type services struct {
|
|||||||
PollingCleanup *pollingSvc.CleanupService
|
PollingCleanup *pollingSvc.CleanupService
|
||||||
PollingManualTrigger *pollingSvc.ManualTriggerService
|
PollingManualTrigger *pollingSvc.ManualTriggerService
|
||||||
Asset *assetSvc.Service
|
Asset *assetSvc.Service
|
||||||
|
AssetLifecycle *assetSvc.LifecycleService
|
||||||
AssetWallet *assetWalletSvc.Service
|
AssetWallet *assetWalletSvc.Service
|
||||||
StopResumeService *iotCardSvc.StopResumeService
|
StopResumeService *iotCardSvc.StopResumeService
|
||||||
WechatConfig *wechatConfigSvc.Service
|
WechatConfig *wechatConfigSvc.Service
|
||||||
@@ -158,6 +159,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
|
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
|
||||||
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, 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),
|
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),
|
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
|
||||||
StopResumeService: iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger),
|
StopResumeService: iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger),
|
||||||
WechatConfig: wechatConfig,
|
WechatConfig: wechatConfig,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
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/callback"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
@@ -19,14 +18,12 @@ type Handlers struct {
|
|||||||
Shop *admin.ShopHandler
|
Shop *admin.ShopHandler
|
||||||
ShopRole *admin.ShopRoleHandler
|
ShopRole *admin.ShopRoleHandler
|
||||||
AdminAuth *admin.AuthHandler
|
AdminAuth *admin.AuthHandler
|
||||||
H5Auth *h5.AuthHandler
|
|
||||||
ShopCommission *admin.ShopCommissionHandler
|
ShopCommission *admin.ShopCommissionHandler
|
||||||
CommissionWithdrawal *admin.CommissionWithdrawalHandler
|
CommissionWithdrawal *admin.CommissionWithdrawalHandler
|
||||||
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
|
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
|
||||||
Enterprise *admin.EnterpriseHandler
|
Enterprise *admin.EnterpriseHandler
|
||||||
EnterpriseCard *admin.EnterpriseCardHandler
|
EnterpriseCard *admin.EnterpriseCardHandler
|
||||||
EnterpriseDevice *admin.EnterpriseDeviceHandler
|
EnterpriseDevice *admin.EnterpriseDeviceHandler
|
||||||
EnterpriseDeviceH5 *h5.EnterpriseDeviceHandler
|
|
||||||
Authorization *admin.AuthorizationHandler
|
Authorization *admin.AuthorizationHandler
|
||||||
MyCommission *admin.MyCommissionHandler
|
MyCommission *admin.MyCommissionHandler
|
||||||
IotCard *admin.IotCardHandler
|
IotCard *admin.IotCardHandler
|
||||||
@@ -39,13 +36,10 @@ type Handlers struct {
|
|||||||
PackageSeries *admin.PackageSeriesHandler
|
PackageSeries *admin.PackageSeriesHandler
|
||||||
Package *admin.PackageHandler
|
Package *admin.PackageHandler
|
||||||
PackageUsage *admin.PackageUsageHandler
|
PackageUsage *admin.PackageUsageHandler
|
||||||
H5PackageUsage *h5.PackageUsageHandler
|
|
||||||
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
|
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
|
||||||
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
|
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
|
||||||
ShopSeriesGrant *admin.ShopSeriesGrantHandler
|
ShopSeriesGrant *admin.ShopSeriesGrantHandler
|
||||||
AdminOrder *admin.OrderHandler
|
AdminOrder *admin.OrderHandler
|
||||||
H5Order *h5.OrderHandler
|
|
||||||
H5Recharge *h5.RechargeHandler
|
|
||||||
PaymentCallback *callback.PaymentHandler
|
PaymentCallback *callback.PaymentHandler
|
||||||
PollingConfig *admin.PollingConfigHandler
|
PollingConfig *admin.PollingConfigHandler
|
||||||
PollingConcurrency *admin.PollingConcurrencyHandler
|
PollingConcurrency *admin.PollingConcurrencyHandler
|
||||||
@@ -54,6 +48,7 @@ type Handlers struct {
|
|||||||
PollingCleanup *admin.PollingCleanupHandler
|
PollingCleanup *admin.PollingCleanupHandler
|
||||||
PollingManualTrigger *admin.PollingManualTriggerHandler
|
PollingManualTrigger *admin.PollingManualTriggerHandler
|
||||||
Asset *admin.AssetHandler
|
Asset *admin.AssetHandler
|
||||||
|
AssetLifecycle *admin.AssetLifecycleHandler
|
||||||
AssetWallet *admin.AssetWalletHandler
|
AssetWallet *admin.AssetWalletHandler
|
||||||
WechatConfig *admin.WechatConfigHandler
|
WechatConfig *admin.WechatConfigHandler
|
||||||
AgentRecharge *admin.AgentRechargeHandler
|
AgentRecharge *admin.AgentRechargeHandler
|
||||||
@@ -64,6 +59,5 @@ type Handlers struct {
|
|||||||
type Middlewares struct {
|
type Middlewares struct {
|
||||||
PersonalAuth *middleware.PersonalAuthMiddleware
|
PersonalAuth *middleware.PersonalAuthMiddleware
|
||||||
AdminAuth func(*fiber.Ctx) error
|
AdminAuth func(*fiber.Ctx) error
|
||||||
H5Auth func(*fiber.Ctx) error
|
|
||||||
// TODO: 新增 Middleware 在此添加字段
|
// TODO: 新增 Middleware 在此添加字段
|
||||||
}
|
}
|
||||||
|
|||||||
59
internal/handler/admin/asset_lifecycle.go
Normal file
59
internal/handler/admin/asset_lifecycle.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
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/internal/service/personal_customer"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
"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
|
// PersonalCustomerDTO 个人客户 DTO
|
||||||
type PersonalCustomerDTO struct {
|
type PersonalCustomerDTO struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
@@ -74,87 +34,6 @@ type PersonalCustomerDTO struct {
|
|||||||
Status int `json:"status"`
|
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 更新个人资料请求
|
// UpdateProfileRequest 更新个人资料请求
|
||||||
type UpdateProfileRequest struct {
|
type UpdateProfileRequest struct {
|
||||||
Nickname string `json:"nickname"` // 昵称
|
Nickname string `json:"nickname"` // 昵称
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ package model
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"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"`
|
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"`
|
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"`
|
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"`
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import (
|
|||||||
|
|
||||||
type Carrier struct {
|
type Carrier struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
BaseModel `gorm:"embedded"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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"`
|
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 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type Device struct {
|
|||||||
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分,废弃,使用按系列追踪)" json:"accumulated_recharge"`
|
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:"-"`
|
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:"-"`
|
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 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
type CreateCarrierRequest struct {
|
type CreateCarrierRequest struct {
|
||||||
CarrierCode string `json:"carrier_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" 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:"运营商名称"`
|
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:中国广电)"`
|
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:"运营商描述"`
|
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 {
|
type UpdateCarrierRequest struct {
|
||||||
CarrierName *string `json:"carrier_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" 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:"运营商描述"`
|
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 {
|
type CarrierListRequest struct {
|
||||||
@@ -25,14 +29,16 @@ type UpdateCarrierStatusRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CarrierResponse struct {
|
type CarrierResponse struct {
|
||||||
ID uint `json:"id" description:"运营商ID"`
|
ID uint `json:"id" description:"运营商ID"`
|
||||||
CarrierCode string `json:"carrier_code" description:"运营商编码"`
|
CarrierCode string `json:"carrier_code" description:"运营商编码"`
|
||||||
CarrierName string `json:"carrier_name" description:"运营商名称"`
|
CarrierName string `json:"carrier_name" description:"运营商名称"`
|
||||||
CarrierType string `json:"carrier_type" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
|
CarrierType string `json:"carrier_type" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
|
||||||
Description string `json:"description" description:"运营商描述"`
|
Description string `json:"description" description:"运营商描述"`
|
||||||
Status int `json:"status" description:"状态 (1:启用, 0:禁用)"`
|
RealnameLinkType string `json:"realname_link_type" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
|
||||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
RealnameLinkTemplate string `json:"realname_link_template" description:"实名链接模板URL"`
|
||||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
Status int `json:"status" description:"状态 (1:启用, 0:禁用)"`
|
||||||
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
|
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCarrierParams struct {
|
type UpdateCarrierParams struct {
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ type PackageResponse struct {
|
|||||||
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
|
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
|
||||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||||
|
RetailPrice *int64 `json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`
|
||||||
ProfitMargin *int64 `json:"profit_margin,omitempty" description:"利润空间(分,仅代理用户可见)"`
|
ProfitMargin *int64 `json:"profit_margin,omitempty" description:"利润空间(分,仅代理用户可见)"`
|
||||||
CurrentCommissionRate string `json:"current_commission_rate,omitempty" description:"当前返佣比例(仅代理用户可见)"`
|
CurrentCommissionRate string `json:"current_commission_rate,omitempty" description:"当前返佣比例(仅代理用户可见)"`
|
||||||
TierInfo *CommissionTierInfo `json:"tier_info,omitempty" description:"梯度返佣信息(仅代理用户可见)"`
|
TierInfo *CommissionTierInfo `json:"tier_info,omitempty" description:"梯度返佣信息(仅代理用户可见)"`
|
||||||
|
|||||||
@@ -4,12 +4,20 @@ package dto
|
|||||||
type BatchUpdateCostPriceRequest struct {
|
type BatchUpdateCostPriceRequest struct {
|
||||||
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"店铺ID"`
|
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"店铺ID"`
|
||||||
SeriesID *uint `json:"series_id" validate:"omitempty" 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:"价格调整配置"`
|
PriceAdjustment PriceAdjustment `json:"price_adjustment" validate:"required" required:"true" description:"价格调整配置"`
|
||||||
ChangeReason string `json:"change_reason" validate:"omitempty,max=255" maxLength:"255" description:"变更原因"`
|
ChangeReason string `json:"change_reason" validate:"omitempty,max=255" maxLength:"255" description:"变更原因"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchUpdateCostPriceResponse 批量调价响应
|
// BatchUpdateCostPriceResponse 批量调价响应
|
||||||
type BatchUpdateCostPriceResponse struct {
|
type BatchUpdateCostPriceResponse struct {
|
||||||
UpdatedCount int `json:"updated_count" description:"更新数量"`
|
UpdatedCount int `json:"updated_count" description:"更新数量"`
|
||||||
AffectedIDs []uint `json:"affected_ids" description:"受影响的分配ID列表"`
|
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:"跳过原因"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ type IotCard struct {
|
|||||||
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
|
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
|
||||||
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_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"`
|
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"`
|
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"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ type Order struct {
|
|||||||
SellerCostPrice int64 `gorm:"column:seller_cost_price;type:bigint;default:0;comment:销售成本价(分,用于计算利润)" json:"seller_cost_price"`
|
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"`
|
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"`
|
IsPurchaseOnBehalf bool `gorm:"column:is_purchase_on_behalf;type:boolean;default:false;comment:是否为代购订单" json:"is_purchase_on_behalf"`
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ type PackageUsage struct {
|
|||||||
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);comment:流量重置周期(从Package复制,用于历史记录)" json:"data_reset_cycle"`
|
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"`
|
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"`
|
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 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
// 手机号、ICCID、设备号通过关联表存储
|
// 手机号、ICCID、设备号通过关联表存储
|
||||||
type PersonalCustomer struct {
|
type PersonalCustomer struct {
|
||||||
gorm.Model
|
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"`
|
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"`
|
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"`
|
AvatarURL string `gorm:"column:avatar_url;type:varchar(500);comment:微信头像URL" json:"avatar_url"`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type ShopPackageAllocation struct {
|
|||||||
SeriesAllocationID *uint `gorm:"column:series_allocation_id;index;comment:关联的系列分配ID" json:"series_allocation_id"`
|
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"`
|
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"`
|
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 指定表名
|
// TableName 指定表名
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
|||||||
if handlers.Device != nil {
|
if handlers.Device != nil {
|
||||||
registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath)
|
registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath)
|
||||||
}
|
}
|
||||||
|
if handlers.AssetLifecycle != nil {
|
||||||
|
registerAssetLifecycleRoutes(authGroup, handlers.AssetLifecycle, doc, basePath)
|
||||||
|
}
|
||||||
if handlers.AssetAllocationRecord != nil {
|
if handlers.AssetAllocationRecord != nil {
|
||||||
registerAssetAllocationRecordRoutes(authGroup, handlers.AssetAllocationRecord, doc, basePath)
|
registerAssetAllocationRecordRoutes(authGroup, handlers.AssetAllocationRecord, doc, basePath)
|
||||||
}
|
}
|
||||||
|
|||||||
28
internal/routes/asset_lifecycle.go
Normal file
28
internal/routes/asset_lifecycle.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ 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/callback"
|
"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/internal/model/dto"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
"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 注册支付回调路由
|
// registerPaymentCallbackRoutes 注册支付回调路由
|
||||||
func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.PaymentHandler, doc *openapi.Generator, basePath string) {
|
func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.PaymentHandler, doc *openapi.Generator, basePath string) {
|
||||||
Register(router, doc, basePath, "POST", "/wechat-pay", handler.WechatPayCallback, RouteSpec{
|
Register(router, doc, basePath, "POST", "/wechat-pay", handler.WechatPayCallback, RouteSpec{
|
||||||
|
|||||||
@@ -6,60 +6,16 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
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/middleware"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterPersonalCustomerRoutes 注册个人客户路由
|
// RegisterPersonalCustomerRoutes 注册个人客户路由
|
||||||
// 路由挂载在 /api/c/v1 下
|
// 路由挂载在 /api/c/v1 下
|
||||||
func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers, personalAuthMiddleware *middleware.PersonalAuthMiddleware) {
|
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 := router.Group("")
|
||||||
authGroup.Use(personalAuthMiddleware.Authenticate())
|
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{
|
Register(authGroup, doc, basePath, "GET", "/profile", handlers.PersonalCustomer.GetProfile, RouteSpec{
|
||||||
Summary: "获取个人资料",
|
Summary: "获取个人资料",
|
||||||
|
|||||||
@@ -1,44 +1 @@
|
|||||||
package routes
|
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,15 +28,11 @@ func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlew
|
|||||||
adminGroup := app.Group("/api/admin")
|
adminGroup := app.Group("/api/admin")
|
||||||
RegisterAdminRoutes(adminGroup, handlers, middlewares, doc, "/api/admin")
|
RegisterAdminRoutes(adminGroup, handlers, middlewares, doc, "/api/admin")
|
||||||
|
|
||||||
// 4. H5 域 (挂载在 /api/h5)
|
// 4. 个人客户路由 (挂载在 /api/c/v1)
|
||||||
h5Group := app.Group("/api/h5")
|
|
||||||
RegisterH5Routes(h5Group, handlers, middlewares, doc, "/api/h5")
|
|
||||||
|
|
||||||
// 5. 个人客户路由 (挂载在 /api/c/v1)
|
|
||||||
personalGroup := app.Group("/api/c/v1")
|
personalGroup := app.Group("/api/c/v1")
|
||||||
RegisterPersonalCustomerRoutes(personalGroup, doc, "/api/c/v1", handlers, middlewares.PersonalAuth)
|
RegisterPersonalCustomerRoutes(personalGroup, doc, "/api/c/v1", handlers, middlewares.PersonalAuth)
|
||||||
|
|
||||||
// 6. 支付回调路由 (挂载在 /api/callback,无需认证)
|
// 5. 支付回调路由 (挂载在 /api/callback,无需认证)
|
||||||
if handlers.PaymentCallback != nil {
|
if handlers.PaymentCallback != nil {
|
||||||
callbackGroup := app.Group("/api/callback")
|
callbackGroup := app.Group("/api/callback")
|
||||||
registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback")
|
registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback")
|
||||||
|
|||||||
88
internal/service/asset/lifecycle_service.go
Normal file
88
internal/service/asset/lifecycle_service.go
Normal 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
|
||||||
|
}
|
||||||
@@ -41,6 +41,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
|
|||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Status: constants.StatusEnabled,
|
Status: constants.StatusEnabled,
|
||||||
}
|
}
|
||||||
|
if req.RealnameLinkType != nil {
|
||||||
|
carrier.RealnameLinkType = *req.RealnameLinkType
|
||||||
|
}
|
||||||
|
if req.RealnameLinkTemplate != nil {
|
||||||
|
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
|
||||||
|
}
|
||||||
carrier.Creator = currentUserID
|
carrier.Creator = currentUserID
|
||||||
|
|
||||||
if err := s.carrierStore.Create(ctx, carrier); err != nil {
|
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 {
|
if req.Description != nil {
|
||||||
carrier.Description = *req.Description
|
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
|
carrier.Updater = currentUserID
|
||||||
|
|
||||||
if err := s.carrierStore.Update(ctx, carrier); err != nil {
|
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 {
|
func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
|
||||||
return &dto.CarrierResponse{
|
return &dto.CarrierResponse{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
CarrierCode: c.CarrierCode,
|
CarrierCode: c.CarrierCode,
|
||||||
CarrierName: c.CarrierName,
|
CarrierName: c.CarrierName,
|
||||||
CarrierType: c.CarrierType,
|
CarrierType: c.CarrierType,
|
||||||
Description: c.Description,
|
Description: c.Description,
|
||||||
Status: c.Status,
|
RealnameLinkType: c.RealnameLinkType,
|
||||||
CreatedAt: c.CreatedAt.Format(time.RFC3339),
|
RealnameLinkTemplate: c.RealnameLinkTemplate,
|
||||||
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
|
Status: c.Status,
|
||||||
|
CreatedAt: c.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"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/service/commission_stats"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"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/errors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"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 {
|
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
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
order := &model.Order{
|
||||||
BaseModel: model.BaseModel{
|
BaseModel: model.BaseModel{
|
||||||
Creator: userID,
|
Creator: userID,
|
||||||
@@ -571,6 +582,8 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
|||||||
},
|
},
|
||||||
OrderNo: s.orderStore.GenerateOrderNo(),
|
OrderNo: s.orderStore.GenerateOrderNo(),
|
||||||
OrderType: req.OrderType,
|
OrderType: req.OrderType,
|
||||||
|
Source: constants.OrderSourceAdmin,
|
||||||
|
Generation: assetGeneration,
|
||||||
BuyerType: orderBuyerType,
|
BuyerType: orderBuyerType,
|
||||||
BuyerID: orderBuyerID,
|
BuyerID: orderBuyerID,
|
||||||
IotCardID: req.IotCardID,
|
IotCardID: req.IotCardID,
|
||||||
|
|||||||
@@ -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)
|
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, pkg.ID)
|
||||||
if err == nil && allocation != nil {
|
if err == nil && allocation != nil {
|
||||||
resp.CostPrice = allocation.CostPrice
|
resp.CostPrice = allocation.CostPrice
|
||||||
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
|
resp.RetailPrice = &allocation.RetailPrice
|
||||||
|
profitMargin := allocation.RetailPrice - allocation.CostPrice
|
||||||
resp.ProfitMargin = &profitMargin
|
resp.ProfitMargin = &profitMargin
|
||||||
// 代理查询时,shelf_status 返回自己分配记录的值,而非平台全局值
|
|
||||||
resp.ShelfStatus = allocation.ShelfStatus
|
resp.ShelfStatus = allocation.ShelfStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,9 +595,9 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
|
|||||||
if allocationMap != nil {
|
if allocationMap != nil {
|
||||||
if allocation, ok := allocationMap[pkg.ID]; ok {
|
if allocation, ok := allocationMap[pkg.ID]; ok {
|
||||||
resp.CostPrice = allocation.CostPrice
|
resp.CostPrice = allocation.CostPrice
|
||||||
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
|
resp.RetailPrice = &allocation.RetailPrice
|
||||||
|
profitMargin := allocation.RetailPrice - allocation.CostPrice
|
||||||
resp.ProfitMargin = &profitMargin
|
resp.ProfitMargin = &profitMargin
|
||||||
// 代理查询时,shelf_status 返回自己分配记录的值,而非平台全局值
|
|
||||||
resp.ShelfStatus = allocation.ShelfStatus
|
resp.ShelfStatus = allocation.ShelfStatus
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,44 +133,57 @@ func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, serie
|
|||||||
}
|
}
|
||||||
|
|
||||||
if sellerShopID > 0 {
|
if sellerShopID > 0 {
|
||||||
// 代理渠道:检查卖家代理的 allocation.shelf_status,不检查 package.shelf_status
|
// 代理渠道:检查上架状态并获取分配记录,使用零售价
|
||||||
if err := s.validateAgentShelfStatus(ctx, sellerShopID, pkgID); err != nil {
|
allocation, allocErr := s.validateAgentAllocation(ctx, sellerShopID, pkgID)
|
||||||
return nil, 0, err
|
if allocErr != nil {
|
||||||
|
return nil, 0, allocErr
|
||||||
}
|
}
|
||||||
|
// 零售价低于成本价时视为不可购买,防止亏损售卖
|
||||||
|
if allocation.RetailPrice < allocation.CostPrice {
|
||||||
|
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐价格配置异常,暂不可购买")
|
||||||
|
}
|
||||||
|
totalPrice += allocation.RetailPrice
|
||||||
} else {
|
} else {
|
||||||
// 平台自营渠道:检查 package.shelf_status
|
|
||||||
if pkg.ShelfStatus != constants.ShelfStatusOn {
|
if pkg.ShelfStatus != constants.ShelfStatusOn {
|
||||||
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架")
|
return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架")
|
||||||
}
|
}
|
||||||
|
totalPrice += pkg.SuggestedRetailPrice
|
||||||
}
|
}
|
||||||
|
|
||||||
packages = append(packages, pkg)
|
packages = append(packages, pkg)
|
||||||
totalPrice += pkg.SuggestedRetailPrice
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return packages, totalPrice, nil
|
return packages, totalPrice, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateAgentShelfStatus 校验卖家代理的分配记录上架状态
|
// validateAgentAllocation 校验卖家代理的分配记录上架状态,并返回分配记录
|
||||||
func (s *Service) validateAgentShelfStatus(ctx context.Context, sellerShopID, packageID uint) error {
|
func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, packageID uint) (*model.ShopPackageAllocation, error) {
|
||||||
// 使用不带数据权限过滤的查询,避免 buyer ctx 的权限限制干扰系统级校验
|
|
||||||
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID)
|
allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
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 {
|
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 {
|
// GetPurchasePrice 获取购买价格
|
||||||
return pkg.SuggestedRetailPrice
|
// 代理渠道(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 订单专用卡验证
|
// ValidateAdminOfflineCardPurchase 后台 offline 订单专用卡验证
|
||||||
|
|||||||
@@ -306,18 +306,17 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
|
|||||||
// 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金
|
// 6. 事务处理:更新订单状态、增加余额、更新累计充值、触发佣金
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
// 6.1 更新充值订单状态(带状态检查,实现乐观锁)
|
// 6.1 更新充值订单状态(带状态检查,使用事务内 tx 确保原子性)
|
||||||
oldStatus := constants.RechargeStatusPending
|
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 {
|
if err == gorm.ErrRecordNotFound {
|
||||||
// 状态已变更,幂等处理
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单状态失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新充值订单状态失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6.2 更新支付信息
|
// 6.2 更新支付信息(使用事务内 tx)
|
||||||
if err := s.assetRechargeStore.UpdatePaymentInfo(ctx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil {
|
if err := s.assetRechargeStore.UpdatePaymentInfoWithDB(ctx, tx, recharge.ID, &paymentMethod, &paymentTransactionID); err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka
|
|||||||
PackageID: pkg.ID,
|
PackageID: pkg.ID,
|
||||||
AllocatorShopID: allocatorShopID,
|
AllocatorShopID: allocatorShopID,
|
||||||
CostPrice: costPrice,
|
CostPrice: costPrice,
|
||||||
|
RetailPrice: pkg.SuggestedRetailPrice,
|
||||||
SeriesAllocationID: &seriesAllocation.ID,
|
SeriesAllocationID: &seriesAllocation.ID,
|
||||||
Status: constants.StatusEnabled,
|
Status: constants.StatusEnabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,37 +65,86 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
|
|||||||
return nil, errors.New(errors.CodeInvalidParam, "没有找到符合条件的分配记录")
|
return nil, errors.New(errors.CodeInvalidParam, "没有找到符合条件的分配记录")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pricingTarget := req.PricingTarget
|
||||||
|
if pricingTarget == "" {
|
||||||
|
pricingTarget = "cost_price"
|
||||||
|
}
|
||||||
|
|
||||||
updatedCount := 0
|
updatedCount := 0
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
affectedIDs := make([]uint, 0)
|
affectedIDs := make([]uint, 0)
|
||||||
|
skipped := make([]dto.BatchPricingSkipped, 0)
|
||||||
|
|
||||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
for _, allocation := range allocations {
|
for _, allocation := range allocations {
|
||||||
oldPrice := allocation.CostPrice
|
if pricingTarget == "retail_price" {
|
||||||
newPrice := s.calculateAdjustedPrice(oldPrice, &req.PriceAdjustment)
|
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 {
|
history := &model.ShopPackageAllocationPriceHistory{
|
||||||
continue
|
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{
|
allocation.RetailPrice = newRetailPrice
|
||||||
AllocationID: allocation.ID,
|
allocation.Updater = currentUserID
|
||||||
OldCostPrice: oldPrice,
|
if err := tx.Save(allocation).Error; err != nil {
|
||||||
NewCostPrice: newPrice,
|
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
|
||||||
ChangeReason: req.ChangeReason,
|
}
|
||||||
ChangedBy: currentUserID,
|
} else {
|
||||||
EffectiveFrom: now,
|
oldPrice := allocation.CostPrice
|
||||||
}
|
newPrice := s.calculateAdjustedPrice(oldPrice, &req.PriceAdjustment)
|
||||||
|
if newPrice == oldPrice {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Create(history).Error; err != nil {
|
// cost_price 锁定检查:存在下级分配记录时跳过
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "创建价格历史失败")
|
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
|
history := &model.ShopPackageAllocationPriceHistory{
|
||||||
allocation.Updater = currentUserID
|
AllocationID: allocation.ID,
|
||||||
if err := tx.Save(allocation).Error; err != nil {
|
OldCostPrice: oldPrice,
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "更新成本价失败")
|
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)
|
affectedIDs = append(affectedIDs, allocation.ID)
|
||||||
@@ -112,6 +161,7 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
|
|||||||
return &dto.BatchUpdateCostPriceResponse{
|
return &dto.BatchUpdateCostPriceResponse{
|
||||||
UpdatedCount: updatedCount,
|
UpdatedCount: updatedCount,
|
||||||
AffectedIDs: affectedIDs,
|
AffectedIDs: affectedIDs,
|
||||||
|
Skipped: skipped,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,13 +159,13 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop
|
|||||||
// 合并全局 operator 和代理 amount
|
// 合并全局 operator 和代理 amount
|
||||||
tiers := make([]dto.GrantCommissionTierItem, 0, len(config.Tiers))
|
tiers := make([]dto.GrantCommissionTierItem, 0, len(config.Tiers))
|
||||||
for _, globalTier := range config.Tiers {
|
for _, globalTier := range config.Tiers {
|
||||||
tiers = append(tiers, dto.GrantCommissionTierItem{
|
tiers = append(tiers, dto.GrantCommissionTierItem{
|
||||||
Operator: globalTier.Operator,
|
Operator: globalTier.Operator,
|
||||||
Dimension: globalTier.Dimension,
|
Dimension: globalTier.Dimension,
|
||||||
StatScope: globalTier.StatScope,
|
StatScope: globalTier.StatScope,
|
||||||
Threshold: globalTier.Threshold,
|
Threshold: globalTier.Threshold,
|
||||||
Amount: agentAmountMap[globalTier.Threshold],
|
Amount: agentAmountMap[globalTier.Threshold],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
resp.CommissionTiers = tiers
|
resp.CommissionTiers = tiers
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
|||||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "检查授权重复失败")
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "检查授权重复失败")
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
return nil, errors.New(errors.CodeConflict, "该代理已存在此系列授权")
|
return nil, errors.New(errors.CodeConflict, "该代理已存在此系列授权")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 确定 allocatorShopID(代理操作者必须自己有授权才能向下分配)
|
// 3. 确定 allocatorShopID(代理操作者必须自己有授权才能向下分配)
|
||||||
@@ -332,6 +332,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
|
|||||||
PackageID: item.PackageID,
|
PackageID: item.PackageID,
|
||||||
AllocatorShopID: allocatorShopID,
|
AllocatorShopID: allocatorShopID,
|
||||||
CostPrice: item.CostPrice,
|
CostPrice: item.CostPrice,
|
||||||
|
RetailPrice: pkg.SuggestedRetailPrice,
|
||||||
SeriesAllocationID: &allocation.ID,
|
SeriesAllocationID: &allocation.ID,
|
||||||
Status: constants.StatusEnabled,
|
Status: constants.StatusEnabled,
|
||||||
ShelfStatus: 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 {
|
if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
|
||||||
}
|
}
|
||||||
// 写成本价历史
|
|
||||||
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
|
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
|
||||||
AllocationID: pkgAlloc.ID,
|
AllocationID: pkgAlloc.ID,
|
||||||
OldCostPrice: 0,
|
OldCostPrice: 0,
|
||||||
@@ -632,6 +632,16 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
|
|||||||
if findErr == nil {
|
if findErr == nil {
|
||||||
// 已有记录:更新成本价并写历史
|
// 已有记录:更新成本价并写历史
|
||||||
oldPrice := existing.CostPrice
|
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.CostPrice = item.CostPrice
|
||||||
existing.Updater = operatorID
|
existing.Updater = operatorID
|
||||||
if updateErr := txPkgStore.Update(ctx, existing); updateErr != nil {
|
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 {
|
} else {
|
||||||
// W1: 校验套餐归属于该系列,防止跨系列套餐混入
|
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
|
||||||
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
|
if pkgErr != nil || pkg.SeriesID != allocation.SeriesID {
|
||||||
if pkgErr != nil || pkg.SeriesID != allocation.SeriesID {
|
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
|
||||||
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
|
}
|
||||||
}
|
if allocation.AllocatorShopID > 0 {
|
||||||
// W2: 代理操作时,校验分配者已拥有此套餐授权,防止越权分配
|
_, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocation.AllocatorShopID, item.PackageID)
|
||||||
if allocation.AllocatorShopID > 0 {
|
if authErr != nil {
|
||||||
_, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocation.AllocatorShopID, item.PackageID)
|
return errors.New(errors.CodeForbidden, "无权限分配该套餐")
|
||||||
if authErr != nil {
|
}
|
||||||
return errors.New(errors.CodeForbidden, "无权限分配该套餐")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// 新建分配
|
|
||||||
pkgAlloc := &model.ShopPackageAllocation{
|
pkgAlloc := &model.ShopPackageAllocation{
|
||||||
ShopID: allocation.ShopID,
|
ShopID: allocation.ShopID,
|
||||||
PackageID: item.PackageID,
|
PackageID: item.PackageID,
|
||||||
AllocatorShopID: allocation.AllocatorShopID,
|
AllocatorShopID: allocation.AllocatorShopID,
|
||||||
CostPrice: item.CostPrice,
|
CostPrice: item.CostPrice,
|
||||||
|
RetailPrice: pkg.SuggestedRetailPrice,
|
||||||
SeriesAllocationID: &allocation.ID,
|
SeriesAllocationID: &allocation.ID,
|
||||||
Status: constants.StatusEnabled,
|
Status: constants.StatusEnabled,
|
||||||
ShelfStatus: constants.StatusEnabled,
|
ShelfStatus: constants.StatusEnabled,
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ func (s *AssetRechargeStore) List(ctx context.Context, params *ListAssetRecharge
|
|||||||
|
|
||||||
// UpdatePaymentInfo 更新支付信息
|
// UpdatePaymentInfo 更新支付信息
|
||||||
func (s *AssetRechargeStore) UpdatePaymentInfo(ctx context.Context, id uint, paymentMethod *string, paymentTransactionID *string) error {
|
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{}{}
|
updates := map[string]interface{}{}
|
||||||
if paymentMethod != nil {
|
if paymentMethod != nil {
|
||||||
updates["payment_method"] = paymentMethod
|
updates["payment_method"] = paymentMethod
|
||||||
@@ -201,7 +206,7 @@ func (s *AssetRechargeStore) UpdatePaymentInfo(ctx context.Context, id uint, pay
|
|||||||
return nil
|
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 {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
@@ -213,6 +218,11 @@ func (s *AssetRechargeStore) UpdatePaymentInfo(ctx context.Context, id uint, pay
|
|||||||
|
|
||||||
// UpdateStatusWithOptimisticLock 更新充值状态(支持乐观锁)
|
// UpdateStatusWithOptimisticLock 更新充值状态(支持乐观锁)
|
||||||
func (s *AssetRechargeStore) UpdateStatusWithOptimisticLock(ctx context.Context, id uint, oldStatus *int, newStatus int, paidAt interface{}, completedAt interface{}) error {
|
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{}{
|
updates := map[string]interface{}{
|
||||||
"status": newStatus,
|
"status": newStatus,
|
||||||
}
|
}
|
||||||
@@ -223,7 +233,7 @@ func (s *AssetRechargeStore) UpdateStatusWithOptimisticLock(ctx context.Context,
|
|||||||
updates["completed_at"] = completedAt
|
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 {
|
if oldStatus != nil {
|
||||||
query = query.Where("status = ?", *oldStatus)
|
query = query.Where("status = ?", *oldStatus)
|
||||||
|
|||||||
35
migrations/000082_client_api_data_model_fixes.down.sql
Normal file
35
migrations/000082_client_api_data_model_fixes.down.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- 回滚: 客户端接口数据模型基础准备
|
||||||
|
|
||||||
|
-- 9. tb_personal_customer: 恢复唯一索引
|
||||||
|
DROP INDEX IF EXISTS idx_personal_customer_wx_open_id;
|
||||||
|
CREATE UNIQUE INDEX idx_personal_customer_wx_open_id ON tb_personal_customer(wx_open_id) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- 7. tb_shop_package_allocation: 移除 retail_price
|
||||||
|
ALTER TABLE tb_shop_package_allocation DROP COLUMN IF EXISTS retail_price;
|
||||||
|
|
||||||
|
-- 6. tb_carrier: 移除实名链接配置
|
||||||
|
ALTER TABLE tb_carrier DROP COLUMN IF EXISTS realname_link_template;
|
||||||
|
ALTER TABLE tb_carrier DROP COLUMN IF EXISTS realname_link_type;
|
||||||
|
|
||||||
|
-- 5. tb_asset_recharge_record: 移除新增字段
|
||||||
|
ALTER TABLE tb_asset_recharge_record DROP COLUMN IF EXISTS linked_carrier_id;
|
||||||
|
ALTER TABLE tb_asset_recharge_record DROP COLUMN IF EXISTS linked_carrier_type;
|
||||||
|
ALTER TABLE tb_asset_recharge_record DROP COLUMN IF EXISTS linked_order_type;
|
||||||
|
ALTER TABLE tb_asset_recharge_record DROP COLUMN IF EXISTS linked_package_ids;
|
||||||
|
ALTER TABLE tb_asset_recharge_record DROP COLUMN IF EXISTS generation;
|
||||||
|
ALTER TABLE tb_asset_recharge_record DROP COLUMN IF EXISTS operator_type;
|
||||||
|
|
||||||
|
-- 4. tb_package_usage: 移除 generation
|
||||||
|
ALTER TABLE tb_package_usage DROP COLUMN IF EXISTS generation;
|
||||||
|
|
||||||
|
-- 3. tb_order: 移除 source 和 generation
|
||||||
|
ALTER TABLE tb_order DROP COLUMN IF EXISTS generation;
|
||||||
|
ALTER TABLE tb_order DROP COLUMN IF EXISTS source;
|
||||||
|
|
||||||
|
-- 2. tb_device: 移除 asset_status 和 generation
|
||||||
|
ALTER TABLE tb_device DROP COLUMN IF EXISTS generation;
|
||||||
|
ALTER TABLE tb_device DROP COLUMN IF EXISTS asset_status;
|
||||||
|
|
||||||
|
-- 1. tb_iot_card: 移除 asset_status 和 generation
|
||||||
|
ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS generation;
|
||||||
|
ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS asset_status;
|
||||||
58
migrations/000082_client_api_data_model_fixes.up.sql
Normal file
58
migrations/000082_client_api_data_model_fixes.up.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- 客户端接口数据模型基础准备
|
||||||
|
-- 提案: client-api-data-model-fixes
|
||||||
|
-- 包含: 资产状态、世代编号、订单来源、操作人类型、实名链接配置、代理零售价、索引变更
|
||||||
|
|
||||||
|
-- 1. tb_iot_card: 新增 asset_status 和 generation
|
||||||
|
ALTER TABLE tb_iot_card ADD COLUMN asset_status int NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE tb_iot_card ADD COLUMN generation int NOT NULL DEFAULT 1;
|
||||||
|
COMMENT ON COLUMN tb_iot_card.asset_status IS '业务状态 1-在库 2-已销售 3-已换货 4-已停用';
|
||||||
|
COMMENT ON COLUMN tb_iot_card.generation IS '资产世代编号';
|
||||||
|
|
||||||
|
-- 2. tb_device: 新增 asset_status 和 generation
|
||||||
|
ALTER TABLE tb_device ADD COLUMN asset_status int NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE tb_device ADD COLUMN generation int NOT NULL DEFAULT 1;
|
||||||
|
COMMENT ON COLUMN tb_device.asset_status IS '业务状态 1-在库 2-已销售 3-已换货 4-已停用';
|
||||||
|
COMMENT ON COLUMN tb_device.generation IS '资产世代编号';
|
||||||
|
|
||||||
|
-- 3. tb_order: 新增 source 和 generation
|
||||||
|
ALTER TABLE tb_order ADD COLUMN source varchar(20) NOT NULL DEFAULT 'admin';
|
||||||
|
ALTER TABLE tb_order ADD COLUMN generation int NOT NULL DEFAULT 1;
|
||||||
|
COMMENT ON COLUMN tb_order.source IS '订单来源 admin-后台 client-客户端';
|
||||||
|
COMMENT ON COLUMN tb_order.generation IS '资产世代编号';
|
||||||
|
|
||||||
|
-- 4. tb_package_usage: 新增 generation
|
||||||
|
ALTER TABLE tb_package_usage ADD COLUMN generation int NOT NULL DEFAULT 1;
|
||||||
|
COMMENT ON COLUMN tb_package_usage.generation IS '资产世代编号';
|
||||||
|
|
||||||
|
-- 5. tb_asset_recharge_record: 新增 operator_type、generation 和强充关联字段
|
||||||
|
ALTER TABLE tb_asset_recharge_record ADD COLUMN operator_type varchar(20) NOT NULL DEFAULT 'admin_user';
|
||||||
|
ALTER TABLE tb_asset_recharge_record ADD COLUMN generation int NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE tb_asset_recharge_record ADD COLUMN linked_package_ids jsonb DEFAULT '[]';
|
||||||
|
ALTER TABLE tb_asset_recharge_record ADD COLUMN linked_order_type varchar(20);
|
||||||
|
ALTER TABLE tb_asset_recharge_record ADD COLUMN linked_carrier_type varchar(20);
|
||||||
|
ALTER TABLE tb_asset_recharge_record ADD COLUMN linked_carrier_id bigint;
|
||||||
|
COMMENT ON COLUMN tb_asset_recharge_record.operator_type IS '操作人类型 admin_user-后台用户 personal_customer-个人客户';
|
||||||
|
COMMENT ON COLUMN tb_asset_recharge_record.generation IS '资产世代编号';
|
||||||
|
COMMENT ON COLUMN tb_asset_recharge_record.linked_package_ids IS '强充关联套餐ID列表';
|
||||||
|
COMMENT ON COLUMN tb_asset_recharge_record.linked_order_type IS '关联订单类型';
|
||||||
|
COMMENT ON COLUMN tb_asset_recharge_record.linked_carrier_type IS '关联载体类型';
|
||||||
|
COMMENT ON COLUMN tb_asset_recharge_record.linked_carrier_id IS '关联载体ID';
|
||||||
|
|
||||||
|
-- 6. tb_carrier: 新增实名链接配置
|
||||||
|
ALTER TABLE tb_carrier ADD COLUMN realname_link_type varchar(20) NOT NULL DEFAULT 'none';
|
||||||
|
ALTER TABLE tb_carrier ADD COLUMN realname_link_template varchar(500) DEFAULT '';
|
||||||
|
COMMENT ON COLUMN tb_carrier.realname_link_type IS '实名链接类型 none-不支持 template-模板URL gateway-Gateway接口';
|
||||||
|
COMMENT ON COLUMN tb_carrier.realname_link_template IS '实名链接模板URL';
|
||||||
|
|
||||||
|
-- 7. tb_shop_package_allocation: 新增 retail_price
|
||||||
|
ALTER TABLE tb_shop_package_allocation ADD COLUMN retail_price bigint NOT NULL DEFAULT 0;
|
||||||
|
COMMENT ON COLUMN tb_shop_package_allocation.retail_price IS '代理面向终端客户的零售价(分)';
|
||||||
|
|
||||||
|
-- 8. 存量数据修复: 将 retail_price 设为对应套餐的 suggested_retail_price
|
||||||
|
UPDATE tb_shop_package_allocation spa
|
||||||
|
SET retail_price = (SELECT suggested_retail_price FROM tb_package p WHERE p.id = spa.package_id)
|
||||||
|
WHERE retail_price = 0;
|
||||||
|
|
||||||
|
-- 9. tb_personal_customer: wx_open_id 唯一索引改为普通索引
|
||||||
|
DROP INDEX IF EXISTS idx_personal_customer_wx_open_id;
|
||||||
|
CREATE INDEX idx_personal_customer_wx_open_id ON tb_personal_customer(wx_open_id);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-03-18
|
||||||
164
openspec/changes/client-api-data-model-fixes/design.md
Normal file
164
openspec/changes/client-api-data-model-fixes/design.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
### 当前状态
|
||||||
|
|
||||||
|
系统即将启动客户端(C 端)接口体系开发(`/api/c/v1`),但存在以下阻塞项:
|
||||||
|
|
||||||
|
1. **价格计算错误**:`ShopPackageAllocation` 缺少 `retail_price` 字段,`GetPurchasePrice()` 和 `validatePackages()` 始终使用 `Package.SuggestedRetailPrice`,代理无法设定自己的零售价
|
||||||
|
2. **佣金误触发**:后台所有订单(包括代理自购)都可能触发一次性佣金,缺少订单来源区分
|
||||||
|
3. **充值事务不一致**:`HandlePaymentCallback` 中状态更新和支付信息更新使用 `s.db` 而非事务内 `tx`,存在半提交风险
|
||||||
|
4. **基础字段缺失**:客户端接口和换货系统依赖 `asset_status`、`generation`、`operator_type` 等字段,目前模型中均不存在
|
||||||
|
5. **旧接口残留**:`/api/h5` 下的旧接口使用 B 端认证体系,与新的 C 端体系冲突,需完整清理
|
||||||
|
|
||||||
|
### 约束
|
||||||
|
|
||||||
|
- 所有字段新增使用 `NOT NULL DEFAULT` 确保存量数据兼容
|
||||||
|
- 数据库迁移可在线执行,不需停机
|
||||||
|
- 旧接口删除后 bootstrap、路由注册、文档生成器必须同步清理,否则编译失败
|
||||||
|
- 本提案不涉及任何新 API 接口,纯粹是模型/字段/BUG 修复
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- 修复 BUG-1(代理零售价)、BUG-2(佣金误触发)、BUG-4(充值事务)
|
||||||
|
- 为 IotCard/Device 新增 `asset_status`、`generation` 字段
|
||||||
|
- 为 Order/PackageUsage/AssetRechargeRecord 新增 `generation` 字段
|
||||||
|
- 为 Order 新增 `source` 字段
|
||||||
|
- 为 AssetRechargeRecord 新增 `operator_type` 和强充关联字段
|
||||||
|
- 为 Carrier 新增实名链接配置字段
|
||||||
|
- 变更 PersonalCustomer.wx_open_id 索引
|
||||||
|
- 完整删除旧 H5 接口和旧个人客户登录接口
|
||||||
|
- 生成数据库迁移文件
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- 不实现任何客户端 API 接口(属于提案 1~3)
|
||||||
|
- 不实现 ExchangeOrder 换货模型(属于提案 3)
|
||||||
|
- 不实现 PersonalCustomerOpenID 模型(属于提案 1)
|
||||||
|
- 不修改后台管理界面或 Admin API
|
||||||
|
- 不新增 API 路由
|
||||||
|
- 不实现 asset_status 的状态流转逻辑(仅新增字段,流转逻辑在后续提案中实现)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 决策 1:retail_price 字段设计
|
||||||
|
|
||||||
|
**选择**:`tb_shop_package_allocation` 新增 `retail_price bigint NOT NULL DEFAULT 0`。分配创建时自动设为 `Package.SuggestedRetailPrice`。约束 `retail_price >= cost_price`。
|
||||||
|
|
||||||
|
**理由**:最小变更原则。字段放在分配表而非套餐表,因为每个代理可以有不同的零售价。默认值设为建议零售价,确保存量数据行为不变。
|
||||||
|
|
||||||
|
**价格计算修正**:
|
||||||
|
- `GetPurchasePrice()`:代理渠道返回 `allocation.retail_price`,平台渠道返回 `Package.SuggestedRetailPrice`
|
||||||
|
- `validatePackages()` 第 148 行:`totalPrice += pkg.SuggestedRetailPrice` 改为按渠道取价
|
||||||
|
- 代理渠道额外校验:`retail_price < cost_price` 时该套餐不展示(防止亏损售卖)
|
||||||
|
|
||||||
|
**cost_price 分配锁定**:存在下级分配记录时,上级禁止修改该套餐的 `cost_price`。实现方式:修改 cost_price 前查询 `ShopPackageAllocation WHERE allocator_shop_id = 当前店铺 AND package_id = 目标套餐`,有记录则拒绝。
|
||||||
|
|
||||||
|
**备选方案**:在套餐表新增 `agent_retail_price` 字段。但每个代理需要不同的零售价,单字段不够,放分配表更合理。
|
||||||
|
|
||||||
|
### 决策 2:Order.source 字段设计
|
||||||
|
|
||||||
|
**选择**:`tb_order` 新增 `source varchar(20) NOT NULL DEFAULT 'admin'`,值域 `admin` | `client`。
|
||||||
|
|
||||||
|
**理由**:默认 `admin` 确保存量订单行为不变(不触发一次性佣金)。佣金触发条件改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`,实现双重判断。
|
||||||
|
|
||||||
|
**影响点**:
|
||||||
|
- `commission_calculation/service.go`:`triggerOneTimeCommissionForCardInTx` 和 `triggerOneTimeCommissionForDeviceInTx` 中的 `IsPurchaseOnBehalf` 判断,增加 `order.Source == "client"` 条件
|
||||||
|
- `order/service.go`:`CreateAdminOrder` 设置 `Source: "admin"`(默认值已满足,可不显式设置)
|
||||||
|
|
||||||
|
### 决策 3:充值回调事务修复
|
||||||
|
|
||||||
|
**选择**:`AssetRechargeStore` 的 `UpdateStatusWithOptimisticLock` 和 `UpdatePaymentInfo` 方法新增 `tx *gorm.DB` 参数,回调函数内使用事务 `tx` 调用。
|
||||||
|
|
||||||
|
**理由**:当前这两个方法直接使用 `s.db.WithContext(ctx)`,不在事务保护范围内。改为接受 `tx` 参数,确保充值状态变更、支付信息更新、钱包入账在同一事务内完成。
|
||||||
|
|
||||||
|
**备选方案**:使用 GORM 的 `Session(&gorm.Session{NewDB: true})` 从 ctx 中提取事务。但显式传 tx 更清晰,符合项目现有模式(参考 `order/service.go` 中的事务用法)。
|
||||||
|
|
||||||
|
### 决策 4:generation 字段设计
|
||||||
|
|
||||||
|
**选择**:`generation int NOT NULL DEFAULT 1`,在 IotCard、Device、Order、PackageUsage、AssetRechargeRecord 五个表新增。创建关联记录时从资产当前 generation 复制(写时快照)。
|
||||||
|
|
||||||
|
**理由**:写时快照方案简单可靠,无需 JOIN 查询。客户端按 generation 过滤只需加一个 WHERE 条件。后台不受影响(不加 generation 过滤)。钱包流水通过 wallet_id 天然隔离,无需 generation 字段。
|
||||||
|
|
||||||
|
**本次范围**:仅新增字段和 DEFAULT 值。快照逻辑和查询过滤在后续提案中实现。
|
||||||
|
|
||||||
|
### 决策 5:asset_status 字段设计
|
||||||
|
|
||||||
|
**选择**:`asset_status int NOT NULL DEFAULT 1`,在 IotCard 和 Device 新增。值域:1-在库 2-已销售 3-已换货 4-已停用。与 `network_status` 完全独立。
|
||||||
|
|
||||||
|
**理由**:`network_status` 反映运营商侧网络状态(Gateway 同步),`asset_status` 反映 CMP 内部业务生命周期。两者关注点不同,互不干扰。默认值 1(在库)符合导入后的初始状态。
|
||||||
|
|
||||||
|
**本次范围**:仅新增字段和常量定义。状态流转逻辑在后续提案中实现。
|
||||||
|
|
||||||
|
### 决策 6:AssetRechargeRecord 扩展字段
|
||||||
|
|
||||||
|
**选择**:新增以下字段:
|
||||||
|
- `operator_type varchar(20) NOT NULL DEFAULT 'admin_user'`:操作人类型,配合 `user_id` 区分后台用户和个人客户
|
||||||
|
- `generation int NOT NULL DEFAULT 1`:资产世代快照
|
||||||
|
- `linked_package_ids jsonb DEFAULT '[]'`:强充关联套餐 ID 列表
|
||||||
|
- `linked_order_type varchar(20)`:关联订单类型
|
||||||
|
- `linked_carrier_type varchar(20)`:关联载体类型
|
||||||
|
- `linked_carrier_id bigint`:关联载体 ID
|
||||||
|
|
||||||
|
**理由**:`operator_type` 解决后台用户和个人客户共享 `user_id` 字段的 ID 体系歧义。强充关联字段支持两阶段处理(充值回调后异步创建套餐订单)。
|
||||||
|
|
||||||
|
### 决策 7:Carrier 实名链接配置
|
||||||
|
|
||||||
|
**选择**:`tb_carrier` 新增 `realname_link_type varchar(20) NOT NULL DEFAULT 'none'` 和 `realname_link_template varchar(500) DEFAULT ''`。
|
||||||
|
|
||||||
|
**理由**:实名链接有三种模式:不支持(none)、模板 URL(template,支持 `{iccid}`/`{msisdn}`/`{virtual_no}` 占位符)、Gateway 接口(gateway)。配置在运营商级别,同一运营商下所有卡共享同一实名方式。
|
||||||
|
|
||||||
|
**本次范围**:仅新增字段。实名跳转接口在提案 2 中实现。
|
||||||
|
|
||||||
|
### 决策 8:旧接口清理策略
|
||||||
|
|
||||||
|
**选择**:一次性删除所有旧 H5 接口文件和旧个人客户登录方法,同步清理所有引用点。
|
||||||
|
|
||||||
|
**清理清单**:
|
||||||
|
- **删除文件**(8 个):`internal/handler/h5/` 全部 5 个文件 + `internal/routes/h5.go`、`h5_enterprise_device.go`、`h5_package_usage.go`
|
||||||
|
- **修改文件**(7 个):
|
||||||
|
- `internal/routes/routes.go`:移除 `/api/h5` 挂载
|
||||||
|
- `internal/routes/order.go`:移除 `registerH5OrderRoutes` 函数
|
||||||
|
- `internal/routes/recharge.go`:移除 `registerH5RechargeRoutes` 函数
|
||||||
|
- `internal/bootstrap/handlers.go`:移除 H5 Handler 构造(H5Auth、EnterpriseDeviceH5、H5PackageUsage、H5Order、H5Recharge)
|
||||||
|
- `internal/bootstrap/types.go`:移除 H5 Handler 字段
|
||||||
|
- `internal/bootstrap/middlewares.go`:移除 `createH5AuthMiddleware` 和 H5 跳过路径
|
||||||
|
- `pkg/openapi/handlers.go`:移除文档生成中的 H5 Handler 构造
|
||||||
|
- `cmd/api/main.go`:移除 `/api/h5` 限流挂载
|
||||||
|
- **清理旧登录方法**:`internal/handler/app/personal_customer.go` 中删除 Login、SendCode、WechatOAuthLogin、BindWechat 方法,保留 UpdateProfile 和 GetProfile(如有后续使用)
|
||||||
|
- **路由清理**:`internal/routes/personal.go` 中移除指向已删除方法的路由注册
|
||||||
|
|
||||||
|
**理由**:一次性清理比分批清理更安全,避免残留引用导致编译错误或运行时异常。
|
||||||
|
|
||||||
|
### 决策 9:PersonalCustomer.wx_open_id 索引变更
|
||||||
|
|
||||||
|
**选择**:将 `wx_open_id` 的唯一索引改为普通索引。
|
||||||
|
|
||||||
|
**理由**:后续提案 1 引入 `PersonalCustomerOpenID` 表后,唯一性约束迁移到新表。`wx_open_id` 保留为普通索引供兼容查询使用。
|
||||||
|
|
||||||
|
**迁移方式**:DROP 旧唯一索引 + CREATE 新普通索引,在同一迁移文件中执行。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
**[风险] retail_price 默认值 0 与约束冲突** → 分配创建时 Service 层显式设值为 `Package.SuggestedRetailPrice`,不依赖数据库默认值。存量数据通过迁移脚本批量更新 `retail_price = (SELECT suggested_retail_price FROM tb_package WHERE id = package_id)`。
|
||||||
|
|
||||||
|
**[风险] 存量 Order 无 source 字段导致佣金重算** → 默认值 `admin` 确保存量订单不触发一次性佣金,与修复前行为一致(虽然修复前有 BUG,但存量佣金已发放的不回收)。
|
||||||
|
|
||||||
|
**[风险] 删除 H5 接口导致在用功能中断** → 需确认前端已不使用旧 H5 接口。旧接口使用 B 端认证(AdminAuth),C 端用户无法调用,实际上已不可用。
|
||||||
|
|
||||||
|
**[风险] wx_open_id 索引变更影响查询性能** → 普通索引与唯一索引查询性能无差异,仅丢失数据库层面的唯一性保证。新的唯一性由 PersonalCustomerOpenID 表保证。
|
||||||
|
|
||||||
|
**[风险] generation/asset_status 字段仅新增不使用** → 这些字段在本提案中仅完成数据库结构准备,实际使用逻辑在后续提案中实现。风险是字段闲置占用存储,但 int 字段开销可忽略。
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. **生成迁移文件**:单个迁移文件包含所有 ALTER TABLE 语句(7 张表 15+ 字段)
|
||||||
|
2. **存量数据修复**:迁移中包含 UPDATE 语句,将 `ShopPackageAllocation.retail_price` 批量设为对应套餐的 `SuggestedRetailPrice`
|
||||||
|
3. **部署顺序**:先执行迁移 → 再部署新代码(新增字段有默认值,旧代码不受影响)
|
||||||
|
4. **回滚策略**:可安全回滚代码(字段有默认值),如需回滚迁移则 DROP COLUMN(不可逆,需确认)
|
||||||
|
5. **旧接口清理**:代码部署即生效,无需额外操作
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
无。所有设计决策基于需求说明文档中的明确定义。
|
||||||
67
openspec/changes/client-api-data-model-fixes/proposal.md
Normal file
67
openspec/changes/client-api-data-model-fixes/proposal.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
系统存在 4 个影响资金安全和业务正确性的 BUG,且即将启动客户端(C 端)接口体系开发。本提案作为客户端接口系列提案的**前置基础**,解决三类问题:
|
||||||
|
|
||||||
|
1. **资金/业务 BUG 修复**:代理零售价缺失导致价格计算错误(BUG-1)、后台订单误触发一次性佣金(BUG-2)、充值回调事务半提交风险(BUG-4)
|
||||||
|
2. **基础字段准备**:为客户端接口和换货系统新增必要的模型字段(`asset_status`、`generation`、`source`、`operator_type`、`realname_link_type`)
|
||||||
|
3. **旧接口清理**:删除基于 B 端认证体系的旧 H5 接口和旧个人客户登录接口,为新的 `/api/c/v1` 体系腾出空间
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### BUG 修复
|
||||||
|
|
||||||
|
- **BUG-1 代理零售价修复**:`ShopPackageAllocation` 新增 `retail_price` 字段;`GetPurchasePrice()` 改为代理渠道查 `allocation.retail_price`、平台渠道用 `Package.SuggestedRetailPrice`;`validatePackages()` 内部价格累加同步修正;新增 cost_price 分配锁定规则(存在下级分配时禁止修改 cost_price);**扩展现有 `BatchUpdatePricing` 接口支持 `retail_price` 调整**(新增 `pricing_target` 字段区分调 cost_price 还是 retail_price,默认 `cost_price` 保持向后兼容);**代理套餐列表(`PackageResponse`)新增 `retail_price` 字段**,代理可查看自己的零售价;**利润计算修正**为 `RetailPrice - CostPrice`(代理实际利润 = 零售价 - 成本价,而非建议售价 - 成本价)
|
||||||
|
- **BUG-2 一次性佣金触发修复**:`Order` 新增 `source` 字段(`admin`/`client`);佣金触发条件从 `!order.IsPurchaseOnBehalf` 改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`,确保只有客户端个人客户购买才触发
|
||||||
|
- **BUG-4 充值回调事务修复**:`HandlePaymentCallback` 中 `UpdateStatusWithOptimisticLock` 和 `UpdatePaymentInfo` 从 `s.db.WithContext(ctx)` 改为事务内 `tx`,确保充值单状态变更和钱包入账原子完成
|
||||||
|
|
||||||
|
### 新增模型字段
|
||||||
|
|
||||||
|
- **`IotCard`/`Device` 新增 `asset_status`**:业务生命周期状态(1-在库 2-已销售 3-已换货 4-已停用),与运营商 `network_status` 独立
|
||||||
|
- **`IotCard`/`Device` 新增 `generation`**:资产世代编号,换货转新时 +1,客户端按当前 generation 过滤历史数据
|
||||||
|
- **`Order`/`PackageUsage`/`AssetRechargeRecord` 新增 `generation`**:创建时快照资产当前 generation,客户端查询按此字段过滤
|
||||||
|
- **`AssetRechargeRecord` 新增 `operator_type`**:区分操作人类型(`admin_user`/`personal_customer`),配合 `user_id` 区分不同 ID 体系
|
||||||
|
- **`AssetRechargeRecord` 新增强充关联字段**:`linked_package_ids`、`linked_order_type`、`linked_carrier_type`、`linked_carrier_id`,支持强充两阶段处理
|
||||||
|
- **`Carrier` 新增实名链接配置**:`realname_link_type`(none/template/gateway)、`realname_link_template`(支持 `{iccid}`/`{msisdn}`/`{virtual_no}` 占位符)。**同步更新 Carrier admin DTO**(`CarrierCreateRequest`/`CarrierUpdateRequest`)包含这两个字段,使后台管理员可通过 API 配置运营商实名链接方式
|
||||||
|
- **`PersonalCustomer` 索引变更**:`wx_open_id` 从唯一索引改为普通索引(支持后续多 OpenID 方案)
|
||||||
|
|
||||||
|
### 旧接口删除
|
||||||
|
|
||||||
|
- **删除全部旧 H5 接口**:`internal/handler/h5/` 下所有文件(auth、order、recharge、package_usage、enterprise_device)、`internal/routes/h5*.go` 路由注册
|
||||||
|
- **删除旧个人客户登录接口**:`internal/handler/app/personal_customer.go` 中的 Login、SendCode、WechatOAuthLogin、BindWechat、Profile 方法
|
||||||
|
- **同步清理**:bootstrap 中 H5 Handler 注册、docs.go/gendocs 中引用
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `asset-lifecycle-status`:资产业务生命周期状态管理。IotCard/Device 新增 `asset_status` 字段(在库→已销售→已换货→已停用),定义状态流转规则,与运营商 `network_status` 完全独立
|
||||||
|
- `asset-generation`:资产世代机制。IotCard/Device 的 `generation` 字段,关联表(Order/PackageUsage/AssetRechargeRecord)的 generation 写时快照规则,客户端按世代过滤、后台不过滤的查询规则
|
||||||
|
- `carrier-realname-config`:运营商实名链接配置。Carrier 模型新增 `realname_link_type`/`realname_link_template` 字段,支持 none/template/gateway 三种模式,URL 模板占位符替换。**Carrier admin DTO 同步更新**,后台可通过现有运营商管理接口配置实名链接
|
||||||
|
- `agent-retail-price`:代理零售价管理。ShopPackageAllocation 新增 `retail_price` 字段,支持代理设定面向终端客户的零售价,约束 `retail_price >= cost_price`,cost_price 分配锁定规则。**扩展 `BatchUpdatePricing` 接口**支持 `pricing_target=retail_price` 批量调整零售价;**代理套餐列表展示 retail_price**;**利润计算修正**为 `RetailPrice - CostPrice`
|
||||||
|
- `asset-manual-deactivation`:资产手动停用。新增后台接口 `PATCH /api/admin/iot-cards/:id/deactivate` 和 `PATCH /api/admin/devices/:id/deactivate`,将 `asset_status` 设为 4(已停用),仅 `asset_status=1`(在库)或 `asset_status=2`(已销售)时可操作
|
||||||
|
- `h5-legacy-cleanup`:旧 H5 接口和旧登录接口的完整删除,包括 handler、route、bootstrap 注册、文档生成器引用的清理
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `package-purchase-validation`:`GetPurchasePrice()` 价格来源改为按渠道区分(代理→retail_price,平台→SuggestedRetailPrice);`validatePackages()` 价格累加逻辑同步修正
|
||||||
|
- `package-list`:代理查询套餐列表时,`PackageResponse` 新增 `retail_price` 字段;`ProfitMargin` 计算从 `SuggestedRetailPrice - CostPrice` 改为 `RetailPrice - CostPrice`
|
||||||
|
- `batch-pricing`:`BatchUpdatePricing` 接口扩展支持 `pricing_target` 参数(`cost_price`/`retail_price`),默认 `cost_price` 保持向后兼容;retail_price 调整时校验 `>= cost_price`
|
||||||
|
- `one-time-commission-trigger`:触发条件增加 `order.Source == "client"` 判断,确保仅客户端个人客户购买才触发
|
||||||
|
- `wallet-recharge`:`HandlePaymentCallback` 事务一致性修复,Store 方法支持传入事务 `tx`
|
||||||
|
- `iot-order`:Order 模型新增 `source`(订单来源)和 `generation`(世代)字段;`CreateAdminOrder()` 创建订单时从资产快照当前 `generation` 写入订单(而非依赖默认值 1)
|
||||||
|
- `iot-card`:IotCard 模型新增 `asset_status` 和 `generation` 字段
|
||||||
|
- `device`:Device 模型新增 `asset_status` 和 `generation` 字段
|
||||||
|
- `personal-customer`:`wx_open_id` 索引从唯一改为普通索引
|
||||||
|
- `asset-recharge-adaptation`:AssetRechargeRecord 新增 `operator_type`、`generation`、强充关联字段
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **模型文件**:`shop_package_allocation.go`、`carrier.go`、`order.go`、`iot_card.go`、`device.go`、`package_usage.go`、`asset_recharge_record.go`、`personal_customer.go`
|
||||||
|
- **Service 文件**:`purchase_validation/service.go`(价格计算)、`commission_calculation/service.go`(佣金触发)、`recharge/service.go`(回调事务)、`shop_package_batch_pricing/service.go`(扩展支持 retail_price + cost_price 锁定)、`shop_series_grant/service.go`(cost_price 锁定)、`order/service.go`(source 设置 + generation 快照)、`package/service.go`(利润计算修正 + PackageResponse 新增 retail_price)
|
||||||
|
- **Handler/DTO 文件**:`shop_package_batch_pricing.go` Handler(扩展)、`shop_package_batch_pricing_dto.go`(新增 `pricing_target` 字段)、`package_dto.go`(`PackageResponse` 新增 `retail_price`)、`carrier_dto.go`(新增实名链接字段)
|
||||||
|
- **Store 文件**:`asset_recharge_store.go`(支持事务传入)
|
||||||
|
- **删除文件**:`internal/handler/h5/` 全部(5 个文件)、`internal/routes/h5*.go`(3 个文件)、`internal/handler/app/personal_customer.go` 中旧方法
|
||||||
|
- **数据库迁移**:7 张表共 15+ 个字段变更,1 个索引变更
|
||||||
|
- **文档生成器**:`cmd/api/docs.go`、`cmd/gendocs/main.go` 移除 H5 Handler 引用
|
||||||
|
- **Bootstrap**:移除 H5 Handler 注册
|
||||||
|
- **性能**:所有变更为字段新增/修复,无查询性能影响;新增字段均带 DEFAULT 值,迁移可在线执行
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 分配零售价字段定义
|
||||||
|
|
||||||
|
系统 MUST 在 `ShopPackageAllocation` 新增 `retail_price bigint NOT NULL DEFAULT 0` 字段。
|
||||||
|
|
||||||
|
#### Scenario: 新字段存在且非空
|
||||||
|
- **WHEN** 执行分配记录建表或迁移
|
||||||
|
- **THEN** `retail_price` MUST 为非空整型字段,默认值为 `0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 分配创建默认零售价规则
|
||||||
|
|
||||||
|
系统 MUST 在创建分配记录时将 `retail_price` 自动设置为对应 `Package.SuggestedRetailPrice`。
|
||||||
|
|
||||||
|
#### Scenario: 创建分配自动带出建议零售价
|
||||||
|
- **WHEN** 平台给代理创建套餐分配记录
|
||||||
|
- **THEN** 新记录的 `retail_price` MUST 等于该套餐的 `suggested_retail_price`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 零售价约束规则
|
||||||
|
|
||||||
|
系统 MUST 强制校验:`retail_price >= cost_price` 且 `retail_price <= suggested_retail_price * 2`。
|
||||||
|
|
||||||
|
#### Scenario: 零售价低于成本价
|
||||||
|
- **WHEN** 代理设置 `retail_price < cost_price`
|
||||||
|
- **THEN** 系统 MUST 拒绝保存并返回价格约束错误
|
||||||
|
|
||||||
|
#### Scenario: 零售价超过建议价两倍
|
||||||
|
- **WHEN** 代理设置 `retail_price > suggested_retail_price * 2`
|
||||||
|
- **THEN** 系统 MUST 拒绝保存并返回价格约束错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 成本价分配锁定规则
|
||||||
|
|
||||||
|
当某分配存在下级分配记录时,系统 MUST 禁止修改该分配的 `cost_price`。
|
||||||
|
|
||||||
|
#### Scenario: 存在下级分配时修改成本价
|
||||||
|
- **WHEN** 上级分配记录已被继续分配到下级店铺
|
||||||
|
- **THEN** 系统 MUST 拒绝对该记录的 `cost_price` 修改
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 代理零售价可调与存量迁移
|
||||||
|
|
||||||
|
系统 MUST 允许代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`。
|
||||||
|
|
||||||
|
#### Scenario: 代理调整自己的零售价
|
||||||
|
- **WHEN** 代理修改自己分配记录的 `retail_price` 且满足价格约束
|
||||||
|
- **THEN** 系统 MUST 允许更新
|
||||||
|
|
||||||
|
#### Scenario: 存量数据回填零售价
|
||||||
|
- **WHEN** 执行本次数据迁移
|
||||||
|
- **THEN** 系统 MUST 将历史 `ShopPackageAllocation.retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 资产表新增代际字段
|
||||||
|
|
||||||
|
系统 MUST 在资产主表新增 `generation int NOT NULL DEFAULT 1` 字段,覆盖 `IotCard` 与 `Device`。
|
||||||
|
|
||||||
|
#### Scenario: 新资产默认代际为 1
|
||||||
|
- **WHEN** 创建新的 IoT 卡或设备
|
||||||
|
- **THEN** 系统 MUST 将 `generation` 初始化为 `1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 关联业务表新增代际字段
|
||||||
|
|
||||||
|
系统 MUST 在以下关联业务表新增 `generation int NOT NULL DEFAULT 1` 字段:`Order`、`PackageUsage`、`AssetRechargeRecord`。
|
||||||
|
|
||||||
|
#### Scenario: 新关联记录默认代际为 1
|
||||||
|
- **WHEN** 创建订单、套餐使用记录或资产充值记录
|
||||||
|
- **THEN** 系统 MUST 将记录的 `generation` 默认为 `1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 写时快照代际规则
|
||||||
|
|
||||||
|
系统 MUST 在创建关联记录时执行代际写时快照:从当前资产(IoT 卡/设备)的 `generation` 复制到新建的 `Order`、`PackageUsage`、`AssetRechargeRecord` 记录。
|
||||||
|
|
||||||
|
#### Scenario: 创建订单时复制资产代际
|
||||||
|
- **WHEN** 某资产当前 `generation=3`,并基于该资产创建订单
|
||||||
|
- **THEN** 该订单记录的 `generation` MUST 写入为 `3`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 查询过滤规则
|
||||||
|
|
||||||
|
系统 MUST 支持客户端按 `generation` 过滤历史数据;后台管理侧 MUST 不默认按 `generation` 过滤。
|
||||||
|
|
||||||
|
本提案阶段 MUST 仅新增字段定义,具体过滤逻辑在后续提案实现。
|
||||||
|
|
||||||
|
#### Scenario: 客户端按代际查看历史
|
||||||
|
- **WHEN** 客户端请求携带指定 `generation`
|
||||||
|
- **THEN** 系统 MUST 仅返回该代际的数据(在后续提案中实现)
|
||||||
|
|
||||||
|
#### Scenario: 后台查询不按代际裁剪
|
||||||
|
- **WHEN** 管理端查询订单或充值记录且未显式指定 `generation`
|
||||||
|
- **THEN** 系统 MUST 返回全部代际数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 钱包流水不引入代际字段
|
||||||
|
|
||||||
|
系统 MUST NOT 在钱包流水相关表新增 `generation` 字段,因为钱包流水已通过 `wallet_id` 天然隔离。
|
||||||
|
|
||||||
|
#### Scenario: 钱包流水按钱包隔离
|
||||||
|
- **WHEN** 查询某资产钱包流水
|
||||||
|
- **THEN** 系统 MUST 仅依赖 `wallet_id` 完成数据隔离,不新增 `generation` 参与过滤
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 资产生命周期状态字段定义
|
||||||
|
|
||||||
|
系统 MUST 在 `IotCard` 与 `Device` 数据模型中新增 `asset_status int NOT NULL DEFAULT 1` 字段,用于表达资产生命周期状态。
|
||||||
|
|
||||||
|
状态值域 MUST 固定为:`1-在库`、`2-已销售`、`3-已换货`、`4-已停用`。
|
||||||
|
|
||||||
|
#### Scenario: 新建资产默认在库
|
||||||
|
- **WHEN** 系统创建新的 IoT 卡或设备记录
|
||||||
|
- **THEN** `asset_status` MUST 默认为 `1`(在库)
|
||||||
|
|
||||||
|
#### Scenario: 非法状态值被拒绝
|
||||||
|
- **WHEN** 写入 `asset_status` 为 `0`、`5` 或其他非约定值
|
||||||
|
- **THEN** 系统 MUST 拒绝该写入并提示状态值不合法
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 资产生命周期状态常量定义
|
||||||
|
|
||||||
|
系统 MUST 在 `pkg/constants/` 中定义资产生命周期状态常量,并统一由业务层引用,禁止在业务代码中硬编码状态值。
|
||||||
|
|
||||||
|
#### Scenario: 业务代码引用常量
|
||||||
|
- **WHEN** Service 层执行资产状态判断或赋值
|
||||||
|
- **THEN** 代码 MUST 使用 `pkg/constants/` 中定义的资产状态常量而不是硬编码数字
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 资产状态与网络状态独立
|
||||||
|
|
||||||
|
系统 MUST 保证 `asset_status` 与运营商侧 `network_status` 完全独立,二者不互相推导、不互相覆盖。
|
||||||
|
|
||||||
|
本提案阶段 MUST 仅新增字段与常量定义,状态流转逻辑(导入→在库、首次绑定/分配→已销售、换货完成→已换货、转新→在库且代际+1、手动停用→已停用)在后续提案实现。
|
||||||
|
|
||||||
|
#### Scenario: 网络状态变化不影响资产状态
|
||||||
|
- **WHEN** Gateway 同步将 `network_status` 从开机改为停机
|
||||||
|
- **THEN** 系统 MUST 保持 `asset_status` 不变
|
||||||
|
|
||||||
|
#### Scenario: 资产状态变化不强制修改网络状态
|
||||||
|
- **WHEN** 管理端将资产手动停用(`asset_status=4`)
|
||||||
|
- **THEN** 系统 MUST 不自动改写 `network_status`
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 资产充值表结构变更
|
||||||
|
|
||||||
|
系统 MUST 在 `tb_asset_recharge_record` 新增以下字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `operator_type` | varchar(20) | ✅ | 操作人类型,枚举 `admin_user` / `personal_customer`,默认 `admin_user` |
|
||||||
|
| `generation` | int | ✅ | 资产代际,默认 `1` |
|
||||||
|
| `linked_package_ids` | jsonb | ❌ | 关联套餐 ID 列表,默认 `'[]'` |
|
||||||
|
| `linked_order_type` | varchar(20) | ❌ | 关联订单类型 |
|
||||||
|
| `linked_carrier_type` | varchar(20) | ❌ | 关联载体类型(如 iot_card/device) |
|
||||||
|
| `linked_carrier_id` | bigint | ❌ | 关联载体 ID |
|
||||||
|
|
||||||
|
#### Scenario: 新建充值记录默认字段值
|
||||||
|
- **WHEN** 系统创建新的资产充值记录且未显式传入新增字段
|
||||||
|
- **THEN** `operator_type` MUST 默认为 `admin_user`
|
||||||
|
- **THEN** `generation` MUST 默认为 `1`
|
||||||
|
- **THEN** `linked_package_ids` MUST 默认为空数组 `[]`
|
||||||
|
|
||||||
|
#### Scenario: 写入关联上下文信息
|
||||||
|
- **WHEN** 充值记录由订单或套餐联动产生
|
||||||
|
- **THEN** 系统 MUST 可写入 `linked_order_type`、`linked_carrier_type`、`linked_carrier_id` 作为关联上下文
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 运营商实名链接配置字段定义
|
||||||
|
|
||||||
|
系统 MUST 在 Carrier 模型新增以下字段:
|
||||||
|
- `realname_link_type varchar(20) NOT NULL DEFAULT 'none'`
|
||||||
|
- `realname_link_template varchar(500) DEFAULT ''`
|
||||||
|
|
||||||
|
#### Scenario: 默认配置为不支持在线实名
|
||||||
|
- **WHEN** 创建新的运营商记录且未显式设置实名链接配置
|
||||||
|
- **THEN** 系统 MUST 将 `realname_link_type` 设为 `none`,`realname_link_template` 设为空字符串
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 实名链接三种模式
|
||||||
|
|
||||||
|
系统 MUST 支持并仅支持以下实名链接模式:
|
||||||
|
- `none`:不支持在线实名
|
||||||
|
- `template`:使用模板 URL 生成实名链接
|
||||||
|
- `gateway`:通过 Gateway 接口动态获取实名链接
|
||||||
|
|
||||||
|
#### Scenario: none 模式
|
||||||
|
- **WHEN** `realname_link_type=none`
|
||||||
|
- **THEN** 系统 MUST 视为不支持在线实名跳转
|
||||||
|
|
||||||
|
#### Scenario: template 模式
|
||||||
|
- **WHEN** `realname_link_type=template`
|
||||||
|
- **THEN** 系统 MUST 使用 `realname_link_template` 作为实名链接模板
|
||||||
|
|
||||||
|
#### Scenario: gateway 模式
|
||||||
|
- **WHEN** `realname_link_type=gateway`
|
||||||
|
- **THEN** 系统 MUST 通过 Gateway 能力获取实名链接
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 模板占位符规则
|
||||||
|
|
||||||
|
当 `realname_link_type=template` 时,系统 MUST 支持模板中的占位符 `{iccid}`、`{msisdn}`、`{virtual_no}`。
|
||||||
|
|
||||||
|
本提案阶段 MUST 仅新增字段,不实现实名跳转接口逻辑。
|
||||||
|
|
||||||
|
#### Scenario: 模板占位符可被解析
|
||||||
|
- **WHEN** 模板 URL 包含 `{iccid}`、`{msisdn}` 或 `{virtual_no}`
|
||||||
|
- **THEN** 系统 MUST 在后续实名跳转实现中按占位符语义进行参数替换
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 设备实体定义
|
||||||
|
|
||||||
|
系统 SHALL 在 `Device` 模型新增以下字段:
|
||||||
|
- `asset_status int NOT NULL DEFAULT 1`
|
||||||
|
- `generation int NOT NULL DEFAULT 1`
|
||||||
|
|
||||||
|
#### Scenario: 新建设备默认资产状态
|
||||||
|
- **WHEN** 创建新的设备记录
|
||||||
|
- **THEN** `asset_status` MUST 默认为 `1`(在库)
|
||||||
|
|
||||||
|
#### Scenario: 新建设备默认代际
|
||||||
|
- **WHEN** 创建新的设备记录
|
||||||
|
- **THEN** `generation` MUST 默认为 `1`
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: 旧 H5 接口文件删除清单
|
||||||
|
|
||||||
|
系统 MUST 完整删除以下旧 H5 文件:
|
||||||
|
- `internal/handler/h5/auth.go`
|
||||||
|
- `internal/handler/h5/order.go`
|
||||||
|
- `internal/handler/h5/recharge.go`
|
||||||
|
- `internal/handler/h5/package_usage.go`
|
||||||
|
- `internal/handler/h5/enterprise_device.go`
|
||||||
|
- `internal/routes/h5.go`
|
||||||
|
- `internal/routes/h5_enterprise_device.go`
|
||||||
|
- `internal/routes/h5_package_usage.go`
|
||||||
|
|
||||||
|
#### Scenario: 旧 H5 文件不存在
|
||||||
|
- **WHEN** 执行本提案改造完成后检查仓库
|
||||||
|
- **THEN** 上述文件 MUST 全部不存在
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 旧 H5 与旧登录引用清理清单
|
||||||
|
|
||||||
|
系统 MUST 清理以下代码引用:
|
||||||
|
- bootstrap:`handlers.go` 中 `H5Auth`、`EnterpriseDeviceH5`、`H5PackageUsage`、`H5Order`、`H5Recharge`
|
||||||
|
- bootstrap:`types.go` 对应字段
|
||||||
|
- bootstrap:`middlewares.go` 中 `createH5AuthMiddleware`
|
||||||
|
- 路由:`routes.go` 的 `/api/h5` 挂载
|
||||||
|
- 路由:`order.go` 的 `registerH5OrderRoutes`
|
||||||
|
- 路由:`recharge.go` 的 `registerH5RechargeRoutes`
|
||||||
|
- 文档:`pkg/openapi/handlers.go` 中 H5 Handler 构造
|
||||||
|
- 限流:`cmd/api/main.go` 中 `/api/h5` 限流配置
|
||||||
|
- 旧登录方法:`internal/handler/app/personal_customer.go` 中 `Login`、`SendCode`、`WechatOAuthLogin`、`BindWechat`
|
||||||
|
- 旧登录路由:`internal/routes/personal.go` 中指向已删除方法的路由
|
||||||
|
|
||||||
|
#### Scenario: 编译期无已删除符号引用
|
||||||
|
- **WHEN** 清理完成后执行编译
|
||||||
|
- **THEN** 系统 MUST 不再出现对上述已删除 Handler、路由或方法的引用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: 清理后编译通过
|
||||||
|
|
||||||
|
系统 MUST 在完成文件删除与引用清理后保持工程可编译。
|
||||||
|
|
||||||
|
#### Scenario: 全量编译验证通过
|
||||||
|
- **WHEN** 执行构建命令
|
||||||
|
- **THEN** 工程 MUST 编译通过且无 H5 旧接口残留导致的编译错误
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: IoT 卡实体定义
|
||||||
|
|
||||||
|
系统 SHALL 在 `IotCard` 模型新增以下字段:
|
||||||
|
- `asset_status int NOT NULL DEFAULT 1`
|
||||||
|
- `generation int NOT NULL DEFAULT 1`
|
||||||
|
|
||||||
|
#### Scenario: 新建 IoT 卡默认资产状态
|
||||||
|
- **WHEN** 创建新的 IoT 卡记录
|
||||||
|
- **THEN** `asset_status` MUST 默认为 `1`(在库)
|
||||||
|
|
||||||
|
#### Scenario: 新建 IoT 卡默认代际
|
||||||
|
- **WHEN** 创建新的 IoT 卡记录
|
||||||
|
- **THEN** `generation` MUST 默认为 `1`
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 订单实体定义
|
||||||
|
|
||||||
|
系统 SHALL 定义订单(Order)实体并新增来源与代际字段:
|
||||||
|
- `source varchar(20) NOT NULL DEFAULT 'admin'`,取值 `admin/client`
|
||||||
|
- `generation int NOT NULL DEFAULT 1`
|
||||||
|
|
||||||
|
#### Scenario: 新建订单默认后台来源
|
||||||
|
- **WHEN** 系统创建订单且未显式指定来源
|
||||||
|
- **THEN** `source` MUST 默认为 `admin`
|
||||||
|
|
||||||
|
#### Scenario: 客户端下单写入客户端来源
|
||||||
|
- **WHEN** 客户端入口创建订单
|
||||||
|
- **THEN** `source` MUST 写入为 `client`
|
||||||
|
|
||||||
|
#### Scenario: 新建订单默认代际为 1
|
||||||
|
- **WHEN** 系统创建订单且未显式指定代际
|
||||||
|
- **THEN** `generation` MUST 默认为 `1`
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 一次性佣金触发条件
|
||||||
|
|
||||||
|
系统 SHALL 在满足一次性佣金阈值规则的前提下,仅对客户端订单触发一次性佣金。
|
||||||
|
|
||||||
|
完整触发判断 MUST 为:`!order.IsPurchaseOnBehalf && order.Source == "client"`。
|
||||||
|
|
||||||
|
#### Scenario: 客户端自购订单触发
|
||||||
|
- **WHEN** 订单满足阈值条件,且 `order.IsPurchaseOnBehalf=false`,`order.Source="client"`
|
||||||
|
- **THEN** 系统 SHALL 触发一次性佣金计算
|
||||||
|
|
||||||
|
#### Scenario: 代购订单不触发
|
||||||
|
- **WHEN** 订单满足阈值条件,但 `order.IsPurchaseOnBehalf=true`
|
||||||
|
- **THEN** 系统 SHALL 不触发一次性佣金
|
||||||
|
|
||||||
|
#### Scenario: 后台订单不触发
|
||||||
|
- **WHEN** 订单满足阈值条件,且 `order.Source="admin"`
|
||||||
|
- **THEN** 系统 SHALL 不触发一次性佣金
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 获取购买价格
|
||||||
|
|
||||||
|
系统 MUST 根据购买渠道返回正确的购买价格。
|
||||||
|
|
||||||
|
#### Scenario: 代理渠道使用分配零售价
|
||||||
|
- **WHEN** 客户通过代理渠道购买套餐
|
||||||
|
- **THEN** 系统 MUST 使用 `allocation.retail_price` 作为支付金额
|
||||||
|
|
||||||
|
#### Scenario: 平台渠道使用套餐建议零售价
|
||||||
|
- **WHEN** 客户通过平台自营渠道购买套餐
|
||||||
|
- **THEN** 系统 MUST 使用 `Package.SuggestedRetailPrice` 作为支付金额
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: validatePackages 价格累加与展示校验
|
||||||
|
|
||||||
|
系统 MUST 在 `validatePackages()` 中按渠道来源使用一致的价格来源进行累加计算,并在代理渠道增加价格展示可见性校验。
|
||||||
|
|
||||||
|
#### Scenario: 代理渠道累加使用 retail_price
|
||||||
|
- **WHEN** `validatePackages()` 处理代理渠道的多套餐下单
|
||||||
|
- **THEN** 总价累加 MUST 基于各套餐的 `allocation.retail_price`
|
||||||
|
|
||||||
|
#### Scenario: 平台渠道累加使用 SuggestedRetailPrice
|
||||||
|
- **WHEN** `validatePackages()` 处理平台渠道的多套餐下单
|
||||||
|
- **THEN** 总价累加 MUST 基于各套餐的 `Package.SuggestedRetailPrice`
|
||||||
|
|
||||||
|
#### Scenario: 代理渠道过滤异常零售价
|
||||||
|
- **WHEN** 代理渠道某套餐存在 `retail_price < cost_price`
|
||||||
|
- **THEN** 系统 MUST 不展示该套餐,且不允许该套餐进入下单校验
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 微信标识索引策略
|
||||||
|
|
||||||
|
系统 MUST 将 `tb_personal_customer.wx_open_id` 的索引从唯一索引调整为普通索引:删除 `uniqueIndex`,改为 `index`。
|
||||||
|
|
||||||
|
#### Scenario: 多条记录允许相同 wx_open_id
|
||||||
|
- **WHEN** 数据库中写入两条具有相同 `wx_open_id` 的个人客户记录
|
||||||
|
- **THEN** 数据库层 MUST 不再因唯一约束报错
|
||||||
|
|
||||||
|
#### Scenario: 查询性能仍受索引保障
|
||||||
|
- **WHEN** 按 `wx_open_id` 执行查询
|
||||||
|
- **THEN** 系统 MUST 继续命中普通索引以保障查询性能
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: 充值支付回调处理
|
||||||
|
|
||||||
|
系统 SHALL 处理微信和支付宝的支付回调,验证签名,更新充值订单状态,增加钱包余额。
|
||||||
|
|
||||||
|
关键一致性修复:`HandlePaymentCallback` 内的 `UpdateStatusWithOptimisticLock` 与 `UpdatePaymentInfo` MUST 使用同一个事务内 `tx` 执行。
|
||||||
|
|
||||||
|
#### Scenario: 回调处理中状态更新与支付信息更新同事务
|
||||||
|
- **WHEN** 收到支付成功回调并进入 `HandlePaymentCallback`
|
||||||
|
- **THEN** 系统 MUST 在同一事务 `tx` 内执行 `UpdateStatusWithOptimisticLock`
|
||||||
|
- **THEN** 系统 MUST 在同一事务 `tx` 内执行 `UpdatePaymentInfo`
|
||||||
|
|
||||||
|
#### Scenario: 事务失败整体回滚
|
||||||
|
- **WHEN** 回调处理中任一步骤失败
|
||||||
|
- **THEN** 系统 MUST 回滚该事务,保证订单状态与支付信息不出现部分成功
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Requirement: Store 方法签名支持事务参数
|
||||||
|
|
||||||
|
系统 MUST 调整充值相关 Store 方法签名,支持显式传入 `*gorm.DB tx` 参数,以保证事务边界可控。
|
||||||
|
|
||||||
|
#### Scenario: Service 传入事务句柄
|
||||||
|
- **WHEN** Service 在事务上下文调用 Store 更新充值记录
|
||||||
|
- **THEN** Store 方法 MUST 接收并使用传入的 `tx` 执行数据库操作
|
||||||
107
openspec/changes/client-api-data-model-fixes/tasks.md
Normal file
107
openspec/changes/client-api-data-model-fixes/tasks.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
## 1. 常量定义
|
||||||
|
|
||||||
|
- [x] 1.1 在 `pkg/constants/` 新增 `asset_status.go`,定义资产业务状态常量:`AssetStatusInStock = 1`(在库)、`AssetStatusSold = 2`(已销售)、`AssetStatusExchanged = 3`(已换货)、`AssetStatusDeactivated = 4`(已停用),每个常量必须有中文注释
|
||||||
|
- [x] 1.2 在 `pkg/constants/` 新增 `order_source.go`,定义订单来源常量:`OrderSourceAdmin = "admin"`(后台)、`OrderSourceClient = "client"`(客户端),每个常量必须有中文注释
|
||||||
|
- [x] 1.3 在 `pkg/constants/` 新增 `operator_type.go`,定义操作人类型常量:`OperatorTypeAdminUser = "admin_user"`(后台用户)、`OperatorTypePersonalCustomer = "personal_customer"`(个人客户),每个常量必须有中文注释
|
||||||
|
- [x] 1.4 在 `pkg/constants/` 新增 `realname_link.go`,定义实名链接类型常量:`RealnameLinkTypeNone = "none"`、`RealnameLinkTypeTemplate = "template"`、`RealnameLinkTypeGateway = "gateway"`,每个常量必须有中文注释
|
||||||
|
|
||||||
|
## 2. 模型字段新增
|
||||||
|
|
||||||
|
- [x] 2.1 在 `internal/model/iot_card.go` 的 `IotCard` 结构体中新增 `AssetStatus int` 字段(gorm: `column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用`)和 `Generation int` 字段(gorm: `column:generation;type:int;not null;default:1;comment:资产世代编号`)
|
||||||
|
- [x] 2.2 在 `internal/model/device.go` 的 `Device` 结构体中新增 `AssetStatus int` 和 `Generation int` 字段,gorm tag 同 2.1
|
||||||
|
- [x] 2.3 在 `internal/model/order.go` 的 `Order` 结构体中新增 `Source string` 字段(gorm: `column:source;type:varchar(20);not null;default:'admin';comment:订单来源 admin-后台 client-客户端`)和 `Generation int` 字段(gorm: `column:generation;type:int;not null;default:1;comment:资产世代编号`)
|
||||||
|
- [x] 2.4 在 `internal/model/package.go` 的 `PackageUsage` 结构体中新增 `Generation int` 字段(gorm: `column:generation;type:int;not null;default:1;comment:资产世代编号`)
|
||||||
|
- [x] 2.5 在 `internal/model/asset_wallet.go` 的 `AssetRechargeRecord` 结构体中新增以下字段:`OperatorType string`(`column:operator_type;type:varchar(20);not null;default:'admin_user';comment:操作人类型`)、`Generation int`(`column:generation;type:int;not null;default:1;comment:资产世代编号`)、`LinkedPackageIDs datatypes.JSON`(`column:linked_package_ids;type:jsonb;default:'[]';comment:强充关联套餐ID列表`)、`LinkedOrderType string`(`column:linked_order_type;type:varchar(20);comment:关联订单类型`)、`LinkedCarrierType string`(`column:linked_carrier_type;type:varchar(20);comment:关联载体类型`)、`LinkedCarrierID *uint`(`column:linked_carrier_id;type:bigint;comment:关联载体ID`)
|
||||||
|
- [x] 2.6 在 `internal/model/carrier.go` 的 `Carrier` 结构体中新增 `RealnameLinkType string`(`column:realname_link_type;type:varchar(20);not null;default:'none';comment:实名链接类型 none-不支持 template-模板URL gateway-Gateway接口`)和 `RealnameLinkTemplate string`(`column:realname_link_template;type:varchar(500);default:'';comment:实名链接模板URL`)
|
||||||
|
- [x] 2.7 在 `internal/model/shop_package_allocation.go` 的 `ShopPackageAllocation` 结构体中新增 `RetailPrice int64` 字段(gorm: `column:retail_price;type:bigint;not null;default:0;comment:代理面向终端客户的零售价(分)`)
|
||||||
|
- [x] 2.8 在 `internal/model/personal_customer.go` 中将 `WxOpenID` 字段的 gorm tag 从 `uniqueIndex:idx_personal_customer_wx_open_id,where:deleted_at IS NULL` 改为 `index:idx_personal_customer_wx_open_id`(唯一索引改为普通索引)
|
||||||
|
|
||||||
|
## 3. 数据库迁移
|
||||||
|
|
||||||
|
- [x] 3.1 创建迁移文件(使用 golang-migrate 工具),包含以下 ALTER TABLE 语句:
|
||||||
|
- `tb_iot_card` ADD `asset_status int NOT NULL DEFAULT 1`、ADD `generation int NOT NULL DEFAULT 1`
|
||||||
|
- `tb_device` ADD `asset_status int NOT NULL DEFAULT 1`、ADD `generation int NOT NULL DEFAULT 1`
|
||||||
|
- `tb_order` ADD `source varchar(20) NOT NULL DEFAULT 'admin'`、ADD `generation int NOT NULL DEFAULT 1`
|
||||||
|
- `tb_package_usage` ADD `generation int NOT NULL DEFAULT 1`
|
||||||
|
- `tb_asset_recharge_record` ADD `operator_type varchar(20) NOT NULL DEFAULT 'admin_user'`、ADD `generation int NOT NULL DEFAULT 1`、ADD `linked_package_ids jsonb DEFAULT '[]'`、ADD `linked_order_type varchar(20)`、ADD `linked_carrier_type varchar(20)`、ADD `linked_carrier_id bigint`
|
||||||
|
- `tb_carrier` ADD `realname_link_type varchar(20) NOT NULL DEFAULT 'none'`、ADD `realname_link_template varchar(500) DEFAULT ''`
|
||||||
|
- `tb_shop_package_allocation` ADD `retail_price bigint NOT NULL DEFAULT 0`
|
||||||
|
- [x] 3.2 在同一迁移文件中添加存量数据修复 SQL:`UPDATE tb_shop_package_allocation spa SET retail_price = (SELECT suggested_retail_price FROM tb_package p WHERE p.id = spa.package_id) WHERE retail_price = 0`
|
||||||
|
- [x] 3.3 在同一迁移文件中添加索引变更:DROP `idx_personal_customer_wx_open_id` 唯一索引,CREATE 普通索引 `idx_personal_customer_wx_open_id` ON `tb_personal_customer(wx_open_id)`
|
||||||
|
- [x] 3.4 编写 down 迁移文件(回滚用),包含对应的 DROP COLUMN 和索引恢复语句
|
||||||
|
- [x] 3.5 执行迁移,使用 PostgreSQL MCP 工具验证所有字段已添加、存量 `retail_price` 已更新、索引已变更
|
||||||
|
|
||||||
|
## 4. BUG-1 修复:代理零售价
|
||||||
|
|
||||||
|
- [x] 4.1 修改 `internal/service/purchase_validation/service.go` 的 `GetPurchasePrice()` 方法(约第 172 行):增加渠道判断,代理渠道(`card.ShopID > 0`)时查询 `ShopPackageAllocation` 获取 `RetailPrice` 并返回,平台渠道继续返回 `Package.SuggestedRetailPrice`
|
||||||
|
- [x] 4.2 修改 `internal/service/purchase_validation/service.go` 的 `validatePackages()` 方法(约第 148 行):将 `totalPrice += pkg.SuggestedRetailPrice` 改为按渠道取价逻辑(复用 4.1 的渠道判断),代理渠道额外校验 `allocation.RetailPrice >= allocation.CostPrice`,不满足则该套餐视为不可购买
|
||||||
|
- [x] 4.3 修改 `internal/service/shop_package_batch_allocation/service.go`:创建分配记录时设置 `RetailPrice` 为 `Package.SuggestedRetailPrice`(约第 84-105 行创建 allocation 的位置)
|
||||||
|
- [x] 4.4 修改 `internal/service/shop_series_grant/service.go`:创建/更新 `ShopPackageAllocation` 时同步设置 `RetailPrice`(约第 302-352 行和第 614-685 行)
|
||||||
|
- [x] 4.5 在 `internal/service/shop_package_batch_pricing/service.go` 的 `BatchUpdatePricing()` 方法中新增 cost_price 锁定检查:更新每条 allocation 的 cost_price 前,查询是否存在下级分配记录(`ShopPackageAllocation WHERE allocator_shop_id = allocation.ShopID AND package_id = allocation.PackageID`),存在则跳过该条并记录到响应的 `skipped` 列表,附带原因"存在下级分配记录,请先回收后再修改成本价"
|
||||||
|
- [x] 4.6 在 `internal/service/shop_series_grant/service.go` 中同步添加 cost_price 锁定检查:更新现有 allocation 的 CostPrice 时(约第 634 行),查询是否存在下级分配记录,存在则拒绝修改并返回错误
|
||||||
|
|
||||||
|
## 5. 代理零售价后台管理
|
||||||
|
|
||||||
|
- [x] 5.1 修改 `internal/model/dto/shop_package_batch_pricing_dto.go` 的 `BatchUpdateCostPriceRequest`:新增 `PricingTarget string` 字段(`json:"pricing_target" validate:"omitempty,oneof=cost_price retail_price" description:"调价目标 cost_price-成本价(默认) retail_price-零售价"`),不传时默认为 `cost_price` 保持向后兼容
|
||||||
|
- [x] 5.2 修改 `internal/service/shop_package_batch_pricing/service.go` 的 `BatchUpdatePricing()` 方法:根据 `req.PricingTarget` 分流——`cost_price` 走现有逻辑(含 4.5 的锁定检查),`retail_price` 走新逻辑:计算新零售价、校验 `newRetailPrice >= allocation.CostPrice`(不满足则跳过并报错"零售价不能低于成本价")、更新 `allocation.RetailPrice`、记录价格历史(`ShopPackageAllocationPriceHistory` 增加 `old_retail_price`/`new_retail_price` 字段或复用 `OldCostPrice`/`NewCostPrice` 字段并新增 `price_type` 标识)
|
||||||
|
- [x] 5.3 修改 `internal/model/dto/package_dto.go` 的 `PackageResponse` 结构体:新增 `RetailPrice *int64` 字段(`json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`)
|
||||||
|
- [x] 5.4 修改 `internal/service/package/service.go` 的 `toResponse()` 方法(约第 530-541 行):代理用户查询时,从 allocation 读取 `RetailPrice` 设入 `resp.RetailPrice`;同时修正 `ProfitMargin` 计算:从 `pkg.SuggestedRetailPrice - allocation.CostPrice` 改为 `allocation.RetailPrice - allocation.CostPrice`
|
||||||
|
- [x] 5.5 修改 `internal/service/package/service.go` 的 `toResponseWithAllocation()` 方法(约第 595-603 行):同 5.4,从 allocation 读取 `RetailPrice`、修正 `ProfitMargin` 计算
|
||||||
|
|
||||||
|
## 6. Carrier 管理 DTO 更新
|
||||||
|
|
||||||
|
- [x] 6.1 修改 `internal/model/dto/carrier_dto.go`(或 Carrier 相关 DTO 文件):在 `CarrierCreateRequest` 和 `CarrierUpdateRequest` 中新增 `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} 占位符"`)
|
||||||
|
- [x] 6.2 修改 Carrier Service 的 Create/Update 方法:将 DTO 中的 `RealnameLinkType` 和 `RealnameLinkTemplate` 写入 Carrier 模型;Update 时 `realname_link_type` 为 `template` 时校验 `realname_link_template` 非空
|
||||||
|
- [x] 6.3 修改 Carrier 的响应 DTO(如 `CarrierResponse`):新增 `RealnameLinkType` 和 `RealnameLinkTemplate` 字段,后台列表/详情可展示
|
||||||
|
|
||||||
|
## 7. 后台订单 generation 快照
|
||||||
|
|
||||||
|
- [x] 7.1 修改 `internal/service/order/service.go` 的 `CreateAdminOrder()` 方法:创建 Order 时从资产(IotCard/Device)获取当前 `Generation` 值并设入 `order.Generation`,不再依赖数据库默认值 1
|
||||||
|
|
||||||
|
## 8. 资产手动停用
|
||||||
|
|
||||||
|
- [x] 8.1 在 `internal/handler/admin/asset.go`(或新建 `internal/handler/admin/asset_lifecycle.go`)新增 `DeactivateAsset` Handler 方法:接收资产类型和 ID,调用 Service 将 `asset_status` 设为 `constants.AssetStatusDeactivated`(4)
|
||||||
|
- [x] 8.2 在 `internal/service/asset/service.go`(或相关 Service)新增 `Deactivate()` 方法:校验当前 `asset_status` 为 1(在库)或 2(已销售)才允许停用,已换货(3)或已停用(4)的拒绝操作;使用条件更新 `WHERE asset_status IN (1, 2)` 确保幂等
|
||||||
|
- [x] 8.3 在 `internal/routes/` 注册停用路由:`PATCH /api/admin/iot-cards/:id/deactivate` 和 `PATCH /api/admin/devices/:id/deactivate`(或统一为 `PATCH /api/admin/assets/:type/:id/deactivate`)
|
||||||
|
- [x] 8.4 更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`:如新增了 Handler 则同步注册到文档生成器
|
||||||
|
|
||||||
|
## 9. BUG-2 修复:一次性佣金触发条件
|
||||||
|
|
||||||
|
- [x] 9.1 修改 `internal/service/commission_calculation/service.go` 中的 `triggerOneTimeCommissionForCardInTx` 方法:将 `if order.IsPurchaseOnBehalf` 的跳过逻辑改为 `if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient`,即代购订单或非客户端来源订单均跳过一次性佣金
|
||||||
|
- [x] 9.2 修改 `internal/service/commission_calculation/service.go` 中的 `triggerOneTimeCommissionForDeviceInTx` 方法:同 9.1 逻辑
|
||||||
|
|
||||||
|
## 10. BUG-4 修复:充值回调事务一致性
|
||||||
|
|
||||||
|
- [x] 10.1 修改 `internal/store/postgres/asset_recharge_store.go` 的 `UpdateStatusWithOptimisticLock` 方法:新增 `tx *gorm.DB` 参数(方法签名变更),内部使用传入的 `tx` 替代 `s.db.WithContext(ctx)`;同时保留原方法签名的兼容版本或修改所有调用点
|
||||||
|
- [x] 10.2 修改 `internal/store/postgres/asset_recharge_store.go` 的 `UpdatePaymentInfo` 方法:同 10.1,新增 `tx *gorm.DB` 参数
|
||||||
|
- [x] 10.3 修改 `internal/service/recharge/service.go` 的 `HandlePaymentCallback` 方法(约第 308-321 行):在事务闭包内调用 Store 方法时传入事务 `tx`,确保 `UpdateStatusWithOptimisticLock`、`UpdatePaymentInfo` 和钱包入账在同一事务内
|
||||||
|
- [x] 10.4 检查并更新 `UpdateStatusWithOptimisticLock` 和 `UpdatePaymentInfo` 的其他调用点(如有),确保传入正确的 db 或 tx 参数
|
||||||
|
|
||||||
|
## 11. 旧 H5 接口清理
|
||||||
|
|
||||||
|
- [x] 11.1 删除 `internal/handler/h5/` 目录下全部 5 个文件:`auth.go`、`order.go`、`recharge.go`、`package_usage.go`、`enterprise_device.go`
|
||||||
|
- [x] 11.2 删除 `internal/routes/h5.go`、`internal/routes/h5_enterprise_device.go`、`internal/routes/h5_package_usage.go`
|
||||||
|
- [x] 11.3 修改 `internal/routes/routes.go`:移除 `/api/h5` 路由组挂载(约第 31-33 行)
|
||||||
|
- [x] 11.4 修改 `internal/routes/order.go`:移除 `registerH5OrderRoutes` 函数(约第 56-105 行)
|
||||||
|
- [x] 11.5 修改 `internal/routes/recharge.go`:移除 `registerH5RechargeRoutes` 函数(约第 11-44 行)
|
||||||
|
- [x] 11.6 修改 `internal/bootstrap/handlers.go`:移除 H5 Handler 构造(H5Auth、EnterpriseDeviceH5、H5PackageUsage、H5Order、H5Recharge,约第 24、31、44、49、50 行)
|
||||||
|
- [x] 11.7 修改 `internal/bootstrap/types.go`:移除 Handlers 结构体中的 H5 Handler 字段(约第 22、29、42、47、48 行)
|
||||||
|
- [x] 11.8 修改 `internal/bootstrap/middlewares.go`:移除 `createH5AuthMiddleware` 函数和 H5 跳过路径配置(约第 72-95 行)
|
||||||
|
- [x] 11.9 修改 `pkg/openapi/handlers.go`:移除文档生成中的 H5 Handler 构造(EnterpriseDeviceH5、H5PackageUsage、H5Order、H5Recharge)
|
||||||
|
- [x] 11.10 修改 `cmd/api/main.go`:移除 `/api/h5` 限流挂载(约第 250-257 行)
|
||||||
|
|
||||||
|
## 12. 旧个人客户登录接口清理
|
||||||
|
|
||||||
|
- [x] 12.1 修改 `internal/handler/app/personal_customer.go`:删除 Login(约第 79 行)、SendCode(约第 35 行)、WechatOAuthLogin(约第 114 行)、BindWechat(约第 134 行)方法。保留 UpdateProfile 和 GetProfile
|
||||||
|
- [x] 12.2 修改 `internal/routes/personal.go`:移除指向已删除方法的路由注册(Login、SendCode、WechatOAuthLogin、BindWechat 的路由)
|
||||||
|
- [x] 12.3 检查 `internal/bootstrap/handlers.go` 和 `internal/bootstrap/types.go`:如果 PersonalCustomer Handler 有初始化引用已删除方法的依赖,同步清理
|
||||||
|
|
||||||
|
## 13. 验证
|
||||||
|
|
||||||
|
- [x] 13.1 执行 `go build ./...` 确认编译通过,无任何编译错误
|
||||||
|
- [x] 13.2 对所有修改的文件执行 `lsp_diagnostics` 确认无错误和警告
|
||||||
|
- [x] 13.3 使用 PostgreSQL MCP 工具验证数据库:确认 7 张表的新字段存在、默认值正确、存量 `retail_price` 已填充、`wx_open_id` 索引已变更
|
||||||
|
- [x] 13.4 验证删除的 H5 路由不再注册:检查代码中无 `/api/h5` 相关路由残留
|
||||||
|
- [x] 13.5 验证 `BatchUpdatePricing` 接口扩展:确认 `pricing_target=retail_price` 参数可正常使用,不传时默认走 `cost_price` 逻辑(向后兼容)
|
||||||
|
- [x] 13.6 验证代理套餐列表:确认 `PackageResponse` 包含 `retail_price` 字段,`profit_margin` 计算基于 `retail_price - cost_price`
|
||||||
|
- [x] 13.7 撰写功能总结文档 `docs/client-api-data-model-fixes/功能总结.md`,记录所有变更内容
|
||||||
11
pkg/constants/asset_status.go
Normal file
11
pkg/constants/asset_status.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
// 资产业务状态常量
|
||||||
|
// 用于 IotCard/Device 的 asset_status 字段,表示资产在 CMP 内部的业务生命周期状态
|
||||||
|
// 与运营商侧的 network_status 完全独立
|
||||||
|
const (
|
||||||
|
AssetStatusInStock = 1 // 在库
|
||||||
|
AssetStatusSold = 2 // 已销售
|
||||||
|
AssetStatusExchanged = 3 // 已换货
|
||||||
|
AssetStatusDeactivated = 4 // 已停用
|
||||||
|
)
|
||||||
8
pkg/constants/operator_type.go
Normal file
8
pkg/constants/operator_type.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
// 操作人类型常量
|
||||||
|
// 用于 AssetRechargeRecord.operator_type 字段,区分操作人的 ID 体系
|
||||||
|
const (
|
||||||
|
OperatorTypeAdminUser = "admin_user" // 后台用户
|
||||||
|
OperatorTypePersonalCustomer = "personal_customer" // 个人客户
|
||||||
|
)
|
||||||
8
pkg/constants/order_source.go
Normal file
8
pkg/constants/order_source.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
// 订单来源常量
|
||||||
|
// 用于 Order.source 字段,区分订单创建渠道
|
||||||
|
const (
|
||||||
|
OrderSourceAdmin = "admin" // 后台
|
||||||
|
OrderSourceClient = "client" // 客户端
|
||||||
|
)
|
||||||
9
pkg/constants/realname_link.go
Normal file
9
pkg/constants/realname_link.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package constants
|
||||||
|
|
||||||
|
// 实名链接类型常量
|
||||||
|
// 用于 Carrier.realname_link_type 字段,定义运营商实名认证链接的生成方式
|
||||||
|
const (
|
||||||
|
RealnameLinkTypeNone = "none" // 不支持实名链接
|
||||||
|
RealnameLinkTypeTemplate = "template" // 模板URL(支持 {iccid}/{msisdn}/{virtual_no} 占位符)
|
||||||
|
RealnameLinkTypeGateway = "gateway" // Gateway接口
|
||||||
|
)
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
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/callback"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuildDocHandlers 构造文档生成用的 handlers(所有依赖传 nil)
|
// BuildDocHandlers 构造文档生成用的 handlers(所有依赖传 nil)
|
||||||
@@ -25,7 +24,6 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
|||||||
Enterprise: admin.NewEnterpriseHandler(nil),
|
Enterprise: admin.NewEnterpriseHandler(nil),
|
||||||
EnterpriseCard: admin.NewEnterpriseCardHandler(nil),
|
EnterpriseCard: admin.NewEnterpriseCardHandler(nil),
|
||||||
EnterpriseDevice: admin.NewEnterpriseDeviceHandler(nil),
|
EnterpriseDevice: admin.NewEnterpriseDeviceHandler(nil),
|
||||||
EnterpriseDeviceH5: h5.NewEnterpriseDeviceHandler(nil),
|
|
||||||
Authorization: admin.NewAuthorizationHandler(nil),
|
Authorization: admin.NewAuthorizationHandler(nil),
|
||||||
MyCommission: admin.NewMyCommissionHandler(nil),
|
MyCommission: admin.NewMyCommissionHandler(nil),
|
||||||
IotCard: admin.NewIotCardHandler(nil),
|
IotCard: admin.NewIotCardHandler(nil),
|
||||||
@@ -38,13 +36,10 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
|||||||
PackageSeries: admin.NewPackageSeriesHandler(nil),
|
PackageSeries: admin.NewPackageSeriesHandler(nil),
|
||||||
Package: admin.NewPackageHandler(nil),
|
Package: admin.NewPackageHandler(nil),
|
||||||
PackageUsage: admin.NewPackageUsageHandler(nil),
|
PackageUsage: admin.NewPackageUsageHandler(nil),
|
||||||
H5PackageUsage: h5.NewPackageUsageHandler(nil, nil),
|
|
||||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(nil),
|
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(nil),
|
||||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
|
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
|
||||||
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil),
|
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil),
|
||||||
AdminOrder: admin.NewOrderHandler(nil, nil),
|
AdminOrder: admin.NewOrderHandler(nil, nil),
|
||||||
H5Order: h5.NewOrderHandler(nil),
|
|
||||||
H5Recharge: h5.NewRechargeHandler(nil),
|
|
||||||
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil),
|
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil),
|
||||||
PollingConfig: admin.NewPollingConfigHandler(nil),
|
PollingConfig: admin.NewPollingConfigHandler(nil),
|
||||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),
|
PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),
|
||||||
|
|||||||
Reference in New Issue
Block a user