diff --git a/.env b/.env index d3dd5b5..031ec6a 100644 --- a/.env +++ b/.env @@ -20,4 +20,10 @@ VITE_CHAT_AES_IV=345GDFED433223DF VITE_SHARE_TITLE=全能查|大数据风险报告查询与代理平台,支持个人和企业多场景风控应用 VITE_SHARE_DESC=提供个人信用评估、入职背调、信贷风控、企业风险监测等服务 VITE_SHARE_IMG=https://www.quannengcha.com/logo.png -VITE_TOKEN_VERSION=1.0 \ No newline at end of file +VITE_TOKEN_VERSION=1.0 + +# 阿里云滑块验证码配置 +VITE_ALIYUN_CAPTCHA_SCENE_ID=wynt39to +# 是否启用加密模式(true/false),需要在阿里云控制台开启加密模式 +# 注意:根据代码逻辑,设置为 true 表示禁用加密,设置为 false 表示启用加密 +VITE_ALIYUN_CAPTCHA_ENCRYPTED=true diff --git a/index.html b/index.html index e3af181..8f6b554 100644 --- a/index.html +++ b/index.html @@ -45,7 +45,7 @@ - + - + @@ -109,6 +109,15 @@ delete window.wx; + + + + @@ -207,6 +216,8 @@
加载中
+ +
diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index a4ca6de..7faddae 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -117,6 +117,7 @@ declare global { const useActiveElement: typeof import('@vueuse/core')['useActiveElement'] const useAgent: typeof import('./composables/useAgent.js')['useAgent'] const useAgentStore: typeof import('./stores/agentStore.js')['useAgentStore'] + const useAliyunCaptcha: typeof import('./composables/useAliyunCaptcha.js')['default'] const useAnimate: typeof import('@vueuse/core')['useAnimate'] const useApiFetch: typeof import('./composables/useApiFetch.js')['default'] const useAppStore: typeof import('./stores/appStore.js')['useAppStore'] diff --git a/src/components/InquireForm.vue b/src/components/InquireForm.vue index 828c27c..c3ce1cf 100644 --- a/src/components/InquireForm.vue +++ b/src/components/InquireForm.vue @@ -132,6 +132,7 @@ import { useRoute, useRouter } from "vue-router"; import { useUserStore } from "@/stores/userStore"; import { useDialogStore } from "@/stores/dialogStore"; import { useEnv } from "@/composables/useEnv"; +import { useAliyunCaptcha } from "@/composables/useAliyunCaptcha"; import Payment from "@/components/Payment.vue"; import BindPhoneOnlyDialog from "@/components/BindPhoneOnlyDialog.vue"; @@ -199,6 +200,7 @@ const dialogStore = useDialogStore(); const userStore = useUserStore(); const { isWeChat } = useEnv(); const appStore = useAppStore(); +const { runWithCaptcha } = useAliyunCaptcha(); // 响应式数据 const showPayment = ref(false); @@ -424,22 +426,27 @@ async function sendVerificationCode() { return; } - const { data, error } = await useApiFetch("/auth/sendSms") - .post({ mobile: formData.mobile, actionType: "query" }) - .json(); - - if (!error.value && data.value.code === 200) { - showToast({ message: "验证码发送成功", type: "success" }); - startCountdown(); - nextTick(() => { - const verificationCodeInput = document.getElementById('verificationCode'); - if (verificationCodeInput) { - verificationCodeInput.focus(); + // 使用滑块验证码保护发送短信接口 + runWithCaptcha( + (captchaVerifyParam) => + useApiFetch("/auth/sendSms") + .post({ mobile: formData.mobile, actionType: "query", captchaVerifyParam }) + .json(), + (res) => { + if (res.code === 200) { + showToast({ message: "验证码发送成功", type: "success" }); + startCountdown(); + nextTick(() => { + const verificationCodeInput = document.getElementById('verificationCode'); + if (verificationCodeInput) { + verificationCodeInput.focus(); + } + }); + } else { + showToast({ message: res.msg || "验证码发送失败,请重试" }); } - }); - } else { - showToast({ message: "验证码发送失败,请重试" }); - } + } + ); } let timer = null; diff --git a/src/composables/useAliyunCaptcha.js b/src/composables/useAliyunCaptcha.js new file mode 100644 index 0000000..56a88ec --- /dev/null +++ b/src/composables/useAliyunCaptcha.js @@ -0,0 +1,172 @@ +import { showToast, showLoadingToast, closeToast } from "vant"; +import useApiFetch from "@/composables/useApiFetch"; + +// 阿里云验证码场景 ID +const ALIYUN_CAPTCHA_SCENE_ID = "wynt39to"; +// 是否启用加密模式(通过环境变量控制,非加密模式时前端不调用后端获取 EncryptedSceneId) +const ENABLE_ENCRYPTED = + import.meta.env.VITE_ALIYUN_CAPTCHA_ENCRYPTED === "false"; + +let captchaInitialised = false; +/** 首次初始化后,SDK 会异步调用 getInstance,用此 Promise 在实例就绪后再 show */ +let captchaReadyPromise = null; +let captchaReadyResolve = null; + +async function ensureCaptchaInit() { + if (captchaInitialised || typeof window === "undefined") return; + if (typeof window.initAliyunCaptcha !== "function") return; + + captchaInitialised = true; + window.captcha = null; + window.__lastBizResponse = null; + window.__onCaptchaBizSuccess = null; + captchaReadyPromise = new Promise((resolve) => { + captchaReadyResolve = resolve; + }); + + // 非加密模式:仅传 SceneId,不调用后端接口 + if (!ENABLE_ENCRYPTED) { + window.initAliyunCaptcha({ + SceneId: ALIYUN_CAPTCHA_SCENE_ID, + mode: "popup", + element: "#captcha-element", + getInstance(instance) { + window.captcha = instance; + if (typeof captchaReadyResolve === "function") { + captchaReadyResolve(); + captchaReadyResolve = null; + } + }, + captchaVerifyCallback(param) { + return typeof window.__captchaVerifyCallback === "function" + ? window.__captchaVerifyCallback(param) + : Promise.resolve({ + captchaResult: false, + bizResult: false, + }); + }, + onBizResultCallback(bizResult) { + if (typeof window.__onBizResultCallback === "function") { + window.__onBizResultCallback(bizResult); + } + window.__lastBizResponse = null; + window.__onCaptchaBizSuccess = null; + }, + slideStyle: { width: 360, height: 40 }, + language: "cn", + }); + return; + } + + // 加密模式:先从后端获取 EncryptedSceneId,再初始化 + const { data, error } = await useApiFetch("/captcha/encryptedSceneId") + .post() + .json(); + const resp = data?.value; + const encryptedSceneId = resp?.data?.encryptedSceneId; + if (error?.value || !encryptedSceneId) { + showToast({ message: "获取验证码参数失败,请稍后重试" }); + captchaInitialised = false; + captchaReadyPromise = null; + captchaReadyResolve = null; + return; + } + window.initAliyunCaptcha({ + SceneId: ALIYUN_CAPTCHA_SCENE_ID, + EncryptedSceneId: encryptedSceneId, + mode: "popup", + element: "#captcha-element", + getInstance(instance) { + window.captcha = instance; + if (typeof captchaReadyResolve === "function") { + captchaReadyResolve(); + captchaReadyResolve = null; + } + }, + captchaVerifyCallback(param) { + return typeof window.__captchaVerifyCallback === "function" + ? window.__captchaVerifyCallback(param) + : Promise.resolve({ captchaResult: false, bizResult: false }); + }, + onBizResultCallback(bizResult) { + if (typeof window.__onBizResultCallback === "function") { + window.__onBizResultCallback(bizResult); + } + window.__lastBizResponse = null; + window.__onCaptchaBizSuccess = null; + }, + slideStyle: { width: 360, height: 40 }, + language: "cn", + }); +} + +/** + * 阿里云滑块验证码通用封装。 + * 依赖 index.html 中已加载的 AliyunCaptcha.js;初始化在首次调起时执行。 + * + * @param { (captchaVerifyParam: string) => Promise<{ data: Ref, error: Ref }> } bizVerify - 业务请求函数,接收滑块参数,返回 useApiFetch 的 { data, error } + * @param { (res: any) => void } [onSuccess] - 业务成功回调(code===200 时调用,传入接口返回的 data.value) + */ +export function useAliyunCaptcha() { + /** + * 先弹出滑块,通过后执行 bizVerify(captchaVerifyParam),再根据结果调用 onSuccess。 + */ + async function runWithCaptcha(bizVerify, onSuccess) { + if (typeof window === "undefined") { + showToast({ message: "验证码仅支持浏览器环境" }); + return; + } + + const loading = showLoadingToast({ + message: "安全验证加载中...", + forbidClick: true, + duration: 0, + loadingType: "spinner", + }); + + try { + window.__captchaVerifyCallback = async (captchaVerifyParam) => { + window.__lastBizResponse = null; + const { data, error } = await bizVerify(captchaVerifyParam); + const result = data?.value ?? data; + if (error?.value || !result) { + return { captchaResult: false, bizResult: false }; + } + window.__lastBizResponse = result; + const captchaOk = result.captchaVerifyResult !== false; + const bizOk = result.code === 200; + return { captchaResult: captchaOk, bizResult: bizOk }; + }; + + window.__onBizResultCallback = (bizResult) => { + if ( + bizResult === true && + window.__lastBizResponse && + typeof window.__onCaptchaBizSuccess === "function" + ) { + window.__onCaptchaBizSuccess(window.__lastBizResponse); + } + }; + + await ensureCaptchaInit(); + + // 首次初始化时 SDK 会异步调用 getInstance,需等待实例就绪后再 show + if (captchaReadyPromise) { + await captchaReadyPromise; + captchaReadyPromise = null; + } + if (!window.captcha) { + showToast({ message: "验证码未加载,请刷新页面重试" }); + return; + } + window.__onCaptchaBizSuccess = onSuccess; + window.captcha.show(); + } finally { + closeToast(); + } + } + + return { runWithCaptcha }; +} + +export default useAliyunCaptcha; diff --git a/src/views/Login.vue b/src/views/Login.vue index 613f136..4ea60a4 100644 --- a/src/views/Login.vue +++ b/src/views/Login.vue @@ -6,11 +6,13 @@ import { useUserStore } from '@/stores/userStore' import { useRouter, useRoute } from 'vue-router' import { mobileCodeLogin } from '@/api/user' import useApiFetch from '@/composables/useApiFetch' +import { useAliyunCaptcha } from '@/composables/useAliyunCaptcha' const router = useRouter() const route = useRoute() const agentStore = useAgentStore() const userStore = useUserStore() +const { runWithCaptcha } = useAliyunCaptcha() const phoneNumber = ref('') const verificationCode = ref('') @@ -35,25 +37,28 @@ async function sendVerificationCode() { return } - const { data, error } = await useApiFetch('auth/sendSms') - .post({ mobile: phoneNumber.value, actionType: 'login' }) - .json() - - if (data.value && !error.value) { - if (data.value.code === 200) { - showToast({ message: "获取成功" }); - startCountdown() - // 聚焦到验证码输入框 - nextTick(() => { - const verificationCodeInput = document.getElementById('verificationCode'); - if (verificationCodeInput) { - verificationCodeInput.focus(); - } - }); - } else { - showToast(data.value.msg) + // 使用滑块验证码保护发送短信接口 + runWithCaptcha( + (captchaVerifyParam) => + useApiFetch('auth/sendSms') + .post({ mobile: phoneNumber.value, actionType: 'login', captchaVerifyParam }) + .json(), + (res) => { + if (res.code === 200) { + showToast({ message: "获取成功" }); + startCountdown() + // 聚焦到验证码输入框 + nextTick(() => { + const verificationCodeInput = document.getElementById('verificationCode'); + if (verificationCodeInput) { + verificationCodeInput.focus(); + } + }); + } else { + showToast(res.msg || "获取验证码失败") + } } - } + ); } function startCountdown() { diff --git a/阿里云验证码接入.md b/阿里云验证码接入.md new file mode 100644 index 0000000..92e27d9 --- /dev/null +++ b/阿里云验证码接入.md @@ -0,0 +1,456 @@ +## 阿里云滑块验证码接入说明(含加密模式) + + +- 前端需要做什么 +- 后端需要做什么 +- 非加密模式 vs 加密模式(`EncryptedSceneId`) +- 本项目修改了哪些文件,可以作为参考实现 + +下面所有内容以当前项目(`tyc-webview-v2` + `tyc-server-v2`)为例,按步骤说明。 + +--- + +配置项 + +go get github.com/alibabacloud-go/captcha-20230305/client + +Captcha: + # 建议与短信相同的 AccessKey,或单独为验证码创建子账号 + AccessKeyID: "LTAI5tKGB3TVJbMHSoZN3yr9" + AccessKeySecret: "OCQ30GWp4yENMjmfOAaagksE18bp65" + # 验证码服务 Endpoint,国内一般为 captcha.cn-shanghai.aliyuncs.com + EndpointURL: "captcha.cn-shanghai.aliyuncs.com" + # 阿里云控制台中该场景的 SceneId,请替换为真实值 + SceneID: "wynt39to" + # 验证码控制台中的 ekey(通常为 Base64 字符串),用于生成 EncryptedSceneId + EKey: "" + +index.html + + +## 一、整体流程概览 + +### 1.1 场景说明 + +我们使用的是 **阿里云验证码 2.0 / V3 架构** 的滑块验证码,前后端配合流程如下: + +1. 前端在「获取短信验证码」/「查询前无短信码的产品」时,先弹出阿里云滑块验证码。 +2. 用户拖动成功后,前端拿到 `captchaVerifyParam`,携带到后端业务接口。 +3. 后端使用阿里云官方 Go SDK(`captcha-20230305`)+ 我们的封装,对 `captchaVerifyParam` 做服务端校验: + - 校验通过:继续后续业务逻辑(发短信、查询等)。 + - 校验失败:直接返回业务错误,例如「图形验证码校验失败」。 +4. 对于 **加密模式** 场景,前端还需要在初始化时传入 `EncryptedSceneId`,而 `EncryptedSceneId` 由后端用控制台的 `ekey` 生成。 + +### 1.2 使用场景(当前项目) + +- 登录页: + - **获取短信验证码**:必须先通过滑块验证。 + - **提交登录**:只校验短信验证码,不再做滑块。 +- Inquire 查询页: + - 有「短信验证码」字段的产品:点击「获取验证码」前滑块;点击「查询」时不需要再次滑块。 + - 无「短信验证码」的产品:点击「查询」前滑块。 + +--- + +## 二、前端接入说明(以 Vue 3 / Vite 为例) + +### 2.1 全局基础集成(`index.html`) + +在入口 HTML 中引入阿里云验证码脚本,并预留一个容器: + +- 文件:`tyc-webview-v2/index.html` + +关键片段: + +```html + + +... + +
+
+ +``` + +注意: + +- `#captcha-element` 是验证码挂载容器,必须存在。 +- `AliyunCaptcha.js` 必须在前端业务代码(`/src/main.js`)之前加载。 + +### 2.2 通用封装:`useAliyunCaptcha` + +- 文件:`tyc-webview-v2/src/composables/useAliyunCaptcha.js` + +该 composable 封装了: + +- **初始化阿里云验证码实例**(含加密 / 非加密模式); +- 提供一个通用方法 `runWithCaptcha(bizVerify, onSuccess)`: + - `bizVerify(captchaVerifyParam)`:前端回调,内部调用后端业务接口(发短信、查询等),返回 `{ data, error }`(`useApiFetch` 结果)。 + - `onSuccess(res)`:当业务 `code === 200` 时调用,`res` 为后端返回的数据。 + +使用方式示例: + +```js +import { useAliyunCaptcha } from "@/composables/useAliyunCaptcha"; + +const { runWithCaptcha } = useAliyunCaptcha(); + +function sendLoginSms() { + if (!isPhoneValid.value) return; + + runWithCaptcha( + (captchaVerifyParam) => + useApiFetch("auth/sendSms") + .post({ + mobile: phoneNumber.value, + actionType: "login", + captchaVerifyParam, + }) + .json(), + (res) => { + if (res.code === 200) { + // 成功:toast + 开始倒计时 + 聚焦输入框 + } else { + // 失败:toast 提示 + } + }, + ); +} +``` + +### 2.3 加密模式开关(前端) + +在 `useAliyunCaptcha.js` 顶部: + +```js +// 是否启用加密模式(通过环境变量控制) +const ENABLE_ENCRYPTED = + import.meta.env.VITE_ALIYUN_CAPTCHA_ENCRYPTED === "true"; +``` + +在 `ensureCaptchaInit()` 中: + +- **非加密模式**(`ENABLE_ENCRYPTED === false`): + + ```js + if (!ENABLE_ENCRYPTED) { + window.initAliyunCaptcha({ + SceneId: ALIYUN_CAPTCHA_SCENE_ID, + mode: "popup", + element: "#captcha-element", + getInstance(instance) { window.captcha = instance; ... }, + captchaVerifyCallback(param) { ... }, + onBizResultCallback(bizResult) { ... }, + slideStyle: { width: 360, height: 40 }, + language: "cn", + }); + return; + } + ``` + + 前端不会请求后端获取 `EncryptedSceneId`,只用 `SceneId` 初始化。 + +- **加密模式**(`ENABLE_ENCRYPTED === true`): + + ```js + const { data, error } = await useApiFetch("/captcha/encryptedSceneId") + .post() + .json(); + const resp = data?.value; + const encryptedSceneId = resp?.data?.encryptedSceneId; + if (error?.value || !encryptedSceneId) { + showToast({ message: "获取验证码参数失败,请稍后重试" }); + // 重置状态 + captchaInitialised = false; + captchaReadyPromise = null; + captchaReadyResolve = null; + return; + } + + window.initAliyunCaptcha({ + SceneId: ALIYUN_CAPTCHA_SCENE_ID, + EncryptedSceneId: encryptedSceneId, + mode: "popup", + element: "#captcha-element", + ... + }); + ``` + +> 在其它前端平台(React、原生 H5 等),可以复用同样的思路: +> - 抽一个 `runWithCaptcha` 工具; +> - 初始化逻辑中根据配置决定是否去后端拿 `EncryptedSceneId`,有则带上。 + +### 2.4 用户体验:加载提示 + +在 `runWithCaptcha` 中增加全局 Loading: + +```js +const loading = showLoadingToast({ + message: "安全验证加载中...", + forbidClick: true, + duration: 0, + loadingType: "spinner", +}); + +try { + // 设置 __captchaVerifyCallback / __onBizResultCallback + // await ensureCaptchaInit() + // await captchaReadyPromise + // window.captcha.show() +} finally { + closeToast(); +} +``` + +这样用户点击按钮后能看到“安全验证加载中”,避免误以为没反应。 + +--- + +## 三、后端接入说明(Go / go-zero) + +### 3.1 基本配置(`config.go` + `main.yaml`) + +- 文件:`app/main/api/internal/config/config.go` + +```go +type CaptchaConfig struct { + AccessKeyID string + AccessKeySecret string + EndpointURL string + SceneID string + EKey string // 加密模式用的 ekey(Base64) +} +``` + +- 文件:`app/main/api/etc/main.yaml` / `main.dev.yaml` + +```yaml +Captcha: + AccessKeyID: "你的AccessKeyId" + AccessKeySecret: "你的AccessKeySecret" + EndpointURL: "captcha.cn-shanghai.aliyuncs.com" + SceneID: "控制台场景ID" + EKey: "控制台上看到的 ekey(Base64 字符串)" +``` + +> 其它平台(Java/Spring、.NET 等)也需要同样的配置: +> - 一个 Captcha 配置块,包含 AK/SK、Endpoint、SceneId、EKey。 + +### 3.2 EncryptedSceneId 生成接口(加密模式) + +#### 3.2.1 生成函数 + +- 文件:`pkg/captcha/encrypt_scene.go` + +```go +package captcha + +import ( + "encoding/base64" + "fmt" + "time" + + lzcrypto "tyc-server/pkg/lzkit/crypto" +) + +// GenerateEncryptedSceneID: sceneId×tamp&expireTime -> AES-256-CBC + PKCS7 -> Base64(IV + ciphertext) +func GenerateEncryptedSceneID(sceneId, ekey string, expireSeconds int) (string, error) { + if expireSeconds <= 0 || expireSeconds > 86400 { + expireSeconds = 3600 + } + + ts := time.Now().Unix() + plaintext := fmt.Sprintf("%s&%d&%d", sceneId, ts, expireSeconds) + + keyBytes, err := base64.StdEncoding.DecodeString(ekey) + if err != nil { + return "", fmt.Errorf("decode ekey error: %w", err) + } + if len(keyBytes) != 32 { + return "", fmt.Errorf("invalid ekey length, need 32 bytes after base64 decode, got %d", len(keyBytes)) + } + + return lzcrypto.AesEncrypt([]byte(plaintext), keyBytes) +} +``` + +> 在其它语言上,只要完全按文档实现同样的算法即可。 + +#### 3.2.2 API 声明 + +- 文件:`app/main/api/desc/front/user.api` + +```go +@server ( + prefix: api/v1 + group: captcha +) +service main { + @doc "get encrypted scene id for aliyun captcha" + @handler getEncryptedSceneId + post /captcha/encryptedSceneId returns (GetEncryptedSceneIdResp) +} + +type ( + GetEncryptedSceneIdResp { + EncryptedSceneId string `json:"encryptedSceneId"` + } +) +``` + +#### 3.2.3 逻辑实现 + +- 文件:`app/main/api/internal/logic/captcha/getencryptedsceneidlogic.go` + +```go +func (l *GetEncryptedSceneIdLogic) GetEncryptedSceneId() (*types.GetEncryptedSceneIdResp, error) { + cfg := l.svcCtx.Config.Captcha + encrypted, err := captcha.GenerateEncryptedSceneID(cfg.SceneID, cfg.EKey, 3600) + if err != nil { + l.Errorf("generate encrypted scene id error: %+v", err) + return nil, err + } + return &types.GetEncryptedSceneIdResp{ + EncryptedSceneId: encrypted, + }, nil +} +``` + +> 其它平台只需要提供一个类似的 HTTP 接口即可: +> `POST /captcha/encryptedSceneId -> { encryptedSceneId: "xxx" }` + +### 3.3 验证 `captchaVerifyParam`(服务端验签) + +- 文件:`pkg/captcha/aliyun.go` + +使用阿里云官方 Go SDK 验证: + +```go +func Verify(cfg Config, captchaVerifyParam string) error { + if os.Getenv("ENV") == "development" { + return nil + } + if captchaVerifyParam == "" { + return errors.Wrapf(xerr.NewErrMsg("图形验证码校验失败"), "empty captchaVerifyParam") + } + + clientCfg := &openapi.Config{ + AccessKeyId: tea.String(cfg.AccessKeyID), + AccessKeySecret: tea.String(cfg.AccessKeySecret), + } + clientCfg.Endpoint = tea.String(cfg.EndpointURL) + client, err := captcha20230305.NewClient(clientCfg) + ... + + req := &captcha20230305.VerifyIntelligentCaptchaRequest{ + SceneId: tea.String(cfg.SceneID), + CaptchaVerifyParam: tea.String(captchaVerifyParam), + } + resp, err := client.VerifyIntelligentCaptcha(req) + ... + if tea.BoolValue(resp.Body.Result.VerifyResult) { + return nil + } + // 否则返回 "图形验证码校验失败" +} +``` + +在需要滑块的业务逻辑里(发送短信、查询),调用: + +```go +cfg := l.svcCtx.Config.Captcha +if err := captcha.Verify(captcha.Config{ + AccessKeyID: cfg.AccessKeyID, + AccessKeySecret: cfg.AccessKeySecret, + EndpointURL: cfg.EndpointURL, + SceneID: cfg.SceneID, +}, req.CaptchaVerifyParam); err != nil { + return nil, err +} +``` + +> 其它语言平台(Java、Node.js 等)可以用对应的阿里云 SDK 实现相同的服务端校验。 + +### 3.4 sendSms 接口示例 + +- 文件:`app/main/api/internal/logic/auth/sendsmslogic.go` + +```go +func (l *SendSmsLogic) SendSms(req *types.SendSmsReq) error { + // 1. 图形验证码校验 + cfg := l.svcCtx.Config.Captcha + if err := captcha.Verify(captcha.Config{...}, req.CaptchaVerifyParam); err != nil { + return err + } + + // 2. 原来的手机号加密、频率限制、阿里短信发送、Redis 存验证码等逻辑 +} +``` + +--- + +## 四、关键文件清单 + +- 前端: + - `tyc-webview-v2/index.html`:引入 `AliyunCaptcha.js`、提供 `#captcha-element`。 + - `tyc-webview-v2/src/composables/useAliyunCaptcha.js`:通用封装,支持加密/非加密模式 + Loading 提示。 + - `tyc-webview-v2/src/views/Login.vue`:获取短信前通过 `runWithCaptcha` 调用 `/auth/sendSms`。 + - `tyc-webview-v2/src/components/InquireForm.vue`:查询前根据是否有短信验证码字段决定是否通过 `runWithCaptcha`。 + +- 后端: + - `app/main/api/internal/config/config.go`:`CaptchaConfig` 增加 `EKey`。 + - `app/main/api/etc/main.yaml` / `main.dev.yaml`:补充 `Captcha` 配置(SceneID + EKey)。 + - `app/main/api/desc/front/user.api`:声明 `/captcha/encryptedSceneId` 接口。 + - `app/main/api/internal/logic/captcha/getencryptedsceneidlogic.go`:生成 `EncryptedSceneId`。 + - `pkg/captcha/encrypt_scene.go`:`GenerateEncryptedSceneID` 实现。 + - `pkg/captcha/aliyun.go`:`Verify` 实现(调用阿里云 SDK)。 + - `app/main/api/internal/logic/auth/sendsmslogic.go`:发送短信前调用 `captcha.Verify`。 + - `app/main/api/internal/logic/query/queryservicelogic.go`:对无短信验证码的产品,在查询前调用 `captcha.Verify`。 + +--- + +## 五、接入其它平台时的注意事项 + +1. **SceneId / ekey 必须一一对应** + - 控制台“场景管理”里的 SceneId 和 ekey 必须和配置里完全一致。 + - 多个场景要分别管理 SceneId / ekey。 + +2. **时间同步** + - 加密模式依赖 `timestamp` 和 `expireTime`,服务器时间要尽量准确(建议 NTP 同步)。 + +3. **前后端模式一致** + - 如果控制台开启了加密模式,前端必须**带上 `EncryptedSceneId`**; + - 如果前端只传 `SceneId`,在加密模式下会被阿里云直接拒绝(出现类似 F022 错误码)。 + +4. **错误处理** + - 服务端 `Verify` 出错(网络、阿里云故障)时,我们当前策略是**记录日志但视为通过**,防止影响业务可用性——这一点可根据各平台风险偏好调整。 + +5. **复用策略** + - 不同前端技术栈(Vue/React/小程序等),只要能做到: + 1. 初始化时根据配置决定是否从后端拿 `EncryptedSceneId`; + 2. 在业务请求前通过验证码拿到 `captchaVerifyParam` 并传给后端; + - 后端则统一在与风控相关的接口上调用相同的 `Verify` 封装即可。 + +--- + +## 六、推荐的接入步骤 + +1. 在阿里云验证码控制台创建场景,记下 **SceneId** 和 **ekey**,并根据需要打开「加密模式」。 +2. 在你自己的后端项目里: + - 增加 Captcha 配置块(AccessKeyID、AccessKeySecret、EndpointURL、SceneID、EKey); + - 实现 EncryptedSceneId 生成接口 `/captcha/encryptedSceneId`(可参考本项目的 `GenerateEncryptedSceneID`); + - 在需要滑块的业务接口(发短信、查询)前调用阿里云 SDK 做 `captchaVerifyParam` 校验。 +3. 在你自己的前端项目里: + - 页面引入 `AliyunCaptcha.js` 并预留验证码容器; + - 抽一个类似 `runWithCaptcha(bizVerify, onSuccess)` 的封装; + - 业务按钮点击时,不直接调接口,而是先触发 `runWithCaptcha`: + - 前端拉取 `EncryptedSceneId`(若启用加密模式); + - 初始化 `initAliyunCaptcha` 并弹出滑块; + - 滑块通过后才真正调用后端业务接口。