feat: 实现 C 端完整认证系统(client-auth-system)
实现面向个人客户的 7 个认证接口(A1-A7),覆盖资产验证、 微信公众号/小程序登录、手机号绑定/换绑、退出登录完整流程。 主要变更: - 新增 PersonalCustomerOpenID 模型,支持多 AppID 多 OpenID 管理 - 实现有状态 JWT(JWT + Redis 双重校验),支持服务端主动失效 - 扩展微信 SDK:小程序 Code2Session + 3 个 DB 动态工厂函数 - 实现 A1 资产验证 IP 限流(30/min)和 A4 三层验证码限流 - 新增 7 个错误码(1180-1186)和 6 个 Redis Key 函数 - 注册 /api/c/v1/auth/* 下 7 个端点并更新 OpenAPI 文档 - 数据库迁移 000083:新建 tb_personal_customer_openid 表
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
@@ -24,6 +25,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
|
||||
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
||||
handlers := openapi.BuildDocHandlers()
|
||||
handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil)
|
||||
handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil)
|
||||
|
||||
// 4. 注册所有路由到文档生成器
|
||||
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
@@ -33,6 +34,7 @@ func generateAdminDocs(outputPath string) error {
|
||||
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
||||
handlers := openapi.BuildDocHandlers()
|
||||
handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil)
|
||||
handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil)
|
||||
|
||||
// 4. 注册所有路由到文档生成器
|
||||
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
141
docs/client-auth-system/功能总结.md
Normal file
141
docs/client-auth-system/功能总结.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# C 端认证系统功能总结
|
||||
|
||||
## 概述
|
||||
|
||||
本次实现了面向个人客户(C 端)的完整认证体系,替代旧 H5 登录接口。支持微信公众号和小程序两种登录方式,基于「资产标识符验证 → 微信授权 → 自动绑定资产 → 可选绑定手机号」的流程。
|
||||
|
||||
## 接口列表
|
||||
|
||||
| 接口 | 路径 | 认证 | 说明 |
|
||||
|------|------|------|------|
|
||||
| A1 | `POST /api/c/v1/auth/verify-asset` | 否 | 资产标识符验证,返回 asset_token |
|
||||
| A2 | `POST /api/c/v1/auth/wechat-login` | 否 | 微信公众号登录 |
|
||||
| A3 | `POST /api/c/v1/auth/miniapp-login` | 否 | 微信小程序登录 |
|
||||
| A4 | `POST /api/c/v1/auth/send-code` | 否 | 发送手机验证码 |
|
||||
| A5 | `POST /api/c/v1/auth/bind-phone` | 是 | 首次绑定手机号 |
|
||||
| A6 | `POST /api/c/v1/auth/change-phone` | 是 | 换绑手机号(双验证码) |
|
||||
| A7 | `POST /api/c/v1/auth/logout` | 是 | 退出登录 |
|
||||
|
||||
## 登录流程
|
||||
|
||||
```
|
||||
用户输入资产标识符(SN/IMEI/ICCID)
|
||||
│
|
||||
▼
|
||||
[A1] verify-asset → asset_token(5分钟有效)
|
||||
│
|
||||
▼
|
||||
微信授权(前端完成)
|
||||
│
|
||||
├── 公众号 → [A2] wechat-login (code + asset_token)
|
||||
└── 小程序 → [A3] miniapp-login (code + asset_token)
|
||||
│
|
||||
▼
|
||||
解析 asset_token → 获取微信 openid
|
||||
→ 查找/创建客户 → 绑定资产
|
||||
→ 签发 JWT + Redis 存储
|
||||
│
|
||||
▼
|
||||
返回 { token, need_bind_phone, is_new_user }
|
||||
│
|
||||
▼
|
||||
need_bind_phone == true?
|
||||
YES → [A4] 发送验证码 → [A5] 绑定手机号
|
||||
NO → 进入主页面
|
||||
```
|
||||
|
||||
## 核心设计
|
||||
|
||||
### 有状态 JWT(JWT + Redis)
|
||||
|
||||
- JWT payload 仅含 `customer_id` + `exp`
|
||||
- 登录时将 token 写入 Redis,TTL 与 JWT 一致
|
||||
- 每次请求在中间件同时校验 JWT 签名和 Redis 有效状态
|
||||
- 支持服务端主动失效(封禁、强制下线、退出登录)
|
||||
- 单点登录:新登录覆盖旧 token
|
||||
|
||||
### OpenID 多记录管理
|
||||
|
||||
- 新增 `tb_personal_customer_openid` 表
|
||||
- 同一客户可在多个 AppID(公众号/小程序)下拥有不同 OpenID
|
||||
- 唯一约束:`UNIQUE(app_id, open_id) WHERE deleted_at IS NULL`
|
||||
- 客户查找逻辑:openid 精确匹配 → unionid 回退合并 → 创建新客户
|
||||
|
||||
### 资产绑定
|
||||
|
||||
- 每次登录创建 `PersonalCustomerDevice` 绑定记录
|
||||
- 同一资产允许被多个客户绑定(支持转手场景)
|
||||
- 首次绑定时自动将资产状态从「在库(1)」更新为「已销售(2)」
|
||||
|
||||
### 微信配置动态加载
|
||||
|
||||
- 登录时从数据库 `tb_wechat_config` 动态读取激活配置
|
||||
- 优先走 WechatConfigService 的 Redis 缓存
|
||||
- 小程序登录直接 HTTP 调用微信 `jscode2session`(不依赖 PowerWeChat SDK)
|
||||
|
||||
## 限流策略
|
||||
|
||||
| 接口 | 维度 | 限制 |
|
||||
|------|------|------|
|
||||
| A1 | IP | 30 次/分钟 |
|
||||
| A4 | 手机号 | 60 秒冷却 |
|
||||
| A4 | IP | 20 次/小时 |
|
||||
| A4 | 手机号 | 10 次/天 |
|
||||
|
||||
## 新增/修改文件
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `internal/model/personal_customer_openid.go` | OpenID 关联模型 |
|
||||
| `internal/model/dto/client_auth_dto.go` | A1-A7 请求/响应 DTO |
|
||||
| `internal/store/postgres/personal_customer_openid_store.go` | OpenID Store |
|
||||
| `internal/service/client_auth/service.go` | 认证 Service(核心业务逻辑) |
|
||||
| `internal/handler/app/client_auth.go` | 认证 Handler(7 个端点) |
|
||||
| `pkg/wechat/miniapp.go` | 小程序 SDK 封装 |
|
||||
| `migrations/000083_add_personal_customer_openid.up.sql` | 迁移文件 |
|
||||
| `migrations/000083_add_personal_customer_openid.down.sql` | 回滚文件 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `internal/middleware/personal_auth.go` | 增加 Redis 双重校验 |
|
||||
| `pkg/constants/redis.go` | 新增 token 和限流 Redis Key |
|
||||
| `pkg/errors/codes.go` | 新增错误码 1180-1186 |
|
||||
| `pkg/config/defaults/config.yaml` | 新增 `client.require_phone_binding` |
|
||||
| `pkg/wechat/wechat.go` | 新增 MiniAppServiceInterface |
|
||||
| `pkg/wechat/config.go` | 新增 3 个 DB 动态工厂函数 |
|
||||
| `internal/bootstrap/types.go` | 新增 ClientAuth Handler 字段 |
|
||||
| `internal/bootstrap/handlers.go` | 实例化 ClientAuth Handler |
|
||||
| `internal/bootstrap/services.go` | 初始化 ClientAuth Service |
|
||||
| `internal/bootstrap/stores.go` | 初始化 OpenID Store |
|
||||
| `internal/routes/personal.go` | 注册 7 个认证端点 |
|
||||
| `cmd/api/docs.go` | 注册文档生成器 |
|
||||
| `cmd/gendocs/main.go` | 注册文档生成器 |
|
||||
|
||||
## 错误码
|
||||
|
||||
| 码值 | 常量名 | 说明 |
|
||||
|------|--------|------|
|
||||
| 1180 | CodeAssetNotFound | 资产不存在 |
|
||||
| 1181 | CodeWechatConfigUnavailable | 微信配置不可用 |
|
||||
| 1182 | CodeSmsSendFailed | 短信发送失败 |
|
||||
| 1183 | CodeVerificationCodeInvalid | 验证码错误或已过期 |
|
||||
| 1184 | CodePhoneAlreadyBound | 手机号已被其他客户绑定 |
|
||||
| 1185 | CodeAlreadyBoundPhone | 已绑定手机号不可重复绑定 |
|
||||
| 1186 | CodeOldPhoneMismatch | 旧手机号与当前绑定不匹配 |
|
||||
|
||||
## 数据库变更
|
||||
|
||||
- 新建表 `tb_personal_customer_openid`(迁移 000083)
|
||||
- 唯一索引:`idx_pco_app_id_open_id` (app_id, open_id) 软删除条件
|
||||
- 普通索引:`idx_pco_customer_id` (customer_id)
|
||||
- 条件索引:`idx_pco_union_id` (union_id) WHERE union_id != ''
|
||||
|
||||
## 配置项
|
||||
|
||||
| 配置路径 | 环境变量 | 默认值 | 说明 |
|
||||
|---------|---------|-------|------|
|
||||
| `client.require_phone_binding` | `JUNHONG_CLIENT_REQUIRE_PHONE_BINDING` | `true` | 是否要求绑定手机号 |
|
||||
@@ -17,6 +17,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
Role: admin.NewRoleHandler(svc.Role, validate),
|
||||
Permission: admin.NewPermissionHandler(svc.Permission),
|
||||
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
|
||||
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
|
||||
Shop: admin.NewShopHandler(svc.Shop),
|
||||
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
||||
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
||||
|
||||
@@ -22,7 +22,7 @@ func initMiddlewares(deps *Dependencies, stores *stores) *Middlewares {
|
||||
jwtManager := pkgauth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration)
|
||||
|
||||
// 创建个人客户认证中间件
|
||||
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Logger)
|
||||
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Redis, deps.Logger)
|
||||
|
||||
// 创建 Token Manager(用于后台和H5认证)
|
||||
accessTTL := time.Duration(cfg.JWT.AccessTokenTTL) * time.Second
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
|
||||
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
|
||||
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
|
||||
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
|
||||
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
||||
@@ -47,6 +48,7 @@ type services struct {
|
||||
Role *roleSvc.Service
|
||||
Permission *permissionSvc.Service
|
||||
PersonalCustomer *personalCustomerSvc.Service
|
||||
ClientAuth *clientAuthSvc.Service
|
||||
Shop *shopSvc.Service
|
||||
Auth *authSvc.Service
|
||||
ShopCommission *shopCommissionSvc.Service
|
||||
@@ -107,6 +109,20 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
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),
|
||||
ClientAuth: clientAuthSvc.New(
|
||||
deps.DB,
|
||||
s.PersonalCustomerOpenID,
|
||||
s.PersonalCustomer,
|
||||
s.PersonalCustomerDevice,
|
||||
s.PersonalCustomerPhone,
|
||||
s.IotCard,
|
||||
s.Device,
|
||||
wechatConfig,
|
||||
deps.VerificationService,
|
||||
deps.JWTManager,
|
||||
deps.Redis,
|
||||
deps.Logger,
|
||||
),
|
||||
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet),
|
||||
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
|
||||
ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord),
|
||||
|
||||
@@ -14,6 +14,8 @@ type stores struct {
|
||||
ShopRole *postgres.ShopRoleStore
|
||||
RolePermission *postgres.RolePermissionStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerOpenID *postgres.PersonalCustomerOpenIDStore
|
||||
PersonalCustomerDevice *postgres.PersonalCustomerDeviceStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
|
||||
CommissionRecord *postgres.CommissionRecordStore
|
||||
@@ -68,6 +70,8 @@ func initStores(deps *Dependencies) *stores {
|
||||
ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis),
|
||||
RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerOpenID: postgres.NewPersonalCustomerOpenIDStore(deps.DB),
|
||||
PersonalCustomerDevice: postgres.NewPersonalCustomerDeviceStore(deps.DB),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
|
||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||
|
||||
@@ -15,6 +15,7 @@ type Handlers struct {
|
||||
Role *admin.RoleHandler
|
||||
Permission *admin.PermissionHandler
|
||||
PersonalCustomer *app.PersonalCustomerHandler
|
||||
ClientAuth *app.ClientAuthHandler
|
||||
Shop *admin.ShopHandler
|
||||
ShopRole *admin.ShopRoleHandler
|
||||
AdminAuth *admin.AuthHandler
|
||||
|
||||
165
internal/handler/app/client_auth.go
Normal file
165
internal/handler/app/client_auth.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var clientAuthValidator = validator.New()
|
||||
|
||||
// ClientAuthHandler C 端认证处理器
|
||||
type ClientAuthHandler struct {
|
||||
service *clientAuthSvc.Service
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewClientAuthHandler 创建 C 端认证处理器
|
||||
func NewClientAuthHandler(service *clientAuthSvc.Service, logger *zap.Logger) *ClientAuthHandler {
|
||||
return &ClientAuthHandler{service: service, logger: logger}
|
||||
}
|
||||
|
||||
// VerifyAsset A1 资产验证
|
||||
// POST /api/c/v1/auth/verify-asset
|
||||
func (h *ClientAuthHandler) VerifyAsset(c *fiber.Ctx) error {
|
||||
var req dto.VerifyAssetRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("资产验证参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.VerifyAsset(c.UserContext(), &req, c.IP())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// WechatLogin A2 公众号登录
|
||||
// POST /api/c/v1/auth/wechat-login
|
||||
func (h *ClientAuthHandler) WechatLogin(c *fiber.Ctx) error {
|
||||
var req dto.WechatLoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("公众号登录参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.WechatLogin(c.UserContext(), &req, c.IP())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// MiniappLogin A3 小程序登录
|
||||
// POST /api/c/v1/auth/miniapp-login
|
||||
func (h *ClientAuthHandler) MiniappLogin(c *fiber.Ctx) error {
|
||||
var req dto.MiniappLoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("小程序登录参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.MiniappLogin(c.UserContext(), &req, c.IP())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// SendCode A4 发送验证码
|
||||
// POST /api/c/v1/auth/send-code
|
||||
func (h *ClientAuthHandler) SendCode(c *fiber.Ctx) error {
|
||||
var req dto.ClientSendCodeRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("发送验证码参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.SendCode(c.UserContext(), &req, c.IP())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// BindPhone A5 绑定手机号
|
||||
// POST /api/c/v1/auth/bind-phone
|
||||
func (h *ClientAuthHandler) BindPhone(c *fiber.Ctx) error {
|
||||
customerID, ok := middleware.GetCustomerID(c)
|
||||
if !ok || customerID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
|
||||
var req dto.BindPhoneRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("绑定手机号参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.BindPhone(c.UserContext(), customerID, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// ChangePhone A6 更换手机号
|
||||
// POST /api/c/v1/auth/change-phone
|
||||
func (h *ClientAuthHandler) ChangePhone(c *fiber.Ctx) error {
|
||||
customerID, ok := middleware.GetCustomerID(c)
|
||||
if !ok || customerID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
|
||||
var req dto.ChangePhoneRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("更换手机号参数校验失败", zap.Error(err))
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
resp, err := h.service.ChangePhone(c.UserContext(), customerID, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
|
||||
// Logout A7 退出登录
|
||||
// POST /api/c/v1/auth/logout
|
||||
func (h *ClientAuthHandler) Logout(c *fiber.Ctx) error {
|
||||
customerID, ok := middleware.GetCustomerID(c)
|
||||
if !ok || customerID == 0 {
|
||||
return errors.New(errors.CodeUnauthorized)
|
||||
}
|
||||
|
||||
resp, err := h.service.Logout(c.UserContext(), customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, resp)
|
||||
}
|
||||
@@ -1,32 +1,37 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PersonalAuthMiddleware 个人客户认证中间件
|
||||
type PersonalAuthMiddleware struct {
|
||||
jwtManager *auth.JWTManager
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPersonalAuthMiddleware 创建个人客户认证中间件
|
||||
func NewPersonalAuthMiddleware(jwtManager *auth.JWTManager, logger *zap.Logger) *PersonalAuthMiddleware {
|
||||
func NewPersonalAuthMiddleware(jwtManager *auth.JWTManager, rdb *redis.Client, logger *zap.Logger) *PersonalAuthMiddleware {
|
||||
return &PersonalAuthMiddleware{
|
||||
jwtManager: jwtManager,
|
||||
redis: rdb,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate 认证中间件
|
||||
// JWT + Redis 双重校验:先验证 JWT 签名和有效期,再检查 Redis 中 token 是否存在
|
||||
func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// 从 Authorization header 获取 token
|
||||
authHeader := c.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
m.logger.Warn("个人客户认证失败:缺少 Authorization header",
|
||||
@@ -36,7 +41,6 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
||||
return errors.New(errors.CodeUnauthorized, "未提供认证令牌")
|
||||
}
|
||||
|
||||
// 检查 Bearer 前缀
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
m.logger.Warn("个人客户认证失败:Authorization header 格式错误",
|
||||
@@ -48,7 +52,6 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
||||
|
||||
token := parts[1]
|
||||
|
||||
// 验证 token
|
||||
claims, err := m.jwtManager.VerifyPersonalCustomerToken(token)
|
||||
if err != nil {
|
||||
m.logger.Warn("个人客户认证失败:token 验证失败",
|
||||
@@ -58,12 +61,35 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
|
||||
return errors.New(errors.CodeUnauthorized, "认证令牌无效或已过期")
|
||||
}
|
||||
|
||||
// 将客户信息存储到 context 中
|
||||
// Redis 有效性检查:token 必须在 Redis 中存在才视为有效
|
||||
// 支持服务端主动失效(封禁/强制下线/退出登录)
|
||||
redisKey := constants.RedisPersonalCustomerTokenKey(claims.CustomerID)
|
||||
storedToken, redisErr := m.redis.Get(context.Background(), redisKey).Result()
|
||||
if redisErr == redis.Nil {
|
||||
m.logger.Warn("个人客户认证失败:token 已被服务端失效",
|
||||
zap.Uint("customer_id", claims.CustomerID),
|
||||
zap.String("path", c.Path()),
|
||||
)
|
||||
return errors.New(errors.CodeUnauthorized, "认证令牌已失效,请重新登录")
|
||||
}
|
||||
if redisErr != nil {
|
||||
m.logger.Error("个人客户认证:Redis 查询异常",
|
||||
zap.Uint("customer_id", claims.CustomerID),
|
||||
zap.Error(redisErr),
|
||||
)
|
||||
return errors.New(errors.CodeUnauthorized, "认证服务异常,请稍后重试")
|
||||
}
|
||||
// 比对 Redis 中存储的 token 与当前请求 token 是否一致
|
||||
if storedToken != token {
|
||||
m.logger.Warn("个人客户认证失败:token 不匹配(可能已在其他设备登录)",
|
||||
zap.Uint("customer_id", claims.CustomerID),
|
||||
zap.String("path", c.Path()),
|
||||
)
|
||||
return errors.New(errors.CodeUnauthorized, "认证令牌已失效,请重新登录")
|
||||
}
|
||||
|
||||
c.Locals("customer_id", claims.CustomerID)
|
||||
c.Locals("customer_phone", claims.Phone)
|
||||
|
||||
// 设置 SkipOwnerFilter 标记,跳过 B 端数据权限过滤
|
||||
// 个人客户不参与 RBAC 权限体系,不需要 Owner 过滤
|
||||
c.Locals("skip_owner_filter", true)
|
||||
|
||||
m.logger.Debug("个人客户认证成功",
|
||||
|
||||
103
internal/model/dto/client_auth_dto.go
Normal file
103
internal/model/dto/client_auth_dto.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package dto
|
||||
|
||||
// ========================================
|
||||
// A1 资产验证
|
||||
// ========================================
|
||||
|
||||
// VerifyAssetRequest A1 资产验证请求
|
||||
type VerifyAssetRequest struct {
|
||||
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"`
|
||||
}
|
||||
|
||||
// VerifyAssetResponse A1 资产验证响应
|
||||
type VerifyAssetResponse struct {
|
||||
AssetToken string `json:"asset_token" description:"资产令牌(5分钟有效)"`
|
||||
ExpiresIn int `json:"expires_in" description:"过期时间(秒)"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A2 公众号登录
|
||||
// ========================================
|
||||
|
||||
// WechatLoginRequest A2 公众号登录请求
|
||||
type WechatLoginRequest struct {
|
||||
Code string `json:"code" validate:"required" required:"true" description:"微信OAuth授权码"`
|
||||
AssetToken string `json:"asset_token" validate:"required" required:"true" description:"A1返回的资产令牌"`
|
||||
}
|
||||
|
||||
// WechatLoginResponse A2/A3 登录统一响应
|
||||
type WechatLoginResponse struct {
|
||||
Token string `json:"token" description:"登录JWT令牌"`
|
||||
NeedBindPhone bool `json:"need_bind_phone" description:"是否需要绑定手机号"`
|
||||
IsNewUser bool `json:"is_new_user" description:"是否新创建用户"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A3 小程序登录
|
||||
// ========================================
|
||||
|
||||
// MiniappLoginRequest A3 小程序登录请求
|
||||
type MiniappLoginRequest struct {
|
||||
Code string `json:"code" validate:"required" required:"true" description:"小程序登录凭证"`
|
||||
AssetToken string `json:"asset_token" validate:"required" required:"true" description:"A1返回的资产令牌"`
|
||||
Nickname string `json:"nickname" description:"用户昵称(前端授权后传入)"`
|
||||
AvatarURL string `json:"avatar_url" description:"用户头像URL(前端授权后传入)"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A4 发送验证码
|
||||
// ========================================
|
||||
|
||||
// ClientSendCodeRequest A4 发送验证码请求
|
||||
type ClientSendCodeRequest struct {
|
||||
Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"手机号"`
|
||||
Scene string `json:"scene" validate:"required,oneof=bind_phone change_phone_old change_phone_new" required:"true" description:"业务场景 (bind_phone:绑定手机号, change_phone_old:换绑旧手机, change_phone_new:换绑新手机)"`
|
||||
}
|
||||
|
||||
// ClientSendCodeResponse A4 发送验证码响应
|
||||
type ClientSendCodeResponse struct {
|
||||
CooldownSeconds int `json:"cooldown_seconds" description:"冷却秒数"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A5 绑定手机号
|
||||
// ========================================
|
||||
|
||||
// BindPhoneRequest A5 绑定手机号请求
|
||||
type BindPhoneRequest struct {
|
||||
Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"手机号"`
|
||||
Code string `json:"code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"验证码"`
|
||||
}
|
||||
|
||||
// BindPhoneResponse A5 绑定手机号响应
|
||||
type BindPhoneResponse struct {
|
||||
Phone string `json:"phone" description:"已绑定手机号"`
|
||||
BoundAt string `json:"bound_at" description:"绑定时间"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A6 换绑手机号
|
||||
// ========================================
|
||||
|
||||
// ChangePhoneRequest A6 换绑手机号请求
|
||||
type ChangePhoneRequest struct {
|
||||
OldPhone string `json:"old_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"旧手机号"`
|
||||
OldCode string `json:"old_code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"旧手机号验证码"`
|
||||
NewPhone string `json:"new_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"新手机号"`
|
||||
NewCode string `json:"new_code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"新手机号验证码"`
|
||||
}
|
||||
|
||||
// ChangePhoneResponse A6 换绑手机号响应
|
||||
type ChangePhoneResponse struct {
|
||||
Phone string `json:"phone" description:"换绑后手机号"`
|
||||
ChangedAt string `json:"changed_at" description:"换绑时间"`
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// A7 退出登录
|
||||
// ========================================
|
||||
|
||||
// LogoutResponse A7 退出登录响应
|
||||
type LogoutResponse struct {
|
||||
Success bool `json:"success" description:"是否成功"`
|
||||
}
|
||||
23
internal/model/personal_customer_openid.go
Normal file
23
internal/model/personal_customer_openid.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PersonalCustomerOpenID 个人客户 OpenID 关联模型
|
||||
// 保存客户在不同微信应用(公众号/小程序)下的 OpenID 记录
|
||||
// 同一客户可在多个 AppID 下拥有不同的 OpenID
|
||||
// 唯一约束:UNIQUE(app_id, open_id) WHERE deleted_at IS NULL
|
||||
type PersonalCustomerOpenID struct {
|
||||
gorm.Model
|
||||
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;index:idx_pco_customer_id;comment:关联个人客户ID" json:"customer_id"`
|
||||
AppID string `gorm:"column:app_id;type:varchar(100);not null;comment:微信应用标识(公众号或小程序AppID)" json:"app_id"`
|
||||
OpenID string `gorm:"column:open_id;type:varchar(100);not null;comment:当前应用下的OpenID" json:"open_id"`
|
||||
UnionID string `gorm:"column:union_id;type:varchar(100);not null;default:'';comment:微信开放平台统一标识(可选)" json:"union_id"`
|
||||
AppType string `gorm:"column:app_type;type:varchar(20);not null;default:'';comment:应用类型(official_account/miniapp)" json:"app_type"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (PersonalCustomerOpenID) TableName() string {
|
||||
return "tb_personal_customer_openid"
|
||||
}
|
||||
@@ -6,12 +6,74 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// RegisterPersonalCustomerRoutes 注册个人客户路由
|
||||
// 路由挂载在 /api/c/v1 下
|
||||
func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers, personalAuthMiddleware *middleware.PersonalAuthMiddleware) {
|
||||
authBasePath := "/auth"
|
||||
authPublicGroup := router.Group(authBasePath)
|
||||
authProtectedGroup := router.Group(authBasePath)
|
||||
authProtectedGroup.Use(personalAuthMiddleware.Authenticate())
|
||||
|
||||
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/verify-asset", handlers.ClientAuth.VerifyAsset, RouteSpec{
|
||||
Summary: "资产验证",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: false,
|
||||
Input: &dto.VerifyAssetRequest{},
|
||||
Output: &dto.VerifyAssetResponse{},
|
||||
})
|
||||
|
||||
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/wechat-login", handlers.ClientAuth.WechatLogin, RouteSpec{
|
||||
Summary: "公众号登录",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: false,
|
||||
Input: &dto.WechatLoginRequest{},
|
||||
Output: &dto.WechatLoginResponse{},
|
||||
})
|
||||
|
||||
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/miniapp-login", handlers.ClientAuth.MiniappLogin, RouteSpec{
|
||||
Summary: "小程序登录",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: false,
|
||||
Input: &dto.MiniappLoginRequest{},
|
||||
Output: &dto.WechatLoginResponse{},
|
||||
})
|
||||
|
||||
Register(authPublicGroup, doc, basePath+authBasePath, "POST", "/send-code", handlers.ClientAuth.SendCode, RouteSpec{
|
||||
Summary: "发送验证码",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: false,
|
||||
Input: &dto.ClientSendCodeRequest{},
|
||||
Output: &dto.ClientSendCodeResponse{},
|
||||
})
|
||||
|
||||
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/bind-phone", handlers.ClientAuth.BindPhone, RouteSpec{
|
||||
Summary: "绑定手机号",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: true,
|
||||
Input: &dto.BindPhoneRequest{},
|
||||
Output: &dto.BindPhoneResponse{},
|
||||
})
|
||||
|
||||
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/change-phone", handlers.ClientAuth.ChangePhone, RouteSpec{
|
||||
Summary: "更换手机号",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: true,
|
||||
Input: &dto.ChangePhoneRequest{},
|
||||
Output: &dto.ChangePhoneResponse{},
|
||||
})
|
||||
|
||||
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/logout", handlers.ClientAuth.Logout, RouteSpec{
|
||||
Summary: "退出登录",
|
||||
Tags: []string{"个人客户 - 认证"},
|
||||
Auth: true,
|
||||
Input: nil,
|
||||
Output: &dto.LogoutResponse{},
|
||||
})
|
||||
|
||||
// 需要认证的路由
|
||||
authGroup := router.Group("")
|
||||
authGroup.Use(personalAuthMiddleware.Authenticate())
|
||||
|
||||
761
internal/service/client_auth/service.go
Normal file
761
internal/service/client_auth/service.go
Normal file
@@ -0,0 +1,761 @@
|
||||
// Package client_auth 提供 C 端认证业务逻辑
|
||||
// 包含资产验证、微信登录、手机号绑定与退出登录等能力
|
||||
package client_auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
|
||||
"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"
|
||||
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/spf13/viper"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
assetTypeIotCard = "iot_card"
|
||||
assetTypeDevice = "device"
|
||||
|
||||
appTypeOfficialAccount = "official_account"
|
||||
appTypeMiniapp = "miniapp"
|
||||
|
||||
assetTokenExpireSeconds = 300
|
||||
)
|
||||
|
||||
var identifierRegex = regexp.MustCompile(`^[A-Za-z0-9-]{1,50}$`)
|
||||
|
||||
// Service C 端认证服务
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
openidStore *postgres.PersonalCustomerOpenIDStore
|
||||
customerStore *postgres.PersonalCustomerStore
|
||||
deviceBindStore *postgres.PersonalCustomerDeviceStore
|
||||
phoneStore *postgres.PersonalCustomerPhoneStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
deviceStore *postgres.DeviceStore
|
||||
wechatConfigService *wechatConfigSvc.Service
|
||||
verificationService *verification.Service
|
||||
jwtManager *auth.JWTManager
|
||||
redis *redis.Client
|
||||
logger *zap.Logger
|
||||
wechatCache kernel.CacheInterface
|
||||
}
|
||||
|
||||
// New 创建 C 端认证服务实例
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
openidStore *postgres.PersonalCustomerOpenIDStore,
|
||||
customerStore *postgres.PersonalCustomerStore,
|
||||
deviceBindStore *postgres.PersonalCustomerDeviceStore,
|
||||
phoneStore *postgres.PersonalCustomerPhoneStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
wechatConfigService *wechatConfigSvc.Service,
|
||||
verificationService *verification.Service,
|
||||
jwtManager *auth.JWTManager,
|
||||
redisClient *redis.Client,
|
||||
logger *zap.Logger,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
openidStore: openidStore,
|
||||
customerStore: customerStore,
|
||||
deviceBindStore: deviceBindStore,
|
||||
phoneStore: phoneStore,
|
||||
iotCardStore: iotCardStore,
|
||||
deviceStore: deviceStore,
|
||||
wechatConfigService: wechatConfigService,
|
||||
verificationService: verificationService,
|
||||
jwtManager: jwtManager,
|
||||
redis: redisClient,
|
||||
logger: logger,
|
||||
wechatCache: wechat.NewRedisCache(redisClient),
|
||||
}
|
||||
}
|
||||
|
||||
type assetTokenClaims struct {
|
||||
AssetType string `json:"asset_type"`
|
||||
AssetID uint `json:"asset_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// VerifyAsset A1 验证资产并签发短期资产令牌
|
||||
func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest, clientIP string) (*dto.VerifyAssetResponse, error) {
|
||||
if req == nil || !identifierRegex.MatchString(req.Identifier) {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
if err := s.checkAssetVerifyRateLimit(ctx, clientIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assetType, assetID, err := s.resolveAsset(ctx, req.Identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assetToken, err := s.signAssetToken(assetType, assetID)
|
||||
if err != nil {
|
||||
s.logger.Error("签发资产令牌失败", zap.Error(err))
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "签发资产令牌失败")
|
||||
}
|
||||
|
||||
return &dto.VerifyAssetResponse{
|
||||
AssetToken: assetToken,
|
||||
ExpiresIn: assetTokenExpireSeconds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WechatLogin A2 公众号登录
|
||||
func (s *Service) WechatLogin(ctx context.Context, req *dto.WechatLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
assetClaims, err := s.verifyAssetToken(req.AssetToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wechatConfig == nil {
|
||||
return nil, errors.New(errors.CodeWechatConfigUnavailable)
|
||||
}
|
||||
|
||||
oaApp, err := wechat.NewOfficialAccountAppFromConfig(wechatConfig, s.wechatCache, s.logger)
|
||||
if err != nil {
|
||||
s.logger.Error("创建公众号实例失败", zap.Error(err))
|
||||
return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "微信公众号配置不可用")
|
||||
}
|
||||
oaService := wechat.NewOfficialAccountService(oaApp, s.logger)
|
||||
|
||||
userInfo, err := oaService.GetUserInfoDetailed(ctx, req.Code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
customerID, isNewUser, err := s.loginByOpenID(
|
||||
ctx,
|
||||
assetClaims.AssetType,
|
||||
assetClaims.AssetID,
|
||||
wechatConfig.OaAppID,
|
||||
userInfo.OpenID,
|
||||
userInfo.UnionID,
|
||||
userInfo.Nickname,
|
||||
userInfo.Avatar,
|
||||
appTypeOfficialAccount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, needBindPhone, err := s.issueLoginToken(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("公众号登录成功",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.String("client_ip", clientIP),
|
||||
)
|
||||
|
||||
return &dto.WechatLoginResponse{
|
||||
Token: token,
|
||||
NeedBindPhone: needBindPhone,
|
||||
IsNewUser: isNewUser,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MiniappLogin A3 小程序登录
|
||||
func (s *Service) MiniappLogin(ctx context.Context, req *dto.MiniappLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
assetClaims, err := s.verifyAssetToken(req.AssetToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wechatConfig == nil {
|
||||
return nil, errors.New(errors.CodeWechatConfigUnavailable)
|
||||
}
|
||||
|
||||
miniService, err := wechat.NewMiniAppServiceFromConfig(wechatConfig, s.logger)
|
||||
if err != nil {
|
||||
s.logger.Error("创建小程序服务失败", zap.Error(err))
|
||||
return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "小程序配置不可用")
|
||||
}
|
||||
|
||||
openID, unionID, _, err := miniService.Code2Session(ctx, req.Code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
customerID, isNewUser, err := s.loginByOpenID(
|
||||
ctx,
|
||||
assetClaims.AssetType,
|
||||
assetClaims.AssetID,
|
||||
wechatConfig.MiniappAppID,
|
||||
openID,
|
||||
unionID,
|
||||
req.Nickname,
|
||||
req.AvatarURL,
|
||||
appTypeMiniapp,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, needBindPhone, err := s.issueLoginToken(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("小程序登录成功",
|
||||
zap.Uint("customer_id", customerID),
|
||||
zap.String("client_ip", clientIP),
|
||||
)
|
||||
|
||||
return &dto.WechatLoginResponse{
|
||||
Token: token,
|
||||
NeedBindPhone: needBindPhone,
|
||||
IsNewUser: isNewUser,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SendCode A4 发送验证码
|
||||
func (s *Service) SendCode(ctx context.Context, req *dto.ClientSendCodeRequest, clientIP string) (*dto.ClientSendCodeResponse, error) {
|
||||
if req == nil || req.Phone == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
if err := s.checkSendCodeRateLimit(ctx, req.Phone, clientIP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.verificationService.SendCode(ctx, req.Phone); err != nil {
|
||||
s.logger.Error("发送验证码失败", zap.String("phone", req.Phone), zap.Error(err))
|
||||
return nil, errors.Wrap(errors.CodeSmsSendFailed, err, "发送验证码失败")
|
||||
}
|
||||
|
||||
cooldownKey := constants.RedisClientSendCodePhoneLimitKey(req.Phone)
|
||||
if err := s.redis.Set(ctx, cooldownKey, "1", 60*time.Second).Err(); err != nil {
|
||||
s.logger.Error("设置验证码冷却键失败", zap.String("phone", req.Phone), zap.Error(err))
|
||||
return nil, errors.Wrap(errors.CodeRedisError, err, "设置验证码冷却失败")
|
||||
}
|
||||
|
||||
return &dto.ClientSendCodeResponse{CooldownSeconds: 60}, nil
|
||||
}
|
||||
|
||||
// BindPhone A5 绑定手机号
|
||||
func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindPhoneRequest) (*dto.BindPhoneResponse, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == nil {
|
||||
return nil, errors.New(errors.CodeAlreadyBoundPhone)
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
|
||||
}
|
||||
|
||||
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
}
|
||||
|
||||
if existed, err := s.phoneStore.GetByPhone(ctx, req.Phone); err == nil {
|
||||
if existed.CustomerID != customerID {
|
||||
return nil, errors.New(errors.CodePhoneAlreadyBound)
|
||||
}
|
||||
return nil, errors.New(errors.CodeAlreadyBoundPhone)
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
|
||||
}
|
||||
|
||||
record := &model.PersonalCustomerPhone{
|
||||
CustomerID: customerID,
|
||||
Phone: req.Phone,
|
||||
IsPrimary: true,
|
||||
Status: 1,
|
||||
}
|
||||
if err := s.phoneStore.Create(ctx, record); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
|
||||
}
|
||||
|
||||
return &dto.BindPhoneResponse{
|
||||
Phone: req.Phone,
|
||||
BoundAt: record.VerifiedAt.Format("2006-01-02 15:04:05"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ChangePhone A6 换绑手机号
|
||||
func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.ChangePhoneRequest) (*dto.ChangePhoneResponse, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
primary, err := s.phoneStore.GetPrimaryPhone(ctx, customerID)
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeOldPhoneMismatch)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
|
||||
}
|
||||
|
||||
if primary.Phone != req.OldPhone {
|
||||
return nil, errors.New(errors.CodeOldPhoneMismatch)
|
||||
}
|
||||
|
||||
if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
}
|
||||
if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
}
|
||||
|
||||
if existed, err := s.phoneStore.GetByPhone(ctx, req.NewPhone); err == nil && existed.CustomerID != customerID {
|
||||
return nil, errors.New(errors.CodePhoneAlreadyBound)
|
||||
} else if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := s.db.WithContext(ctx).Model(&model.PersonalCustomerPhone{}).
|
||||
Where("id = ? AND customer_id = ?", primary.ID, customerID).
|
||||
Updates(map[string]any{
|
||||
"phone": req.NewPhone,
|
||||
"verified_at": now,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新手机号失败")
|
||||
}
|
||||
|
||||
return &dto.ChangePhoneResponse{
|
||||
Phone: req.NewPhone,
|
||||
ChangedAt: now.Format("2006-01-02 15:04:05"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Logout A7 退出登录
|
||||
func (s *Service) Logout(ctx context.Context, customerID uint) (*dto.LogoutResponse, error) {
|
||||
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
|
||||
if err := s.redis.Del(ctx, redisKey).Err(); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeRedisError, err, "退出登录失败")
|
||||
}
|
||||
|
||||
return &dto.LogoutResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
func (s *Service) checkAssetVerifyRateLimit(ctx context.Context, clientIP string) error {
|
||||
if clientIP == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
key := constants.RedisClientAuthRateLimitIPKey(clientIP)
|
||||
count, err := s.redis.Incr(ctx, key).Result()
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeRedisError, err, "校验资产限流失败")
|
||||
}
|
||||
if count == 1 {
|
||||
if expErr := s.redis.Expire(ctx, key, 60*time.Second).Err(); expErr != nil {
|
||||
return errors.Wrap(errors.CodeRedisError, expErr, "设置资产限流过期时间失败")
|
||||
}
|
||||
}
|
||||
if count > 30 {
|
||||
return errors.New(errors.CodeTooManyRequests)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveAsset(ctx context.Context, identifier string) (string, uint, error) {
|
||||
var card model.IotCard
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("iccid = ?", identifier).
|
||||
First(&card).Error; err == nil {
|
||||
return assetTypeIotCard, card.ID, nil
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
|
||||
}
|
||||
|
||||
var device model.Device
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("virtual_no = ? OR imei = ?", identifier, identifier).
|
||||
First(&device).Error; err == nil {
|
||||
return assetTypeDevice, device.ID, nil
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
|
||||
}
|
||||
|
||||
return "", 0, errors.New(errors.CodeAssetNotFound)
|
||||
}
|
||||
|
||||
func (s *Service) signAssetToken(assetType string, assetID uint) (string, error) {
|
||||
now := time.Now()
|
||||
claims := &assetTokenClaims{
|
||||
AssetType: assetType,
|
||||
AssetID: assetID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(viper.GetString("jwt.secret_key") + ":asset"))
|
||||
}
|
||||
|
||||
func (s *Service) verifyAssetToken(assetToken string) (*assetTokenClaims, error) {
|
||||
if assetToken == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
parsed, err := jwt.ParseWithClaims(assetToken, &assetTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New(errors.CodeInvalidToken)
|
||||
}
|
||||
return []byte(viper.GetString("jwt.secret_key") + ":asset"), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidToken)
|
||||
}
|
||||
|
||||
claims, ok := parsed.Claims.(*assetTokenClaims)
|
||||
if !ok || !parsed.Valid || claims.AssetID == 0 || claims.AssetType == "" {
|
||||
return nil, errors.New(errors.CodeInvalidToken)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *Service) loginByOpenID(
|
||||
ctx context.Context,
|
||||
assetType string,
|
||||
assetID uint,
|
||||
appID string,
|
||||
openID string,
|
||||
unionID string,
|
||||
nickname string,
|
||||
avatar string,
|
||||
appType string,
|
||||
) (uint, bool, error) {
|
||||
var (
|
||||
customerID uint
|
||||
isNewUser bool
|
||||
)
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
cid, created, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType)
|
||||
if findErr != nil {
|
||||
return findErr
|
||||
}
|
||||
if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil {
|
||||
return bindErr
|
||||
}
|
||||
|
||||
customerID = cid
|
||||
isNewUser = created
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
|
||||
return customerID, isNewUser, nil
|
||||
}
|
||||
|
||||
// findOrCreateCustomer 根据 OpenID/UnionID 查找或创建客户
|
||||
func (s *Service) findOrCreateCustomer(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
appID string,
|
||||
openID string,
|
||||
unionID string,
|
||||
nickname string,
|
||||
avatar string,
|
||||
appType string,
|
||||
) (uint, bool, error) {
|
||||
openidStore := postgres.NewPersonalCustomerOpenIDStore(tx)
|
||||
customerStore := postgres.NewPersonalCustomerStore(tx, s.redis)
|
||||
|
||||
if existed, err := openidStore.FindByAppIDAndOpenID(ctx, appID, openID); err == nil {
|
||||
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
|
||||
if getErr != nil {
|
||||
if getErr == gorm.ErrRecordNotFound {
|
||||
return 0, false, errors.New(errors.CodeCustomerNotFound)
|
||||
}
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
|
||||
}
|
||||
if customer.Status == 0 {
|
||||
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
|
||||
}
|
||||
|
||||
if nickname != "" && customer.Nickname != nickname {
|
||||
customer.Nickname = nickname
|
||||
}
|
||||
if avatar != "" && customer.AvatarURL != avatar {
|
||||
customer.AvatarURL = avatar
|
||||
}
|
||||
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
|
||||
}
|
||||
return customer.ID, false, nil
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败")
|
||||
}
|
||||
|
||||
if unionID != "" {
|
||||
if existed, err := openidStore.FindByUnionID(ctx, unionID); err == nil {
|
||||
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
|
||||
if getErr != nil {
|
||||
if getErr == gorm.ErrRecordNotFound {
|
||||
return 0, false, errors.New(errors.CodeCustomerNotFound)
|
||||
}
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
|
||||
}
|
||||
if customer.Status == 0 {
|
||||
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
|
||||
}
|
||||
|
||||
record := &model.PersonalCustomerOpenID{
|
||||
CustomerID: customer.ID,
|
||||
AppID: appID,
|
||||
OpenID: openID,
|
||||
UnionID: unionID,
|
||||
AppType: appType,
|
||||
}
|
||||
if createErr := openidStore.Create(ctx, record); createErr != nil {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败")
|
||||
}
|
||||
|
||||
if nickname != "" && customer.Nickname != nickname {
|
||||
customer.Nickname = nickname
|
||||
}
|
||||
if avatar != "" && customer.AvatarURL != avatar {
|
||||
customer.AvatarURL = avatar
|
||||
}
|
||||
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
|
||||
}
|
||||
|
||||
return customer.ID, false, nil
|
||||
} else if err != gorm.ErrRecordNotFound {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败")
|
||||
}
|
||||
}
|
||||
|
||||
newCustomer := &model.PersonalCustomer{
|
||||
WxOpenID: openID,
|
||||
WxUnionID: unionID,
|
||||
Nickname: nickname,
|
||||
AvatarURL: avatar,
|
||||
Status: 1,
|
||||
}
|
||||
if err := customerStore.Create(ctx, newCustomer); err != nil {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建客户失败")
|
||||
}
|
||||
|
||||
record := &model.PersonalCustomerOpenID{
|
||||
CustomerID: newCustomer.ID,
|
||||
AppID: appID,
|
||||
OpenID: openID,
|
||||
UnionID: unionID,
|
||||
AppType: appType,
|
||||
}
|
||||
if err := openidStore.Create(ctx, record); err != nil {
|
||||
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败")
|
||||
}
|
||||
|
||||
return newCustomer.ID, true, nil
|
||||
}
|
||||
|
||||
// bindAsset 绑定客户与资产关系
|
||||
func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error {
|
||||
assetKey, err := s.resolveAssetBindingKey(ctx, tx, assetType, assetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var bindCount int64
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&model.PersonalCustomerDevice{}).
|
||||
Where("virtual_no = ?", assetKey).
|
||||
Count(&bindCount).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询资产绑定关系失败")
|
||||
}
|
||||
firstEverBind := bindCount == 0
|
||||
|
||||
bindStore := postgres.NewPersonalCustomerDeviceStore(tx)
|
||||
exists, err := bindStore.ExistsByCustomerAndDevice(ctx, customerID, assetKey)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "查询客户资产绑定关系失败")
|
||||
}
|
||||
|
||||
if !exists {
|
||||
record := &model.PersonalCustomerDevice{
|
||||
CustomerID: customerID,
|
||||
VirtualNo: assetKey,
|
||||
Status: 1,
|
||||
}
|
||||
if err := bindStore.Create(ctx, record); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建资产绑定关系失败")
|
||||
}
|
||||
}
|
||||
|
||||
if firstEverBind {
|
||||
if err := s.markAssetAsSold(ctx, tx, assetType, assetID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveAssetBindingKey(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, error) {
|
||||
if assetType == assetTypeIotCard {
|
||||
var card model.IotCard
|
||||
if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return "", errors.New(errors.CodeAssetNotFound)
|
||||
}
|
||||
return "", errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
|
||||
}
|
||||
return card.ICCID, nil
|
||||
}
|
||||
|
||||
if assetType == assetTypeDevice {
|
||||
var device model.Device
|
||||
if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return "", errors.New(errors.CodeAssetNotFound)
|
||||
}
|
||||
return "", errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
|
||||
}
|
||||
if device.VirtualNo != "" {
|
||||
return device.VirtualNo, nil
|
||||
}
|
||||
return device.IMEI, nil
|
||||
}
|
||||
|
||||
return "", errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
func (s *Service) markAssetAsSold(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
|
||||
if assetType == assetTypeIotCard {
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&model.IotCard{}).
|
||||
Where("id = ? AND asset_status = ?", assetID, 1).
|
||||
Update("asset_status", 2).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新卡资产状态失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if assetType == assetTypeDevice {
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&model.Device{}).
|
||||
Where("id = ? AND asset_status = ?", assetID, 1).
|
||||
Update("asset_status", 2).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新设备资产状态失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
func (s *Service) issueLoginToken(ctx context.Context, customerID uint) (string, bool, error) {
|
||||
token, err := s.jwtManager.GeneratePersonalCustomerToken(customerID, "")
|
||||
if err != nil {
|
||||
return "", false, errors.Wrap(errors.CodeInternalError, err, "生成登录令牌失败")
|
||||
}
|
||||
|
||||
claims, err := s.jwtManager.VerifyPersonalCustomerToken(token)
|
||||
if err != nil {
|
||||
return "", false, errors.Wrap(errors.CodeInternalError, err, "解析登录令牌失败")
|
||||
}
|
||||
|
||||
ttl := time.Until(claims.ExpiresAt.Time)
|
||||
if ttl <= 0 {
|
||||
ttl = 24 * time.Hour
|
||||
}
|
||||
|
||||
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
|
||||
if err := s.redis.Set(ctx, redisKey, token, ttl).Err(); err != nil {
|
||||
return "", false, errors.Wrap(errors.CodeRedisError, err, "保存登录状态失败")
|
||||
}
|
||||
|
||||
needBindPhone := false
|
||||
if viper.GetBool("client.require_phone_binding") {
|
||||
if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == gorm.ErrRecordNotFound {
|
||||
needBindPhone = true
|
||||
} else if err != nil {
|
||||
return "", false, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
|
||||
}
|
||||
}
|
||||
|
||||
return token, needBindPhone, nil
|
||||
}
|
||||
|
||||
func (s *Service) checkSendCodeRateLimit(ctx context.Context, phone, clientIP string) error {
|
||||
phoneCooldownKey := constants.RedisClientSendCodePhoneLimitKey(phone)
|
||||
exists, err := s.redis.Exists(ctx, phoneCooldownKey).Result()
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeRedisError, err, "检查手机号冷却失败")
|
||||
}
|
||||
if exists > 0 {
|
||||
return errors.New(errors.CodeTooManyRequests, "验证码发送过于频繁,请稍后再试")
|
||||
}
|
||||
|
||||
ipKey := constants.RedisClientSendCodeIPHourKey(clientIP)
|
||||
ipCount, err := s.redis.Incr(ctx, ipKey).Result()
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeRedisError, err, "检查 IP 限流失败")
|
||||
}
|
||||
if ipCount == 1 {
|
||||
if expErr := s.redis.Expire(ctx, ipKey, time.Hour).Err(); expErr != nil {
|
||||
return errors.Wrap(errors.CodeRedisError, expErr, "设置 IP 限流过期时间失败")
|
||||
}
|
||||
}
|
||||
if ipCount > 20 {
|
||||
return errors.New(errors.CodeTooManyRequests)
|
||||
}
|
||||
|
||||
phoneDayKey := constants.RedisClientSendCodePhoneDayKey(phone)
|
||||
phoneDayCount, err := s.redis.Incr(ctx, phoneDayKey).Result()
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeRedisError, err, "检查手机号日限流失败")
|
||||
}
|
||||
if phoneDayCount == 1 {
|
||||
nextDay := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour)
|
||||
ttl := time.Until(nextDay)
|
||||
if expErr := s.redis.Expire(ctx, phoneDayKey, ttl).Err(); expErr != nil {
|
||||
return errors.Wrap(errors.CodeRedisError, expErr, "设置手机号日限流过期时间失败")
|
||||
}
|
||||
}
|
||||
if phoneDayCount > 10 {
|
||||
return errors.New(errors.CodeTooManyRequests)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
58
internal/store/postgres/personal_customer_openid_store.go
Normal file
58
internal/store/postgres/personal_customer_openid_store.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PersonalCustomerOpenIDStore 个人客户 OpenID 关联数据访问层
|
||||
type PersonalCustomerOpenIDStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewPersonalCustomerOpenIDStore 创建个人客户 OpenID Store
|
||||
func NewPersonalCustomerOpenIDStore(db *gorm.DB) *PersonalCustomerOpenIDStore {
|
||||
return &PersonalCustomerOpenIDStore{db: db}
|
||||
}
|
||||
|
||||
// FindByAppIDAndOpenID 根据 AppID 和 OpenID 查询关联记录
|
||||
func (s *PersonalCustomerOpenIDStore) FindByAppIDAndOpenID(ctx context.Context, appID, openID string) (*model.PersonalCustomerOpenID, error) {
|
||||
var record model.PersonalCustomerOpenID
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("app_id = ? AND open_id = ?", appID, openID).
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// FindByUnionID 根据 UnionID 查询首条关联记录
|
||||
func (s *PersonalCustomerOpenIDStore) FindByUnionID(ctx context.Context, unionID string) (*model.PersonalCustomerOpenID, error) {
|
||||
var record model.PersonalCustomerOpenID
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("union_id = ?", unionID).
|
||||
Order("id ASC").
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// Create 创建 OpenID 关联记录
|
||||
func (s *PersonalCustomerOpenIDStore) Create(ctx context.Context, record *model.PersonalCustomerOpenID) error {
|
||||
return s.db.WithContext(ctx).Create(record).Error
|
||||
}
|
||||
|
||||
// ListByCustomerID 根据客户 ID 查询所有 OpenID 关联记录
|
||||
func (s *PersonalCustomerOpenIDStore) ListByCustomerID(ctx context.Context, customerID uint) ([]*model.PersonalCustomerOpenID, error) {
|
||||
var records []*model.PersonalCustomerOpenID
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Order("id ASC").
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
2
migrations/000083_add_personal_customer_openid.down.sql
Normal file
2
migrations/000083_add_personal_customer_openid.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- 回滚:删除个人客户 OpenID 关联表
|
||||
DROP TABLE IF EXISTS tb_personal_customer_openid;
|
||||
35
migrations/000083_add_personal_customer_openid.up.sql
Normal file
35
migrations/000083_add_personal_customer_openid.up.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- 新增个人客户 OpenID 关联表
|
||||
-- 保存客户在不同微信应用(公众号/小程序)下的 OpenID 记录
|
||||
CREATE TABLE IF NOT EXISTS tb_personal_customer_openid (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
customer_id BIGINT NOT NULL,
|
||||
app_id VARCHAR(100) NOT NULL,
|
||||
open_id VARCHAR(100) NOT NULL,
|
||||
union_id VARCHAR(100) NOT NULL DEFAULT '',
|
||||
app_type VARCHAR(20) NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- 软删除条件下的唯一索引:同一应用下 OpenID 唯一
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pco_app_id_open_id
|
||||
ON tb_personal_customer_openid (app_id, open_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- 客户ID索引:按客户查询所有 OpenID 记录
|
||||
CREATE INDEX IF NOT EXISTS idx_pco_customer_id
|
||||
ON tb_personal_customer_openid (customer_id);
|
||||
|
||||
-- UnionID索引:按 UnionID 回查合并客户
|
||||
CREATE INDEX IF NOT EXISTS idx_pco_union_id
|
||||
ON tb_personal_customer_openid (union_id)
|
||||
WHERE union_id != '' AND deleted_at IS NULL;
|
||||
|
||||
-- 字段注释
|
||||
COMMENT ON TABLE tb_personal_customer_openid IS '个人客户OpenID关联表';
|
||||
COMMENT ON COLUMN tb_personal_customer_openid.customer_id IS '关联个人客户ID';
|
||||
COMMENT ON COLUMN tb_personal_customer_openid.app_id IS '微信应用标识(公众号或小程序AppID)';
|
||||
COMMENT ON COLUMN tb_personal_customer_openid.open_id IS '当前应用下的OpenID';
|
||||
COMMENT ON COLUMN tb_personal_customer_openid.union_id IS '微信开放平台统一标识(可选)';
|
||||
COMMENT ON COLUMN tb_personal_customer_openid.app_type IS '应用类型(official_account/miniapp)';
|
||||
2
openspec/changes/client-auth-system/.openspec.yaml
Normal file
2
openspec/changes/client-auth-system/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-18
|
||||
171
openspec/changes/client-auth-system/design.md
Normal file
171
openspec/changes/client-auth-system/design.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# client-auth-system 设计文档
|
||||
|
||||
## Context
|
||||
|
||||
当前个人客户认证状态如下:
|
||||
|
||||
- 个人客户当前使用纯 JWT(`pkg/auth/jwt.go`),未做 Redis token 状态存储,服务端无法主动失效。
|
||||
- 微信配置已在数据库 `tb_wechat_config` 中存在(`WechatConfig`),但现有能力中仍存在 YAML 静态配置依赖。
|
||||
- 个人客户相关模型已存在:`PersonalCustomer`、`PersonalCustomerPhone`、`PersonalCustomerDevice`。
|
||||
- 现有 `/api/c/v1` 路由将被本次完整认证体系替换为新的 `/api/c/v1/auth/*` 七个端点。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
|
||||
- 交付完整 C 端认证系统,覆盖 A1~A7 七个接口:资产验证、微信登录(公众号/小程序)、验证码发送、手机号绑定、手机号换绑、退出登录。
|
||||
- 建立有状态 JWT(JWT + Redis)机制,支持服务端主动失效。
|
||||
- 建立 OpenID 多记录模型,兼容公众号与小程序不同 AppID 场景。
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- 不实现业务域 API(如充值、套餐、订单等)。
|
||||
- 不包含兑换系统(exchange)相关设计与实现。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) asset_token 设计
|
||||
|
||||
- 方案:`asset_token` 使用短时效 JWT(5 分钟),payload 仅包含 `asset_type` + `asset_id`,并使用独立于主登录 JWT 的签名密钥。
|
||||
- Why:A1 是无认证接口,若直接暴露内部 `asset_id` 会造成可枚举风险;短时效 + 独立密钥可降低 token 泄露影响范围。
|
||||
|
||||
### 2) Stateful JWT with Redis
|
||||
|
||||
- 方案:登录成功签发 JWT 后,将 token 状态写入 Redis 并设置 TTL;每次请求在中间件同时校验 JWT 与 Redis 状态。
|
||||
- Redis Key:`RedisPersonalCustomerTokenKey(customerID)`。
|
||||
- Why:纯 JWT 无法服务端撤销;Redis 状态可支持封禁、强制下线、单点退出等主动失效场景。
|
||||
|
||||
### 3) OpenID multi-record strategy
|
||||
|
||||
- 方案:新增 `PersonalCustomerOpenID` 表,约束 `UNIQUE(app_id, open_id)`(软删条件下唯一);客户查找逻辑采用:
|
||||
1. 先按 `(app_id, open_id)` 精确命中;
|
||||
2. 未命中时按 `unionid` 回查并合并;
|
||||
3. 仍未命中则创建新客户。
|
||||
- Why:公众号与小程序可能不在同一开放平台,需支持“一客户多 OpenID 记录”。
|
||||
|
||||
### 4) WechatConfig dynamic loading + SDK 实例工厂
|
||||
|
||||
- 方案:登录时动态读取 `tb_wechat_config WHERE is_active=true`,使用工厂函数按需创建 SDK 实例:
|
||||
- OfficialAccount 使用 `oa_app_id + oa_app_secret`
|
||||
- Miniapp 使用 `miniapp_app_id + miniapp_app_secret`
|
||||
- Payment 使用 `wx_mch_id + wx_api_v3_key + wx_cert_content + wx_key_content + wx_serial_no`
|
||||
- Why:避免 YAML 静态配置导致多环境切换和配置漂移,支持运营侧动态切换。
|
||||
|
||||
**现有 SDK 能力盘点(`pkg/wechat/`)**:
|
||||
|
||||
| 文件 | 已有能力 | 客户端接口需要 |
|
||||
|------|---------|--------------|
|
||||
| `official_account.go` | `GetUserInfo(code)`(snsapi_base)、`GetUserInfoDetailed(code)`(snsapi_userinfo)、`GetUserInfoByToken()` | A2 公众号登录 ✅ 直接复用 `GetUserInfoDetailed` |
|
||||
| `payment.go` | `CreateJSAPIOrder()`、`CreateH5Order()`、`QueryOrder()`、`CloseOrder()`、`HandlePaymentNotify()` | 提案 2 支付 ✅ |
|
||||
| `config.go` | `NewOfficialAccountApp(cfg)` — 仅从 YAML 创建 | ❌ 需新增 DB 动态工厂 |
|
||||
| **缺失** `miniapp.go` | 无 | ❌ A3 小程序登录需要 |
|
||||
|
||||
**需要新增的 SDK 代码**:
|
||||
|
||||
1. **`pkg/wechat/miniapp.go`** — 小程序服务封装:
|
||||
|
||||
```go
|
||||
// MiniAppService 微信小程序服务实现
|
||||
type MiniAppService struct {
|
||||
appID string
|
||||
appSecret string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// MiniAppServiceInterface 微信小程序服务接口
|
||||
type MiniAppServiceInterface interface {
|
||||
Code2Session(ctx context.Context, code string) (openID, unionID, sessionKey string, err error)
|
||||
}
|
||||
|
||||
// Code2Session 通过小程序 login code 换取 openid + session_key
|
||||
// 调用微信 https://api.weixin.qq.com/sns/jscode2session 接口
|
||||
// 注意: 小程序无法通过 code 直接获取用户信息(昵称/头像由前端授权后传入)
|
||||
func (s *MiniAppService) Code2Session(ctx context.Context, code string) (openID, unionID, sessionKey string, err error)
|
||||
```
|
||||
|
||||
2. **`pkg/wechat/config.go`** — 新增 DB 动态工厂函数:
|
||||
|
||||
```go
|
||||
// NewOfficialAccountAppFromConfig 从 WechatConfig DB 记录创建公众号应用实例
|
||||
func NewOfficialAccountAppFromConfig(wechatConfig *model.WechatConfig, cache kernel.CacheInterface, logger *zap.Logger) (*officialAccount.OfficialAccount, error)
|
||||
|
||||
// NewPaymentAppFromConfig 从 WechatConfig DB 记录创建支付应用实例
|
||||
// appID 参数决定支付关联的应用:公众号传 oa_app_id,小程序传 miniapp_app_id
|
||||
func NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cache kernel.CacheInterface, logger *zap.Logger) (*payment.Payment, error)
|
||||
|
||||
// NewMiniAppServiceFromConfig 从 WechatConfig DB 记录创建小程序服务实例
|
||||
func NewMiniAppServiceFromConfig(wechatConfig *model.WechatConfig, logger *zap.Logger) (*MiniAppService, error)
|
||||
```
|
||||
|
||||
3. **`pkg/wechat/wechat.go`** — 新增 `MiniAppServiceInterface` 接口定义和编译时类型检查。
|
||||
|
||||
**A2/A3 登录时 SDK 调用链路**:
|
||||
|
||||
```
|
||||
A2 公众号登录:
|
||||
client_auth.Service
|
||||
→ wechatConfigService.GetActiveConfig() // 从 DB/Redis 缓存获取配置
|
||||
→ wechat.NewOfficialAccountAppFromConfig(config) // 动态创建公众号实例
|
||||
→ wechat.NewOfficialAccountService(app) // 包装为 Service
|
||||
→ officialAccountService.GetUserInfoDetailed(code) // 现有方法,直接复用
|
||||
→ 返回 openID + unionID + nickname + avatar
|
||||
|
||||
A3 小程序登录:
|
||||
client_auth.Service
|
||||
→ wechatConfigService.GetActiveConfig()
|
||||
→ wechat.NewMiniAppServiceFromConfig(config) // 新增方法
|
||||
→ miniAppService.Code2Session(code) // 新增方法
|
||||
→ 返回 openID + unionID + sessionKey
|
||||
→ nickname/avatar 从请求体获取(前端授权后传入)
|
||||
```
|
||||
|
||||
**关键约束**:小程序 `Code2Session` 不调用 PowerWeChat SDK(该 SDK 主要封装公众号和支付),而是直接 HTTP 请求微信 `jscode2session` 接口。这更简单可控。
|
||||
|
||||
### 5) Phone binding config
|
||||
|
||||
- 方案:手机号绑定策略使用 Viper 配置项 `client.require_phone_binding`(bool),在登录时实时读取,不新增 DB 配置表。
|
||||
- Why:该策略属于部署级开关,配置中心化更轻量,减少数据库复杂度。
|
||||
|
||||
### 6) Asset binding on login
|
||||
|
||||
- 方案:每次登录都创建 `PersonalCustomerDevice` 绑定记录;同一资产允许被多个客户绑定,不做覆盖写入。
|
||||
- Why:业务上存在转手、共用、历史归属追踪需求,强唯一会丢失使用关系。
|
||||
|
||||
### 7) Rate limiting strategy
|
||||
|
||||
- A1:IP 级限频 `30/min`。
|
||||
- A4:手机号维度 `60s` 冷却 + IP 维度 `20/hour` + 手机号维度 `10/day`。
|
||||
- Why:A1 主要防资产暴力枚举;A4 主要防短信轰炸与资源滥用,采用多维限流降低绕过概率。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
1. **Redis 强依赖风险**
|
||||
- 风险:Redis 异常会导致 token 校验失败、登录态不可用。
|
||||
- 缓解:中间件区分“无效 token”与“Redis 不可用”并记录告警;部署 Redis 高可用;关键路径加入超时与重试上限。
|
||||
|
||||
2. **OpenID 合并误关联风险**
|
||||
- 风险:若第三方返回异常 unionid,可能出现错误合并。
|
||||
- 缓解:仅在 unionid 非空且满足格式校验时启用回退合并;记录合并审计日志(customer_id、app_id、openid、unionid)。
|
||||
|
||||
3. **资产多人绑定带来的业务歧义**
|
||||
- 风险:后续业务查询若默认“单资产单用户”,可能读取歧义。
|
||||
- 缓解:规范下游以“当前登录 customer_id + asset”联合查询;在文档中明确“资产可多客户绑定”语义。
|
||||
|
||||
4. **动态微信配置切换风险**
|
||||
- 风险:运营误切换 `is_active` 导致登录瞬时失败。
|
||||
- 缓解:限制仅单条激活、增加配置健康检查与缓存短 TTL、错误回退到最近一次可用配置。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **数据库迁移**
|
||||
- 新增 `tb_personal_customer_openid` 表(含 `customer_id/app_id/open_id/union_id` 等字段)。
|
||||
- 创建唯一索引:`UNIQUE(app_id, open_id) WHERE deleted_at IS NULL`。
|
||||
|
||||
2. **配置更新**
|
||||
- 在 `pkg/config/defaults/config.yaml` 增加:
|
||||
- `client.require_phone_binding: true|false`
|
||||
|
||||
3. **灰度切换顺序**
|
||||
- 先上线迁移与新配置;
|
||||
- 再上线新认证接口与中间件增强;
|
||||
- 最后切换前端调用到 `/api/c/v1/auth/*`。
|
||||
135
openspec/changes/client-auth-system/proposal.md
Normal file
135
openspec/changes/client-auth-system/proposal.md
Normal file
@@ -0,0 +1,135 @@
|
||||
## Why
|
||||
|
||||
系统需要一套面向个人客户(C 端)的完整认证体系,替代已删除的旧 H5 登录接口。客户端(微信公众号 H5 / 微信小程序)的登录流程与 B 端完全不同:基于**资产标识符**而非用户账号密码,先验证资产 → 再微信授权 → 自动绑定资产 → 可选绑定手机号。同时,公众号和小程序可能使用不同 AppID 且不一定绑定同一微信开放平台,需要支持多 OpenID 管理。
|
||||
|
||||
**前置依赖**:提案 0(`client-api-data-model-fixes`)已完成 PersonalCustomer.wx_open_id 索引变更和旧接口删除。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 新增模型
|
||||
|
||||
- **PersonalCustomerOpenID**:个人客户 OpenID 关联表,支持同一客户在不同 AppID(公众号/小程序)下的多 OpenID 记录。唯一索引 `UNIQUE(app_id, open_id) WHERE deleted_at IS NULL`
|
||||
|
||||
### 认证接口(`/api/c/v1/auth/`)
|
||||
|
||||
- **A1 验证资产标识符** `POST /verify-asset`:无需认证。输入 SN/IMEI/虚拟号/ICCID/MSISDN → 返回 `asset_token`(短时效 JWT,5 分钟过期,payload 含 asset_type + asset_id)。IP 级别限频(30 次/分钟)防暴力枚举。不暴露内部 asset_id
|
||||
- **A2 微信公众号登录** `POST /wechat-login`:无需认证。用微信 OAuth code + asset_token → 查找/创建客户 → 绑定资产 → 签发有状态 JWT Token(Redis 存储)→ 返回 token + 是否需要绑定手机号
|
||||
- **A3 微信小程序登录** `POST /miniapp-login`:无需认证。用小程序 jscode2session + asset_token → 同 A2 后续流程
|
||||
- **A4 发送验证码** `POST /send-code`:无需认证。限频:同手机号 60s、同 IP 20 次/小时、每手机号 10 次/天
|
||||
- **A5 绑定手机号** `POST /bind-phone`:需 JWT。首次绑定,检查重复
|
||||
- **A6 换绑手机号** `POST /change-phone`:需 JWT。双重验证码(旧+新手机)
|
||||
- **A7 退出登录** `POST /logout`:需 JWT。删除 Redis token 记录
|
||||
|
||||
### 基础设施
|
||||
|
||||
- **有状态 JWT Token 管理**:JWT payload 仅含 `customer_id` + `exp`,Redis 存储 token 有效状态,支持服务端主动失效(封禁/强制下线)
|
||||
- **PersonalAuthMiddleware 增强**:增加 Redis 有效性检查,token 不在 Redis 中则拒绝
|
||||
- **统一资产解析公共方法** `resolveAssetFromIdentifier()`:个人客户调用不走 shop_id 数据权限过滤
|
||||
- **OpenID 安全规范**:所有需要 OpenID 的接口(支付、充值),OpenID 由后端根据 `customer_id` + `app_type` 查 PersonalCustomerOpenID 表获取,禁止客户端传入
|
||||
- **手机号绑定配置**:通过 Viper 配置 `client.require_phone_binding`(boolean),登录时检查并返回 `need_bind_phone` 标识
|
||||
|
||||
### 登录完整流程
|
||||
|
||||
```
|
||||
用户打开客户端
|
||||
│
|
||||
▼
|
||||
输入资产标识符(SN/IMEI/虚拟号/ICCID)
|
||||
│
|
||||
▼
|
||||
[A1] POST /verify-asset ──→ 返回 asset_token(5分钟有效)
|
||||
│
|
||||
▼
|
||||
微信授权(前端完成)
|
||||
│
|
||||
├─── 公众号 ──→ [A2] POST /wechat-login (code + asset_token)
|
||||
│
|
||||
└─── 小程序 ──→ [A3] POST /miniapp-login (code + asset_token)
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ 解析 asset_token │
|
||||
│ 获取微信 openid │
|
||||
│ 查找/创建客户 │
|
||||
│ 绑定资产 │
|
||||
│ 签发 JWT + Redis │
|
||||
└──────┬───────────┘
|
||||
│
|
||||
▼
|
||||
返回 { token, need_bind_phone, is_new_user }
|
||||
│
|
||||
▼
|
||||
need_bind_phone == true?
|
||||
│ │
|
||||
YES NO
|
||||
│ │
|
||||
▼ ▼
|
||||
[A4] 发送验证码 进入主页面
|
||||
[A5] 绑定手机号
|
||||
│
|
||||
▼
|
||||
进入主页面
|
||||
```
|
||||
|
||||
### 客户查找/创建逻辑(A2/A3 共享)
|
||||
|
||||
```
|
||||
收到 openid + (可选)unionid
|
||||
│
|
||||
▼
|
||||
查 PersonalCustomerOpenID WHERE app_id=当前AppID AND open_id=openid
|
||||
│
|
||||
├── 找到 → 获取 customer_id → 已有客户
|
||||
│
|
||||
└── 没找到
|
||||
│
|
||||
▼
|
||||
有 unionid?
|
||||
│
|
||||
├── YES → 查 PersonalCustomerOpenID WHERE union_id=unionid
|
||||
│ │
|
||||
│ ├── 找到 → 获取 customer_id → 新增当前 AppID 的 openid 记录
|
||||
│ │
|
||||
│ └── 没找到 → 创建新客户 + openid 记录
|
||||
│
|
||||
└── NO → 创建新客户 + openid 记录
|
||||
```
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `client-asset-token`:资产验证令牌机制。A1 接口、asset_token JWT 生成/验证、IP 限频、安全规范(不暴露 asset_id)
|
||||
- `client-wechat-login`:微信登录(公众号+小程序)。A2/A3 接口、OAuth/jscode2session 对接、客户查找/创建/合并逻辑、资产绑定(**首次绑定时触发 `asset_status` 从 1→2**)、OpenID 多记录管理
|
||||
- `client-phone-bindng`:手机号绑定/换绑。A4/A5/A6 接口、验证码发送/校验、限频规则、绑定/换绑逻辑
|
||||
- `client-token-management`:有状态 JWT Token 管理。签发、Redis 存储、有效性检查、退出登录(A7)、服务端主动失效
|
||||
- `personal-customer-openid`:PersonalCustomerOpenID 模型定义、唯一索引、与 PersonalCustomer 的关系
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `personal-customer`:PersonalCustomer 模型行为变化——登录逻辑从手机号+验证码改为微信授权,wx_open_id 字段保留但逻辑迁移到 PersonalCustomerOpenID 表
|
||||
- `asset-lifecycle-status`:首次客户绑定资产时,`asset_status` 从 1(在库)自动更新为 2(已销售),使用条件更新确保幂等
|
||||
- `wechat-official-account`:OAuth 配置来源变化——从 YAML 静态配置改为从 WechatConfig 表动态读取公众号/小程序 AppID+AppSecret
|
||||
|
||||
### 微信 SDK 使用说明
|
||||
|
||||
本提案使用项目中已有的微信 SDK(`pkg/wechat/`,基于 PowerWeChat v3),同时需要扩展小程序能力:
|
||||
|
||||
| 场景 | SDK 方法 | 文件 | 状态 |
|
||||
|------|---------|------|------|
|
||||
| A2 公众号登录 | `OfficialAccountService.GetUserInfoDetailed(code)` | `pkg/wechat/official_account.go:69` | ✅ 已有,直接复用 |
|
||||
| A3 小程序登录 | `MiniAppService.Code2Session(code)` | `pkg/wechat/miniapp.go` | ❌ **需新建**,直接 HTTP 调用微信 jscode2session |
|
||||
| SDK 实例创建 | `NewOfficialAccountAppFromConfig(wechatConfig)` | `pkg/wechat/config.go` | ❌ **需新增**,从 DB 动态创建 |
|
||||
| SDK 实例创建 | `NewMiniAppServiceFromConfig(wechatConfig)` | `pkg/wechat/config.go` | ❌ **需新增** |
|
||||
| SDK 实例创建 | `NewPaymentAppFromConfig(wechatConfig, appID)` | `pkg/wechat/config.go` | ❌ **需新增**,供提案 2 支付使用 |
|
||||
|
||||
**现有 `NewOfficialAccountApp(cfg)` 从 YAML 创建实例,客户端场景需要从 `tb_wechat_config` DB 动态加载。**
|
||||
|
||||
## Impact
|
||||
|
||||
- **新增文件**:`internal/model/personal_customer_openid.go`(模型)、`internal/handler/app/client_auth.go`(认证 Handler)、`internal/service/client_auth/service.go`(认证 Service)、`internal/store/postgres/personal_customer_openid_store.go`(Store)、**`pkg/wechat/miniapp.go`(小程序 SDK 封装)**、DTO 文件、迁移文件、常量定义
|
||||
- **修改文件**:`internal/middleware/personal_auth.go`(增加 Redis 检查)、`internal/routes/personal.go`(新增路由)、`internal/bootstrap/`(注册新模块)、`cmd/api/docs.go` + `cmd/gendocs/main.go`(文档生成器)、`pkg/config/defaults/config.yaml`(新增 client 配置节)、`internal/model/system.go`(AutoMigrate 注册新模型)、**`pkg/wechat/config.go`(新增 3 个 DB 动态工厂函数)**、**`pkg/wechat/wechat.go`(新增 MiniAppServiceInterface)**
|
||||
- **新增 API 路由**:`/api/c/v1/auth/` 下 7 个端点
|
||||
- **数据库变更**:新建 `tb_personal_customer_openid` 表
|
||||
- **新增依赖**:无(微信 SDK 已有 PowerWeChat v3,小程序 jscode2session 为纯 HTTP 调用)
|
||||
- **配置变更**:config.yaml 新增 `client.require_phone_binding` 配置项
|
||||
@@ -0,0 +1,71 @@
|
||||
# client-asset-token Specification
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: A1 资产标识符验证接口
|
||||
|
||||
系统 MUST 提供无认证资产验证接口 `POST /api/c/v1/auth/verify-asset`,用于将外部资产标识符兑换为短时效 `asset_token`。
|
||||
|
||||
- HTTP Method + Path: `POST /api/c/v1/auth/verify-asset`
|
||||
- 请求体字段:
|
||||
- `identifier` string,MUST,资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)
|
||||
- 响应体字段:
|
||||
- `asset_token` string,MUST,5 分钟有效
|
||||
- `expires_in` int,MUST,单位秒
|
||||
- 错误码:
|
||||
- `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`
|
||||
@@ -0,0 +1,94 @@
|
||||
# client-phone-binding Specification
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: A4 发送验证码接口
|
||||
|
||||
系统 MUST 提供无认证验证码接口 `POST /api/c/v1/auth/send-code`,并复用现有验证码服务。
|
||||
|
||||
- HTTP Method + Path: `POST /api/c/v1/auth/send-code`
|
||||
- 请求体字段:
|
||||
- `phone` string,MUST,手机号
|
||||
- `scene` string,MUST,业务场景(`bind_phone` / `change_phone_old` / `change_phone_new`)
|
||||
- 响应体字段:
|
||||
- `cooldown_seconds` int,MUST,本次发送后的冷却秒数
|
||||
- 错误码:
|
||||
- `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` string,MUST,新手机号
|
||||
- `code` string,MUST,验证码
|
||||
- 响应体字段:
|
||||
- `phone` string,MUST,已绑定手机号
|
||||
- `bound_at` string,MUST,绑定时间
|
||||
- 错误码:
|
||||
- `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` string,MUST,旧手机号
|
||||
- `old_code` string,MUST,旧手机号验证码
|
||||
- `new_phone` string,MUST,新手机号
|
||||
- `new_code` string,MUST,新手机号验证码
|
||||
- 响应体字段:
|
||||
- `phone` string,MUST,换绑后的手机号
|
||||
- `changed_at` string,MUST,换绑时间
|
||||
- 错误码:
|
||||
- `1001` 缺失认证令牌
|
||||
- `1002` 认证令牌无效
|
||||
- `1006` 参数错误
|
||||
- `1035` 验证码错误或过期
|
||||
- `1037` 新手机号已被绑定
|
||||
- `1039` 旧手机号不匹配
|
||||
|
||||
#### Scenario: 换绑成功
|
||||
- **WHEN** 登录客户提交正确旧/新验证码且新手机号未占用
|
||||
- **THEN** 系统 SHALL 更新绑定手机号为新手机号
|
||||
|
||||
#### Scenario: 旧手机号校验失败
|
||||
- **WHEN** `old_phone` 与当前客户绑定手机号不一致或 `old_code` 错误
|
||||
- **THEN** 系统 MUST 拒绝换绑并返回对应错误码
|
||||
@@ -0,0 +1,57 @@
|
||||
# client-token-management Specification
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 登录 JWT 签发与 Redis 状态存储
|
||||
|
||||
系统 MUST 在 A2/A3 登录成功后签发个人客户 JWT,并将 token 状态写入 Redis。
|
||||
|
||||
- JWT payload 字段:
|
||||
- `customer_id` uint,MUST
|
||||
- `exp` int64,MUST
|
||||
- Redis Key:`RedisPersonalCustomerTokenKey(customerID)`
|
||||
- Redis Value:当前有效 token(或 token 集合,取决于实现)
|
||||
- TTL:MUST 与 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` bool,MUST
|
||||
- 错误码:
|
||||
- `1001` 缺失认证令牌
|
||||
- `1002` 认证令牌无效
|
||||
|
||||
#### Scenario: 退出登录成功
|
||||
- **WHEN** 登录客户调用 A7
|
||||
- **THEN** 系统 SHALL 删除 `RedisPersonalCustomerTokenKey(customerID)`
|
||||
- **THEN** 系统 SHALL 返回成功
|
||||
|
||||
### Requirement: 服务端主动失效能力
|
||||
|
||||
系统 MUST 支持服务端主动使 token 失效(如封禁/强制下线),且无需等待 JWT 自然过期。
|
||||
|
||||
#### Scenario: 服务端主动踢出
|
||||
- **WHEN** 管理动作触发客户强制下线
|
||||
- **THEN** 系统 SHALL 删除对应 Redis token 状态
|
||||
- **THEN** 该客户后续请求 MUST 被中间件拒绝
|
||||
@@ -0,0 +1,105 @@
|
||||
# client-wechat-login Specification
|
||||
|
||||
## ADDED 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` string,MUST,微信 OAuth 授权码
|
||||
- `asset_token` string,MUST,A1 返回的资产令牌
|
||||
- 响应体字段:
|
||||
- `token` string,MUST,登录 JWT
|
||||
- `need_bind_phone` bool,MUST,是否需要绑定手机号
|
||||
- `is_new_user` bool,MUST,是否新创建用户
|
||||
- 错误码:
|
||||
- `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` string,MUST,小程序登录凭证
|
||||
- `asset_token` string,MUST,A1 返回的资产令牌
|
||||
- 响应体字段:
|
||||
- `token` string,MUST,登录 JWT
|
||||
- `need_bind_phone` bool,MUST
|
||||
- `is_new_user` bool,MUST
|
||||
- 错误码:
|
||||
- `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`
|
||||
@@ -0,0 +1,37 @@
|
||||
# personal-customer-openid Specification
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: PersonalCustomerOpenID 模型定义
|
||||
|
||||
系统 MUST 新增 `PersonalCustomerOpenID` 模型与数据表 `tb_personal_customer_openid`,用于保存客户在不同 AppID 下的 OpenID 记录。
|
||||
|
||||
- 关键字段:
|
||||
- `id` uint,主键
|
||||
- `customer_id` uint,MUST,关联个人客户 ID
|
||||
- `app_id` string,MUST,微信应用标识
|
||||
- `open_id` string,MUST,当前应用下 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 记录
|
||||
@@ -0,0 +1,52 @@
|
||||
# personal-customer Specification
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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 场景
|
||||
@@ -0,0 +1,47 @@
|
||||
# wechat-official-account Specification
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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
|
||||
70
openspec/changes/client-auth-system/tasks.md
Normal file
70
openspec/changes/client-auth-system/tasks.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# client-auth-system 实施任务清单
|
||||
|
||||
## 1. 模型与迁移
|
||||
|
||||
- [x] 1.1 新增 `internal/model/personal_customer_openid.go`,定义 PersonalCustomerOpenID 模型与 TableName
|
||||
- [x] 1.2 创建迁移文件,新增 `tb_personal_customer_openid` 表及 `UNIQUE(app_id, open_id) WHERE deleted_at IS NULL` 索引
|
||||
- [x] 1.3 在 `internal/model/system.go` 注册新模型以纳入 AutoMigrate
|
||||
- [x] 1.4 更新 `pkg/config/defaults/config.yaml`,新增 `client.require_phone_binding` 配置项
|
||||
|
||||
## 2. PersonalAuthMiddleware 增强
|
||||
|
||||
- [x] 2.1 在 `pkg/constants/redis.go` 新增 `RedisPersonalCustomerTokenKey(customerID)` 常量函数
|
||||
- [x] 2.2 增强 `internal/middleware/personal_auth.go`,增加 JWT + Redis 双重校验
|
||||
- [x] 2.3 完成 token 不在 Redis 时的拒绝逻辑与统一错误返回
|
||||
|
||||
## 3. 资产验证令牌(A1)
|
||||
|
||||
- [x] 3.1 新增认证 DTO(A1 请求/响应)并补齐 OpenAPI 标签
|
||||
- [x] 3.2 新增 `internal/handler/app/client_auth.go` 的 `VerifyAsset` Handler
|
||||
- [x] 3.3 新增 `internal/service/client_auth/service.go` 的资产解析与 `asset_token` 签发逻辑(5 分钟)
|
||||
- [x] 3.4 实现 A1 IP 限流(30/min)与错误码映射
|
||||
|
||||
## 4. 微信 SDK 扩展(小程序 + 动态配置工厂)
|
||||
|
||||
- [x] 4.1 新增 `pkg/wechat/miniapp.go`:定义 `MiniAppService` 结构体 + `MiniAppServiceInterface` 接口 + `Code2Session(ctx, code)` 方法(直接 HTTP 调用微信 `jscode2session` 接口,不依赖 PowerWeChat SDK)
|
||||
- [x] 4.2 在 `pkg/wechat/wechat.go` 中新增 `MiniAppServiceInterface` 接口定义和编译时类型检查 `var _ MiniAppServiceInterface = (*MiniAppService)(nil)`
|
||||
- [x] 4.3 在 `pkg/wechat/config.go` 中新增 `NewOfficialAccountAppFromConfig(wechatConfig *model.WechatConfig, cache, logger)` 工厂函数——从 DB 记录的 `oa_app_id` + `oa_app_secret` 创建公众号实例(复用 PowerWeChat `officialAccount.NewOfficialAccount`)
|
||||
- [x] 4.4 在 `pkg/wechat/config.go` 中新增 `NewMiniAppServiceFromConfig(wechatConfig *model.WechatConfig, logger)` 工厂函数——从 DB 记录的 `miniapp_app_id` + `miniapp_app_secret` 创建小程序服务
|
||||
- [x] 4.5 在 `pkg/wechat/config.go` 中新增 `NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cache, logger)` 工厂函数——从 DB 记录创建支付实例,`appID` 参数决定关联应用(公众号/小程序)
|
||||
|
||||
## 5. 微信登录(A2+A3)
|
||||
|
||||
- [x] 5.1 新增 A2/A3 请求响应 DTO(公众号与小程序)
|
||||
- [x] 5.2 在 `client_auth/service.go` 中实现动态读取 `tb_wechat_config WHERE is_active=true` 的配置加载逻辑(优先走 WechatConfigService 的 Redis 缓存)
|
||||
- [x] 5.3 实现公众号登录(A2):调用 `NewOfficialAccountAppFromConfig` → `NewOfficialAccountService` → `GetUserInfoDetailed(code)` 获取 openid+unionid+昵称+头像(复用现有 `official_account.go` 的方法,不重新实现)
|
||||
- [x] 5.4 实现小程序登录(A3):调用 `NewMiniAppServiceFromConfig` → `Code2Session(code)` 获取 openid+unionid+sessionKey;昵称/头像从请求体获取
|
||||
- [x] 5.5 实现客户查找/创建/合并逻辑(openid 优先,unionid 回退)
|
||||
- [x] 5.6 新增 `internal/store/postgres/personal_customer_openid_store.go` 与相关查询/写入方法
|
||||
- [x] 5.7 实现每次登录创建 PersonalCustomerDevice 绑定记录(允许同资产多客户);**首次绑定时**(该资产此前无任何 PersonalCustomerDevice 记录),将资产的 `asset_status` 从 1(在库)更新为 2(已销售),使用条件更新 `WHERE asset_status = 1` 确保幂等(已是 2 或其他状态则不变)
|
||||
- [x] 5.8 实现登录 JWT 签发、Redis 存储与 `need_bind_phone` 计算
|
||||
|
||||
## 6. 验证码与手机号(A4+A5+A6)
|
||||
|
||||
- [x] 6.1 复用现有验证码服务(`internal/service/verification/service.go` 的 `SendCode`)实现 A4 发送验证码
|
||||
- [x] 6.2 实现 A4 限流:手机号 60s、IP 20/hour、手机号 10/day
|
||||
- [x] 6.3 实现 A5 首次绑定手机号逻辑(已绑定拒绝)
|
||||
- [x] 6.4 实现 A6 双验证码换绑逻辑(旧手机号+新手机号)
|
||||
- [x] 6.5 增补手机号绑定/换绑错误码与中文错误信息
|
||||
|
||||
## 7. 退出登录(A7)
|
||||
|
||||
- [x] 7.1 新增 A7 请求响应 DTO
|
||||
- [x] 7.2 实现 `POST /api/c/v1/auth/logout` Handler 与 Service
|
||||
- [x] 7.3 在 A7 中删除 `RedisPersonalCustomerTokenKey(customerID)` 完成服务端失效
|
||||
|
||||
## 8. 路由注册与文档
|
||||
|
||||
- [x] 8.1 在 `internal/bootstrap/types.go` 增加 ClientAuth Handler 字段
|
||||
- [x] 8.2 在 `internal/bootstrap/handlers.go` 实例化 ClientAuth Handler
|
||||
- [x] 8.3 在 `internal/routes/personal.go` 使用 `Register()` 注册 `/api/c/v1/auth/*` 七个端点
|
||||
- [x] 8.4 在 `cmd/api/docs.go` 注册新 Handler 供文档生成器使用
|
||||
- [x] 8.5 在 `cmd/gendocs/main.go` 注册新 Handler 供文档生成器使用
|
||||
- [x] 8.6 执行 `go run cmd/gendocs/main.go` 并确认新接口出现在 OpenAPI 文档
|
||||
|
||||
## 9. 验证
|
||||
|
||||
- [x] 9.1 执行 `go build ./...`,确保构建通过
|
||||
- [x] 9.2 运行 `lsp_diagnostics`,确保修改文件无错误
|
||||
- [x] 9.3 按数据库验证规范检查新表与索引存在且结构正确
|
||||
- [x] 9.4 在 `docs/client-auth-system/` 补充中文功能总结文档
|
||||
@@ -91,6 +91,10 @@ middleware:
|
||||
expiration: "1m"
|
||||
storage: "memory"
|
||||
|
||||
# 客户端配置
|
||||
client:
|
||||
require_phone_binding: true # 是否要求个人客户绑定手机号
|
||||
|
||||
# 短信服务配置
|
||||
sms:
|
||||
gateway_url: "" # 可选:JUNHONG_SMS_GATEWAY_URL
|
||||
|
||||
@@ -53,6 +53,49 @@ func RedisShopSubordinatesKey(shopID uint) string {
|
||||
return fmt.Sprintf("shop:subordinates:%d", shopID)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 个人客户认证相关 Redis Key
|
||||
// ========================================
|
||||
|
||||
// RedisPersonalCustomerTokenKey 生成个人客户登录令牌的 Redis 键
|
||||
// 用途:有状态 JWT,存储当前有效 token 字符串,支持服务端主动失效
|
||||
// 过期时间:与 JWT 过期时间一致
|
||||
func RedisPersonalCustomerTokenKey(customerID uint) string {
|
||||
return fmt.Sprintf("personal:customer:token:%d", customerID)
|
||||
}
|
||||
|
||||
// RedisClientAuthRateLimitIPKey 生成 C 端资产验证 IP 限流键
|
||||
// 用途:A1 接口 IP 级限频 30 次/分钟
|
||||
// 过期时间:1 分钟
|
||||
func RedisClientAuthRateLimitIPKey(ip string) string {
|
||||
return fmt.Sprintf("client:auth:ratelimit:ip:%s", ip)
|
||||
}
|
||||
|
||||
// RedisClientSendCodePhoneLimitKey 生成验证码手机号冷却键
|
||||
// 用途:A4 接口同手机号 60 秒冷却
|
||||
// 过期时间:60 秒
|
||||
func RedisClientSendCodePhoneLimitKey(phone string) string {
|
||||
return fmt.Sprintf("client:auth:sendcode:phone:limit:%s", phone)
|
||||
}
|
||||
|
||||
// RedisClientSendCodeIPHourKey 生成验证码 IP 小时限流键
|
||||
// 用途:A4 接口同 IP 每小时 20 次
|
||||
// 过期时间:1 小时
|
||||
func RedisClientSendCodeIPHourKey(ip string) string {
|
||||
return fmt.Sprintf("client:auth:sendcode:ip:hour:%s", ip)
|
||||
}
|
||||
|
||||
// RedisClientSendCodePhoneDayKey 生成验证码手机号日限流键
|
||||
// 用途:A4 接口同手机号每日 10 次
|
||||
// 过期时间:当日剩余时间
|
||||
func RedisClientSendCodePhoneDayKey(phone string) string {
|
||||
return fmt.Sprintf("client:auth:sendcode:phone:day:%s", phone)
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 验证码相关 Redis Key
|
||||
// ========================================
|
||||
|
||||
// RedisVerificationCodeKey 生成验证码的 Redis 键
|
||||
// 用途:存储手机验证码
|
||||
// 过期时间:5 分钟
|
||||
|
||||
@@ -141,6 +141,15 @@ const (
|
||||
CodeFuiouCallbackInvalid = 1174 // 富友回调签名验证失败
|
||||
CodeNoPaymentConfig = 1175 // 当前无可用的支付配置
|
||||
|
||||
// C端认证相关错误 (1180-1199)
|
||||
CodeAssetNotFound = 1180 // 资产不存在(A1 资产验证失败)
|
||||
CodeWechatConfigUnavailable = 1181 // 微信配置不可用(无激活配置)
|
||||
CodeSmsSendFailed = 1182 // 短信发送失败
|
||||
CodeVerificationCodeInvalid = 1183 // 验证码错误或已过期
|
||||
CodePhoneAlreadyBound = 1184 // 手机号已被其他客户绑定
|
||||
CodeAlreadyBoundPhone = 1185 // 当前客户已绑定手机号,不可重复绑定
|
||||
CodeOldPhoneMismatch = 1186 // 旧手机号与当前绑定不匹配
|
||||
|
||||
// 服务端错误 (2000-2999) -> 5xx HTTP 状态码
|
||||
CodeInternalError = 2001 // 内部服务器错误
|
||||
CodeDatabaseError = 2002 // 数据库错误
|
||||
@@ -258,6 +267,13 @@ var allErrorCodes = []int{
|
||||
CodeFuiouPayFailed,
|
||||
CodeFuiouCallbackInvalid,
|
||||
CodeNoPaymentConfig,
|
||||
CodeAssetNotFound,
|
||||
CodeWechatConfigUnavailable,
|
||||
CodeSmsSendFailed,
|
||||
CodeVerificationCodeInvalid,
|
||||
CodePhoneAlreadyBound,
|
||||
CodeAlreadyBoundPhone,
|
||||
CodeOldPhoneMismatch,
|
||||
CodeInternalError,
|
||||
CodeDatabaseError,
|
||||
CodeRedisError,
|
||||
@@ -373,6 +389,13 @@ var errorMessages = map[int]string{
|
||||
CodeFuiouPayFailed: "支付发起失败,请重试",
|
||||
CodeFuiouCallbackInvalid: "支付回调签名验证失败",
|
||||
CodeNoPaymentConfig: "当前无可用的支付配置,请联系管理员",
|
||||
CodeAssetNotFound: "资产不存在",
|
||||
CodeWechatConfigUnavailable: "微信配置不可用",
|
||||
CodeSmsSendFailed: "短信发送失败",
|
||||
CodeVerificationCodeInvalid: "验证码错误或已过期",
|
||||
CodePhoneAlreadyBound: "手机号已被其他客户绑定",
|
||||
CodeAlreadyBoundPhone: "当前客户已绑定手机号,不可重复绑定",
|
||||
CodeOldPhoneMismatch: "旧手机号与当前绑定不匹配",
|
||||
CodeInvalidCredentials: "用户名或密码错误",
|
||||
CodeAccountLocked: "账号已锁定",
|
||||
CodePasswordExpired: "密码已过期",
|
||||
|
||||
@@ -2,9 +2,12 @@ package wechat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
|
||||
"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"
|
||||
@@ -49,3 +52,115 @@ func NewOfficialAccountApp(cfg *config.Config, cache kernel.CacheInterface, logg
|
||||
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 {
|
||||
return nil, fmt.Errorf("微信配置不能为空")
|
||||
}
|
||||
if wechatConfig.OaAppID == "" || wechatConfig.OaAppSecret == "" {
|
||||
return nil, fmt.Errorf("微信公众号配置不完整:缺少 oa_app_id 或 oa_app_secret")
|
||||
}
|
||||
|
||||
userConfig := &officialAccount.UserConfig{
|
||||
AppID: wechatConfig.OaAppID,
|
||||
Secret: wechatConfig.OaAppSecret,
|
||||
Cache: cache,
|
||||
}
|
||||
|
||||
if wechatConfig.OaToken != "" {
|
||||
userConfig.Token = wechatConfig.OaToken
|
||||
}
|
||||
if wechatConfig.OaAesKey != "" {
|
||||
userConfig.AESKey = wechatConfig.OaAesKey
|
||||
}
|
||||
|
||||
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", wechatConfig.OaAppID))
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// NewMiniAppServiceFromConfig 从数据库配置创建小程序服务实例
|
||||
func NewMiniAppServiceFromConfig(wechatConfig *model.WechatConfig, logger *zap.Logger) (*MiniAppService, error) {
|
||||
if wechatConfig == nil {
|
||||
return nil, fmt.Errorf("微信配置不能为空")
|
||||
}
|
||||
if wechatConfig.MiniappAppID == "" || wechatConfig.MiniappAppSecret == "" {
|
||||
return nil, fmt.Errorf("小程序配置不完整:缺少 miniapp_app_id 或 miniapp_app_secret")
|
||||
}
|
||||
|
||||
return NewMiniAppService(wechatConfig.MiniappAppID, wechatConfig.MiniappAppSecret, logger), nil
|
||||
}
|
||||
|
||||
// NewPaymentAppFromConfig 从数据库配置创建微信支付应用实例
|
||||
func NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cache kernel.CacheInterface, logger *zap.Logger) (*payment.Payment, error) {
|
||||
if wechatConfig == nil {
|
||||
return nil, fmt.Errorf("微信配置不能为空")
|
||||
}
|
||||
if appID == "" {
|
||||
return nil, fmt.Errorf("appID 不能为空")
|
||||
}
|
||||
if wechatConfig.WxMchID == "" || wechatConfig.WxAPIV3Key == "" || wechatConfig.WxSerialNo == "" {
|
||||
return nil, fmt.Errorf("微信支付配置不完整:缺少 wx_mch_id/wx_api_v3_key/wx_serial_no")
|
||||
}
|
||||
|
||||
certPath, err := writeWechatPemTempFile("wechat_cert_*.pem", wechatConfig.WxCertContent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("写入微信支付证书失败: %w", err)
|
||||
}
|
||||
keyPath, err := writeWechatPemTempFile("wechat_key_*.pem", wechatConfig.WxKeyContent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("写入微信支付私钥失败: %w", err)
|
||||
}
|
||||
|
||||
userConfig := &payment.UserConfig{
|
||||
AppID: appID,
|
||||
MchID: wechatConfig.WxMchID,
|
||||
MchApiV3Key: wechatConfig.WxAPIV3Key,
|
||||
Key: wechatConfig.WxAPIV2Key,
|
||||
CertPath: certPath,
|
||||
KeyPath: keyPath,
|
||||
SerialNo: wechatConfig.WxSerialNo,
|
||||
Cache: cache,
|
||||
NotifyURL: wechatConfig.WxNotifyURL,
|
||||
}
|
||||
|
||||
app, err := payment.NewPayment(userConfig)
|
||||
if err != nil {
|
||||
logger.Error("创建微信支付应用失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("创建微信支付应用失败: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("微信支付应用初始化成功",
|
||||
zap.String("app_id", appID),
|
||||
zap.String("mch_id", wechatConfig.WxMchID),
|
||||
)
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func writeWechatPemTempFile(pattern, content string) (string, error) {
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("证书内容不能为空")
|
||||
}
|
||||
|
||||
file, err := os.CreateTemp("", pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err = file.WriteString(content); err != nil {
|
||||
file.Close()
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = file.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return file.Name(), nil
|
||||
}
|
||||
|
||||
97
pkg/wechat/miniapp.go
Normal file
97
pkg/wechat/miniapp.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const miniAppCode2SessionURL = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
|
||||
// MiniAppServiceInterface 微信小程序服务接口
|
||||
type MiniAppServiceInterface interface {
|
||||
Code2Session(ctx context.Context, code string) (openID, unionID, sessionKey string, err error)
|
||||
}
|
||||
|
||||
// MiniAppService 微信小程序服务实现
|
||||
type MiniAppService struct {
|
||||
appID string
|
||||
appSecret string
|
||||
client *http.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewMiniAppService 创建微信小程序服务
|
||||
func NewMiniAppService(appID, appSecret string, logger *zap.Logger) *MiniAppService {
|
||||
return &MiniAppService{
|
||||
appID: appID,
|
||||
appSecret: appSecret,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
type code2SessionResponse struct {
|
||||
OpenID string `json:"openid"`
|
||||
UnionID string `json:"unionid"`
|
||||
SessionKey string `json:"session_key"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// Code2Session 通过小程序 code 换取 openid/session_key
|
||||
func (s *MiniAppService) Code2Session(ctx context.Context, code string) (openID, unionID, sessionKey string, err error) {
|
||||
if code == "" {
|
||||
return "", "", "", errors.New(errors.CodeInvalidParam, "授权码不能为空")
|
||||
}
|
||||
if s.appID == "" || s.appSecret == "" {
|
||||
return "", "", "", errors.New(errors.CodeWechatConfigUnavailable, "小程序配置不完整")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("appid", s.appID)
|
||||
params.Set("secret", s.appSecret)
|
||||
params.Set("js_code", code)
|
||||
params.Set("grant_type", "authorization_code")
|
||||
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, miniAppCode2SessionURL+"?"+params.Encode(), nil)
|
||||
if reqErr != nil {
|
||||
s.logger.Error("构建小程序 Code2Session 请求失败", zap.Error(reqErr))
|
||||
return "", "", "", errors.Wrap(errors.CodeWechatOAuthFailed, reqErr, "构建微信请求失败")
|
||||
}
|
||||
|
||||
resp, doErr := s.client.Do(req)
|
||||
if doErr != nil {
|
||||
s.logger.Error("调用小程序 Code2Session 失败", zap.Error(doErr))
|
||||
return "", "", "", errors.Wrap(errors.CodeWechatOAuthFailed, doErr, "调用微信接口失败")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result code2SessionResponse
|
||||
if decodeErr := json.NewDecoder(resp.Body).Decode(&result); decodeErr != nil {
|
||||
s.logger.Error("解析小程序 Code2Session 响应失败", zap.Error(decodeErr))
|
||||
return "", "", "", errors.Wrap(errors.CodeWechatOAuthFailed, decodeErr, "解析微信响应失败")
|
||||
}
|
||||
|
||||
if result.ErrCode != 0 {
|
||||
s.logger.Error("小程序 Code2Session 返回错误",
|
||||
zap.Int("errcode", result.ErrCode),
|
||||
zap.String("errmsg", result.ErrMsg),
|
||||
)
|
||||
return "", "", "", errors.New(errors.CodeWechatOAuthFailed)
|
||||
}
|
||||
|
||||
if result.OpenID == "" || result.SessionKey == "" {
|
||||
s.logger.Error("小程序 Code2Session 响应缺少关键字段", zap.String("open_id", result.OpenID))
|
||||
return "", "", "", errors.New(errors.CodeWechatOAuthFailed, "微信返回数据不完整")
|
||||
}
|
||||
|
||||
return result.OpenID, result.UnionID, result.SessionKey, nil
|
||||
}
|
||||
@@ -42,5 +42,6 @@ type UserInfo struct {
|
||||
var (
|
||||
_ Service = (*OfficialAccountService)(nil)
|
||||
_ OfficialAccountServiceInterface = (*OfficialAccountService)(nil)
|
||||
_ MiniAppServiceInterface = (*MiniAppService)(nil)
|
||||
_ PaymentServiceInterface = (*PaymentService)(nil)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user