From df76e33105c1c841dedbe74410ec3d52d506f8fe Mon Sep 17 00:00:00 2001 From: huang Date: Thu, 19 Mar 2026 11:33:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20C=20=E7=AB=AF?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E8=AE=A4=E8=AF=81=E7=B3=BB=E7=BB=9F=EF=BC=88?= =?UTF-8?q?client-auth-system=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现面向个人客户的 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 表 --- cmd/api/docs.go | 2 + cmd/gendocs/main.go | 2 + docs/admin-openapi.yaml | 3156 ++++++++++------- docs/client-auth-system/功能总结.md | 141 + internal/bootstrap/handlers.go | 1 + internal/bootstrap/middlewares.go | 2 +- internal/bootstrap/services.go | 26 +- internal/bootstrap/stores.go | 4 + internal/bootstrap/types.go | 1 + internal/handler/app/client_auth.go | 165 + internal/middleware/personal_auth.go | 42 +- internal/model/dto/client_auth_dto.go | 103 + internal/model/personal_customer_openid.go | 23 + internal/routes/personal.go | 62 + internal/service/client_auth/service.go | 761 ++++ .../personal_customer_openid_store.go | 58 + ...0083_add_personal_customer_openid.down.sql | 2 + ...000083_add_personal_customer_openid.up.sql | 35 + .../changes/client-auth-system/.openspec.yaml | 2 + openspec/changes/client-auth-system/design.md | 171 + .../changes/client-auth-system/proposal.md | 135 + .../specs/client-asset-token/spec.md | 71 + .../specs/client-phone-binding/spec.md | 94 + .../specs/client-token-management/spec.md | 57 + .../specs/client-wechat-login/spec.md | 105 + .../specs/personal-customer-openid/spec.md | 37 + .../specs/personal-customer/spec.md | 52 + .../specs/wechat-official-account/spec.md | 47 + openspec/changes/client-auth-system/tasks.md | 70 + pkg/config/defaults/config.yaml | 4 + pkg/constants/redis.go | 43 + pkg/errors/codes.go | 23 + pkg/wechat/config.go | 115 + pkg/wechat/miniapp.go | 97 + pkg/wechat/wechat.go | 1 + 35 files changed, 4348 insertions(+), 1362 deletions(-) create mode 100644 docs/client-auth-system/功能总结.md create mode 100644 internal/handler/app/client_auth.go create mode 100644 internal/model/dto/client_auth_dto.go create mode 100644 internal/model/personal_customer_openid.go create mode 100644 internal/service/client_auth/service.go create mode 100644 internal/store/postgres/personal_customer_openid_store.go create mode 100644 migrations/000083_add_personal_customer_openid.down.sql create mode 100644 migrations/000083_add_personal_customer_openid.up.sql create mode 100644 openspec/changes/client-auth-system/.openspec.yaml create mode 100644 openspec/changes/client-auth-system/design.md create mode 100644 openspec/changes/client-auth-system/proposal.md create mode 100644 openspec/changes/client-auth-system/specs/client-asset-token/spec.md create mode 100644 openspec/changes/client-auth-system/specs/client-phone-binding/spec.md create mode 100644 openspec/changes/client-auth-system/specs/client-token-management/spec.md create mode 100644 openspec/changes/client-auth-system/specs/client-wechat-login/spec.md create mode 100644 openspec/changes/client-auth-system/specs/personal-customer-openid/spec.md create mode 100644 openspec/changes/client-auth-system/specs/personal-customer/spec.md create mode 100644 openspec/changes/client-auth-system/specs/wechat-official-account/spec.md create mode 100644 openspec/changes/client-auth-system/tasks.md create mode 100644 pkg/wechat/miniapp.go diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 76c9854..7828c8f 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -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) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index d60994e..13d9165 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -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) diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 16c3889..f7e0970 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -1,19 +1,5 @@ components: schemas: - AppLoginRequest: - properties: - code: - type: string - phone: - type: string - type: object - AppLoginResponse: - properties: - customer: - $ref: '#/components/schemas/AppPersonalCustomerDTO' - token: - type: string - type: object AppPersonalCustomerDTO: properties: avatar_url: @@ -30,11 +16,6 @@ components: wx_open_id: type: string type: object - AppSendCodeRequest: - properties: - phone: - type: string - type: object AppUpdateProfileRequest: properties: avatar_url: @@ -143,6 +124,87 @@ components: nullable: true type: array type: object + DtoAgentOfflinePayParams: + properties: + operation_password: + description: 操作密码 + type: string + required: + - operation_password + type: object + DtoAgentRechargeListResponse: + properties: + list: + description: 充值记录列表 + items: + $ref: '#/components/schemas/DtoAgentRechargeResponse' + nullable: true + type: array + page: + description: 当前页码 + type: integer + page_size: + description: 每页条数 + type: integer + total: + description: 总记录数 + type: integer + type: object + DtoAgentRechargeResponse: + properties: + agent_wallet_id: + description: 代理钱包ID + minimum: 0 + type: integer + amount: + description: 充值金额(分) + type: integer + completed_at: + description: 完成时间 + nullable: true + type: string + created_at: + description: 创建时间 + type: string + id: + description: 充值记录ID + minimum: 0 + type: integer + paid_at: + description: 支付时间 + nullable: true + type: string + payment_channel: + description: 实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账) + type: string + payment_config_id: + description: 关联支付配置ID,线下充值为null + minimum: 0 + nullable: true + type: integer + payment_method: + description: 支付方式 (wechat:微信在线支付, offline:线下转账) + type: string + payment_transaction_id: + description: 第三方支付流水号 + type: string + recharge_no: + description: 充值单号(ARCH前缀) + type: string + shop_id: + description: 店铺ID + minimum: 0 + type: integer + shop_name: + description: 店铺名称 + type: string + status: + description: 状态 (1:待支付, 2:已完成, 3:已取消) + type: integer + updated_at: + description: 更新时间 + type: string + type: object DtoAllocateCardsReq: properties: iccids: @@ -1002,6 +1064,16 @@ components: - shop_id - series_id type: object + DtoBatchPricingSkipped: + properties: + allocation_id: + description: 分配ID + minimum: 0 + type: integer + reason: + description: 跳过原因 + type: string + type: object DtoBatchSetCardSeriesBindngRequest: properties: iccids: @@ -1077,6 +1149,9 @@ components: type: string price_adjustment: $ref: '#/components/schemas/DtoPriceAdjustment' + pricing_target: + description: 调价目标 cost_price-成本价(默认) retail_price-零售价 + type: string series_id: description: 套餐系列ID(可选,不填则调整所有) minimum: 0 @@ -1099,6 +1174,11 @@ components: type: integer nullable: true type: array + skipped: + description: 跳过的记录 + items: + $ref: '#/components/schemas/DtoBatchPricingSkipped' + type: array updated_count: description: 更新数量 type: integer @@ -1128,6 +1208,31 @@ components: description: 提示信息 type: string type: object + DtoBindPhoneRequest: + properties: + code: + description: 验证码 + maxLength: 6 + minLength: 6 + type: string + phone: + description: 手机号 + maxLength: 11 + minLength: 11 + type: string + required: + - phone + - code + type: object + DtoBindPhoneResponse: + properties: + bound_at: + description: 绑定时间 + type: string + phone: + description: 已绑定手机号 + type: string + type: object DtoBoundCardInfo: properties: card_id: @@ -1208,6 +1313,12 @@ components: description: 运营商ID minimum: 0 type: integer + realname_link_template: + description: 实名链接模板URL + type: string + realname_link_type: + description: 实名链接类型 none-不支持 template-模板URL gateway-Gateway接口 + type: string status: description: 状态 (1:启用, 0:禁用) type: integer @@ -1222,6 +1333,63 @@ components: old_password: type: string type: object + DtoChangePhoneRequest: + properties: + new_code: + description: 新手机号验证码 + maxLength: 6 + minLength: 6 + type: string + new_phone: + description: 新手机号 + maxLength: 11 + minLength: 11 + type: string + old_code: + description: 旧手机号验证码 + maxLength: 6 + minLength: 6 + type: string + old_phone: + description: 旧手机号 + maxLength: 11 + minLength: 11 + type: string + required: + - old_phone + - old_code + - new_phone + - new_code + type: object + DtoChangePhoneResponse: + properties: + changed_at: + description: 换绑时间 + type: string + phone: + description: 换绑后手机号 + type: string + type: object + DtoClientSendCodeRequest: + properties: + phone: + description: 手机号 + maxLength: 11 + minLength: 11 + type: string + scene: + description: 业务场景 (bind_phone:绑定手机号, change_phone_old:换绑旧手机, change_phone_new:换绑新手机) + type: string + required: + - phone + - scene + type: object + DtoClientSendCodeResponse: + properties: + cooldown_seconds: + description: 冷却秒数 + type: integer + type: object DtoCommissionStatsResponse: properties: cost_diff_amount: @@ -1300,6 +1468,25 @@ components: - password - user_type type: object + DtoCreateAgentRechargeRequest: + properties: + amount: + description: 充值金额(分),范围100元~100万元 + maximum: 1e+08 + minimum: 10000 + type: integer + payment_method: + description: 支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用) + type: string + shop_id: + description: 目标店铺ID,代理只能填自己店铺 + minimum: 0 + type: integer + required: + - shop_id + - amount + - payment_method + type: object DtoCreateCarrierRequest: properties: carrier_code: @@ -1319,6 +1506,15 @@ components: description: 运营商描述 maxLength: 500 type: string + realname_link_template: + description: 实名链接模板URL,支持 {iccid}/{msisdn}/{virtual_no} 占位符 + maxLength: 500 + nullable: true + type: string + realname_link_type: + description: 实名链接类型 none-不支持 template-模板URL gateway-Gateway接口 + nullable: true + type: string required: - carrier_code - carrier_name @@ -1708,22 +1904,6 @@ components: - config_name - priority type: object - DtoCreateRechargeRequest: - properties: - amount: - description: 充值金额(分) - type: integer - payment_method: - description: 支付方式 - type: string - resource_id: - description: 资源ID - minimum: 0 - type: integer - resource_type: - description: 资源类型 - type: string - type: object DtoCreateRoleRequest: properties: role_desc: @@ -1846,6 +2026,104 @@ components: minimum: 0 type: integer type: object + DtoCreateWechatConfigRequest: + properties: + description: + description: 配置描述 + maxLength: 500 + type: string + fy_api_url: + description: 富友API地址 + maxLength: 500 + type: string + fy_ins_cd: + description: 富友机构号 + maxLength: 50 + type: string + fy_mchnt_cd: + description: 富友商户号 + maxLength: 50 + type: string + fy_notify_url: + description: 富友支付回调地址 + maxLength: 500 + type: string + fy_private_key: + description: 富友私钥(PEM格式) + type: string + fy_public_key: + description: 富友公钥(PEM格式) + type: string + fy_term_id: + description: 富友终端号 + maxLength: 50 + type: string + miniapp_app_id: + description: 小程序AppID + maxLength: 100 + type: string + miniapp_app_secret: + description: 小程序AppSecret + maxLength: 200 + type: string + name: + description: 配置名称 + maxLength: 100 + minLength: 1 + type: string + oa_aes_key: + description: 公众号AES加密Key + maxLength: 200 + type: string + oa_app_id: + description: 公众号AppID + maxLength: 100 + type: string + oa_app_secret: + description: 公众号AppSecret + maxLength: 200 + type: string + oa_oauth_redirect_url: + description: OAuth回调地址 + maxLength: 500 + type: string + oa_token: + description: 公众号Token + maxLength: 200 + type: string + provider_type: + description: 支付渠道类型 (wechat:微信直连, fuiou:富友) + type: string + wx_api_v2_key: + description: 微信APIv2密钥 + maxLength: 200 + type: string + wx_api_v3_key: + description: 微信APIv3密钥 + maxLength: 200 + type: string + wx_cert_content: + description: 微信支付证书内容(PEM格式) + type: string + wx_key_content: + description: 微信支付密钥内容(PEM格式) + type: string + wx_mch_id: + description: 微信商户号 + maxLength: 100 + type: string + wx_notify_url: + description: 微信支付回调地址 + maxLength: 500 + type: string + wx_serial_no: + description: 微信证书序列号 + maxLength: 200 + type: string + required: + - name + - provider_type + type: object DtoCreateWithdrawalSettingReq: properties: daily_withdrawal_limit: @@ -2066,28 +2344,6 @@ components: description: 卡状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) type: integer type: object - DtoDeviceCardInfo: - properties: - card_id: - description: 卡ID - minimum: 0 - type: integer - carrier_name: - description: 运营商名称 - type: string - iccid: - description: ICCID - type: string - msisdn: - description: 手机号 - type: string - network_status: - description: 网络状态:0=停机 1=开机 - type: integer - network_status_name: - description: 网络状态名称 - type: string - type: object DtoDeviceImportResultItemDTO: properties: line: @@ -2415,40 +2671,6 @@ components: description: 总记录数 type: integer type: object - DtoEnterpriseDeviceDetailResp: - properties: - cards: - description: 绑定卡列表 - items: - $ref: '#/components/schemas/DtoDeviceCardInfo' - nullable: true - type: array - device: - $ref: '#/components/schemas/DtoEnterpriseDeviceInfo' - type: object - DtoEnterpriseDeviceInfo: - properties: - authorized_at: - description: 授权时间 - format: date-time - type: string - device_id: - description: 设备ID - minimum: 0 - type: integer - device_model: - description: 设备型号 - type: string - device_name: - description: 设备名称 - type: string - device_type: - description: 设备类型 - type: string - virtual_no: - description: 设备虚拟号 - type: string - type: object DtoEnterpriseDeviceItem: properties: authorized_at: @@ -2999,6 +3221,12 @@ components: user: $ref: '#/components/schemas/DtoUserInfo' type: object + DtoLogoutResponse: + properties: + success: + description: 是否成功 + type: boolean + type: object DtoManageGrantPackagesParams: properties: packages: @@ -3119,6 +3347,24 @@ components: description: 路由路径 type: string type: object + DtoMiniappLoginRequest: + properties: + asset_token: + description: A1返回的资产令牌 + type: string + avatar_url: + description: 用户头像URL(前端授权后传入) + type: string + code: + description: 小程序登录凭证 + type: string + nickname: + description: 用户昵称(前端授权后传入) + type: string + required: + - code + - asset_token + type: object DtoMyCommissionRecordItem: properties: amount: @@ -3440,6 +3686,10 @@ components: real_data_mb: description: 真流量额度(MB) type: integer + retail_price: + description: 代理零售价(分),仅代理用户可见 + nullable: true + type: integer series_id: description: 套餐系列ID minimum: 0 @@ -3521,19 +3771,6 @@ components: description: 更新时间 type: string type: object - DtoPackageUsageCustomerViewResponse: - properties: - addon_packages: - description: 加油包列表(按priority排序) - items: - $ref: '#/components/schemas/DtoPackageUsageItemResponse' - nullable: true - type: array - main_package: - $ref: '#/components/schemas/DtoPackageUsageItemResponse' - total: - $ref: '#/components/schemas/DtoPackageUsageTotalInfo' - type: object DtoPackageUsageDailyRecordResponse: properties: cumulative_usage_mb: @@ -3565,50 +3802,6 @@ components: description: 总使用流量(MB) type: integer type: object - DtoPackageUsageItemResponse: - properties: - activated_at: - description: 激活时间 - type: string - expires_at: - description: 过期时间 - type: string - package_id: - description: 套餐ID - minimum: 0 - type: integer - package_name: - description: 套餐名称 - type: string - package_usage_id: - description: 套餐使用记录ID - minimum: 0 - type: integer - priority: - description: 优先级(数字越小优先级越高) - type: integer - status: - description: 状态 (0:待生效, 1:生效中, 2:已用完, 3:已过期, 4:已失效) - type: integer - status_text: - description: 状态文本 - type: string - total_mb: - description: 总流量(MB) - type: integer - used_mb: - description: 已使用流量(MB) - type: integer - type: object - DtoPackageUsageTotalInfo: - properties: - total_mb: - description: 总流量(MB) - type: integer - used_mb: - description: 总已使用流量(MB) - type: integer - type: object DtoPermissionPageResult: properties: items: @@ -3713,37 +3906,6 @@ components: description: 请求路径 type: string type: object - DtoPersonalCustomerResponse: - properties: - avatar_url: - description: 头像URL - type: string - created_at: - description: 创建时间 - type: string - id: - description: 客户ID - minimum: 0 - type: integer - nickname: - description: 昵称 - type: string - phone: - description: 手机号 - type: string - status: - description: 状态 (0:禁用, 1:启用) - type: integer - updated_at: - description: 更新时间 - type: string - wx_open_id: - description: 微信OpenID - type: string - wx_union_id: - description: 微信UnionID - type: string - type: object DtoPollingAlertHistoryListResp: properties: items: @@ -4290,113 +4452,6 @@ components: description: 设备虚拟号 type: string type: object - DtoRechargeCheckResponse: - properties: - current_accumulated: - description: 当前累计充值金额(分) - type: integer - first_commission_paid: - description: 一次性佣金是否已发放 - type: boolean - force_recharge_amount: - description: 强充金额(分) - type: integer - max_amount: - description: 最大充值金额(分) - type: integer - message: - description: 提示信息 - type: string - min_amount: - description: 最小充值金额(分) - type: integer - need_force_recharge: - description: 是否需要强充 - type: boolean - threshold: - description: 佣金触发阈值(分) - type: integer - trigger_type: - description: 触发类型 - type: string - type: object - DtoRechargeListResponse: - properties: - list: - description: 列表数据 - items: - $ref: '#/components/schemas/DtoRechargeResponse' - nullable: true - type: array - page: - description: 当前页码 - type: integer - page_size: - description: 每页数量 - type: integer - total: - description: 总记录数 - type: integer - total_pages: - description: 总页数 - type: integer - type: object - DtoRechargeResponse: - properties: - amount: - description: 充值金额(分) - type: integer - completed_at: - description: 完成时间 - format: date-time - nullable: true - type: string - created_at: - description: 创建时间 - format: date-time - type: string - id: - description: 充值订单ID - minimum: 0 - type: integer - paid_at: - description: 支付时间 - format: date-time - nullable: true - type: string - payment_channel: - description: 支付渠道 - nullable: true - type: string - payment_method: - description: 支付方式 - type: string - payment_transaction_id: - description: 第三方支付交易号 - nullable: true - type: string - recharge_no: - description: 充值订单号 - type: string - status: - description: 充值状态 - type: integer - status_text: - description: 状态文本 - type: string - updated_at: - description: 更新时间 - format: date-time - type: string - user_id: - description: 用户ID - minimum: 0 - type: integer - wallet_id: - description: 钱包ID - minimum: 0 - type: integer - type: object DtoRefreshTokenRequest: properties: refresh_token: @@ -5260,6 +5315,15 @@ components: maxLength: 500 nullable: true type: string + realname_link_template: + description: 实名链接模板URL,支持 {iccid}/{msisdn}/{virtual_no} 占位符 + maxLength: 500 + nullable: true + type: string + realname_link_type: + description: 实名链接类型 none-不支持 template-模板URL gateway-Gateway接口 + nullable: true + type: string type: object DtoUpdateCarrierStatusParams: properties: @@ -5712,6 +5776,125 @@ components: required: - status type: object + DtoUpdateWechatConfigParams: + properties: + description: + description: 配置描述 + maxLength: 500 + nullable: true + type: string + fy_api_url: + description: 富友API地址 + maxLength: 500 + nullable: true + type: string + fy_ins_cd: + description: 富友机构号 + maxLength: 50 + nullable: true + type: string + fy_mchnt_cd: + description: 富友商户号 + maxLength: 50 + nullable: true + type: string + fy_notify_url: + description: 富友支付回调地址 + maxLength: 500 + nullable: true + type: string + fy_private_key: + description: 富友私钥(PEM格式) + nullable: true + type: string + fy_public_key: + description: 富友公钥(PEM格式) + nullable: true + type: string + fy_term_id: + description: 富友终端号 + maxLength: 50 + nullable: true + type: string + miniapp_app_id: + description: 小程序AppID + maxLength: 100 + nullable: true + type: string + miniapp_app_secret: + description: 小程序AppSecret + maxLength: 200 + nullable: true + type: string + name: + description: 配置名称 + maxLength: 100 + minLength: 1 + nullable: true + type: string + oa_aes_key: + description: 公众号AES加密Key + maxLength: 200 + nullable: true + type: string + oa_app_id: + description: 公众号AppID + maxLength: 100 + nullable: true + type: string + oa_app_secret: + description: 公众号AppSecret + maxLength: 200 + nullable: true + type: string + oa_oauth_redirect_url: + description: OAuth回调地址 + maxLength: 500 + nullable: true + type: string + oa_token: + description: 公众号Token + maxLength: 200 + nullable: true + type: string + provider_type: + description: 支付渠道类型 (wechat:微信直连, fuiou:富友) + nullable: true + type: string + wx_api_v2_key: + description: 微信APIv2密钥 + maxLength: 200 + nullable: true + type: string + wx_api_v3_key: + description: 微信APIv3密钥 + maxLength: 200 + nullable: true + type: string + wx_cert_content: + description: 微信支付证书内容(PEM格式) + nullable: true + type: string + wx_key_content: + description: 微信支付密钥内容(PEM格式) + nullable: true + type: string + wx_mch_id: + description: 微信商户号 + maxLength: 100 + nullable: true + type: string + wx_notify_url: + description: 微信支付回调地址 + maxLength: 500 + nullable: true + type: string + wx_serial_no: + description: 微信证书序列号 + maxLength: 200 + nullable: true + type: string + type: object DtoUserInfo: properties: enterprise_id: @@ -5745,71 +5928,153 @@ components: description: 用户名 type: string type: object - DtoWechatH5Detail: + DtoVerifyAssetRequest: properties: - type: - description: 场景类型 (iOS:苹果, Android:安卓, Wap:浏览器) - type: string - type: object - DtoWechatH5SceneInfo: - properties: - h5_info: - $ref: '#/components/schemas/DtoWechatH5Detail' - payer_client_ip: - description: 用户终端IP + identifier: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 type: string required: - - payer_client_ip + - identifier type: object - DtoWechatOAuthRequest: + DtoVerifyAssetResponse: properties: + asset_token: + description: 资产令牌(5分钟有效) + type: string + expires_in: + description: 过期时间(秒) + type: integer + type: object + DtoWechatConfigListResponse: + properties: + list: + description: 配置列表 + items: + $ref: '#/components/schemas/DtoWechatConfigResponse' + nullable: true + type: array + page: + description: 当前页 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数 + type: integer + type: object + DtoWechatConfigResponse: + properties: + created_at: + description: 创建时间 + type: string + description: + description: 配置描述 + type: string + fy_api_url: + description: 富友API地址 + type: string + fy_ins_cd: + description: 富友机构号 + type: string + fy_mchnt_cd: + description: 富友商户号 + type: string + fy_notify_url: + description: 富友支付回调地址 + type: string + fy_private_key: + description: 富友私钥(配置状态) + type: string + fy_public_key: + description: 富友公钥(配置状态) + type: string + fy_term_id: + description: 富友终端号 + type: string + id: + description: 配置ID + minimum: 0 + type: integer + is_active: + description: 是否激活 + type: boolean + miniapp_app_id: + description: 小程序AppID + type: string + miniapp_app_secret: + description: 小程序AppSecret(已脱敏) + type: string + name: + description: 配置名称 + type: string + oa_aes_key: + description: 公众号AES加密Key(已脱敏) + type: string + oa_app_id: + description: 公众号AppID + type: string + oa_app_secret: + description: 公众号AppSecret(已脱敏) + type: string + oa_oauth_redirect_url: + description: OAuth回调地址 + type: string + oa_token: + description: 公众号Token(已脱敏) + type: string + provider_type: + description: 支付渠道类型 (wechat:微信直连, fuiou:富友) + type: string + updated_at: + description: 更新时间 + type: string + wx_api_v2_key: + description: 微信APIv2密钥(已脱敏) + type: string + wx_api_v3_key: + description: 微信APIv3密钥(已脱敏) + type: string + wx_cert_content: + description: 微信支付证书内容(配置状态) + type: string + wx_key_content: + description: 微信支付密钥内容(配置状态) + type: string + wx_mch_id: + description: 微信商户号 + type: string + wx_notify_url: + description: 微信支付回调地址 + type: string + wx_serial_no: + description: 微信证书序列号 + type: string + type: object + DtoWechatLoginRequest: + properties: + asset_token: + description: A1返回的资产令牌 + type: string code: - description: 微信授权码 + description: 微信OAuth授权码 type: string required: - code + - asset_token type: object - DtoWechatOAuthResponse: + DtoWechatLoginResponse: properties: - access_token: - description: 访问令牌 - type: string - customer: - $ref: '#/components/schemas/DtoPersonalCustomerResponse' - expires_in: - description: 令牌有效期(秒) - type: integer - type: object - DtoWechatPayH5Params: - properties: - scene_info: - $ref: '#/components/schemas/DtoWechatH5SceneInfo' - required: - - scene_info - type: object - DtoWechatPayH5Response: - properties: - h5_url: - description: 微信支付跳转URL - type: string - type: object - DtoWechatPayJSAPIParams: - properties: - openid: - description: 用户OpenID - type: string - required: - - openid - type: object - DtoWechatPayJSAPIResponse: - properties: - pay_config: - additionalProperties: {} - description: JSSDK支付配置 - nullable: true - type: object - prepay_id: - description: 预支付交易会话标识 + is_new_user: + description: 是否新创建用户 + type: boolean + need_bind_phone: + description: 是否需要绑定手机号 + type: boolean + token: + description: 登录JWT令牌 type: string type: object DtoWithdrawalApprovalResp: @@ -6731,6 +6996,308 @@ paths: summary: 修改账号状态 tags: - 账号管理 + /api/admin/agent-recharges: + get: + parameters: + - description: 页码,默认1 + in: query + name: page + schema: + description: 页码,默认1 + minimum: 1 + type: integer + - description: 每页条数,默认20,最大100 + in: query + name: page_size + schema: + description: 每页条数,默认20,最大100 + maximum: 100 + minimum: 1 + type: integer + - description: 按店铺ID过滤 + in: query + name: shop_id + schema: + description: 按店铺ID过滤 + minimum: 0 + nullable: true + type: integer + - description: 按状态过滤 (1:待支付, 2:已完成, 3:已取消) + in: query + name: status + schema: + description: 按状态过滤 (1:待支付, 2:已完成, 3:已取消) + nullable: true + type: integer + - description: 创建时间起始日期(YYYY-MM-DD) + in: query + name: start_date + schema: + description: 创建时间起始日期(YYYY-MM-DD) + type: string + - description: 创建时间截止日期(YYYY-MM-DD) + in: query + name: end_date + schema: + description: 创建时间截止日期(YYYY-MM-DD) + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentRechargeListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询代理充值订单列表 + tags: + - 代理预充值 + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoCreateAgentRechargeRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentRechargeResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 创建代理充值订单 + tags: + - 代理预充值 + /api/admin/agent-recharges/{id}: + get: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentRechargeResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询代理充值订单详情 + tags: + - 代理预充值 + /api/admin/agent-recharges/{id}/offline-pay: + post: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoAgentOfflinePayParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentRechargeResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 确认线下充值 + tags: + - 代理预充值 /api/admin/asset-allocation-records: get: parameters: @@ -9680,6 +10247,47 @@ paths: summary: 解绑设备上的卡 tags: - 设备管理 + /api/admin/devices/{id}/deactivate: + patch: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 手动停用设备 + tags: + - 设备管理 /api/admin/devices/allocate: post: description: 分配设备给直属下级店铺。分配时自动同步绑定的所有卡的 shop_id。 @@ -11429,6 +12037,47 @@ paths: summary: 获取实名认证链接 tags: - IoT卡管理 + /api/admin/iot-cards/{id}/deactivate: + patch: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 手动停用IoT卡 + tags: + - IoT卡管理 /api/admin/iot-cards/import: post: description: |- @@ -17785,6 +18434,526 @@ paths: summary: 获取文件上传预签名 URL tags: - 对象存储 + /api/admin/wechat-configs: + get: + parameters: + - description: 页码 + in: query + name: page + schema: + description: 页码 + minimum: 1 + type: integer + - description: 每页数量 + in: query + name: page_size + schema: + description: 每页数量 + maximum: 100 + minimum: 1 + type: integer + - description: 支付渠道类型 (wechat:微信直连, fuiou:富友) + in: query + name: provider_type + schema: + description: 支付渠道类型 (wechat:微信直连, fuiou:富友) + nullable: true + type: string + - description: 是否激活 (true:已激活, false:未激活) + in: query + name: is_active + schema: + description: 是否激活 (true:已激活, false:未激活) + nullable: true + type: boolean + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWechatConfigListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 获取支付配置列表 + tags: + - 微信支付配置管理 + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoCreateWechatConfigRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWechatConfigResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 创建支付配置 + tags: + - 微信支付配置管理 + /api/admin/wechat-configs/{id}: + delete: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 删除支付配置 + tags: + - 微信支付配置管理 + get: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWechatConfigResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 获取支付配置详情 + tags: + - 微信支付配置管理 + put: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoUpdateWechatConfigParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWechatConfigResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 更新支付配置 + tags: + - 微信支付配置管理 + /api/admin/wechat-configs/{id}/activate: + post: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWechatConfigResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 激活支付配置 + tags: + - 微信支付配置管理 + /api/admin/wechat-configs/{id}/deactivate: + post: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWechatConfigResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 停用支付配置 + tags: + - 微信支付配置管理 + /api/admin/wechat-configs/active: + get: + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWechatConfigResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 获取当前生效的支付配置 + tags: + - 微信支付配置管理 /api/auth/login: post: requestBody: @@ -18010,15 +19179,40 @@ paths: summary: 刷新 Token tags: - 统一认证 - /api/c/v1/bind-wechat: + /api/c/v1/auth/bind-phone: post: - description: 绑定微信账号到当前个人客户 requestBody: content: application/json: schema: - $ref: '#/components/schemas/DtoWechatOAuthRequest' + $ref: '#/components/schemas/DtoBindPhoneRequest' responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoBindPhoneResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 "400": content: application/json: @@ -18045,17 +19239,16 @@ paths: description: 服务器内部错误 security: - BearerAuth: [] - summary: 绑定微信 + summary: 绑定手机号 tags: - - 个人客户 - 账户 - /api/c/v1/login: + - 个人客户 - 认证 + /api/c/v1/auth/change-phone: post: - description: 使用手机号和验证码登录 requestBody: content: application/json: schema: - $ref: '#/components/schemas/AppLoginRequest' + $ref: '#/components/schemas/DtoChangePhoneRequest' responses: "200": content: @@ -18067,7 +19260,128 @@ paths: example: 0 type: integer data: - $ref: '#/components/schemas/AppLoginResponse' + $ref: '#/components/schemas/DtoChangePhoneResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 更换手机号 + tags: + - 个人客户 - 认证 + /api/c/v1/auth/logout: + post: + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoLogoutResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 退出登录 + tags: + - 个人客户 - 认证 + /api/c/v1/auth/miniapp-login: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoMiniappLoginRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWechatLoginResponse' msg: description: 响应消息 example: success @@ -18095,18 +19409,43 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' description: 服务器内部错误 - summary: 手机号登录 + summary: 小程序登录 tags: - 个人客户 - 认证 - /api/c/v1/login/send-code: + /api/c/v1/auth/send-code: post: - description: 向指定手机号发送登录验证码 requestBody: content: application/json: schema: - $ref: '#/components/schemas/AppSendCodeRequest' + $ref: '#/components/schemas/DtoClientSendCodeRequest' responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoClientSendCodeResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 "400": content: application/json: @@ -18122,6 +19461,104 @@ paths: summary: 发送验证码 tags: - 个人客户 - 认证 + /api/c/v1/auth/verify-asset: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoVerifyAssetRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoVerifyAssetResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + summary: 资产验证 + tags: + - 个人客户 - 认证 + /api/c/v1/auth/wechat-login: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoWechatLoginRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWechatLoginResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + summary: 公众号登录 + tags: + - 个人客户 - 认证 /api/c/v1/profile: get: description: 获取当前登录客户的个人资料 @@ -18218,56 +19655,6 @@ paths: summary: 更新个人资料 tags: - 个人客户 - 账户 - /api/c/v1/wechat/auth: - post: - description: 使用微信授权码登录,自动创建或关联用户 - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoWechatOAuthRequest' - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoWechatOAuthResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - summary: 微信授权登录 - tags: - - 个人客户 - 认证 /api/callback/alipay: post: responses: @@ -18286,6 +19673,24 @@ paths: summary: 支付宝回调 tags: - 支付回调 + /api/callback/fuiou-pay: + post: + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + summary: 富友支付回调 + tags: + - 支付回调 /api/callback/wechat-pay: post: responses: @@ -18304,951 +19709,6 @@ paths: summary: 微信支付回调 tags: - 支付回调 - /api/h5/devices: - get: - parameters: - - description: 页码 - in: query - name: page - schema: - description: 页码 - type: integer - - description: 每页数量 - in: query - name: page_size - schema: - description: 每页数量 - type: integer - - description: 虚拟号(模糊搜索) - in: query - name: virtual_no - schema: - description: 虚拟号(模糊搜索) - type: string - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoEnterpriseDeviceListResp' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 企业设备列表(H5) - tags: - - H5-企业设备 - /api/h5/devices/{device_id}: - get: - parameters: - - description: 设备ID - in: path - name: device_id - required: true - schema: - description: 设备ID - minimum: 0 - type: integer - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoEnterpriseDeviceDetailResp' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 获取设备详情(H5) - tags: - - H5-企业设备 - /api/h5/orders: - get: - parameters: - - description: 页码 - in: query - name: page - schema: - description: 页码 - minimum: 1 - type: integer - - description: 每页数量 - in: query - name: page_size - schema: - description: 每页数量 - maximum: 100 - minimum: 1 - type: integer - - description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款) - in: query - name: payment_status - schema: - description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款) - maximum: 4 - minimum: 1 - nullable: true - type: integer - - description: 订单类型 (single_card:单卡购买, device:设备购买) - in: query - name: order_type - schema: - description: 订单类型 (single_card:单卡购买, device:设备购买) - type: string - - description: 订单号(精确查询) - in: query - name: order_no - schema: - description: 订单号(精确查询) - maxLength: 30 - type: string - - description: 订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买) - in: query - name: purchase_role - schema: - description: 订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买) - type: string - - description: 创建时间起始 - in: query - name: start_time - schema: - description: 创建时间起始 - format: date-time - nullable: true - type: string - - description: 创建时间结束 - in: query - name: end_time - schema: - description: 创建时间结束 - format: date-time - nullable: true - type: string - - description: 是否已过期 (true:已过期, false:未过期) - in: query - name: is_expired - schema: - description: 是否已过期 (true:已过期, false:未过期) - nullable: true - type: boolean - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoOrderListResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 获取订单列表 - tags: - - H5 订单 - post: - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoCreateOrderRequest' - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoOrderResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 创建订单 - tags: - - H5 订单 - /api/h5/orders/{id}: - get: - parameters: - - description: 订单ID - in: path - name: id - required: true - schema: - description: 订单ID - minimum: 0 - type: integer - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoOrderResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 获取订单详情 - tags: - - H5 订单 - /api/h5/orders/{id}/wallet-pay: - post: - parameters: - - description: 订单ID - in: path - name: id - required: true - schema: - description: 订单ID - minimum: 0 - type: integer - responses: - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 钱包支付 - tags: - - H5 订单 - /api/h5/orders/{id}/wechat-pay/h5: - post: - parameters: - - description: 订单ID - in: path - name: id - required: true - schema: - description: 订单ID - minimum: 0 - type: integer - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoWechatPayH5Params' - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoWechatPayH5Response' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 微信 H5 支付 - tags: - - H5 订单 - /api/h5/orders/{id}/wechat-pay/jsapi: - post: - parameters: - - description: 订单ID - in: path - name: id - required: true - schema: - description: 订单ID - minimum: 0 - type: integer - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoWechatPayJSAPIParams' - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoWechatPayJSAPIResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 微信 JSAPI 支付 - tags: - - H5 订单 - /api/h5/packages/my-usage: - get: - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoPackageUsageCustomerViewResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 获取我的套餐使用情况 - tags: - - H5-套餐 - /api/h5/wallets/recharge: - post: - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoCreateRechargeRequest' - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoRechargeResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 创建充值订单 - tags: - - H5 充值 - /api/h5/wallets/recharge-check: - get: - parameters: - - description: 资源类型 - in: query - name: resource_type - schema: - description: 资源类型 - type: string - - description: 资源ID - in: query - name: resource_id - schema: - description: 资源ID - minimum: 0 - type: integer - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoRechargeCheckResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 充值预检 - tags: - - H5 充值 - /api/h5/wallets/recharges: - get: - parameters: - - description: 页码 - in: query - name: page - schema: - description: 页码 - type: integer - - description: 每页数量 - in: query - name: page_size - schema: - description: 每页数量 - type: integer - - description: 钱包ID - in: query - name: wallet_id - schema: - description: 钱包ID - minimum: 0 - nullable: true - type: integer - - description: 状态 - in: query - name: status - schema: - description: 状态 - nullable: true - type: integer - - description: 开始时间 - in: query - name: start_time - schema: - description: 开始时间 - format: date-time - nullable: true - type: string - - description: 结束时间 - in: query - name: end_time - schema: - description: 结束时间 - format: date-time - nullable: true - type: string - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoRechargeListResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 获取充值订单列表 - tags: - - H5 充值 - /api/h5/wallets/recharges/{id}: - get: - parameters: - - description: 充值订单ID - in: path - name: id - required: true - schema: - description: 充值订单ID - minimum: 0 - type: integer - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoRechargeResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 获取充值订单详情 - tags: - - H5 充值 /health: get: responses: diff --git a/docs/client-auth-system/功能总结.md b/docs/client-auth-system/功能总结.md new file mode 100644 index 0000000..fe89e8f --- /dev/null +++ b/docs/client-auth-system/功能总结.md @@ -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` | 是否要求绑定手机号 | diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 745deb0..75ddb09 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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), diff --git a/internal/bootstrap/middlewares.go b/internal/bootstrap/middlewares.go index d5b82df..c46be2a 100644 --- a/internal/bootstrap/middlewares.go +++ b/internal/bootstrap/middlewares.go @@ -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 diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index ca44ee9..ada1996 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -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 @@ -102,11 +104,25 @@ func initServices(s *stores, deps *Dependencies) *services { wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, accountAudit, deps.Redis, deps.Logger) return &services{ - Account: account, - AccountAudit: accountAudit, - Role: roleSvc.New(s.Role, s.Permission, s.RolePermission), - Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis), - PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger), + Account: account, + AccountAudit: accountAudit, + Role: roleSvc.New(s.Role, s.Permission, s.RolePermission), + Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis), + PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger), + 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), diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 2aa3ce1..7ec87ce 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -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), diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index a3518a3..a1b8401 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -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 diff --git a/internal/handler/app/client_auth.go b/internal/handler/app/client_auth.go new file mode 100644 index 0000000..14b0a35 --- /dev/null +++ b/internal/handler/app/client_auth.go @@ -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) +} diff --git a/internal/middleware/personal_auth.go b/internal/middleware/personal_auth.go index ca29fe0..bae77b2 100644 --- a/internal/middleware/personal_auth.go +++ b/internal/middleware/personal_auth.go @@ -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("个人客户认证成功", diff --git a/internal/model/dto/client_auth_dto.go b/internal/model/dto/client_auth_dto.go new file mode 100644 index 0000000..e02e58b --- /dev/null +++ b/internal/model/dto/client_auth_dto.go @@ -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:"是否成功"` +} diff --git a/internal/model/personal_customer_openid.go b/internal/model/personal_customer_openid.go new file mode 100644 index 0000000..1f46861 --- /dev/null +++ b/internal/model/personal_customer_openid.go @@ -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" +} diff --git a/internal/routes/personal.go b/internal/routes/personal.go index 23c6e20..00f73a9 100644 --- a/internal/routes/personal.go +++ b/internal/routes/personal.go @@ -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()) diff --git a/internal/service/client_auth/service.go b/internal/service/client_auth/service.go new file mode 100644 index 0000000..a1c58b3 --- /dev/null +++ b/internal/service/client_auth/service.go @@ -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 +} diff --git a/internal/store/postgres/personal_customer_openid_store.go b/internal/store/postgres/personal_customer_openid_store.go new file mode 100644 index 0000000..a627479 --- /dev/null +++ b/internal/store/postgres/personal_customer_openid_store.go @@ -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 +} diff --git a/migrations/000083_add_personal_customer_openid.down.sql b/migrations/000083_add_personal_customer_openid.down.sql new file mode 100644 index 0000000..78ab73d --- /dev/null +++ b/migrations/000083_add_personal_customer_openid.down.sql @@ -0,0 +1,2 @@ +-- 回滚:删除个人客户 OpenID 关联表 +DROP TABLE IF EXISTS tb_personal_customer_openid; diff --git a/migrations/000083_add_personal_customer_openid.up.sql b/migrations/000083_add_personal_customer_openid.up.sql new file mode 100644 index 0000000..f56a7f4 --- /dev/null +++ b/migrations/000083_add_personal_customer_openid.up.sql @@ -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)'; diff --git a/openspec/changes/client-auth-system/.openspec.yaml b/openspec/changes/client-auth-system/.openspec.yaml new file mode 100644 index 0000000..3c861dd --- /dev/null +++ b/openspec/changes/client-auth-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-18 diff --git a/openspec/changes/client-auth-system/design.md b/openspec/changes/client-auth-system/design.md new file mode 100644 index 0000000..8bc51f3 --- /dev/null +++ b/openspec/changes/client-auth-system/design.md @@ -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/*`。 diff --git a/openspec/changes/client-auth-system/proposal.md b/openspec/changes/client-auth-system/proposal.md new file mode 100644 index 0000000..720a505 --- /dev/null +++ b/openspec/changes/client-auth-system/proposal.md @@ -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` 配置项 diff --git a/openspec/changes/client-auth-system/specs/client-asset-token/spec.md b/openspec/changes/client-auth-system/specs/client-asset-token/spec.md new file mode 100644 index 0000000..e61af29 --- /dev/null +++ b/openspec/changes/client-auth-system/specs/client-asset-token/spec.md @@ -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` diff --git a/openspec/changes/client-auth-system/specs/client-phone-binding/spec.md b/openspec/changes/client-auth-system/specs/client-phone-binding/spec.md new file mode 100644 index 0000000..a1e31f4 --- /dev/null +++ b/openspec/changes/client-auth-system/specs/client-phone-binding/spec.md @@ -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 拒绝换绑并返回对应错误码 diff --git a/openspec/changes/client-auth-system/specs/client-token-management/spec.md b/openspec/changes/client-auth-system/specs/client-token-management/spec.md new file mode 100644 index 0000000..9f5ce23 --- /dev/null +++ b/openspec/changes/client-auth-system/specs/client-token-management/spec.md @@ -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 被中间件拒绝 diff --git a/openspec/changes/client-auth-system/specs/client-wechat-login/spec.md b/openspec/changes/client-auth-system/specs/client-wechat-login/spec.md new file mode 100644 index 0000000..9fda7ae --- /dev/null +++ b/openspec/changes/client-auth-system/specs/client-wechat-login/spec.md @@ -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` diff --git a/openspec/changes/client-auth-system/specs/personal-customer-openid/spec.md b/openspec/changes/client-auth-system/specs/personal-customer-openid/spec.md new file mode 100644 index 0000000..b7d2d87 --- /dev/null +++ b/openspec/changes/client-auth-system/specs/personal-customer-openid/spec.md @@ -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 记录 diff --git a/openspec/changes/client-auth-system/specs/personal-customer/spec.md b/openspec/changes/client-auth-system/specs/personal-customer/spec.md new file mode 100644 index 0000000..850b9f7 --- /dev/null +++ b/openspec/changes/client-auth-system/specs/personal-customer/spec.md @@ -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 场景 diff --git a/openspec/changes/client-auth-system/specs/wechat-official-account/spec.md b/openspec/changes/client-auth-system/specs/wechat-official-account/spec.md new file mode 100644 index 0000000..ca8f87f --- /dev/null +++ b/openspec/changes/client-auth-system/specs/wechat-official-account/spec.md @@ -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 diff --git a/openspec/changes/client-auth-system/tasks.md b/openspec/changes/client-auth-system/tasks.md new file mode 100644 index 0000000..ea147af --- /dev/null +++ b/openspec/changes/client-auth-system/tasks.md @@ -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/` 补充中文功能总结文档 diff --git a/pkg/config/defaults/config.yaml b/pkg/config/defaults/config.yaml index f7d7473..3f0cf8c 100644 --- a/pkg/config/defaults/config.yaml +++ b/pkg/config/defaults/config.yaml @@ -91,6 +91,10 @@ middleware: expiration: "1m" storage: "memory" +# 客户端配置 +client: + require_phone_binding: true # 是否要求个人客户绑定手机号 + # 短信服务配置 sms: gateway_url: "" # 可选:JUNHONG_SMS_GATEWAY_URL diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index 01b3391..ad6ff86 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -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 分钟 diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index d5b8cae..984544f 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -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: "密码已过期", diff --git a/pkg/wechat/config.go b/pkg/wechat/config.go index b5d98ad..3de5090 100644 --- a/pkg/wechat/config.go +++ b/pkg/wechat/config.go @@ -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 +} diff --git a/pkg/wechat/miniapp.go b/pkg/wechat/miniapp.go new file mode 100644 index 0000000..128c56a --- /dev/null +++ b/pkg/wechat/miniapp.go @@ -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 +} diff --git a/pkg/wechat/wechat.go b/pkg/wechat/wechat.go index 0a27bec..68713cd 100644 --- a/pkg/wechat/wechat.go +++ b/pkg/wechat/wechat.go @@ -42,5 +42,6 @@ type UserInfo struct { var ( _ Service = (*OfficialAccountService)(nil) _ OfficialAccountServiceInterface = (*OfficialAccountService)(nil) + _ MiniAppServiceInterface = (*MiniAppService)(nil) _ PaymentServiceInterface = (*PaymentService)(nil) )