fix: 修正零售价架构错误 + 清理旧微信配置 + 归档提案 + 前端接口文档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m12s

1. 修正 retail_price 架构:
   - 删除 batch-pricing 接口的 pricing_target 字段和 retail_price 分支
     (上级只能改下级成本价,不能改零售价)
   - 新增 PATCH /api/admin/packages/:id/retail-price 接口
     (代理自己改自己的零售价,校验 retail_price >= cost_price)

2. 清理旧微信 YAML 配置(已全部迁移到数据库 tb_wechat_config):
   - 删除 config.yaml 中 wechat.official_account 配置节
   - 删除 NewOfficialAccountApp() 旧工厂函数
   - 清理 personal_customer service 中的死代码(旧登录/绑定微信方法)
   - 清理 docker-compose.prod.yml 中旧微信环境变量和证书挂载注释

3. 归档四个已完成提案到 openspec/changes/archive/

4. 新增前端接口变更说明文档(docs/前端接口变更说明.md)

5. 修正归档提案和 specs 中关于 pricing_target 的错误描述
This commit is contained in:
2026-03-19 17:39:43 +08:00
parent 9bd55a1695
commit b9733c4913
98 changed files with 3665 additions and 571 deletions

View File

@@ -42,8 +42,6 @@ func main() {
// 3. 初始化日志
appLogger := initLogger(cfg)
// 4. 验证微信配置
validateWechatConfig(cfg, appLogger)
defer func() {
_ = logger.Sync()
}()
@@ -352,20 +350,3 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
return client
}
func validateWechatConfig(cfg *config.Config, appLogger *zap.Logger) {
wechatCfg := cfg.Wechat
if wechatCfg.OfficialAccount.AppID == "" {
appLogger.Warn("微信公众号配置未设置OAuth 相关功能将不可用")
return
}
if wechatCfg.OfficialAccount.AppSecret == "" {
appLogger.Fatal("微信公众号配置不完整",
zap.String("missing", "app_secret"),
zap.String("env", "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET"))
}
appLogger.Info("微信公众号配置已验证",
zap.String("app_id", wechatCfg.OfficialAccount.AppID))
}

View File

@@ -22,9 +22,11 @@ version: '3.8'
#
# 可选配置(根据需要启用):
# - Gateway 服务配置JUNHONG_GATEWAY_*
# - 微信公众号配置JUNHONG_WECHAT_OFFICIAL_ACCOUNT_*
# - 微信支付配置JUNHONG_WECHAT_PAYMENT_*
# - 对象存储配置JUNHONG_STORAGE_*
# - 短信服务配置JUNHONG_SMS_*
#
# 微信公众号/小程序/支付配置已迁移至数据库tb_wechat_config 表),
# 不再需要环境变量和证书文件挂载。
services:
api:
@@ -65,28 +67,8 @@ services:
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
- JUNHONG_GATEWAY_TIMEOUT=30
# 微信公众号配置(可选)
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback
# 微信支付配置(可选)
# - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id
# - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id
# - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key
# - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key
# - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem
# - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem
# - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no
# - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay
# - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false
# - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s
volumes:
# 仅挂载日志目录(配置已嵌入二进制文件)
- ./logs:/app/logs
# 微信支付证书目录(如果使用微信支付,需要挂载证书)
# - ./certs:/app/certs:ro
networks:
- junhong-network
healthcheck:
@@ -137,27 +119,8 @@ services:
- JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
- JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
- JUNHONG_GATEWAY_TIMEOUT=30
# 微信公众号配置(可选)
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback
# 微信支付配置(可选)
# - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id
# - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id
# - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key
# - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key
# - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem
# - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem
# - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no
# - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay
# - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false
# - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s
volumes:
- ./logs:/app/logs
# 微信支付证书目录(如果使用微信支付,需要挂载证书)
# - ./certs:/app/certs:ro
networks:
- junhong-network
depends_on:

View File

@@ -17,7 +17,8 @@
- `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` 保持向后兼容
- `BatchUpdatePricing` 接口仅支持成本价批量调整(保留 cost_price 锁定规则)
- 新增独立接口 `PATCH /api/admin/packages/:id/retail-price`,代理可修改自己的套餐零售价
- `PackageResponse` 新增 `retail_price` 字段,利润计算修正为 `RetailPrice - CostPrice`
**涉及文件**

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,5 @@ type Dependencies struct {
QueueClient *queue.Client // Asynq 任务队列客户端
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil
WechatOfficialAccount wechat.OfficialAccountServiceInterface // 微信公众号服务(可选)
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
}

View File

@@ -110,7 +110,7 @@ func initServices(s *stores, deps *Dependencies) *services {
AccountAudit: accountAudit,
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.Logger),
ClientAuth: clientAuthSvc.New(
deps.DB,
s.PersonalCustomerOpenID,

View File

@@ -128,3 +128,21 @@ func (h *PackageHandler) UpdateShelfStatus(c *fiber.Ctx) error {
return response.Success(c, nil)
}
func (h *PackageHandler) UpdateRetailPrice(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.UpdateRetailPriceRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.UpdateRetailPrice(c.UserContext(), uint(id), req.RetailPrice); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -56,6 +56,11 @@ type UpdatePackageShelfStatusRequest struct {
ShelfStatus int `json:"shelf_status" validate:"required,oneof=1 2" required:"true" description:"上架状态 (1:上架, 2:下架)"`
}
// UpdateRetailPriceRequest 更新零售价请求
type UpdateRetailPriceRequest struct {
RetailPrice int64 `json:"retail_price" validate:"required,min=0" required:"true" minimum:"0" description:"零售价(单位:分)"`
}
// CommissionTierInfo 返佣梯度信息
type CommissionTierInfo struct {
CurrentRate string `json:"current_rate" description:"当前返佣比例"`
@@ -111,6 +116,12 @@ type UpdatePackageShelfStatusParams struct {
UpdatePackageShelfStatusRequest
}
// UpdateRetailPriceParams 更新零售价聚合参数
type UpdateRetailPriceParams struct {
IDReq
UpdateRetailPriceRequest
}
// PackagePageResult 套餐分页结果
type PackagePageResult struct {
List []*PackageResponse `json:"list" description:"套餐列表"`

View File

@@ -4,7 +4,6 @@ package dto
type BatchUpdateCostPriceRequest struct {
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"店铺ID"`
SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID可选不填则调整所有"`
PricingTarget string `json:"pricing_target" validate:"omitempty,oneof=cost_price retail_price" description:"调价目标 cost_price-成本价(默认) retail_price-零售价"`
PriceAdjustment PriceAdjustment `json:"price_adjustment" validate:"required" required:"true" description:"价格调整配置"`
ChangeReason string `json:"change_reason" validate:"omitempty,max=255" maxLength:"255" description:"变更原因"`
}

View File

@@ -67,4 +67,12 @@ func registerPackageRoutes(router fiber.Router, handler *admin.PackageHandler, d
Output: nil,
Auth: true,
})
Register(packages, doc, groupPath, "PATCH", "/:id/retail-price", handler.UpdateRetailPrice, RouteSpec{
Summary: "修改零售价(代理)",
Tags: []string{"套餐管理"},
Input: new(dto.UpdateRetailPriceParams),
Output: nil,
Auth: true,
})
}

View File

@@ -456,6 +456,42 @@ func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus in
return nil
}
// UpdateRetailPrice 代理修改自己店铺的套餐零售价
func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "仅代理用户可修改零售价")
}
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺")
}
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "该套餐未分配给您")
}
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
if retailPrice < allocation.CostPrice {
return errors.New(errors.CodeInvalidParam, "零售价不能低于成本价")
}
if err := s.packageAllocationStore.UpdateRetailPrice(ctx, allocation.ID, retailPrice, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
}
return nil
}
// updateAgentShelfStatus 代理上下架路径:更新分配记录的 shelf_status
func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, shelfStatus int, updaterID uint) error {
shopID := middleware.GetShopIDFromContext(ctx)

View File

@@ -6,12 +6,10 @@ import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"go.uber.org/zap"
"gorm.io/gorm"
)
@@ -22,7 +20,6 @@ type Service struct {
phoneStore *postgres.PersonalCustomerPhoneStore
verificationService *verification.Service
jwtManager *auth.JWTManager
wechatOfficialAccount wechat.OfficialAccountServiceInterface
logger *zap.Logger
}
@@ -32,7 +29,6 @@ func NewService(
phoneStore *postgres.PersonalCustomerPhoneStore,
verificationService *verification.Service,
jwtManager *auth.JWTManager,
wechatOfficialAccount wechat.OfficialAccountServiceInterface,
logger *zap.Logger,
) *Service {
return &Service{
@@ -40,7 +36,6 @@ func NewService(
phoneStore: phoneStore,
verificationService: verificationService,
jwtManager: jwtManager,
wechatOfficialAccount: wechatOfficialAccount,
logger: logger,
}
}
@@ -55,115 +50,6 @@ func (s *Service) VerifyCode(ctx context.Context, phone string, code string) err
return s.verificationService.VerifyCode(ctx, phone, code)
}
// LoginByPhone 通过手机号登录
// 如果手机号不存在,自动创建新的个人客户
// 注意:此方法是临时实现,完整的登录流程应该是先微信授权,再绑定手机号
func (s *Service) LoginByPhone(ctx context.Context, phone string, code string) (string, *model.PersonalCustomer, error) {
// 验证验证码
if err := s.verificationService.VerifyCode(ctx, phone, code); err != nil {
s.logger.Warn("验证码验证失败",
zap.String("phone", phone),
zap.Error(err),
)
return "", nil, err
}
// 查找或创建个人客户
customer, err := s.store.GetByPhone(ctx, phone)
if err != nil {
if err == gorm.ErrRecordNotFound {
// 客户不存在,创建新客户
// 注意:临时实现,使用空的微信信息(正式应该先微信授权)
customer = &model.PersonalCustomer{
WxOpenID: "", // 临时为空,后续需绑定微信
WxUnionID: "", // 临时为空,后续需绑定微信
Status: 1, // 默认启用
}
if err := s.store.Create(ctx, customer); err != nil {
s.logger.Error("创建个人客户失败",
zap.String("phone", phone),
zap.Error(err),
)
return "", nil, errors.Wrap(errors.CodeInternalError, err, "创建个人客户失败")
}
// 创建手机号绑定记录
// TODO: 这里需要通过 PersonalCustomerPhoneStore 来创建
// 暂时跳过,等待 PersonalCustomerPhoneStore 实现
s.logger.Info("创建新个人客户",
zap.Uint("customer_id", customer.ID),
zap.String("phone", phone),
)
} else {
s.logger.Error("查询个人客户失败",
zap.String("phone", phone),
zap.Error(err),
)
return "", nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
}
// 检查客户状态
if customer.Status == 0 {
s.logger.Warn("个人客户已被禁用",
zap.Uint("customer_id", customer.ID),
zap.String("phone", phone),
)
return "", nil, errors.New(errors.CodeForbidden, "账号已被禁用")
}
// 生成 Token临时传递 phone后续应该从 Token 中移除 phone 字段)
token, err := s.jwtManager.GeneratePersonalCustomerToken(customer.ID, phone)
if err != nil {
s.logger.Error("生成 Token 失败",
zap.Uint("customer_id", customer.ID),
zap.String("phone", phone),
zap.Error(err),
)
return "", nil, errors.Wrap(errors.CodeInternalError, err, "生成 Token 失败")
}
s.logger.Info("个人客户登录成功",
zap.Uint("customer_id", customer.ID),
zap.String("phone", phone),
)
return token, customer, nil
}
// BindWechat 绑定微信信息
func (s *Service) BindWechat(ctx context.Context, customerID uint, wxOpenID, wxUnionID string) error {
// 获取客户
customer, err := s.store.GetByID(ctx, customerID)
if err != nil {
s.logger.Error("查询个人客户失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败")
}
// 更新微信信息
customer.WxOpenID = wxOpenID
customer.WxUnionID = wxUnionID
if err := s.store.Update(ctx, customer); err != nil {
s.logger.Error("更新微信信息失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "更新微信信息失败")
}
s.logger.Info("绑定微信信息成功",
zap.Uint("customer_id", customerID),
zap.String("wx_open_id", wxOpenID),
)
return nil
}
// UpdateProfile 更新个人资料
func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname, avatarURL string) error {
customer, err := s.store.GetByID(ctx, customerID)
@@ -241,190 +127,3 @@ func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*mo
return customer, phone, nil
}
// WechatOAuthLogin 微信 OAuth 登录
// 通过微信授权码登录,如果用户不存在则自动创建
func (s *Service) WechatOAuthLogin(ctx context.Context, code string) (*dto.WechatOAuthResponse, error) {
// 检查微信服务是否已配置
if s.wechatOfficialAccount == nil {
s.logger.Error("微信公众号服务未配置")
return nil, errors.New(errors.CodeWechatOAuthFailed, "微信服务未配置")
}
// 通过授权码获取用户详细信息
userInfo, err := s.wechatOfficialAccount.GetUserInfoDetailed(ctx, code)
if err != nil {
s.logger.Error("获取微信用户信息失败",
zap.String("code", code),
zap.Error(err),
)
return nil, err
}
// 通过 OpenID 查找现有客户
customer, err := s.store.GetByWxOpenID(ctx, userInfo.OpenID)
if err != nil {
if err == gorm.ErrRecordNotFound {
// 客户不存在,创建新客户
customer = &model.PersonalCustomer{
WxOpenID: userInfo.OpenID,
WxUnionID: userInfo.UnionID,
Nickname: userInfo.Nickname,
AvatarURL: userInfo.Avatar,
Status: 1, // 默认启用
}
if err := s.store.Create(ctx, customer); err != nil {
s.logger.Error("创建微信用户失败",
zap.String("open_id", userInfo.OpenID),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建用户失败")
}
s.logger.Info("通过微信创建新用户",
zap.Uint("customer_id", customer.ID),
zap.String("open_id", userInfo.OpenID),
)
} else {
s.logger.Error("查询微信用户失败",
zap.String("open_id", userInfo.OpenID),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeInternalError, err, "查询用户失败")
}
} else {
// 客户已存在,更新昵称和头像(如果有变化)
needUpdate := false
if userInfo.Nickname != "" && customer.Nickname != userInfo.Nickname {
customer.Nickname = userInfo.Nickname
needUpdate = true
}
if userInfo.Avatar != "" && customer.AvatarURL != userInfo.Avatar {
customer.AvatarURL = userInfo.Avatar
needUpdate = true
}
if needUpdate {
if err := s.store.Update(ctx, customer); err != nil {
s.logger.Warn("更新微信用户信息失败",
zap.Uint("customer_id", customer.ID),
zap.Error(err),
)
// 不阻断登录流程
}
}
}
// 检查客户状态
if customer.Status == 0 {
s.logger.Warn("微信用户已被禁用",
zap.Uint("customer_id", customer.ID),
zap.String("open_id", userInfo.OpenID),
)
return nil, errors.New(errors.CodeForbidden, "账号已被禁用")
}
// 生成 JWT Token
token, err := s.jwtManager.GeneratePersonalCustomerToken(customer.ID, "")
if err != nil {
s.logger.Error("生成 Token 失败",
zap.Uint("customer_id", customer.ID),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeInternalError, err, "生成 Token 失败")
}
// 获取主手机号(如果有)
phone := ""
primaryPhone, err := s.phoneStore.GetPrimaryPhone(ctx, customer.ID)
if err == nil {
phone = primaryPhone.Phone
}
s.logger.Info("微信 OAuth 登录成功",
zap.Uint("customer_id", customer.ID),
zap.String("open_id", userInfo.OpenID),
)
return &dto.WechatOAuthResponse{
AccessToken: token,
ExpiresIn: 24 * 60 * 60, // 24 小时
Customer: &dto.PersonalCustomerResponse{
ID: customer.ID,
Phone: phone,
Nickname: customer.Nickname,
AvatarURL: customer.AvatarURL,
WxOpenID: customer.WxOpenID,
WxUnionID: customer.WxUnionID,
Status: customer.Status,
CreatedAt: customer.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: customer.UpdatedAt.Format("2006-01-02 15:04:05"),
},
}, nil
}
// BindWechatWithCode 通过微信授权码绑定微信
// customerID: 当前登录的客户 ID
// code: 微信授权码
func (s *Service) BindWechatWithCode(ctx context.Context, customerID uint, code string) error {
// 检查微信服务是否已配置
if s.wechatOfficialAccount == nil {
s.logger.Error("微信公众号服务未配置")
return errors.New(errors.CodeWechatOAuthFailed, "微信服务未配置")
}
// 获取客户信息
customer, err := s.store.GetByID(ctx, customerID)
if err != nil {
s.logger.Error("查询个人客户失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "查询客户失败")
}
// 获取微信用户信息
userInfo, err := s.wechatOfficialAccount.GetUserInfoDetailed(ctx, code)
if err != nil {
s.logger.Error("获取微信用户信息失败",
zap.Uint("customer_id", customerID),
zap.String("code", code),
zap.Error(err),
)
return err
}
// 检查该 OpenID 是否已被其他用户绑定
existingCustomer, err := s.store.GetByWxOpenID(ctx, userInfo.OpenID)
if err == nil && existingCustomer.ID != customerID {
s.logger.Warn("微信账号已被其他用户绑定",
zap.Uint("customer_id", customerID),
zap.Uint("existing_customer_id", existingCustomer.ID),
zap.String("open_id", userInfo.OpenID),
)
return errors.New(errors.CodeConflict, "该微信账号已被其他用户绑定")
}
// 更新微信信息
customer.WxOpenID = userInfo.OpenID
customer.WxUnionID = userInfo.UnionID
if userInfo.Nickname != "" {
customer.Nickname = userInfo.Nickname
}
if userInfo.Avatar != "" {
customer.AvatarURL = userInfo.Avatar
}
if err := s.store.Update(ctx, customer); err != nil {
s.logger.Error("绑定微信信息失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "绑定微信失败")
}
s.logger.Info("绑定微信成功",
zap.Uint("customer_id", customerID),
zap.String("open_id", userInfo.OpenID),
)
return nil
}

View File

@@ -65,11 +65,6 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
return nil, errors.New(errors.CodeInvalidParam, "没有找到符合条件的分配记录")
}
pricingTarget := req.PricingTarget
if pricingTarget == "" {
pricingTarget = "cost_price"
}
updatedCount := 0
now := time.Now()
affectedIDs := make([]uint, 0)
@@ -77,38 +72,6 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
err = s.db.Transaction(func(tx *gorm.DB) error {
for _, allocation := range allocations {
if pricingTarget == "retail_price" {
oldRetailPrice := allocation.RetailPrice
newRetailPrice := s.calculateAdjustedPrice(oldRetailPrice, &req.PriceAdjustment)
if newRetailPrice == oldRetailPrice {
continue
}
if newRetailPrice < allocation.CostPrice {
skipped = append(skipped, dto.BatchPricingSkipped{
AllocationID: allocation.ID,
Reason: "零售价不能低于成本价",
})
continue
}
history := &model.ShopPackageAllocationPriceHistory{
AllocationID: allocation.ID,
OldCostPrice: oldRetailPrice,
NewCostPrice: newRetailPrice,
ChangeReason: req.ChangeReason + "(零售价调整)",
ChangedBy: currentUserID,
EffectiveFrom: now,
}
if err := tx.Create(history).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建价格历史失败")
}
allocation.RetailPrice = newRetailPrice
allocation.Updater = currentUserID
if err := tx.Save(allocation).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
}
} else {
oldPrice := allocation.CostPrice
newPrice := s.calculateAdjustedPrice(oldPrice, &req.PriceAdjustment)
if newPrice == oldPrice {
@@ -145,7 +108,6 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo
if err := tx.Save(allocation).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新成本价失败")
}
}
affectedIDs = append(affectedIDs, allocation.ID)
updatedCount++

View File

@@ -139,6 +139,16 @@ func (s *ShopPackageAllocationStore) UpdateShelfStatus(ctx context.Context, id u
}).Error
}
func (s *ShopPackageAllocationStore) UpdateRetailPrice(ctx context.Context, id uint, retailPrice int64, updater uint) error {
return s.db.WithContext(ctx).
Model(&model.ShopPackageAllocation{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"retail_price": retailPrice,
"updater": updater,
}).Error
}
func (s *ShopPackageAllocationStore) GetByShopID(ctx context.Context, shopID uint) ([]*model.ShopPackageAllocation, error) {
var allocations []*model.ShopPackageAllocation
query := s.db.WithContext(ctx).Where("shop_id = ? AND status = 1", shopID)

View File

@@ -15,7 +15,7 @@
- 所有字段新增使用 `NOT NULL DEFAULT` 确保存量数据兼容
- 数据库迁移可在线执行,不需停机
- 旧接口删除后 bootstrap、路由注册、文档生成器必须同步清理否则编译失败
- 本提案不涉及任何新 API 接口,纯粹是模型/字段/BUG 修复
- 本提案新增 1 个后台接口:`PATCH /api/admin/packages/:id/retail-price`(代理修改自己的零售价)
## Goals / Non-Goals
@@ -36,8 +36,8 @@
- 不实现任何客户端 API 接口(属于提案 1~3
- 不实现 ExchangeOrder 换货模型(属于提案 3
- 不实现 PersonalCustomerOpenID 模型(属于提案 1
- 不修改后台管理界面或 Admin API
- 不新增 API 路由
- 不修改后台管理界面
- `PATCH /api/admin/packages/:id/retail-price` 外,不新增其他 API 路由
- 不实现 asset_status 的状态流转逻辑(仅新增字段,流转逻辑在后续提案中实现)
## Decisions

View File

@@ -10,7 +10,7 @@
### 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-1 代理零售价修复**`ShopPackageAllocation` 新增 `retail_price` 字段;`GetPurchasePrice()` 改为代理渠道查 `allocation.retail_price`、平台渠道用 `Package.SuggestedRetailPrice``validatePackages()` 内部价格累加同步修正;新增 cost_price 分配锁定规则(存在下级分配时禁止修改 cost_price`BatchUpdatePricing` 接口支持成本价批量调整;新增独立接口 `PATCH /api/admin/packages/:id/retail-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`,确保充值单状态变更和钱包入账原子完成
@@ -37,7 +37,7 @@
- `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`
- `agent-retail-price`代理零售价管理。ShopPackageAllocation 新增 `retail_price` 字段,支持代理设定面向终端客户的零售价,约束 `retail_price >= cost_price`cost_price 分配锁定规则。新增独立接口 `PATCH /api/admin/packages/:id/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 注册、文档生成器引用的清理
@@ -45,7 +45,7 @@
- `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`
- `batch-pricing``BatchUpdatePricing` 接口支持 `cost_price` 批量调整;保留 `cost_price` 锁定校验(存在下级分配时不可修改)
- `one-time-commission-trigger`:触发条件增加 `order.Source == "client"` 判断,确保仅客户端个人客户购买才触发
- `wallet-recharge``HandlePaymentCallback` 事务一致性修复Store 方法支持传入事务 `tx`
- `iot-order`Order 模型新增 `source`(订单来源)和 `generation`(世代)字段;`CreateAdminOrder()` 创建订单时从资产快照当前 `generation` 写入订单(而非依赖默认值 1
@@ -57,8 +57,8 @@
## 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`(新增实名链接字段)
- **Service 文件**`purchase_validation/service.go`(价格计算)、`commission_calculation/service.go`(佣金触发)、`recharge/service.go`(回调事务)、`shop_package_batch_pricing/service.go`仅成本价批量调价 + 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.go` Handler新增 `PATCH /packages/:id/retail-price`)、`package_dto.go``PackageResponse` 新增 `retail_price` + 新增更新零售价请求 DTO)、`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 个索引变更

View File

@@ -22,18 +22,12 @@
### Requirement: 零售价约束规则
系统 MUST 强制校验:`retail_price >= cost_price``retail_price <= suggested_retail_price * 2`
系统 MUST 强制校验:`retail_price >= cost_price`
#### Scenario: 零售价低于成本价
- **WHEN** 代理设置 `retail_price < cost_price`
- **THEN** 系统 MUST 拒绝保存并返回价格约束错误
#### Scenario: 零售价超过建议价两倍
- **WHEN** 代理设置 `retail_price > suggested_retail_price * 2`
- **THEN** 系统 MUST 拒绝保存并返回价格约束错误
---
### Requirement: 成本价分配锁定规则
当某分配存在下级分配记录时,系统 MUST 禁止修改该分配的 `cost_price`
@@ -46,7 +40,7 @@
### Requirement: 代理零售价可调与存量迁移
系统 MUST 允许代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`
系统 MUST 提供独立接口 `PATCH /api/admin/packages/:id/retail-price`代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`
#### Scenario: 代理调整自己的零售价
- **WHEN** 代理修改自己分配记录的 `retail_price` 且满足价格约束

View File

@@ -1,6 +1,6 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 资产充值表结构变更
### Requirement: 资产充值记录扩展字段(操作人与代际)
系统 MUST 在 `tb_asset_recharge_record` 新增以下字段:

View File

@@ -1,4 +1,4 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 设备实体定义

View File

@@ -1,8 +1,8 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: IoT 卡实体定义
### Requirement: IoT 卡资产生命周期字段
系统 SHALL 在 `IotCard` 模型新增以下字段:
系统 SHALL 在 `IotCard` 模型新增以下资产生命周期追踪字段:
- `asset_status int NOT NULL DEFAULT 1`
- `generation int NOT NULL DEFAULT 1`

View File

@@ -1,8 +1,8 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 订单实体定义
### Requirement: 订单来源与代际字段
系统 SHALL 定义订单Order实体新增来源与代际字段:
系统 SHALL 订单Order实体新增来源与代际字段
- `source varchar(20) NOT NULL DEFAULT 'admin'`,取值 `admin/client`
- `generation int NOT NULL DEFAULT 1`

View File

@@ -1,8 +1,8 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 获取购买价格
### Requirement: 代理渠道购买价格规则
系统 MUST 根据购买渠道返回正确的购买价格。
系统 MUST 根据购买渠道返回正确的购买价格:代理渠道使用 `allocation.retail_price`,平台渠道使用 `Package.SuggestedRetailPrice`
#### Scenario: 代理渠道使用分配零售价
- **WHEN** 客户通过代理渠道购买套餐

View File

@@ -1,4 +1,4 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 微信标识索引策略

View File

@@ -1,10 +1,8 @@
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 充值支付回调处理
### Requirement: 充值回调事务一致性
系统 SHALL 处理微信和支付宝的支付回调,验证签名,更新充值订单状态,增加钱包余额
关键一致性修复:`HandlePaymentCallback` 内的 `UpdateStatusWithOptimisticLock``UpdatePaymentInfo` MUST 使用同一个事务内 `tx` 执行。
`HandlePaymentCallback` 内的 `UpdateStatusWithOptimisticLock``UpdatePaymentInfo` MUST 使用同一个事务内 `tx` 执行,保证充值状态与支付信息的原子性
#### Scenario: 回调处理中状态更新与支付信息更新同事务
- **WHEN** 收到支付成功回调并进入 `HandlePaymentCallback`

View File

@@ -42,11 +42,15 @@
## 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` 计算
- [x] 5.1 修改 `internal/model/dto/shop_package_batch_pricing_dto.go``BatchUpdateCostPriceRequest`删除 `PricingTarget` 字段,批量调价接口仅保留成本价路径
- [x] 5.2 修改 `internal/service/shop_package_batch_pricing/service.go``BatchUpdatePricing()` 方法:删除 `retail_price` 分支与默认分流逻辑,仅保留 `cost_price` 调整(包含 4.5 的锁定检查)
- [x] 5.3 `internal/model/dto/package_dto.go` 新增 `UpdateRetailPriceRequest``UpdateRetailPriceParams`,用于代理修改自己零售价
- [x] 5.4 `internal/store/postgres/shop_package_allocation_store.go` 新增 `UpdateRetailPrice(ctx context.Context, id uint, retailPrice int64, updater uint) error`
- [x] 5.5 `internal/service/package/service.go` 新增 `UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) error`:仅代理可调用、校验 `retail_price >= cost_price`
- [x] 5.6 在 `internal/handler/admin/package.go` 新增 `UpdateRetailPrice`,并在 `internal/routes/package.go` 注册 `PATCH /api/admin/packages/:id/retail-price`
- [x] 5.7 修改 `internal/model/dto/package_dto.go``PackageResponse` 结构体:新增 `RetailPrice *int64` 字段(`json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`
- [x] 5.8 修改 `internal/service/package/service.go``toResponse()` 方法(约第 530-541 行):代理用户查询时,从 allocation 读取 `RetailPrice` 设入 `resp.RetailPrice`;同时修正 `ProfitMargin` 计算:从 `pkg.SuggestedRetailPrice - allocation.CostPrice` 改为 `allocation.RetailPrice - allocation.CostPrice`
- [x] 5.9 修改 `internal/service/package/service.go``toResponseWithAllocation()` 方法(约第 595-603 行):同 5.8,从 allocation 读取 `RetailPrice`、修正 `ProfitMargin` 计算
## 6. Carrier 管理 DTO 更新
@@ -102,6 +106,6 @@
- [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.5 验证 `BatchUpdatePricing` 接口仅支持成本价调整;并验证 `PATCH /api/admin/packages/:id/retail-price` 可供代理修改自己的零售价
- [x] 13.6 验证代理套餐列表:确认 `PackageResponse` 包含 `retail_price` 字段,`profit_margin` 计算基于 `retail_price - cost_price`
- [x] 13.7 撰写功能总结文档 `docs/client-api-data-model-fixes/功能总结.md`,记录所有变更内容

View File

@@ -1,6 +1,6 @@
# personal-customer Specification
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 个人客户登录主流程改为微信授权

View File

@@ -1,6 +1,6 @@
# wechat-official-account Specification
## MODIFIED Requirements
## ADDED Requirements
### Requirement: 微信配置源从 YAML 改为数据库动态读取

View File

@@ -0,0 +1,177 @@
# 设计文档client-core-business-api
## Context
认证系统(提案 1就绪后客户端需要一套完整业务接口以覆盖资产查询、钱包充值、套餐购买、实名跳转与设备操作。当前后台接口的 Service 层大部分能力可复用,但客户端场景存在以下关键差异:
1. 个人客户访问资源不应受 shop_id 数据权限过滤影响,需要在调用链中显式绕过。
2. 资产操作必须先做归属校验(绑定关系),避免跨客户操作。
3. 历史数据查询需要按资产当前 generation 过滤,避免展示转手前历史。
4. 支付 OpenID 必须后端查表获取,禁止客户端传入,降低伪造风险。
现有代码参考(复用/改造基线):
- `asset.Service.Resolve()``internal/service/asset/service.go:71`
- `asset.Service.Refresh()``internal/service/asset/service.go:295`
- `asset.Service.GetPackages()``internal/service/asset/service.go:347`
- `recharge.Service.GetRechargeCheck()``internal/service/recharge/service.go:168`
- `recharge.Service.Create()``internal/service/recharge/service.go:83`
- `order.Service.CreateH5Order()``internal/service/order/service.go:632`
- `order.Service.checkForceRechargeRequirement()``internal/service/order/service.go:2216`
- `order.Service.WechatPayJSAPI()``internal/service/order/service.go:2095`
- `purchaseValidation.ValidateCardPurchase()``internal/service/purchase_validation/service.go:44`
- Gateway 设备能力:`internal/gateway/device.go:41-67`
- Gateway 实名链接:`internal/gateway/flow_card.go:44`
## Goals
本次变更目标是交付 `/api/c/v1/` 下 18 个客户端业务端点,覆盖 5 个模块:
- 模块 B资产信息B1~B4
- 模块 C钱包与充值C1~C5
- 模块 D套餐购买D1~D3
- 模块 E实名跳转E1
- 模块 F设备能力F1~F5
## Non-Goals
- 不改动后台管理端 API 行为与路由。
- 不引入或改造 exchange交易所体系。
- 不重做既有支付网关对接协议,仅在客户端入口补齐调用与安全约束。
## Decisions
### 1) Handler 组织
客户端 Handler 按模块拆分为 5 个文件,统一放在 `internal/handler/app/`
- `client_asset.go`
- `client_wallet.go`
- `client_order.go`
- `client_realname.go`
- `client_device.go`
这样可保持模块边界清晰,减少单文件复杂度,便于后续迭代。
### 2) Service 复用策略
- 直接复用B1/B3/B4/C1/C2/C3 复用现有 Service 能力(补充客户端上下文约束)。
- 新增逻辑B2 需新增“渠道价 + 加油包前置校验 + 不可售过滤 + 价格排序”逻辑。
- 新建 `client_order` ServiceC4/D1 引入客户端订单编排,复用 `order/recharge` 的底层能力但增加客户端专属流程控制。
### 3) 数据权限绕过
客户端调用 `asset/wallet` 等后台复用 Service 时,统一使用 `gorm.SkipDataPermission(ctx)`,绕过 shop_id 自动过滤,避免个人客户因非店铺主体被误拦截。
### 4) 归属校验方案
所有涉及资产操作接口统一前置:
- 查询 `PersonalCustomerDevice` 条件:`customer_id = 当前登录客户``virtual_no = 资产虚拟号`
- 未命中即返回 403`无权限操作该资产或资源不存在`
该规则覆盖 B/C/D/E/F 全模块写操作与敏感读操作。
### 5) Generation 过滤
客户端历史查询统一附加条件:`WHERE generation = 资产当前 generation`,适用于订单、充值、套餐历史。
- 客户端:必须过滤
- 后台:不加该过滤(保留全量视图)
### 6) OpenID 安全规范 + 微信支付 SDK 实例选择
支付相关接口C4/D1所需 OpenID 必须由后端按 `customer_id + app_type` 查询 `PersonalCustomerOpenID`
- 客户端请求体禁止携带 `openid`,仅传 `app_type``official_account``miniapp`
- 缺失时返回 `OPENID_NOT_FOUND`
**微信支付 SDK 实例选择逻辑**
支付时需根据 `app_type` 创建不同的 `PaymentService` 实例,因为微信 JSAPI 支付绑定的 AppID 必须与用户 OpenID 所属的应用一致:
```
客户端传入 app_type
├─ "official_account" → 用 WechatConfig.oa_app_id 创建 Payment 实例
│ → 查 PersonalCustomerOpenID WHERE app_id=oa_app_id 获取 openid
└─ "miniapp" → 用 WechatConfig.miniapp_app_id 创建 Payment 实例
→ 查 PersonalCustomerOpenID WHERE app_id=miniapp_app_id 获取 openid
```
**使用的现有 SDK 方法**`pkg/wechat/payment.go`,不需要修改):
| 方法 | 签名 | 用途 |
|------|------|------|
| `CreateJSAPIOrder` | `(ctx, orderNo, description, openID string, amount int) (*JSAPIPayResult, error)` | 公众号/小程序内拉起支付,返回 `prepay_id` + `PayConfig`(可直接传给前端 `wx.requestPayment` |
| `HandlePaymentNotify` | `(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error)` | 支付回调验签+解密,回调函数接收 `*PaymentNotifyResult` |
| `QueryOrder` | `(ctx, orderNo string) (*OrderInfo, error)` | 主动查询订单状态 |
| `CloseOrder` | `(ctx, orderNo string) error` | 关闭未支付订单 |
**SDK 实例创建**(使用提案 1 新增的工厂函数):
```go
// 在 client_order Service 中:
config, _ := s.wechatConfigService.GetActiveConfig(ctx) // 从 DB/Redis 缓存
appID := config.OaAppID
if req.AppType == "miniapp" {
appID = config.MiniappAppID
}
paymentApp, _ := wechat.NewPaymentAppFromConfig(config, appID, cache, logger)
paymentService := wechat.NewPaymentService(paymentApp, logger)
// 调用 paymentService.CreateJSAPIOrder(ctx, orderNo, desc, openID, amount)
```
**注意**`CreateH5Order`(外部浏览器 H5 支付)在客户端场景中**不使用**——客户端始终在微信内(公众号 H5 或小程序),一律走 JSAPI 支付。
### 7) 强充两阶段设计
强充场景采用“同步入账 + 异步自动购买”两阶段:
- 第一阶段(同步事务内):
1. 钱入钱包
2. 更新充值记录状态
3. 更新累计充值/首充状态
- 第二阶段(异步 Asynq
1. 从钱包扣款
2. 创建套餐订单
3. 激活套餐
`AssetRechargeRecord` 新增 `auto_purchase_status` 字段追踪异步状态pending/success/failed
### 8) D1 返回结构分流
`POST /api/c/v1/orders/create` 根据是否触发强充返回不同结构:
- `order_type = "package"`:直接返回 `order + pay_config`
- `order_type = "recharge"`:返回 `recharge + pay_config + linked_package_info`
前端据 `order_type` 决定支付结果页与文案。
### 9) 实名闭环说明
运营商实名为外部流程,无平台回调。用户完成实名后需主动触发 B4 刷新资产状态,再重新发起购买流程。
## Risks / Trade-offs
1. **强充异步失败风险**:第二阶段失败会导致“钱已到账、套餐未生效”的中间态。权衡后采用可重试 + `auto_purchase_status=failed` + 用户可手动购买的降级方案。
2. **Gateway 超时风险**B4/F2/F3/F4/F5/E1(gateway) 依赖外部网关,网络抖动可能放大请求延迟。需统一超时、重试与可观测日志。
3. **OpenID 缺失风险**:用户未完成公众号授权时无法拉起支付。需明确错误码 `OPENID_NOT_FOUND` 并引导重新授权。
4. **Generation 不一致风险**:资产转手或切换后,若查询未按 generation 过滤会出现历史串数据。客户端侧强制过滤会增加查询条件复杂度,但可换取数据隔离正确性。
5. **服务复用边界风险**:复用后台 Service 可加速交付,但若遗漏客户端前置条件(归属、权限绕过),会造成逻辑缺口。需在 Handler 层统一封装公共校验。
## Migration Plan
数据库迁移新增字段:
- 表:`tb_asset_recharge_record`
- 字段:`auto_purchase_status`(建议 `varchar(20)`,默认 `pending`
- 用途:记录强充回调后二阶段自动购买状态
迁移步骤:
1. 新增迁移文件up/down
2. 执行迁移并确认版本无 dirty。
3. 更新对应 Model 与常量枚举。
4. 回调逻辑写入状态流转:`pending -> success/failed`

View File

@@ -0,0 +1,175 @@
## Why
认证系统就绪后(提案 1客户端需要完整的业务接口来支撑核心使用场景查看资产信息、购买套餐、钱包充值、查看订单、实名认证跳转、设备操作。本提案覆盖客户端**全部 15 个业务接口**,是 C 端体验的核心支撑。
其中**套餐购买含强充两阶段处理**是最复杂的接口,涉及强充判断 → 微信支付 → 充值回调 → 异步套餐购买的完整链路,需要特别严谨的设计。
**前置依赖**:提案 0数据模型修复、提案 1认证系统
## What Changes
### 模块 B资产信息4 个接口)
- **B1 资产基本信息** `GET /api/c/v1/asset/info?identifier=xxx`:复用 `asset.Service.Resolve()`,个人客户调用不走 shop_id 数据权限过滤
- **B2 可购买套餐列表** `GET /api/c/v1/asset/packages?identifier=xxx`:按渠道区分价格(代理→`allocation.retail_price`,平台→`SuggestedRetailPrice`);过滤条件包含 `Package.status``shelf_status`、加油包前置校验;按价格升序排序
- **B3 历史套餐列表** `GET /api/c/v1/asset/package-history?identifier=xxx`:按资产当前 `generation` 过滤,复用 `dto.AssetPackageResponse`
- **B4 手动刷新** `POST /api/c/v1/asset/refresh`:卡类型调 Gateway 刷新;设备类型有 Redis 冷却时间
### 模块 C钱包与充值5 个接口)
- **C1 钱包详情** `GET /api/c/v1/wallet/detail?identifier=xxx`:不存在则自动创建空钱包
- **C2 钱包流水列表** `GET /api/c/v1/wallet/transactions?identifier=xxx`:通过 wallet_id 天然隔离(不需 generation 过滤),支持 transaction_type / 时间范围筛选
- **C3 充值预检** `GET /api/c/v1/wallet/recharge-check?identifier=xxx`:复用 `recharge.Service.GetRechargeCheck()`,返回是否需要强充、强充金额、触发类型
- **C4 创建充值订单** `POST /api/c/v1/wallet/recharge`客户端仅支持微信支付OpenID 由后端查表获取(安全规范);`operator_type=personal_customer``generation` 写时快照;拉起 JSAPI 支付
- **C5 充值订单列表** `GET /api/c/v1/wallet/recharges?identifier=xxx`:按 `generation` 过滤
### 模块 D套餐购买3 个接口,含核心强充流程)
- **D1 创建套餐购买订单** `POST /api/c/v1/orders/create`**核心接口**。含资产归属校验、套餐校验、实名校验、强充两阶段处理、幂等性保证
- **D2 套餐订单列表** `GET /api/c/v1/orders?identifier=xxx`:按 `generation` 过滤
- **D3 套餐订单详情** `GET /api/c/v1/orders/:id`:归属校验(通过资产虚拟号匹配 PersonalCustomerDevice
### 模块 E实名认证1 个接口)
- **E1 获取实名跳转链接** `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`:两个入口(购买拦截 / 设备卡列表主动选择三种模式none/template/gateway
### 模块 F设备能力5 个接口)
- **F1 设备卡列表** `GET /api/c/v1/device/cards?identifier=xxx`:从 CMP 数据库查,不调 Gateway
- **F2 设备重启** `POST /api/c/v1/device/reboot`
- **F3 恢复出厂** `POST /api/c/v1/device/factory-reset`
- **F4 设置 WiFi** `POST /api/c/v1/device/wifi`:注意 Gateway WiFiReq 的 cardNo 字段实际传入设备 IMEI
- **F5 切卡** `POST /api/c/v1/device/switch-card`
### D1 套餐购买核心流程(含强充两阶段)
```
客户端发起 POST /api/c/v1/orders/create
① 解析标识符 → card/device + asset_type + asset_id
② 资产归属校验
查 PersonalCustomerDevice WHERE customer_id=? AND virtual_no=?
未绑定 → 403 "无权操作该资产"
③ 套餐购买校验
调 purchaseValidationService.ValidateCardPurchase/ValidateDevicePurchase
检查: series_id、Package.status、shelf_status、加油包前置条件
④ 实名校验
套餐 enable_realname_activation=true 且卡 real_name_status=0
→ 返回 { code: NEED_REALNAME, need_realname: true }
⑤ OpenID 查询
根据 customer_id + app_type 从 PersonalCustomerOpenID 查询 openid
找不到 → 返回 { code: OPENID_NOT_FOUND }
⑥ 幂等性检查Redis 业务键 + 分布式锁)
⑦ 强充检查
调 checkForceRechargeRequirement()
├─── 不需要强充 ──────────────────────────────┐
│ │
│ ⑧A 创建 Order │
│ (source="client", generation=当前) │
│ 拉起微信 JSAPI 支付 │
│ → 返回 { order_type: "package", │
│ order, pay_config } │
│ │
└─── 需要强充 ────────────────────────┐ │
│ │ │
▼ │ │
pay_amount = max(force_amount, │ │
package_total_price) │ │
│ │ │
▼ │ │
⑧B 创建 AssetRechargeRecord │ │
(linked_package_ids=package_ids, │ │
generation=当前) │ │
拉起微信 JSAPI 支付 │ │
→ 返回 { order_type: "recharge", │ │
recharge, pay_config, │ │
linked_package_info } │ │
│ │
═══════════════════════════════════════════╧════════╧═══
强充支付成功后的两阶段回调处理:
微信支付成功 → 充值回调
第一阶段(同步,事务内):
├── 1. 钱入钱包(余额增加)
├── 2. 更新充值单状态为已完成
├── 3. 更新累计/首充状态
└── 4. 检查一次性佣金触发
第二阶段异步Asynq 任务):
├── 5. 入队 AutoPurchaseAfterRecharge(recharge_record_id)
├── 6. 异步执行:
│ ├── a. 从钱包扣款payment_method=wallet
│ ├── b. 创建 Ordersource="client", generation=当前)
│ └── c. 激活套餐
└── 7. 失败处理:
├── a. Asynq 自动重试(最多 3 次)
├── b. 全部失败 → 标记 auto_purchase_status="failed"
└── c. 钱已在钱包中,用户可手动操作
```
### 实名跳转流程
```
前端调用 GET /api/c/v1/realname/link?identifier=xxx[&iccid=yyy]
解析标识符 → 确定目标卡:
├── 直接是卡 → 用该卡
├── 是设备 + 传了 iccid → 查该 iccid 对应的卡
└── 是设备 + 没传 iccid → 查 DeviceSimBinding 中 isActive=1 的卡
检查 card.real_name_status == 1?
├── YES → "该卡已完成实名"
└── NO
查 Carrier WHERE id=card.carrier_id → 获取 realname_link_type:
├── 'none' → "该运营商暂不支持在线实名"
├── 'template' → 替换占位符 {iccid}/{msisdn}/{virtual_no} → 返回 URL
└── 'gateway' → 调 gateway.GetRealnameLink(card.ICCID) → 返回 URL
```
## Capabilities
### New Capabilities
- `client-asset-info`客户端资产信息查询B1、可购买套餐列表B2含渠道价格、加油包校验、上下架过滤、历史套餐列表B3含 generation 过滤、手动刷新B4
- `client-wallet-recharge`客户端钱包详情C1、流水列表C2、充值预检C3、创建充值订单C4含 OpenID 安全规范、operator_type、generation 快照、充值订单列表C5
- `client-order-purchase`套餐购买订单创建D1含归属校验、实名校验、强充两阶段、幂等性、订单列表D2、订单详情D3、强充回调异步购买AutoPurchaseAfterRecharge Asynq 任务)
- `client-realname-link`实名跳转链接E1三种模式、两个入口、设备多卡选择
- `client-device-capability`设备卡列表F1、重启F2、恢复出厂F3、WiFi 设置F4、切卡F5
### Modified Capabilities
- `asset-resolve`Resolve 方法增加"无数据权限过滤"的客户端调用入口
- `wallet-recharge`:充值回调增加两阶段处理——同步入账 + 异步自动购买AssetRechargeRecord 新增 `auto_purchase_status` 字段跟踪异步购买状态
- `package-purchase-validation`增加客户端场景的归属校验PersonalCustomerDevice和实名校验拦截
- `iot-order`Order 新增客户端创建路径source="client"),支持 generation 写入
- `force-recharge-check`:强充检查结果输出给客户端,支持前端提示强充金额和套餐价格拆分
## Impact
- **新增文件**`internal/handler/app/client_asset.go``client_wallet.go``client_order.go``client_realname.go``client_device.go`5 个 Handler`internal/service/client_order/service.go`(客户端订单 Service新增 Asynq 任务 `AutoPurchaseAfterRecharge`;新增 DTO 文件;常量和错误码
- **修改文件**`internal/service/order/service.go`(提取强充逻辑供客户端复用);`internal/service/recharge/service.go`(充值回调增加两阶段处理);`internal/service/asset/service.go`(增加无数据权限调用方式);`internal/routes/personal.go`(新增客户端业务路由);`internal/bootstrap/`(注册新模块);`cmd/api/docs.go` + `cmd/gendocs/main.go`(文档生成器)
- **新增 API 路由**`/api/c/v1/` 下 18 个端点
- **数据库变更**AssetRechargeRecord 新增 `auto_purchase_status` 字段
- **新增 Asynq 任务类型**`task:auto_purchase_after_recharge`

View File

@@ -0,0 +1,41 @@
# Capability: 客户端资产信息
## ADDED Requirements
### Requirement: B1 资产基本信息查询接口
系统 SHALL 提供 `GET /api/c/v1/asset/info?identifier=xxx`,并且 MUST 要求个人客户认证C 端 Token。接口 MUST 复用 `asset.Service.Resolve()` 解析标识符,并在调用时使用 `gorm.SkipDataPermission(ctx)` 以绕过 shop_id 数据权限过滤。请求参数 MUST 包含 `identifier`ICCID、虚拟号、设备号之一。响应体 SHALL 返回 `asset_type``asset_id``identifier``virtual_no``status``real_name_status``carrier``generation``wallet_balance`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_NOT_FOUND/资产不存在`
#### Scenario: 个人客户查询已绑定资产
- **WHEN** 客户携带有效 Token 调用 `GET /api/c/v1/asset/info?identifier=8986xxxx` 且资产已绑定到本人
- **THEN** 系统返回 200包含资产基础信息与当前 generation
---
### Requirement: B2 可购买套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/packages?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验通过后返回可购买套餐列表。价格规则 MUST 为:代理渠道取 `allocation.retail_price`,平台渠道取 `Package.SuggestedRetailPrice`。过滤规则 MUST 同时满足:`Package.status=1``shelf_status` 可售、加油包前置主套餐条件成立、`retail_price >= cost_price`。结果 MUST 按展示价格升序。响应体 SHALL 包含 `packages[]`,每项至少含 `package_id``package_name``package_type``retail_price``cost_price``validity``is_addon`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``PACKAGE_NOT_AVAILABLE/当前无可购买套餐`
#### Scenario: 代理渠道价格与过滤生效
- **WHEN** 客户查询可购套餐且其销售链路为代理渠道,部分套餐存在 `retail_price < cost_price`
- **THEN** 系统仅返回可售且满足价格约束的套餐,并按价格升序输出
---
### Requirement: B3 历史套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/package-history?identifier=xxx&page=1&page_size=20`,并且 MUST 要求个人客户认证。接口 MUST 基于标识符解析资产并进行归属校验。查询条件 MUST 自动追加 `generation = 资产当前generation`。请求参数 SHALL 支持 `page``page_size`(默认 20最大 100。响应体 SHALL 返回 `list[]``total``page``page_size`,列表项复用 `dto.AssetPackageResponse` 结构。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 转手后历史隔离
- **WHEN** 资产已发生转手且存在历史套餐记录
- **THEN** 系统只返回当前 generation 的记录,不返回旧 generation 数据
---
### Requirement: B4 手动刷新接口
系统 SHALL 提供 `POST /api/c/v1/asset/refresh`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。当资产为卡时 MUST 调用 Gateway 刷新卡信息;当资产为设备时 MUST 先检查 Redis 冷却窗口,再对设备下卡执行批量刷新。响应体 SHALL 返回 `refresh_type``card`/`device`)、`accepted``cooldown_seconds`(设备场景)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``TOO_MANY_REQUESTS/刷新过于频繁,请稍后重试``GATEWAY_ERROR/网关调用失败`
#### Scenario: 设备刷新冷却拦截
- **WHEN** 客户在冷却时间内重复调用设备刷新
- **THEN** 系统返回频率限制错误并告知剩余冷却时间

View File

@@ -0,0 +1,51 @@
# Capability: 客户端设备能力
## ADDED Requirements
### Requirement: F1 设备卡列表接口
系统 SHALL 提供 `GET /api/c/v1/device/cards?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 仅允许设备类型资产调用,且设备 MUST 具备 IMEI。响应体 SHALL 返回 `cards[]`,每项至少包含:`card_id``iccid``msisdn``carrier_name``network_status``real_name_status``slot_position``is_active`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失`
#### Scenario: 返回设备绑定卡列表
- **WHEN** 客户查询已绑定设备卡列表
- **THEN** 系统返回设备下全部卡及活跃标记
---
### Requirement: F2 设备重启接口
系统 SHALL 提供 `POST /api/c/v1/device/reboot`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.RebootDevice(imei)`。响应体 SHALL 返回 `accepted=true``request_id`(如网关返回)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 设备重启成功受理
- **WHEN** 客户对合法设备发起重启
- **THEN** 系统调用网关成功并返回受理结果
---
### Requirement: F3 设备恢复出厂接口
系统 SHALL 提供 `POST /api/c/v1/device/factory-reset`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.ResetDevice(imei)`。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 恢复出厂失败返回网关错误
- **WHEN** 网关返回失败
- **THEN** 系统返回网关调用失败错误
---
### Requirement: F4 设备 WiFi 设置接口
系统 SHALL 提供 `POST /api/c/v1/device/wifi`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``ssid``password``enabled`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SetWiFi(imei, ssid, password, enabled)`。实现 MUST 将 Gateway 的 `WiFiReq.cardNo` 填充为设备 IMEI。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: WiFi 请求 cardNo 使用 IMEI
- **WHEN** 客户调用设备 WiFi 设置
- **THEN** 系统向网关发送的 `cardNo` 字段值为设备 IMEI
---
### Requirement: F5 设备切卡接口
系统 SHALL 提供 `POST /api/c/v1/device/switch-card`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``target_iccid`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SwitchCard(imei, target_iccid)`。响应体 SHALL 返回 `accepted=true``target_iccid`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 切卡成功返回目标卡号
- **WHEN** 客户请求切换到目标 ICCID 且网关执行成功
- **THEN** 系统返回 `accepted=true` 与目标 ICCID

View File

@@ -0,0 +1,46 @@
# Capability: 客户端套餐购买
## ADDED Requirements
### Requirement: D1 创建套餐购买订单接口
系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``package_ids[]``app_type`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为:
- 无强充:创建套餐订单并返回 `order_type="package"``order``pay_config`
- 需强充:创建充值单并返回 `order_type="recharge"``recharge``pay_config``linked_package_info`
响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``NEED_REALNAME/该套餐需实名认证后购买``OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权``IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交``PACKAGE_NOT_AVAILABLE/套餐不可购买`
#### Scenario: 命中强充返回 recharge 结构
- **WHEN** 客户购买套餐触发强充要求
- **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息
---
### Requirement: D2 套餐订单列表接口
系统 SHALL 提供 `GET /api/c/v1/orders?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 做归属校验并按资产当前 generation 过滤订单。请求参数 SHALL 支持 `payment_status``page``page_size`。响应体 SHALL 返回 `list[]``total``page``page_size`,列表项至少含 `order_id``order_no``total_amount``payment_status``created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 支持支付状态筛选
- **WHEN** 客户带 `payment_status=paid` 查询订单
- **THEN** 系统仅返回当前 generation 且支付状态匹配的订单
---
### Requirement: D3 套餐订单详情接口
系统 SHALL 提供 `GET /api/c/v1/orders/:id`,并且 MUST 要求个人客户认证。接口 MUST 基于订单关联资产执行归属校验(通过资产虚拟号匹配 `PersonalCustomerDevice`)。响应体 SHALL 返回订单详情、套餐明细、支付信息、状态流转时间。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ORDER_NOT_FOUND/订单不存在`
#### Scenario: 查询他人订单被拦截
- **WHEN** 客户请求不属于本人资产的订单详情
- **THEN** 系统返回 403错误消息为无权限操作该资产或资源不存在
---
### Requirement: AutoPurchaseAfterRecharge 异步任务
系统 SHALL 增加 `AutoPurchaseAfterRecharge` Asynq 任务处理强充二阶段。任务输入 MUST 包含 `recharge_record_id`。处理流程 MUST 为:从钱包扣款(`payment_method=wallet`)→ 创建套餐订单(`source="client"`、写入当前 generation→ 激活套餐。任务失败 MUST 自动重试,最大 3 次。全部失败后 MUST 将 `auto_purchase_status` 标记为 `failed`,并保留钱包余额供用户手动购买。成功时 MUST 标记为 `success`
#### Scenario: 异步任务连续失败
- **WHEN** AutoPurchaseAfterRecharge 连续执行失败且达到最大重试次数
- **THEN** 系统将充值记录 `auto_purchase_status` 更新为 `failed`

View File

@@ -0,0 +1,23 @@
# Capability: 客户端实名跳转
## ADDED Requirements
### Requirement: E1 获取实名跳转链接接口
系统 SHALL 提供 `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`,并且 MUST 要求个人客户认证。该接口 MUST 支持两类入口:购买拦截入口与设备卡列表主动入口。目标卡定位 MUST 支持三种路径:
1. 标识符直达卡:直接使用该卡
2. 标识符为设备且传 `iccid`:定位对应设备下卡
3. 标识符为设备且未传 `iccid`:定位设备当前活跃卡
`real_name_status=1` 时 MUST 返回“该卡已完成实名”错误。运营商实名模式 MUST 支持:
- `none`:不支持在线实名,直接报错
- `template`:按模板替换占位符 `{iccid}` `{msisdn}` `{virtual_no}` 返回 URL
- `gateway`:调用网关获取实名链接
响应体 SHALL 至少包含 `realname_mode``realname_url``card_info{iccid,msisdn,virtual_no}``expire_at`(可空)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``REALNAME_ALREADY_DONE/该卡已完成实名``REALNAME_NOT_SUPPORTED/该运营商暂不支持在线实名``GATEWAY_ERROR/获取实名链接失败`
#### Scenario: 设备未传 iccid 自动选活跃卡
- **WHEN** 客户传入设备标识符且不传 `iccid`
- **THEN** 系统自动选择设备活跃卡并返回实名跳转链接

View File

@@ -0,0 +1,51 @@
# Capability: 客户端钱包与充值
## ADDED Requirements
### Requirement: C1 钱包详情接口
系统 SHALL 提供 `GET /api/c/v1/wallet/detail?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 先完成资产解析与归属校验;钱包不存在时 MUST 自动创建空钱包。响应体 SHALL 包含 `wallet_id``resource_type``resource_id``balance``frozen_balance``updated_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 首次访问自动建钱包
- **WHEN** 客户查询资产钱包详情且钱包记录不存在
- **THEN** 系统自动创建钱包并返回余额 0
---
### Requirement: C2 钱包流水列表接口
系统 SHALL 提供 `GET /api/c/v1/wallet/transactions?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 通过归属校验解析出唯一 `wallet_id` 后查询流水,实现天然隔离。请求参数 SHALL 支持 `transaction_type``start_time``end_time``page``page_size`。响应体 SHALL 包含 `list[]``total``page``page_size`,每条记录至少含 `transaction_id``type``amount``balance_after``created_at``remark`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: wallet_id 隔离生效
- **WHEN** 客户查询某资产流水
- **THEN** 系统仅返回该资产钱包对应流水,不返回其他钱包数据
---
### Requirement: C3 充值预检接口
系统 SHALL 提供 `GET /api/c/v1/wallet/recharge-check?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 复用 `recharge.Service.GetRechargeCheck()` 计算强充规则。响应体 SHALL 包含 `need_force_recharge``force_recharge_amount``trigger_type``min_amount``max_amount``message`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 返回强充预检结果
- **WHEN** 资产命中强充规则
- **THEN** 系统返回 `need_force_recharge=true` 与对应强充金额和触发类型
---
### Requirement: C4 创建充值订单接口
系统 SHALL 提供 `POST /api/c/v1/wallet/recharge`,并且 MUST 要求个人客户认证。请求体 MUST 包含:`identifier``amount`100~10000000 分)、`payment_method=wechat``app_type`。接口 MUST 禁止客户端传入 OpenID并由后端按 `customer_id + app_type` 查询 OpenID。订单创建时 MUST 写入:`operator_type=personal_customer` 与资产当前 `generation` 快照。响应体 SHALL 返回 `recharge``pay_config`,其中 `recharge` 至少含 `recharge_id``recharge_no``amount``status``pay_config` 为微信 JSAPI 拉起参数。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权``FORBIDDEN/无权限操作该资产或资源不存在``PAYMENT_NOT_SUPPORTED/仅支持微信支付`
#### Scenario: 后端查 OpenID 并返回支付参数
- **WHEN** 客户传入合法参数且后端成功查询到 OpenID
- **THEN** 系统创建充值单并返回 `recharge + pay_config`
---
### Requirement: C5 充值订单列表接口
系统 SHALL 提供 `GET /api/c/v1/wallet/recharges?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验后按资产当前 generation 过滤充值记录。请求参数 SHALL 支持 `status``page``page_size`。响应体 SHALL 返回 `list[]``total``page``page_size`,每项至少含 `recharge_id``recharge_no``amount``status``payment_method``created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: generation 过滤充值历史
- **WHEN** 资产存在多代充值记录
- **THEN** 系统仅返回当前 generation 对应的充值记录

View File

@@ -0,0 +1,21 @@
# Capability: 强充预检
## MODIFIED Requirements
### Requirement: 强充检查结果对客户端透出
系统 MUST 将强充检查结果输出给客户端接口(充值预检与购买预检),用于前端明确展示支付拆分。输出字段 SHALL 至少包含:`need_force_recharge``force_recharge_amount``trigger_type``total_package_amount``actual_payment``wallet_credit``message`。若无强充,`need_force_recharge=false``actual_payment=total_package_amount`
#### Scenario: 客户端购买预检命中强充
- **WHEN** 客户端调用购买预检且命中强充规则
- **THEN** 系统返回强充金额、实际支付金额和钱包入账金额
---
### Requirement: 前端展示套餐价与强充金额拆分
系统 SHALL 在强充场景提供可直接渲染的拆分语义套餐总价、需支付金额、充值入钱包金额并给出中文提示文案。当前端调用客户端下单接口D1若命中强充 MUST 返回 `order_type="recharge"``linked_package_info`,以便前端保持与预检展示一致。
#### Scenario: 套餐价低于强充金额
- **WHEN** 套餐总价 5000 分,强充金额 10000 分
- **THEN** 预检返回 `actual_payment=10000``wallet_credit=5000`、提示文案可用于前端直接展示

View File

@@ -0,0 +1,33 @@
# Capability: 钱包充值
## MODIFIED Requirements
### Requirement: 充值回调采用两阶段处理
系统 MUST 将强充场景的充值回调改为两阶段:第一阶段同步事务内完成入账与状态更新,第二阶段异步执行自动购买。第一阶段 SHALL 包含:更新充值状态、钱包加款、累计充值更新、首充佣金判断。第二阶段 SHALL 通过 Asynq 任务执行钱包扣款、创建套餐订单、激活套餐。该改造适用于客户端触发的强充路径,且不影响非强充充值主流程。
#### Scenario: 强充回调同步入账成功并触发异步任务
- **WHEN** 强充充值支付回调验签成功
- **THEN** 系统在事务内完成钱包入账与充值单状态更新
- **AND** 入队 `AutoPurchaseAfterRecharge` 异步任务
---
### Requirement: 充值记录新增 auto_purchase_status 状态追踪
系统 MUST 在 `AssetRechargeRecord` 增加 `auto_purchase_status` 字段,用于追踪强充后二阶段自动购买状态。状态集 SHALL 至少包括:`pending``success``failed`。创建强充充值单时 MUST 初始化为 `pending`;异步购买成功后 MUST 更新为 `success`;重试耗尽后 MUST 更新为 `failed`
#### Scenario: 强充充值单创建时默认 pending
- **WHEN** 系统创建与套餐联动的强充充值单
- **THEN** 充值记录 `auto_purchase_status` 初始化为 `pending`
---
### Requirement: 异步自动购买失败处理规范
系统 SHALL 对 `AutoPurchaseAfterRecharge` 失败场景执行统一处理:任务 MUST 自动重试(最多 3 次);全部失败后 MUST 记录错误日志并将 `auto_purchase_status` 置为 `failed`;用户资金 SHALL 保留在钱包中,允许后续手动购买,不得回滚已成功的充值入账。
#### Scenario: 异步任务最终失败
- **WHEN** 自动购买任务连续失败并达到最大重试次数
- **THEN** 系统将 `auto_purchase_status` 标记为 `failed`
- **AND** 钱包余额保持可用,用户可手动下单

View File

@@ -0,0 +1,70 @@
## 1. 常量与错误码
- [x] 1.1 在 `pkg/constants/constants.go` 增加 `auto_purchase_status` 状态常量pending/success/failed
- [x] 1.2 在 `pkg/errors/codes.go` 增加 `NEED_REALNAME``OPENID_NOT_FOUND` 错误码并补充中文消息
- [x] 1.3 在 `pkg/constants/redis.go` 增加客户端购买幂等键与锁键生成函数
## 2. DTO 定义
- [x] 2.1 新增资产模块 DTOB1/B2/B3/B4 请求与响应结构(含 description/validate 标签)
- [x] 2.2 新增钱包充值模块 DTOC1~C5 请求与响应结构(含支付返回 `pay_config`
- [x] 2.3 新增订单模块 DTOD1~D3 请求与响应结构(含 `order_type` 分流结构)
- [x] 2.4 新增实名与设备模块 DTOE1、F1~F5 请求与响应结构
## 3. 模型变更与迁移
- [x] 3.1 在 `internal/model/asset_recharge_record.go` 增加 `auto_purchase_status` 字段与中文注释
- [x] 3.2 创建迁移文件为 `tb_asset_recharge_record` 添加 `auto_purchase_status` 字段up/down
- [x] 3.3 执行迁移并确认版本状态正常(非 dirty
## 4. 资产信息模块B1~B4
- [x] 4.1 新建 `internal/handler/app/client_asset.go` 并实现 B1~B4 路由处理
- [x] 4.2 抽取公共方法 `resolveAssetFromIdentifier`(解析标识符 + 归属校验 + 权限绕过上下文)
- [x] 4.3 实现 B2 渠道价格计算、加油包前置校验、上下架过滤与价格升序
- [x] 4.4 实现 B4 刷新分流:卡走 Gateway、设备走 Redis 冷却 + 批量刷新
## 5. 钱包与充值模块C1~C5
- [x] 5.1 新建 `internal/handler/app/client_wallet.go` 并实现 C1~C5 路由处理
- [x] 5.2 实现 C1 钱包不存在自动创建逻辑与 C2 wallet_id 隔离查询
- [x] 5.3 复用并接入 C3 强充预检返回结构
- [x] 5.4 实现 C4 创建充值订单:根据 `app_type` 查 PersonalCustomerOpenID 获取 openid → 根据 `app_type` 选择 AppID`official_account``oa_app_id``miniapp``miniapp_app_id`)→ 调用提案 1 新增的 `wechat.NewPaymentAppFromConfig(config, appID)` 创建支付实例 → 调用现有 `PaymentService.CreateJSAPIOrder(orderNo, desc, openID, amount)` 拉起支付 → 设置 `operator_type=personal_customer`、写入 `generation` 快照
- [x] 5.5 实现 C5 充值记录 generation 过滤查询
## 6. 套餐购买模块D1~D3
- [x] 6.1 新建 `internal/handler/app/client_order.go` 并实现 D1~D3 路由处理
- [x] 6.2 新建 `internal/service/client_order/service.go` 编排 D1 全流程(归属/套餐/实名/OpenID/幂等/强充分流)。支付调用链:根据 `app_type` 选择 AppID → `wechat.NewPaymentAppFromConfig(config, appID)``PaymentService.CreateJSAPIOrder()` → 返回 `pay_config` 给前端。**客户端一律走 JSAPI 支付(微信内环境),不使用 H5 支付**
- [x] 6.3 实现强充两阶段:同步入账 + 异步自动购买Asynq 入队)。注意第二阶段自动购买创建的订单使用 `payment_method=wallet`(钱包扣款),不涉及微信支付
- [x] 6.4 新增 `AutoPurchaseAfterRecharge` 任务处理器(钱包扣款→创建订单→激活套餐,失败重试 3 次)
- [x] 6.5 实现 D1 `order_type` 双结构返回package/recharge
- [x] 6.6 实现 D2/D3 generation 与归属校验约束
## 7. 实名跳转E1
- [x] 7.1 新建 `internal/handler/app/client_realname.go` 并实现 E1 接口
- [x] 7.2 实现目标卡定位三路径(直接卡/设备+iccid/设备活跃卡)
- [x] 7.3 实现实名模式三分支none/template/gateway与模板占位符替换
## 8. 设备能力F1~F5
- [x] 8.1 新建 `internal/handler/app/client_device.go` 并实现 F1~F5 路由处理
- [x] 8.2 实现通用前置校验(必须设备类型且 IMEI 非空)
- [x] 8.3 对接 Gateway`RebootDevice``ResetDevice``SetWiFi``SwitchCard`
- [x] 8.4 实现 F4 特殊映射:`WiFiReq.cardNo = 设备IMEI`
## 9. 路由注册与文档
- [x] 9.1 在 `internal/bootstrap/types.go` 增加客户端业务 Handler 字段
- [x] 9.2 在 `internal/bootstrap/handlers.go` 完成客户端业务 Handler 实例化
- [x] 9.3 在 `internal/routes/personal.go` 注册 `/api/c/v1/` 18 个端点(使用 `Register()`
- [x] 9.4 在 `cmd/api/docs.go` 注册新增 Handler 供文档生成器使用
- [x] 9.5 在 `cmd/gendocs/main.go` 注册新增 Handler 并生成 OpenAPI 文档
## 10. 验证
- [x] 10.1 运行 `go build ./...` 确认构建通过
- [x] 10.2 运行 LSP diagnostics确保改动文件无错误
- [x] 10.3 使用数据库验证流程确认 `auto_purchase_status` 字段已生效
- [x] 10.4 补充 `docs/client-core-business-api/功能总结.md` 并更新相关索引文档

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-18

View File

@@ -0,0 +1,54 @@
# agent-retail-price Specification
## Purpose
TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive.
## 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`
#### Scenario: 零售价低于成本价
- **WHEN** 代理设置 `retail_price < cost_price`
- **THEN** 系统 MUST 拒绝保存并返回价格约束错误
### Requirement: 成本价分配锁定规则
当某分配存在下级分配记录时,系统 MUST 禁止修改该分配的 `cost_price`
#### Scenario: 存在下级分配时修改成本价
- **WHEN** 上级分配记录已被继续分配到下级店铺
- **THEN** 系统 MUST 拒绝对该记录的 `cost_price` 修改
---
### Requirement: 代理零售价可调与存量迁移
系统 MUST 提供独立接口 `PATCH /api/admin/packages/:id/retail-price` 供代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`
#### Scenario: 代理调整自己的零售价
- **WHEN** 代理修改自己分配记录的 `retail_price` 且满足价格约束
- **THEN** 系统 MUST 允许更新
#### Scenario: 存量数据回填零售价
- **WHEN** 执行本次数据迁移
- **THEN** 系统 MUST 将历史 `ShopPackageAllocation.retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`

View File

@@ -0,0 +1,59 @@
# asset-generation Specification
## Purpose
TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive.
## 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` 参与过滤

View File

@@ -0,0 +1,45 @@
# asset-lifecycle-status Specification
## Purpose
TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive.
## 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`

View File

@@ -1,5 +1,8 @@
## MODIFIED Requirements
# asset-recharge-adaptation Specification
## Purpose
定义资产充值IoT 卡/设备钱包充值)的完整规范:支付配置关联、充值记录表结构变更、回调验签流程及钱包常量从 Card 前缀统一重命名为 Asset 前缀。
## Requirements
### Requirement: 资产充值关联支付配置
系统 SHALL 在创建资产充值订单时记录当前生效的支付配置 ID用于回调处理时加载正确的配置验签。
@@ -76,28 +79,33 @@ Content-Type: application/json
### Requirement: 资产充值表结构变更
`tb_asset_recharge_record` 新增字段:
系统 MUST 在 `tb_asset_recharge_record` 新增以下字段,用于关联支付配置。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `payment_config_id` | bigint | ❌ | 创建充值订单时使用的微信参数配置 ID支付宝支付时为 NULL |
#### Scenario: 新建充值记录含 payment_config_id 字段
- **WHEN** 个人客户创建微信充值订单
- **THEN** 系统 MUST 将当前生效的微信参数配置 ID 写入 `payment_config_id` 字段
---
### Requirement: 资产充值回调按配置验签
- **WHEN** 收到支付回调(微信或富友),订单号前缀为 `CRCH`
- **THEN** 系统查询 `tb_asset_recharge_record`,通过 `payment_config_id` 加载对应配置
- **THEN** 使用该配置的凭证验签
- **THEN** 验签通过后调用 `rechargeService.HandlePaymentCallback()`
系统 MUST 在处理资产充值支付回调时,通过 `payment_config_id` 加载对应配置并使用该配置验签。
> **注意**:当前代码中 `callback/payment.go` 使用废弃的 `RechargeOrderPrefix = "RCH"` 进行前缀匹配,需修复为 `AssetRechargeOrderPrefix = "CRCH"`。
#### Scenario: 收到充值回调按配置验签
- **WHEN** 收到支付回调(微信或富友),订单号前缀为 `CRCH`
- **THEN** 系统 MUST 查询 `tb_asset_recharge_record`,通过 `payment_config_id` 加载对应配置
- **THEN** 系统 MUST 使用该配置的凭证验签
- **THEN** 验签通过后调用 `rechargeService.HandlePaymentCallback()`
---
### Requirement: 常量重命名Card → Asset
`pkg/constants/wallet.go` 中以下常量从 `Card` 前缀重命名为 `Asset` 前缀
系统 MUST 将 `pkg/constants/wallet.go` 中以下常量从 `Card` 前缀重命名为 `Asset` 前缀,旧常量保留为废弃别名。
| 旧名称 | 新名称 |
|--------|--------|
@@ -113,4 +121,30 @@ Content-Type: application/json
| `CardRechargeMinAmount` | `AssetRechargeMinAmount` |
| `CardRechargeMaxAmount` | `AssetRechargeMaxAmount` |
`Card*` 常量保留为废弃别名,添加 `Deprecated` 注释。段落标题 `卡钱包常量``资产钱包常量`
#### Scenario: 新代码使用 Asset 前缀常量
- **WHEN** 业务代码引用钱包资源类型或充值相关常量
- **THEN** 系统 MUST 使用 `Asset*` 前缀常量,`Card*` 常量标注 `Deprecated`
### 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` 作为关联上下文

View File

@@ -185,3 +185,35 @@ TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose
- **WHEN** 创建换卡记录,`old_card_id` 为 99999不存在的 IoT 卡)
- **THEN** 系统拒绝创建,返回错误信息"老卡不存在"
---
### Requirement: 废弃旧换卡模型能力
系统 MUST 废弃 `CardReplacementRecord` 作为主业务能力,原因是其仅覆盖卡换卡且缺少收货信息、物流信息、设备换货与全量迁移能力,无法满足当前换货闭环需求。
#### Scenario: 新换货流程不再写入旧模型
- **WHEN** 执行任意新换货流程H1~H7、G1~G2
- **THEN** 系统 MUST 仅读写 `ExchangeOrder`,不再创建 `CardReplacementRecord` 新记录
---
### Requirement: 旧表迁移为 legacy 保留查询
系统 SHALL 将 `tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy`,仅用于历史查询保留。
系统 MUST NOT 将 legacy 数据回灌到 `tb_exchange_order`
#### Scenario: legacy 数据保留但不参与新流程
- **WHEN** 运营查询历史老换卡记录
- **THEN** 系统可从 legacy 表读取历史数据,但新换货流程 SHALL 不依赖该表
---
### Requirement: 旧代码引用替换
系统 MUST 将旧换卡引用替换为 `ExchangeOrder`,包括 `iot_card_store.go``is_replaced` 过滤逻辑。
#### Scenario: is_replaced 基于新换货单判定
- **WHEN** 查询 IoT 卡并使用 `is_replaced=true` 过滤
- **THEN** 系统 MUST 基于 `ExchangeOrder` 状态判定是否已发生换货,而非 legacy 表

View File

@@ -0,0 +1,48 @@
# carrier-realname-config Specification
## Purpose
TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive.
## 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 在后续实名跳转实现中按占位符语义进行参数替换

View File

@@ -0,0 +1,41 @@
# Capability: 客户端资产信息
## ADDED Requirements
### Requirement: B1 资产基本信息查询接口
系统 SHALL 提供 `GET /api/c/v1/asset/info?identifier=xxx`,并且 MUST 要求个人客户认证C 端 Token。接口 MUST 复用 `asset.Service.Resolve()` 解析标识符,并在调用时使用 `gorm.SkipDataPermission(ctx)` 以绕过 shop_id 数据权限过滤。请求参数 MUST 包含 `identifier`ICCID、虚拟号、设备号之一。响应体 SHALL 返回 `asset_type``asset_id``identifier``virtual_no``status``real_name_status``carrier``generation``wallet_balance`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_NOT_FOUND/资产不存在`
#### Scenario: 个人客户查询已绑定资产
- **WHEN** 客户携带有效 Token 调用 `GET /api/c/v1/asset/info?identifier=8986xxxx` 且资产已绑定到本人
- **THEN** 系统返回 200包含资产基础信息与当前 generation
---
### Requirement: B2 可购买套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/packages?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验通过后返回可购买套餐列表。价格规则 MUST 为:代理渠道取 `allocation.retail_price`,平台渠道取 `Package.SuggestedRetailPrice`。过滤规则 MUST 同时满足:`Package.status=1``shelf_status` 可售、加油包前置主套餐条件成立、`retail_price >= cost_price`。结果 MUST 按展示价格升序。响应体 SHALL 包含 `packages[]`,每项至少含 `package_id``package_name``package_type``retail_price``cost_price``validity``is_addon`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``PACKAGE_NOT_AVAILABLE/当前无可购买套餐`
#### Scenario: 代理渠道价格与过滤生效
- **WHEN** 客户查询可购套餐且其销售链路为代理渠道,部分套餐存在 `retail_price < cost_price`
- **THEN** 系统仅返回可售且满足价格约束的套餐,并按价格升序输出
---
### Requirement: B3 历史套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/package-history?identifier=xxx&page=1&page_size=20`,并且 MUST 要求个人客户认证。接口 MUST 基于标识符解析资产并进行归属校验。查询条件 MUST 自动追加 `generation = 资产当前generation`。请求参数 SHALL 支持 `page``page_size`(默认 20最大 100。响应体 SHALL 返回 `list[]``total``page``page_size`,列表项复用 `dto.AssetPackageResponse` 结构。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 转手后历史隔离
- **WHEN** 资产已发生转手且存在历史套餐记录
- **THEN** 系统只返回当前 generation 的记录,不返回旧 generation 数据
---
### Requirement: B4 手动刷新接口
系统 SHALL 提供 `POST /api/c/v1/asset/refresh`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。当资产为卡时 MUST 调用 Gateway 刷新卡信息;当资产为设备时 MUST 先检查 Redis 冷却窗口,再对设备下卡执行批量刷新。响应体 SHALL 返回 `refresh_type``card`/`device`)、`accepted``cooldown_seconds`(设备场景)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``TOO_MANY_REQUESTS/刷新过于频繁,请稍后重试``GATEWAY_ERROR/网关调用失败`
#### Scenario: 设备刷新冷却拦截
- **WHEN** 客户在冷却时间内重复调用设备刷新
- **THEN** 系统返回频率限制错误并告知剩余冷却时间

View File

@@ -0,0 +1,73 @@
# client-asset-token Specification
## Purpose
TBD - created by archiving change client-auth-system. Update Purpose after archive.
## Requirements
### Requirement: A1 资产标识符验证接口
系统 MUST 提供无认证资产验证接口 `POST /api/c/v1/auth/verify-asset`,用于将外部资产标识符兑换为短时效 `asset_token`
- HTTP Method + Path: `POST /api/c/v1/auth/verify-asset`
- 请求体字段:
- `identifier` stringMUST资产标识符SN/IMEI/虚拟号/ICCID/MSISDN
- 响应体字段:
- `asset_token` stringMUST5 分钟有效
- `expires_in` intMUST单位秒
- 错误码:
- `1006` 参数错误(标识符为空或格式非法)
- `1404` 资产不存在
- `1003` 请求过于频繁
#### Scenario: 资产验证成功并返回 asset_token
- **WHEN** 客户端提交合法且存在的资产标识符
- **THEN** 系统 SHALL 解析并定位资产
- **THEN** 系统 SHALL 签发 5 分钟有效的 `asset_token`
- **THEN** 系统 SHALL 返回 `{asset_token, expires_in}`
#### Scenario: 输入参数非法
- **WHEN** 客户端提交空字符串或不支持格式的标识符
- **THEN** 系统 MUST 返回参数错误码 `1006`
### Requirement: A1 输入校验与安全约束
系统 SHALL 对标识符进行白名单校验,并在 A1 响应中禁止暴露内部 `asset_id`
- 输入校验规则:
- MUST 去除前后空格并做长度限制
- MUST 仅允许预定义字符集(数字、字母、必要分隔符)
- MUST 拒绝 SQL 片段/控制字符
- 输出安全规则:
- MUST NOT 返回 `asset_id`
- MUST NOT 返回内部表名/字段名
#### Scenario: 防止内部主键泄露
- **WHEN** A1 接口返回成功响应
- **THEN** 返回体 MUST 只包含 `asset_token` 与有效期信息
- **THEN** 返回体 MUST NOT 包含 `asset_id`
### Requirement: A1 资产令牌签发规范
`asset_token` SHALL 使用独立签名密钥签发,且 payload 仅包含 `asset_type``asset_id`
- JWT 约束:
- `exp` = 当前时间 + 5 分钟
- payload MUST 包含 `asset_type``asset_id`
- payload MUST NOT 包含手机号、OpenID 等敏感信息
#### Scenario: token 结构与时效符合规范
- **WHEN** 服务端签发 `asset_token`
- **THEN** token MUST 使用资产令牌专用签名密钥
- **THEN** token MUST 在 5 分钟后过期
### Requirement: A1 IP 级限频
系统 SHALL 对 A1 实施 IP 维度限频:`30 次/分钟`
#### Scenario: 限频内请求通过
- **WHEN** 同一 IP 在 1 分钟内请求次数不超过 30 次
- **THEN** 系统 SHALL 正常处理请求
#### Scenario: 超过限频阈值
- **WHEN** 同一 IP 在 1 分钟内请求次数超过 30 次
- **THEN** 系统 MUST 返回错误码 `1003`

View File

@@ -0,0 +1,51 @@
# Capability: 客户端设备能力
## ADDED Requirements
### Requirement: F1 设备卡列表接口
系统 SHALL 提供 `GET /api/c/v1/device/cards?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 仅允许设备类型资产调用,且设备 MUST 具备 IMEI。响应体 SHALL 返回 `cards[]`,每项至少包含:`card_id``iccid``msisdn``carrier_name``network_status``real_name_status``slot_position``is_active`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失`
#### Scenario: 返回设备绑定卡列表
- **WHEN** 客户查询已绑定设备卡列表
- **THEN** 系统返回设备下全部卡及活跃标记
---
### Requirement: F2 设备重启接口
系统 SHALL 提供 `POST /api/c/v1/device/reboot`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.RebootDevice(imei)`。响应体 SHALL 返回 `accepted=true``request_id`(如网关返回)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 设备重启成功受理
- **WHEN** 客户对合法设备发起重启
- **THEN** 系统调用网关成功并返回受理结果
---
### Requirement: F3 设备恢复出厂接口
系统 SHALL 提供 `POST /api/c/v1/device/factory-reset`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.ResetDevice(imei)`。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 恢复出厂失败返回网关错误
- **WHEN** 网关返回失败
- **THEN** 系统返回网关调用失败错误
---
### Requirement: F4 设备 WiFi 设置接口
系统 SHALL 提供 `POST /api/c/v1/device/wifi`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``ssid``password``enabled`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SetWiFi(imei, ssid, password, enabled)`。实现 MUST 将 Gateway 的 `WiFiReq.cardNo` 填充为设备 IMEI。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: WiFi 请求 cardNo 使用 IMEI
- **WHEN** 客户调用设备 WiFi 设置
- **THEN** 系统向网关发送的 `cardNo` 字段值为设备 IMEI
---
### Requirement: F5 设备切卡接口
系统 SHALL 提供 `POST /api/c/v1/device/switch-card`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``target_iccid`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SwitchCard(imei, target_iccid)`。响应体 SHALL 返回 `accepted=true``target_iccid`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 切卡成功返回目标卡号
- **WHEN** 客户请求切换到目标 ICCID 且网关执行成功
- **THEN** 系统返回 `accepted=true` 与目标 ICCID

View File

@@ -0,0 +1,46 @@
# Capability: 客户端套餐购买
## ADDED Requirements
### Requirement: D1 创建套餐购买订单接口
系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``package_ids[]``app_type`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为:
- 无强充:创建套餐订单并返回 `order_type="package"``order``pay_config`
- 需强充:创建充值单并返回 `order_type="recharge"``recharge``pay_config``linked_package_info`
响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``NEED_REALNAME/该套餐需实名认证后购买``OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权``IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交``PACKAGE_NOT_AVAILABLE/套餐不可购买`
#### Scenario: 命中强充返回 recharge 结构
- **WHEN** 客户购买套餐触发强充要求
- **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息
---
### Requirement: D2 套餐订单列表接口
系统 SHALL 提供 `GET /api/c/v1/orders?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 做归属校验并按资产当前 generation 过滤订单。请求参数 SHALL 支持 `payment_status``page``page_size`。响应体 SHALL 返回 `list[]``total``page``page_size`,列表项至少含 `order_id``order_no``total_amount``payment_status``created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 支持支付状态筛选
- **WHEN** 客户带 `payment_status=paid` 查询订单
- **THEN** 系统仅返回当前 generation 且支付状态匹配的订单
---
### Requirement: D3 套餐订单详情接口
系统 SHALL 提供 `GET /api/c/v1/orders/:id`,并且 MUST 要求个人客户认证。接口 MUST 基于订单关联资产执行归属校验(通过资产虚拟号匹配 `PersonalCustomerDevice`)。响应体 SHALL 返回订单详情、套餐明细、支付信息、状态流转时间。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ORDER_NOT_FOUND/订单不存在`
#### Scenario: 查询他人订单被拦截
- **WHEN** 客户请求不属于本人资产的订单详情
- **THEN** 系统返回 403错误消息为无权限操作该资产或资源不存在
---
### Requirement: AutoPurchaseAfterRecharge 异步任务
系统 SHALL 增加 `AutoPurchaseAfterRecharge` Asynq 任务处理强充二阶段。任务输入 MUST 包含 `recharge_record_id`。处理流程 MUST 为:从钱包扣款(`payment_method=wallet`)→ 创建套餐订单(`source="client"`、写入当前 generation→ 激活套餐。任务失败 MUST 自动重试,最大 3 次。全部失败后 MUST 将 `auto_purchase_status` 标记为 `failed`,并保留钱包余额供用户手动购买。成功时 MUST 标记为 `success`
#### Scenario: 异步任务连续失败
- **WHEN** AutoPurchaseAfterRecharge 连续执行失败且达到最大重试次数
- **THEN** 系统将充值记录 `auto_purchase_status` 更新为 `failed`

View File

@@ -0,0 +1,96 @@
# client-phone-binding Specification
## Purpose
TBD - created by archiving change client-auth-system. Update Purpose after archive.
## Requirements
### Requirement: A4 发送验证码接口
系统 MUST 提供无认证验证码接口 `POST /api/c/v1/auth/send-code`,并复用现有验证码服务。
- HTTP Method + Path: `POST /api/c/v1/auth/send-code`
- 请求体字段:
- `phone` stringMUST手机号
- `scene` stringMUST业务场景`bind_phone` / `change_phone_old` / `change_phone_new`
- 响应体字段:
- `cooldown_seconds` intMUST本次发送后的冷却秒数
- 错误码:
- `1006` 参数错误
- `1003` 请求过于频繁(触发任一限流)
- `1050` 短信发送失败
#### Scenario: 发送成功
- **WHEN** 手机号格式合法且未触发限流
- **THEN** 系统 SHALL 发送验证码并返回冷却时间
### Requirement: A4 限频规则
系统 SHALL 对 A4 实施三层限频:手机号 60 秒冷却、同 IP 每小时 20 次、同手机号每日 10 次。
#### Scenario: 60 秒内重复发送
- **WHEN** 同一手机号在 60 秒冷却内再次请求
- **THEN** 系统 MUST 返回 `1003`
#### Scenario: 同 IP 超过小时阈值
- **WHEN** 同一 IP 在 1 小时内发送次数超过 20
- **THEN** 系统 MUST 返回 `1003`
#### Scenario: 同手机号超过日阈值
- **WHEN** 同一手机号在当日发送次数超过 10
- **THEN** 系统 MUST 返回 `1003`
### Requirement: A5 首次绑定手机号接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/bind-phone`,仅允许首次绑定。
- HTTP Method + Path: `POST /api/c/v1/auth/bind-phone`
- 请求体字段:
- `phone` stringMUST新手机号
- `code` stringMUST验证码
- 响应体字段:
- `phone` stringMUST已绑定手机号
- `bound_at` stringMUST绑定时间
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
- `1006` 参数错误
- `1035` 验证码错误或过期
- `1037` 手机号已被绑定
- `1038` 已绑定手机号不可重复绑定
#### Scenario: 首次绑定成功
- **WHEN** 客户已登录、验证码正确且手机号未被占用
- **THEN** 系统 SHALL 完成手机号首次绑定并返回绑定信息
#### Scenario: 已绑定用户再次调用绑定
- **WHEN** 当前客户已存在绑定手机号
- **THEN** 系统 MUST 返回 `1038`
### Requirement: A6 换绑手机号接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/change-phone`,并执行旧手机号与新手机号双验证码校验。
- HTTP Method + Path: `POST /api/c/v1/auth/change-phone`
- 请求体字段:
- `old_phone` stringMUST旧手机号
- `old_code` stringMUST旧手机号验证码
- `new_phone` stringMUST新手机号
- `new_code` stringMUST新手机号验证码
- 响应体字段:
- `phone` stringMUST换绑后的手机号
- `changed_at` stringMUST换绑时间
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
- `1006` 参数错误
- `1035` 验证码错误或过期
- `1037` 新手机号已被绑定
- `1039` 旧手机号不匹配
#### Scenario: 换绑成功
- **WHEN** 登录客户提交正确旧/新验证码且新手机号未占用
- **THEN** 系统 SHALL 更新绑定手机号为新手机号
#### Scenario: 旧手机号校验失败
- **WHEN** `old_phone` 与当前客户绑定手机号不一致或 `old_code` 错误
- **THEN** 系统 MUST 拒绝换绑并返回对应错误码

View File

@@ -0,0 +1,23 @@
# Capability: 客户端实名跳转
## ADDED Requirements
### Requirement: E1 获取实名跳转链接接口
系统 SHALL 提供 `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`,并且 MUST 要求个人客户认证。该接口 MUST 支持两类入口:购买拦截入口与设备卡列表主动入口。目标卡定位 MUST 支持三种路径:
1. 标识符直达卡:直接使用该卡
2. 标识符为设备且传 `iccid`:定位对应设备下卡
3. 标识符为设备且未传 `iccid`:定位设备当前活跃卡
`real_name_status=1` 时 MUST 返回“该卡已完成实名”错误。运营商实名模式 MUST 支持:
- `none`:不支持在线实名,直接报错
- `template`:按模板替换占位符 `{iccid}` `{msisdn}` `{virtual_no}` 返回 URL
- `gateway`:调用网关获取实名链接
响应体 SHALL 至少包含 `realname_mode``realname_url``card_info{iccid,msisdn,virtual_no}``expire_at`(可空)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``REALNAME_ALREADY_DONE/该卡已完成实名``REALNAME_NOT_SUPPORTED/该运营商暂不支持在线实名``GATEWAY_ERROR/获取实名链接失败`
#### Scenario: 设备未传 iccid 自动选活跃卡
- **WHEN** 客户传入设备标识符且不传 `iccid`
- **THEN** 系统自动选择设备活跃卡并返回实名跳转链接

View File

@@ -0,0 +1,59 @@
# client-token-management Specification
## Purpose
TBD - created by archiving change client-auth-system. Update Purpose after archive.
## Requirements
### Requirement: 登录 JWT 签发与 Redis 状态存储
系统 MUST 在 A2/A3 登录成功后签发个人客户 JWT并将 token 状态写入 Redis。
- JWT payload 字段:
- `customer_id` uintMUST
- `exp` int64MUST
- Redis Key`RedisPersonalCustomerTokenKey(customerID)`
- Redis Value当前有效 token或 token 集合,取决于实现)
- TTLMUST 与 JWT 过期时间一致
#### Scenario: 登录成功写入 Redis
- **WHEN** 客户完成微信登录
- **THEN** 系统 SHALL 签发 JWT
- **THEN** 系统 SHALL 将 token 写入 Redis 并设置 TTL
### Requirement: PersonalAuthMiddleware 双重校验
系统 SHALL 在个人客户认证中间件执行双重校验JWT 解析校验 + Redis 状态校验。
#### Scenario: JWT 与 Redis 均有效
- **WHEN** 请求携带有效 JWT 且 Redis 中存在有效状态
- **THEN** 中间件 SHALL 放行并写入 `customer_id` 到上下文
#### Scenario: JWT 有效但 Redis 不存在
- **WHEN** JWT 仍在有效期但 Redis 中不存在该客户 token 状态
- **THEN** 中间件 MUST 返回未认证错误 `1002`
### Requirement: A7 退出登录接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/logout`,用于删除 Redis token 状态。
- HTTP Method + Path: `POST /api/c/v1/auth/logout`
- 请求体字段:无
- 响应体字段:
- `success` boolMUST
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
#### Scenario: 退出登录成功
- **WHEN** 登录客户调用 A7
- **THEN** 系统 SHALL 删除 `RedisPersonalCustomerTokenKey(customerID)`
- **THEN** 系统 SHALL 返回成功
### Requirement: 服务端主动失效能力
系统 MUST 支持服务端主动使 token 失效(如封禁/强制下线),且无需等待 JWT 自然过期。
#### Scenario: 服务端主动踢出
- **WHEN** 管理动作触发客户强制下线
- **THEN** 系统 SHALL 删除对应 Redis token 状态
- **THEN** 该客户后续请求 MUST 被中间件拒绝

View File

@@ -0,0 +1,51 @@
# Capability: 客户端钱包与充值
## ADDED Requirements
### Requirement: C1 钱包详情接口
系统 SHALL 提供 `GET /api/c/v1/wallet/detail?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 先完成资产解析与归属校验;钱包不存在时 MUST 自动创建空钱包。响应体 SHALL 包含 `wallet_id``resource_type``resource_id``balance``frozen_balance``updated_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 首次访问自动建钱包
- **WHEN** 客户查询资产钱包详情且钱包记录不存在
- **THEN** 系统自动创建钱包并返回余额 0
---
### Requirement: C2 钱包流水列表接口
系统 SHALL 提供 `GET /api/c/v1/wallet/transactions?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 通过归属校验解析出唯一 `wallet_id` 后查询流水,实现天然隔离。请求参数 SHALL 支持 `transaction_type``start_time``end_time``page``page_size`。响应体 SHALL 包含 `list[]``total``page``page_size`,每条记录至少含 `transaction_id``type``amount``balance_after``created_at``remark`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: wallet_id 隔离生效
- **WHEN** 客户查询某资产流水
- **THEN** 系统仅返回该资产钱包对应流水,不返回其他钱包数据
---
### Requirement: C3 充值预检接口
系统 SHALL 提供 `GET /api/c/v1/wallet/recharge-check?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 复用 `recharge.Service.GetRechargeCheck()` 计算强充规则。响应体 SHALL 包含 `need_force_recharge``force_recharge_amount``trigger_type``min_amount``max_amount``message`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 返回强充预检结果
- **WHEN** 资产命中强充规则
- **THEN** 系统返回 `need_force_recharge=true` 与对应强充金额和触发类型
---
### Requirement: C4 创建充值订单接口
系统 SHALL 提供 `POST /api/c/v1/wallet/recharge`,并且 MUST 要求个人客户认证。请求体 MUST 包含:`identifier``amount`100~10000000 分)、`payment_method=wechat``app_type`。接口 MUST 禁止客户端传入 OpenID并由后端按 `customer_id + app_type` 查询 OpenID。订单创建时 MUST 写入:`operator_type=personal_customer` 与资产当前 `generation` 快照。响应体 SHALL 返回 `recharge``pay_config`,其中 `recharge` 至少含 `recharge_id``recharge_no``amount``status``pay_config` 为微信 JSAPI 拉起参数。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权``FORBIDDEN/无权限操作该资产或资源不存在``PAYMENT_NOT_SUPPORTED/仅支持微信支付`
#### Scenario: 后端查 OpenID 并返回支付参数
- **WHEN** 客户传入合法参数且后端成功查询到 OpenID
- **THEN** 系统创建充值单并返回 `recharge + pay_config`
---
### Requirement: C5 充值订单列表接口
系统 SHALL 提供 `GET /api/c/v1/wallet/recharges?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验后按资产当前 generation 过滤充值记录。请求参数 SHALL 支持 `status``page``page_size`。响应体 SHALL 返回 `list[]``total``page``page_size`,每项至少含 `recharge_id``recharge_no``amount``status``payment_method``created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: generation 过滤充值历史
- **WHEN** 资产存在多代充值记录
- **THEN** 系统仅返回当前 generation 对应的充值记录

View File

@@ -0,0 +1,107 @@
# client-wechat-login Specification
## Purpose
TBD - created by archiving change client-auth-system. Update Purpose after archive.
## Requirements
### Requirement: A2 微信公众号登录接口
系统 MUST 提供 `POST /api/c/v1/auth/wechat-login`,使用公众号 OAuth code + `asset_token` 完成登录。
- HTTP Method + Path: `POST /api/c/v1/auth/wechat-login`
- 请求体字段:
- `code` stringMUST微信 OAuth 授权码
- `asset_token` stringMUSTA1 返回的资产令牌
- 响应体字段:
- `token` stringMUST登录 JWT
- `need_bind_phone` boolMUST是否需要绑定手机号
- `is_new_user` boolMUST是否新创建用户
- 错误码:
- `1002` token 无效或过期asset_token/JWT
- `1040` 微信授权失败
- `1006` 参数错误
#### Scenario: 公众号登录成功
- **WHEN** 客户端提交有效 `code` 与有效 `asset_token`
- **THEN** 系统 SHALL 调用公众号 OAuth 获取 `openid` 与可选 `unionid`
- **THEN** 系统 SHALL 执行客户查找/创建/合并逻辑
- **THEN** 系统 SHALL 绑定资产并签发登录 token
### Requirement: A3 微信小程序登录接口
系统 MUST 提供 `POST /api/c/v1/auth/miniapp-login`,使用小程序 `jscode2session` + `asset_token` 完成登录。
- HTTP Method + Path: `POST /api/c/v1/auth/miniapp-login`
- 请求体字段:
- `code` stringMUST小程序登录凭证
- `asset_token` stringMUSTA1 返回的资产令牌
- 响应体字段:
- `token` stringMUST登录 JWT
- `need_bind_phone` boolMUST
- `is_new_user` boolMUST
- 错误码:
- `1002` token 无效或过期
- `1040` 微信授权失败
- `1006` 参数错误
#### Scenario: 小程序登录成功
- **WHEN** 客户端提交有效小程序 `code` 与有效 `asset_token`
- **THEN** 系统 SHALL 调用 `jscode2session` 获取 `openid` 与可选 `unionid`
- **THEN** 系统 SHALL 执行与 A2 一致的客户查找/创建/合并、资产绑定与签发逻辑
### Requirement: asset_token 校验与资产解析
系统 SHALL 在 A2/A3 登录前强制校验 `asset_token`,并解析出 `asset_type` + `asset_id`
#### Scenario: asset_token 无效
- **WHEN** `asset_token` 签名不合法或已过期
- **THEN** 系统 MUST 拒绝登录并返回 `1002`
#### Scenario: asset_token 有效
- **WHEN** `asset_token` 可被成功解析
- **THEN** 系统 SHALL 使用解析出的资产信息继续登录流程
### Requirement: 客户查找/创建/合并逻辑
系统 MUST 按以下顺序处理客户归属:
1. 先查 `PersonalCustomerOpenID``(app_id, open_id)`
2. 未命中且存在 `unionid` 时按 `unionid` 回查并复用客户;
3. 仍未命中时创建新 `PersonalCustomer` 与 OpenID 记录。
#### Scenario: openid 命中既有客户
- **WHEN** `(app_id, open_id)` 已存在
- **THEN** 系统 SHALL 直接复用对应 `customer_id`
#### Scenario: openid 未命中但 unionid 命中
- **WHEN** `(app_id, open_id)` 不存在且 `unionid` 命中历史记录
- **THEN** 系统 SHALL 复用已存在客户
- **THEN** 系统 SHALL 新增当前 `app_id + open_id` 记录
#### Scenario: openid/unionid 均未命中
- **WHEN** 无任何匹配记录
- **THEN** 系统 SHALL 创建新客户并写入 OpenID 记录
### Requirement: 登录后资产绑定
系统 SHALL 在 A2/A3 每次登录时创建一条 `PersonalCustomerDevice` 绑定记录,且 MUST 允许同一资产被多个客户绑定。
#### Scenario: 已有绑定时再次登录
- **WHEN** 同一客户再次登录同一资产
- **THEN** 系统 SHALL 记录本次登录绑定关系(按实现可去重或追加历史)
#### Scenario: 不同客户绑定同一资产
- **WHEN** 资产已被其他客户绑定
- **THEN** 系统 MUST 允许新增绑定,不得覆盖已有客户绑定关系
### Requirement: 登录响应与手机号绑定开关
系统 MUST 在登录响应中返回 `need_bind_phone`,该值由 `client.require_phone_binding` 与客户手机号绑定状态共同决定。
#### Scenario: 要求手机号绑定且未绑定
- **WHEN** 配置 `client.require_phone_binding=true` 且客户未绑定手机号
- **THEN** 登录响应 MUST 返回 `need_bind_phone=true`
#### Scenario: 已绑定手机号或配置关闭
- **WHEN** 客户已绑定手机号或 `client.require_phone_binding=false`
- **THEN** 登录响应 MUST 返回 `need_bind_phone=false`

View File

@@ -356,3 +356,42 @@ ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no;
- **WHEN** 前端调用设备列表或详情接口
- **THEN** 响应 JSON 中 key 为 `virtual_no`,不再有 `device_no`
### 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`
---
### Requirement: 设备换货状态语义扩展
系统 SHALL 将 `asset_status=3` 定义为"已换货",用于标记已被换出的旧设备资产。
#### Scenario: 换货完成后旧设备标记
- **WHEN** H5 确认完成且旧资产为设备
- **THEN** 系统 MUST 将旧设备 `asset_status` 更新为 `3`
---
### Requirement: 设备转新重置规则
系统 SHALL 在 H7 转新时对设备执行以下重置:
- `generation = generation + 1`
- `asset_status = 1`(在库)
- 清空累计充值与首充触发相关状态
- 清除个人客户绑定关系
- 创建新空钱包并与新代际设备关联
#### Scenario: 转新后设备可重新销售
- **WHEN** 对已换货设备执行转新
- **THEN** 系统 MUST 使该设备进入新代际并恢复在库可售

View File

@@ -0,0 +1,127 @@
# exchange-admin-management Specification
## Purpose
提供后台换货单管理能力,涵盖换货单的发起、列表查询、详情查看、发货、确认完成、取消及旧资产转新等完整生命周期管理。
## Requirements
### Requirement: H1 发起换货单
系统 SHALL 提供 `POST /api/admin/exchanges`(需后台认证 `Auth=true`),用于发起换货单。
请求体 MUST 包含:`old_asset_type``old_identifier``exchange_reason`,可选 `remark`
系统 MUST 校验:
- 旧资产存在且当前用户有权限
- 同一资产不存在进行中的换货单(`status IN (1,2,3)`
成功响应 SHALL 返回新建换货单信息(含 `id``exchange_no``status=1`)。
错误响应 MUST 至少包含:参数错误、资产不存在或无权限、存在进行中换货单。
#### Scenario: 资产已有进行中换货单
- **WHEN** 后台为同一资产重复发起换货
- **THEN** 系统 MUST 拒绝创建并返回"存在进行中的换货单"
---
### Requirement: H2 换货单列表
系统 SHALL 提供 `GET /api/admin/exchanges``Auth=true`),支持分页与条件查询。
查询条件 SHOULD 支持:`status``identifier`(资产标识搜索)、`created_at_start``created_at_end`、分页参数。
响应 SHALL 返回列表与分页元数据。
#### Scenario: 按状态查询待发货单
- **WHEN** 运营查询 `status=2`
- **THEN** 系统返回所有待发货换货单并按创建时间倒序
---
### Requirement: H3 换货单详情
系统 SHALL 提供 `GET /api/admin/exchanges/:id``Auth=true`)查询换货单详情。
响应 MUST 返回旧/新资产信息、收货信息、物流信息、迁移状态信息。
错误响应 MUST 至少包含:换货单不存在或无权限。
#### Scenario: 查询不存在换货单
- **WHEN** 查询不存在的换货单 ID
- **THEN** 系统 MUST 返回"资源不存在或无权限"
---
### Requirement: H4 发货
系统 SHALL 提供 `POST /api/admin/exchanges/:id/ship``Auth=true`)。
请求体 MUST 包含:`express_company``express_no``new_identifier``migrate_data`
系统 MUST 校验:
- 当前状态必须为 `2`
- 新旧资产类型必须一致(卡换卡/设备换设备)
- 新资产必须 `asset_status=1`(在库)
成功后 SHALL 更新新资产信息、物流信息并将状态改为 `3`
错误响应 MUST 至少包含:非法状态、资产类型不匹配、新资产非在库、资产不存在或无权限。
#### Scenario: 新资产类型不一致
- **WHEN** 旧资产为 iot_card 且新资产为 device
- **THEN** 系统 MUST 拒绝发货并返回"换货资产类型必须一致"
---
### Requirement: H5 确认完成
系统 SHALL 提供 `POST /api/admin/exchanges/:id/complete``Auth=true`)。
系统 MUST 校验当前状态为 `3`。当 `migrate_data=true` 时,系统 MUST 执行全量迁移事务(见 `exchange-data-migration` 能力)。
成功后 SHALL
- `migration_completed=true`(若执行迁移)
- 换货单状态更新为 `4`
错误响应 MUST 至少包含:非法状态、迁移失败、换货单不存在或无权限。
#### Scenario: 需要迁移并完成
- **WHEN** 状态为 `3``migrate_data=true`
- **THEN** 系统 MUST 在事务成功后将状态变为 `4` 并记录迁移结果
---
### Requirement: H6 取消换货
系统 SHALL 提供 `POST /api/admin/exchanges/:id/cancel``Auth=true`)。
系统 MUST 仅允许在 `status IN (1,2)` 时取消,成功后状态更新为 `5`
系统 MUST 禁止已发货单取消(`status=3`)。
#### Scenario: 已发货单取消失败
- **WHEN** 换货单状态为 `3` 发起取消
- **THEN** 系统 MUST 返回状态非法错误
---
### Requirement: H7 旧资产转新
系统 SHALL 提供 `POST /api/admin/exchanges/:id/renew``Auth=true`)。
系统 MUST 校验旧资产当前 `asset_status=3`(已换货),并执行:
- `generation + 1`
- `asset_status -> 1`
- 清除累计充值/首充相关状态
- 清除个人客户绑定
- 创建新空钱包
系统 MUST 保留历史数据,不执行历史删除。
错误响应 MUST 至少包含:资产状态不满足转新条件、换货单不存在或无权限。
#### Scenario: 旧资产未处于已换货状态
- **WHEN** 旧资产 `asset_status != 3` 发起转新
- **THEN** 系统 MUST 拒绝并返回"资产当前状态不允许转新"

View File

@@ -0,0 +1,41 @@
# exchange-client-notification Specification
## Purpose
提供个人客户端换货通知与收货信息填写能力,支持客户查询进行中的换货单状态并提交收货地址。
## Requirements
### Requirement: G1 查询进行中换货通知
系统 SHALL 提供 `GET /api/c/v1/exchange/pending?identifier=xxx`(需个人客户认证 `Auth=true`)。
系统 MUST 根据资产标识查询当前客户可见的进行中换货单,仅返回 `status IN (1,2,3)` 的记录。
响应 SHALL 至少包含:换货单 ID、单号、状态、换货原因、创建时间。
错误响应 MUST 至少包含:参数错误、资产不存在或无权限。
#### Scenario: 命中进行中换货单
- **WHEN** 客户按资产标识查询且存在状态为 2 的换货单
- **THEN** 系统返回该换货单并标识当前状态为待发货
---
### Requirement: G2 填写收货信息
系统 SHALL 提供 `POST /api/c/v1/exchange/:id/shipping-info`(需个人客户认证 `Auth=true`)。
请求体 MUST 包含:`recipient_name``recipient_phone``recipient_address`
系统 MUST 校验:
- 换货单存在且当前客户有权限
- 当前状态必须为 `1`
成功后 SHALL 写入收货信息并将状态更新为 `2`
错误响应 MUST 至少包含:参数错误、状态非法、换货单不存在或无权限。
#### Scenario: 非待填写状态禁止更新收货信息
- **WHEN** 换货单当前状态为 `2``3`
- **THEN** 系统 MUST 拒绝填写并返回状态非法错误

View File

@@ -0,0 +1,66 @@
# exchange-data-migration Specification
## Purpose
定义换货全量迁移事务规则,包括 11 张表的迁移策略、设备换设备特殊规则及旧资产转新的代际隔离策略。
## Requirements
### Requirement: 全量迁移事务边界
系统 MUST 在 H5 确认完成且 `migrate_data=true` 时,使用**单一数据库事务**执行全量迁移。
该事务 SHALL 覆盖资产钱包、套餐、标签、客户绑定及资产状态更新等所有步骤;任一步骤失败 MUST 回滚。
#### Scenario: 迁移中途失败回滚
- **WHEN** 迁移第 N 步发生数据库错误
- **THEN** 系统 MUST 回滚整个事务,换货单状态保持未完成
---
### Requirement: 11 张表迁移规则
系统 SHALL 按以下规则处理 11 张表:
1. `tb_asset_wallet`:将旧资产钱包余额转移到新资产钱包。
2. `tb_asset_wallet_transaction`:生成一条迁移流水记录(明确来源钱包、目标钱包、金额、业务类型)。
3. `tb_asset_recharge_record`:历史充值记录保留,不做更新。
4. `tb_package_usage`:将生效套餐关联到新资产(更新 `iot_card_id``device_id`)。
5. `tb_package_usage_daily_record`:随 `tb_package_usage` 关系迁移(保持套餐日明细连续性)。
6. `tb_order`:历史订单保留,不做更新。
7. `tb_commission`:历史分佣记录保留,不做更新。
8. `tb_data_usage_record`:历史流量记录保留,不做更新。
9. `tb_resource_tag`:复制旧资产标签到新资产。
10. `tb_personal_customer_device`:将绑定记录中的 `virtual_no` 更新为新资产虚拟号。
11. `tb_iot_card`/`tb_device`:迁移累计充值与首充状态到新资产,并将旧资产 `asset_status -> 3`
#### Scenario: 钱包余额转移并记录流水
- **WHEN** 旧资产钱包余额为 5000 分
- **THEN** 新资产钱包余额增加 5000 分,旧钱包余额按迁移策略清零,并写入迁移流水
---
### Requirement: 设备换设备特殊规则
设备换设备流程 MUST NOT 迁移 `DeviceSimBinding`
系统 SHALL 视新设备为新硬件交付,新设备卡绑定由其自身体系决定,旧设备绑定关系保留历史。
#### Scenario: 设备换设备不复制绑定卡
- **WHEN** 执行设备换设备全量迁移
- **THEN** 系统 MUST 不创建或复制任何 `DeviceSimBinding` 记录到新设备
---
### Requirement: 转新规则
系统 SHALL 在 H7 转新时执行代际隔离策略:
- 资产 `generation + 1`
- 创建新空钱包(新 `wallet_id`
- 清除累计充值状态与首充触发状态
- 清除 `PersonalCustomerDevice` 绑定
- 不删除历史业务数据
#### Scenario: 转新后历史数据保留
- **WHEN** 资产转新完成
- **THEN** 历史订单、充值、分佣、流量数据 MUST 仍可在旧代际查询链路中追溯

View File

@@ -0,0 +1,75 @@
# exchange-order-model Specification
## Purpose
定义换货单ExchangeOrder数据模型、状态常量、状态机流转规则及换货单号生成规则作为换货系统的核心数据基础。
## Requirements
### Requirement: ExchangeOrder 换货单模型定义
系统 SHALL 定义 `ExchangeOrder` 模型并映射到 `tb_exchange_order`,用于承载客户端换货完整生命周期。
模型字段 MUST 至少包含:
- 基础:`id``created_at``updated_at``deleted_at``creator``updater`
- 单号:`exchange_no`
- 旧资产:`old_asset_type``old_asset_id``old_asset_identifier`
- 新资产:`new_asset_type``new_asset_id``new_asset_identifier`
- 收货:`recipient_name``recipient_phone``recipient_address`
- 物流:`express_company``express_no`
- 迁移:`migrate_data``migration_completed``migration_balance`
- 业务:`exchange_reason``remark``status`
- 多租户:`shop_id`
`ExchangeOrder` SHALL 嵌入 `BaseModel` 并实现 `TableName() string`,返回 `tb_exchange_order`
#### Scenario: 创建换货单模型实例
- **WHEN** 系统创建新的换货单记录
- **THEN** 记录 MUST 同时包含旧资产快照、收货信息占位、迁移状态字段和多租户字段
---
### Requirement: 换货状态常量定义
系统 MUST 使用 int 常量定义换货状态:
- `1` 待填写信息
- `2` 待发货
- `3` 已发货待确认
- `4` 已完成
- `5` 已取消
#### Scenario: 状态常量一致性
- **WHEN** Service、Store、Handler 读取或更新换货状态
- **THEN** 各层 MUST 使用统一常量值,禁止硬编码散落魔法数字
---
### Requirement: 换货状态机流转规则
系统 SHALL 执行以下状态机:
- 创建换货单后:`1`
- 客户填写收货信息后:`1 -> 2`
- 后台发货后:`2 -> 3`
- 后台确认完成后:`3 -> 4`
- 取消:仅允许 `1/2 -> 5`
系统 MUST 禁止非法流转(如 `3 -> 5``4 -> 2`)。
#### Scenario: 已发货不可取消
- **WHEN** 换货单状态为 `3` 且请求取消
- **THEN** 系统 MUST 拒绝并返回状态流转非法错误
---
### Requirement: 换货单号生成规则
系统 MUST 为每个换货单生成全局可追踪单号,格式为:`EXC + 时间戳片段 + 随机数片段`
生成规则 SHALL 满足:
- 前缀固定为 `EXC`
- 包含日期/时间信息用于人工排查
- 包含随机片段降低并发冲突概率
#### Scenario: 生成换货单号
- **WHEN** 后台发起换货并创建新单
- **THEN** 系统 MUST 生成形如 `EXC20260319XXXXXX` 的单号并写入 `exchange_no`

View File

@@ -143,3 +143,23 @@
#### Scenario: 套餐不存在
- **WHEN** 套餐购买预检时,套餐 ID 不存在
- **THEN** 系统返回错误 "套餐不存在"
---
### Requirement: 强充检查结果对客户端透出
系统 MUST 将强充检查结果输出给客户端接口(充值预检与购买预检),用于前端明确展示支付拆分。输出字段 SHALL 至少包含:`need_force_recharge``force_recharge_amount``trigger_type``total_package_amount``actual_payment``wallet_credit``message`。若无强充,`need_force_recharge=false``actual_payment=total_package_amount`
#### Scenario: 客户端购买预检命中强充
- **WHEN** 客户端调用购买预检且命中强充规则
- **THEN** 系统返回强充金额、实际支付金额和钱包入账金额
---
### Requirement: 前端展示套餐价与强充金额拆分
系统 SHALL 在强充场景提供可直接渲染的拆分语义套餐总价、需支付金额、充值入钱包金额并给出中文提示文案。当前端调用客户端下单接口D1若命中强充 MUST 返回 `order_type="recharge"``linked_package_info`,以便前端保持与预检展示一致。
#### Scenario: 套餐价低于强充金额
- **WHEN** 套餐总价 5000 分,强充金额 10000 分
- **THEN** 预检返回 `actual_payment=10000``wallet_credit=5000`、提示文案可用于前端直接展示

View File

@@ -0,0 +1,51 @@
# h5-legacy-cleanup Specification
## Purpose
TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive.
## 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 旧接口残留导致的编译错误

View File

@@ -624,7 +624,6 @@ This capability supports:
- **WHEN** 系统时间到达每月1号 00:00:00
- **THEN** 系统重置所有 data_reset_cycle=monthly 的套餐 data_usage_mb=0
---
### Requirement: IotCard Handler 分层修复
@@ -749,3 +748,42 @@ IotCard Service SHALL 提供 Gateway API 的代理方法,封装权限检查和
- **WHEN** 系统中有历史导入的卡,没有 virtual_no
- **THEN** 这些卡的 virtual_no = NULL不影响唯一索引部分索引跳过 NULL 值)
### 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`
---
### Requirement: IoT 卡换货状态语义扩展
系统 SHALL 将 `asset_status=3` 定义为"已换货",用于标记已被换出、不可继续作为当前代际在售资产的 IoT 卡。
#### Scenario: 换货完成后旧卡标记为已换货
- **WHEN** H5 确认完成且旧资产为 IoT 卡
- **THEN** 系统 MUST 将旧卡 `asset_status` 更新为 `3`
---
### Requirement: IoT 卡转新重置规则
系统 SHALL 在 H7 转新时对 IoT 卡执行以下重置:
- `generation = generation + 1`
- `asset_status = 1`(在库)
- 清空累计充值与首充触发相关状态(含 `AccumulatedRecharge``FirstCommissionPaid`、系列首充/累计字段)
- 清除个人客户绑定关系
#### Scenario: 转新后进入新代际
- **WHEN** 对旧卡执行转新
- **THEN** 系统 MUST 使该卡进入新代际并以在库状态重新销售

View File

@@ -292,3 +292,21 @@ This capability supports:
---
### 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`

View File

@@ -1,5 +1,8 @@
## ADDED Requirements
# one-time-commission-trigger Specification
## Purpose
一次性佣金触发机制 - 定义单次充值和累计充值两种触发条件、佣金发放规则、配置获取和幂等性保障。
## Requirements
### Requirement: 一次性充值触发佣金
系统 SHALL 支持"一次性充值"触发条件:当单笔订单金额 ≥ 配置阈值时触发一次性佣金。
@@ -138,3 +141,22 @@
- **WHEN** 提供的梯度档位缺少必填字段threshold_value、commission_mode、commission_value
- **THEN** 系统返回错误:梯度佣金档位配置无效(错误码 40105
### 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 不触发一次性佣金

View File

@@ -1,5 +1,8 @@
## ADDED Requirements
# package-purchase-validation Specification
## Purpose
套餐购买验证 - 定义客户端购买套餐前的权限、状态、价格及设备卡验证规则。
## Requirements
### Requirement: 验证卡/设备的套餐购买权限
创建订单前系统 MUST 验证卡/设备是否有权购买指定套餐。
@@ -83,3 +86,34 @@
#### Scenario: 设备无系列关联
- **WHEN** 设备的 series_allocation_id 为空
- **THEN** 验证失败,返回 "该设备未关联套餐系列"
### Requirement: 代理渠道购买价格规则
系统 MUST 根据购买渠道返回正确的购买价格:代理渠道使用 `allocation.retail_price`,平台渠道使用 `Package.SuggestedRetailPrice`
#### 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 不展示该套餐,且不允许该套餐进入下单校验

View File

@@ -0,0 +1,39 @@
# personal-customer-openid Specification
## Purpose
TBD - created by archiving change client-auth-system. Update Purpose after archive.
## Requirements
### Requirement: PersonalCustomerOpenID 模型定义
系统 MUST 新增 `PersonalCustomerOpenID` 模型与数据表 `tb_personal_customer_openid`,用于保存客户在不同 AppID 下的 OpenID 记录。
- 关键字段:
- `id` uint主键
- `customer_id` uintMUST关联个人客户 ID
- `app_id` stringMUST微信应用标识
- `open_id` stringMUST当前应用下 OpenID
- `union_id` string可选开放平台统一标识
- `created_at`/`updated_at`/`deleted_at`
- 索引约束:
- MUST 存在唯一索引 `UNIQUE(app_id, open_id)`(软删条件下唯一)
#### Scenario: 新增 OpenID 记录成功
- **WHEN** 登录流程创建新 OpenID 关系
- **THEN** 系统 SHALL 插入一条包含 `customer_id/app_id/open_id` 的记录
#### Scenario: 重复 app_id + open_id 被拒绝
- **WHEN** 试图插入已存在的 `(app_id, open_id)` 组合
- **THEN** 系统 MUST 触发唯一约束并拒绝写入
### Requirement: 与 PersonalCustomer 的关系约束
系统 SHALL 通过 `customer_id``PersonalCustomer` 建立逻辑关联(不使用数据库外键约束)。
#### Scenario: 根据 customer_id 查询 OpenID 列表
- **WHEN** 业务根据 `customer_id` 查询 OpenID
- **THEN** 系统 SHALL 返回该客户在多 AppID 下的全部有效记录
#### Scenario: 软删除客户后的记录处理
- **WHEN** 客户逻辑删除或状态失效
- **THEN** 系统 MUST 支持按业务策略同步停用或软删除 OpenID 记录

View File

@@ -363,3 +363,86 @@ sms:
---
### 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 继续命中普通索引以保障查询性能
### Requirement: 个人客户登录主流程改为微信授权
系统 SHALL 将个人客户登录主流程从“手机号 + 验证码登录”调整为“资产验证 + 微信授权登录”。
- 新登录入口:
- `POST /api/c/v1/auth/verify-asset`A1无认证
- `POST /api/c/v1/auth/wechat-login`A2无认证
- `POST /api/c/v1/auth/miniapp-login`A3无认证
- 请求与响应要点:
- A2/A3 请求体 MUST 包含 `code` 与 `asset_token`
- A2/A3 响应体 MUST 包含 `token`、`need_bind_phone`、`is_new_user`
- 错误码:
- `1006` 参数错误
- `1002` token 无效或过期
- `1040` 微信授权失败
#### Scenario: 通过微信授权完成登录
- **WHEN** 用户先完成 A1再提交 A2 或 A3
- **THEN** 系统 SHALL 完成客户识别/创建、资产绑定并返回登录 token
#### Scenario: 不再支持旧手机号直登入口
- **WHEN** 客户端调用旧手机号登录路径(如 `/api/c/v1/login`
- **THEN** 系统 MUST 按新路由规范拒绝或迁移提示,不再作为主登录路径
### Requirement: 手机号从“登录凭据”调整为“登录后补充资料”
系统 MUST 将手机号能力调整为登录后绑定/换绑,而非登录入口。
- 相关接口:
- `POST /api/c/v1/auth/send-code`A4无认证
- `POST /api/c/v1/auth/bind-phone`A5需认证
- `POST /api/c/v1/auth/change-phone`A6需认证
- 响应字段:
- A5/A6 MUST 返回绑定后的 `phone`
#### Scenario: 首次登录后要求绑定手机号
- **WHEN** `client.require_phone_binding=true` 且用户未绑定手机号
- **THEN** 登录响应 MUST 返回 `need_bind_phone=true`
- **THEN** 用户通过 A4+A5 完成绑定后进入业务页面
### Requirement: 微信身份字段迁移到 OpenID 关联能力
系统 SHALL 保留 `PersonalCustomer.wx_open_id` 与 `wx_union_id` 字段的兼容性,但新登录链路 MUST 以 `PersonalCustomerOpenID` 为主。
#### Scenario: 读取用户微信身份
- **WHEN** 登录流程需要按微信身份识别客户
- **THEN** 系统 MUST 优先查询 `PersonalCustomerOpenID`
- **THEN** 不再依赖 `PersonalCustomer` 单字段承载多 AppID 场景
---
### Requirement: 换货迁移时更新个人客户资产绑定
系统 SHALL 在 H5 全量迁移成功后,更新 `PersonalCustomerDevice` 的资产标识绑定关系:
- 若旧资产存在客户绑定,绑定中的 `virtual_no` MUST 更新为新资产 `virtual_no`
- 更新后客户对资产访问连续,不需重新登录即可看到新资产
#### Scenario: 迁移后客户绑定跟随新资产
- **WHEN** 旧资产存在个人客户绑定且执行了 `migrate_data=true`
- **THEN** 系统 MUST 将绑定记录的 `virtual_no` 更新为新资产虚拟号
---
### Requirement: 转新时清除个人客户绑定
系统 SHALL 在 H7 转新时清除该资产在 `PersonalCustomerDevice` 中的绑定关系,避免旧客户继续访问新代际资产。
#### Scenario: 转新后旧客户需重新绑定
- **WHEN** 资产转新完成
- **THEN** 系统 MUST 删除或失效对应客户绑定,使旧客户再次访问时触发重新绑定流程

View File

@@ -3,9 +3,7 @@
## Purpose
本 capability 定义钱包充值功能,允许个人客户为卡/设备钱包充值,支持强充验证、第三方支付和充值后的累计充值更新与一次性佣金触发。
## Requirements
### Requirement: 创建钱包充值订单
系统 SHALL 允许个人客户创建钱包充值订单。创建前 MUST 验证强充要求,强充场景下充值金额必须等于要求的强充金额。
@@ -186,3 +184,59 @@
#### Scenario: 充值金额过大
- **WHEN** 客户尝试充值 200000 元
- **THEN** 系统返回错误 "单次充值金额不能超过100000元"
### Requirement: 充值回调事务一致性
`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` 执行数据库操作
---
### Requirement: 充值回调采用两阶段处理
系统 MUST 将强充场景的充值回调改为两阶段:第一阶段同步事务内完成入账与状态更新,第二阶段异步执行自动购买。第一阶段 SHALL 包含:更新充值状态、钱包加款、累计充值更新、首充佣金判断。第二阶段 SHALL 通过 Asynq 任务执行钱包扣款、创建套餐订单、激活套餐。该改造适用于客户端触发的强充路径,且不影响非强充充值主流程。
#### Scenario: 强充回调同步入账成功并触发异步任务
- **WHEN** 强充充值支付回调验签成功
- **THEN** 系统在事务内完成钱包入账与充值单状态更新
- **AND** 入队 `AutoPurchaseAfterRecharge` 异步任务
---
### Requirement: 充值记录新增 auto_purchase_status 状态追踪
系统 MUST 在 `AssetRechargeRecord` 增加 `auto_purchase_status` 字段,用于追踪强充后二阶段自动购买状态。状态集 SHALL 至少包括:`pending``success``failed`。创建强充充值单时 MUST 初始化为 `pending`;异步购买成功后 MUST 更新为 `success`;重试耗尽后 MUST 更新为 `failed`
#### Scenario: 强充充值单创建时默认 pending
- **WHEN** 系统创建与套餐联动的强充充值单
- **THEN** 充值记录 `auto_purchase_status` 初始化为 `pending`
---
### Requirement: 异步自动购买失败处理规范
系统 SHALL 对 `AutoPurchaseAfterRecharge` 失败场景执行统一处理:任务 MUST 自动重试(最多 3 次);全部失败后 MUST 记录错误日志并将 `auto_purchase_status` 置为 `failed`;用户资金 SHALL 保留在钱包中,允许后续手动购买,不得回滚已成功的充值入账。
#### Scenario: 异步任务最终失败
- **WHEN** 自动购买任务连续失败并达到最大重试次数
- **THEN** 系统将 `auto_purchase_status` 标记为 `failed`
- **AND** 钱包余额保持可用,用户可手动下单

View File

@@ -1,7 +1,8 @@
# 微信公众号能力规格说明
## ADDED Requirements
# wechat-official-account Specification
## Purpose
微信公众号能力规范,定义微信 OAuth 2.0 授权登录、账号绑定、OpenID/UnionID 查询、Access Token 中控及配置管理。
## Requirements
### Requirement: 系统必须支持微信 OAuth 2.0 授权登录
系统 SHALL 实现微信公众号 OAuth 2.0 授权流程,允许个人客户通过微信授权获取用户身份信息。
@@ -145,3 +146,48 @@
- **WHEN** 必填配置项AppID、AppSecret缺失
- **THEN** 系统记录 FATAL 级别日志
- **THEN** 系统启动失败并退出
### Requirement: 微信配置源从 YAML 改为数据库动态读取
系统 MUST 将公众号/小程序授权配置源从 YAML 静态配置切换为数据库 `tb_wechat_config` 动态读取(`is_active=true`)。
- 配置读取规则:
- 公众号登录A2使用 `app_id` + `app_secret`
- 小程序登录A3使用 `miniapp_app_id` + `miniapp_app_secret`
- 适配接口:
- `POST /api/c/v1/auth/wechat-login`
- `POST /api/c/v1/auth/miniapp-login`
#### Scenario: 公众号登录读取数据库配置
- **WHEN** 调用 A2 执行 OAuth code 换取 OpenID
- **THEN** 系统 SHALL 从 `tb_wechat_config` 读取当前激活公众号配置
#### Scenario: 小程序登录读取数据库配置
- **WHEN** 调用 A3 执行 jscode2session
- **THEN** 系统 SHALL 从 `tb_wechat_config` 读取当前激活小程序配置
### Requirement: 配置缺失或无激活记录时失败
系统 MUST 在缺少有效数据库配置时拒绝微信登录请求,并返回统一错误。
- 错误码:
- `1041` 微信配置不可用
- `1040` 微信授权失败(第三方调用失败)
#### Scenario: 无激活配置
- **WHEN** `tb_wechat_config` 中不存在 `is_active=true` 记录
- **THEN** 系统 MUST 返回 `1041`
#### Scenario: 配置存在但第三方调用失败
- **WHEN** 已获取数据库配置但调用微信接口失败
- **THEN** 系统 MUST 返回 `1040`
### Requirement: 旧 YAML 配置不再作为登录凭据来源
系统 SHALL 停止在登录链路中使用 `wechat.official_account.*` 静态配置作为 AppID/AppSecret 来源。
#### Scenario: 配置切换后行为一致
- **WHEN** 运维在数据库中更新激活配置
- **THEN** 后续登录请求 SHALL 使用新配置生效
- **THEN** 无需重启服务加载 YAML

View File

@@ -24,7 +24,6 @@ type Config struct {
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
Storage StorageConfig `mapstructure:"storage"`
Gateway GatewayConfig `mapstructure:"gateway"`
Wechat WechatConfig `mapstructure:"wechat"`
}
// ServerConfig HTTP 服务器配置
@@ -157,20 +156,6 @@ type PresignConfig struct {
DownloadExpires time.Duration `mapstructure:"download_expires"` // 下载 URL 有效期默认24h
}
// WechatConfig 微信配置
type WechatConfig struct {
OfficialAccount OfficialAccountConfig `mapstructure:"official_account"`
}
// OfficialAccountConfig 微信公众号配置
type OfficialAccountConfig struct {
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
Token string `mapstructure:"token"`
AESKey string `mapstructure:"aes_key"`
OAuthRedirectURL string `mapstructure:"oauth_redirect_url"`
}
type requiredField struct {
value string
name string

View File

@@ -116,12 +116,3 @@ gateway:
app_secret: "BZeQttaZQt0i73moF"
timeout: 30
# 微信配置(必填项需通过环境变量设置)
wechat:
official_account:
app_id: "" # 必填JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID
app_secret: "" # 必填JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET敏感
token: "" # 可选JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN
aes_key: "" # 可选JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY敏感
oauth_redirect_url: "" # 可选JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL

View File

@@ -8,7 +8,6 @@ import (
"github.com/ArtisanCloud/PowerWeChat/v3/src/officialAccount"
"github.com/ArtisanCloud/PowerWeChat/v3/src/payment"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
@@ -22,37 +21,6 @@ func NewRedisCache(rdb *redis.Client) kernel.CacheInterface {
})
}
// NewOfficialAccountApp 创建微信公众号应用实例
func NewOfficialAccountApp(cfg *config.Config, cache kernel.CacheInterface, logger *zap.Logger) (*officialAccount.OfficialAccount, error) {
oaCfg := cfg.Wechat.OfficialAccount
if oaCfg.AppID == "" || oaCfg.AppSecret == "" {
return nil, fmt.Errorf("微信公众号配置不完整:缺少 AppID 或 AppSecret")
}
userConfig := &officialAccount.UserConfig{
AppID: oaCfg.AppID,
Secret: oaCfg.AppSecret,
Cache: cache,
}
// 可选配置:消息验证 Token 和 AESKey
if oaCfg.Token != "" {
userConfig.Token = oaCfg.Token
}
if oaCfg.AESKey != "" {
userConfig.AESKey = oaCfg.AESKey
}
app, err := officialAccount.NewOfficialAccount(userConfig)
if err != nil {
logger.Error("创建微信公众号应用失败", zap.Error(err))
return nil, fmt.Errorf("创建微信公众号应用失败: %w", err)
}
logger.Info("微信公众号应用初始化成功", zap.String("app_id", oaCfg.AppID))
return app, nil
}
// NewOfficialAccountAppFromConfig 从数据库配置创建微信公众号应用实例
func NewOfficialAccountAppFromConfig(wechatConfig *model.WechatConfig, cache kernel.CacheInterface, logger *zap.Logger) (*officialAccount.OfficialAccount, error) {
if wechatConfig == nil {