feat: 发送短信适配 ContiNew Starter 行为验证码

This commit is contained in:
Bull-BCLS 2023-12-28 23:47:44 +08:00
parent dea64e62e1
commit eab53520c8
27 changed files with 1409 additions and 40 deletions

View File

@ -52,6 +52,12 @@
<artifactId>continew-starter-captcha-graphic</artifactId> <artifactId>continew-starter-captcha-graphic</artifactId>
</dependency> </dependency>
<!-- ContiNew Starter 验证码模块 - 行为验证码 -->
<dependency>
<groupId>top.charles7c.continew</groupId>
<artifactId>continew-starter-captcha-behavior</artifactId>
</dependency>
<!-- ContiNew Starter 文件处理模块 - Excel --> <!-- ContiNew Starter 文件处理模块 - Excel -->
<dependency> <dependency>
<groupId>top.charles7c.continew</groupId> <groupId>top.charles7c.continew</groupId>

View File

@ -22,7 +22,7 @@ import lombok.RequiredArgsConstructor;
/** /**
* 消息模板枚举 * 消息模板枚举
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 19:51 * @since 2023/10/15 19:51
*/ */
@Getter @Getter

View File

@ -29,7 +29,7 @@ import top.charles7c.continew.starter.data.mybatis.plus.base.BaseMapper;
/** /**
* 消息 Mapper * 消息 Mapper
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 19:05 * @since 2023/10/15 19:05
*/ */
public interface MessageMapper extends BaseMapper<MessageDO> { public interface MessageMapper extends BaseMapper<MessageDO> {

View File

@ -24,7 +24,7 @@ import top.charles7c.continew.starter.data.mybatis.plus.base.BaseMapper;
/** /**
* 消息和用户 Mapper * 消息和用户 Mapper
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 20:25 * @since 2023/10/15 20:25
*/ */
public interface MessageUserMapper extends BaseMapper<MessageUserDO> { public interface MessageUserMapper extends BaseMapper<MessageUserDO> {

View File

@ -32,7 +32,7 @@ import top.charles7c.continew.admin.common.enums.MessageTypeEnum;
/** /**
* 消息实体 * 消息实体
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 19:05 * @since 2023/10/15 19:05
*/ */
@Data @Data

View File

@ -26,7 +26,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
/** /**
* 消息和用户关联实体 * 消息和用户关联实体
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 20:25 * @since 2023/10/15 20:25
*/ */
@Data @Data

View File

@ -28,7 +28,7 @@ import top.charles7c.continew.starter.data.mybatis.plus.query.QueryType;
/** /**
* 消息查询条件 * 消息查询条件
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 19:05 * @since 2023/10/15 19:05
*/ */
@Data @Data

View File

@ -33,7 +33,7 @@ import top.charles7c.continew.starter.extension.crud.base.BaseReq;
/** /**
* 创建消息信息 * 创建消息信息
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 19:05 * @since 2023/10/15 19:05
*/ */
@Data @Data

View File

@ -31,7 +31,7 @@ import top.charles7c.continew.admin.common.enums.MessageTypeEnum;
/** /**
* 消息信息 * 消息信息
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 19:05 * @since 2023/10/15 19:05
*/ */
@Data @Data

View File

@ -27,7 +27,7 @@ import top.charles7c.continew.starter.extension.crud.model.resp.PageDataResp;
/** /**
* 消息业务接口 * 消息业务接口
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 19:05 * @since 2023/10/15 19:05
*/ */
public interface MessageService { public interface MessageService {

View File

@ -23,7 +23,7 @@ import top.charles7c.continew.admin.system.model.resp.MessageUnreadResp;
/** /**
* 消息和用户关联业务接口 * 消息和用户关联业务接口
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 19:05 * @since 2023/10/15 19:05
*/ */
public interface MessageUserService { public interface MessageUserService {

View File

@ -47,7 +47,7 @@ import top.charles7c.continew.starter.extension.crud.model.resp.PageDataResp;
/** /**
* 消息业务实现 * 消息业务实现
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 19:05 * @since 2023/10/15 19:05
*/ */
@Service @Service

View File

@ -38,7 +38,7 @@ import top.charles7c.continew.starter.core.util.validate.CheckUtils;
/** /**
* 消息和用户关联业务实现 * 消息和用户关联业务实现
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 19:05 * @since 2023/10/15 19:05
*/ */
@Service @Service

View File

@ -1,11 +1,35 @@
import axios from 'axios'; import axios from 'axios';
import qs from 'query-string';
const BASE_URL = '/common/captcha'; const BASE_URL = '/captcha';
export interface ImageCaptchaRes { export interface ImageCaptchaRes {
uuid: string; uuid: string;
img: string; img: string;
} }
export interface BehaviorCaptchaRes {
originalImageBase64: string;
point: {
x: number;
y: number;
};
jigsawImageBase64: string;
token: string;
secretKey: string;
}
export interface BehaviorCaptchaReq {
captchaType?: string;
captchaVerification?: string;
clientUid?: string;
}
export interface CheckBehaviorCaptchaRes {
repCode: string;
repMsg: string;
}
export function getImageCaptcha() { export function getImageCaptcha() {
return axios.get<ImageCaptchaRes>(`${BASE_URL}/img`); return axios.get<ImageCaptchaRes>(`${BASE_URL}/img`);
} }
@ -14,6 +38,26 @@ export function getMailCaptcha(email: string) {
return axios.get(`${BASE_URL}/mail?email=${email}`); return axios.get(`${BASE_URL}/mail?email=${email}`);
} }
export function getSmsCaptcha(phone: string) { export function getSmsCaptcha(
return axios.get(`${BASE_URL}/sms?phone=${phone}`); phone: string,
behaviorCaptcha: BehaviorCaptchaReq,
) {
return axios.get(
`${BASE_URL}/sms?phone=${phone}&captchaVerification=${encodeURIComponent(
behaviorCaptcha.captchaVerification || '',
)}`,
);
}
export function getBehaviorCaptcha(params: any) {
return axios.get<BehaviorCaptchaRes>(`${BASE_URL}/behavior`, {
params,
paramsSerializer: (obj) => {
return qs.stringify(obj);
},
});
}
export function checkBehaviorCaptcha(params: any) {
return axios.post<CheckBehaviorCaptchaRes>(`${BASE_URL}/behavior`, params);
} }

View File

@ -17,6 +17,7 @@ import RightToolbar from './right-toolbar/index.vue';
import SvgIcon from './svg-icon/index.vue'; import SvgIcon from './svg-icon/index.vue';
import IconSelect from './icon-select/index.vue'; import IconSelect from './icon-select/index.vue';
import download from './crud'; import download from './crud';
import Verify from './verifition/Verify.vue';
// Manually introduce ECharts modules to reduce packing size // Manually introduce ECharts modules to reduce packing size
@ -46,5 +47,6 @@ export default {
Vue.component('RightToolbar', RightToolbar); Vue.component('RightToolbar', RightToolbar);
Vue.component('SvgIcon', SvgIcon); Vue.component('SvgIcon', SvgIcon);
Vue.component('IconSelect', IconSelect); Vue.component('IconSelect', IconSelect);
Vue.component('Verify', Verify);
}, },
}; };

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,296 @@
<template>
<div style="position: relative">
<div class="verify-img-out">
<div
class="verify-img-panel"
:style="{
'width': setSize.imgWidth,
'height': setSize.imgHeight,
'background-size': setSize.imgWidth + ' ' + setSize.imgHeight,
'margin-bottom': vSpace + 'px',
}"
>
<div
v-show="showRefresh"
class="verify-refresh"
style="z-index: 3"
@click="refresh"
>
<i class="iconfont icon-refresh"></i>
</div>
<img
ref="canvas"
:src="'data:image/png;base64,' + pointBackImgBase"
alt=""
style="width: 100%; height: 100%; display: block"
@click="bindingClick ? canvasClick($event) : undefined"
/>
<div
v-for="(tempPoint, index) in tempPoints"
:key="index"
class="point-area"
:style="{
'background-color': '#1abd6c',
'color': '#fff',
'z-index': 9999,
'width': '20px',
'height': '20px',
'text-align': 'center',
'line-height': '20px',
'border-radius': '50%',
'position': 'absolute',
'top': parseInt(tempPoint.y - 10) + 'px',
'left': parseInt(tempPoint.x - 10) + 'px',
}"
>
{{ index + 1 }}
</div>
</div>
</div>
<div
class="verify-bar-area"
:style="{
'width': setSize.imgWidth,
'color': barAreaColor,
'border-color': barAreaBorderColor,
'line-height': barSize.height,
}"
>
<span class="verify-msg">{{ text }}</span>
</div>
</div>
</template>
<script type="text/babel">
import {
checkBehaviorCaptcha,
getBehaviorCaptcha,
} from '@/api/common/captcha';
import {
getCurrentInstance,
nextTick,
onMounted,
reactive,
ref,
toRefs,
} from 'vue';
import { resetSize } from '../utils/util';
import { aesEncrypt } from '../utils/ase';
export default {
name: 'VerifyPoints',
props: {
// popfixed
mode: {
type: String,
default: '',
},
captchaType: {
type: String,
},
//
vSpace: {
type: Number,
default: 5,
},
imgSize: {
type: Object,
default() {
return {
width: '310px',
height: '155px',
};
},
},
barSize: {
type: Object,
default() {
return {
width: '310px',
height: '40px',
};
},
},
},
setup(props) {
const { mode, captchaType } = toRefs(props);
const { proxy } = getCurrentInstance();
const secretKey = ref(''); // ase
const checkNum = ref(3); //
const fontPos = reactive([]); //
const checkPosArr = reactive([]); //
const num = ref(1); //
const pointBackImgBase = ref(''); //
const poinTextList = reactive([]); //
const backToken = ref(''); // token
const setSize = reactive({
imgHeight: 0,
imgWidth: 0,
barHeight: 0,
barWidth: 0,
});
const tempPoints = reactive([]);
const text = ref('');
const barAreaColor = ref(undefined);
const barAreaBorderColor = ref(undefined);
const showRefresh = ref(true);
const bindingClick = ref(true);
//
function getPictrue() {
const data = {
captchaType: captchaType.value,
};
getBehaviorCaptcha(data).then((res) => {
pointBackImgBase.value = res.data.originalImageBase64;
backToken.value = res.data.token;
secretKey.value = res.data.secretKey;
poinTextList.value = res.data.wordList;
text.value = `请依次点击【${poinTextList.value.join(',')}`;
});
}
//
const getMousePos = function (obj, e) {
const x = e.offsetX;
const y = e.offsetY;
return { x, y };
};
//
const createPoint = function (pos) {
tempPoints.push({ ...pos });
return num.value + 1;
};
//
const pointTransfrom = function (pointArr, imgSize) {
return pointArr.map((p) => {
const x = Math.round((310 * p.x) / parseInt(imgSize.imgWidth, 10));
const y = Math.round((155 * p.y) / parseInt(imgSize.imgHeight, 10));
return { x, y };
});
};
const init = () => {
//
fontPos.splice(0, fontPos.length);
checkPosArr.splice(0, checkPosArr.length);
num.value = 1;
getPictrue();
nextTick(() => {
const { imgHeight, imgWidth, barHeight, barWidth } = resetSize(proxy);
setSize.imgHeight = imgHeight;
setSize.imgWidth = imgWidth;
setSize.barHeight = barHeight;
setSize.barWidth = barWidth;
proxy.$parent.$emit('ready', proxy);
});
};
onMounted(() => {
//
init();
proxy.$el.onselectstart = function () {
return false;
};
});
const refresh = function () {
tempPoints.splice(0, tempPoints.length);
barAreaColor.value = '#000';
barAreaBorderColor.value = '#ddd';
bindingClick.value = true;
fontPos.splice(0, fontPos.length);
checkPosArr.splice(0, checkPosArr.length);
num.value = 1;
getPictrue();
text.value = '验证失败';
showRefresh.value = true;
};
const canvas = ref(null);
const canvasClick = (e) => {
checkPosArr.push(getMousePos(canvas, e));
if (num.value === checkNum.value) {
num.value = createPoint(getMousePos(canvas, e));
//
const arr = pointTransfrom(checkPosArr, setSize);
checkPosArr.length = 0;
checkPosArr.push(...arr);
//
setTimeout(() => {
// var flag = this.comparePos(this.fontPos, this.checkPosArr);
//
const captchaVerification = secretKey.value
? aesEncrypt(
`${backToken.value}---${JSON.stringify(checkPosArr)}`,
secretKey.value,
)
: `${backToken.value}---${JSON.stringify(checkPosArr)}`;
const data = {
captchaType: captchaType.value,
pointJson: secretKey.value
? aesEncrypt(JSON.stringify(checkPosArr), secretKey.value)
: JSON.stringify(checkPosArr),
token: backToken.value,
};
checkBehaviorCaptcha(data).then((res) => {
if (res.success && res.data.repCode === '0000') {
barAreaColor.value = '#4cae4c';
barAreaBorderColor.value = '#5cb85c';
text.value = '验证成功';
bindingClick.value = false;
if (mode.value === 'pop') {
setTimeout(() => {
proxy.$parent.clickShow = false;
refresh();
}, 1500);
}
proxy.$parent.$emit('success', { captchaVerification });
} else {
proxy.$parent.$emit('error', proxy);
barAreaColor.value = '#d9534f';
barAreaBorderColor.value = '#d9534f';
text.value = res.data.repMsg;
setTimeout(() => {
refresh();
}, 700);
}
});
}, 400);
}
if (num.value < checkNum.value) {
num.value = createPoint(getMousePos(canvas, e));
}
};
return {
secretKey,
checkNum,
fontPos,
checkPosArr,
num,
pointBackImgBase,
poinTextList,
backToken,
setSize,
tempPoints,
text,
barAreaColor,
barAreaBorderColor,
showRefresh,
bindingClick,
init,
canvas,
canvasClick,
getMousePos,
createPoint,
refresh,
getPictrue,
pointTransfrom,
};
},
};
</script>

View File

@ -0,0 +1,462 @@
<template>
<div style="position: relative">
<div
v-if="type === '2'"
class="verify-img-out"
:style="{ height: parseInt(setSize.imgHeight) + vSpace + 'px' }"
>
<div
class="verify-img-panel"
:style="{ width: setSize.imgWidth, height: setSize.imgHeight }"
>
<img
:src="'data:image/png;base64,' + backImgBase"
alt=""
style="width: 100%; height: 100%; display: block"
/>
<div v-show="showRefresh" class="verify-refresh" @click="refresh"
><i class="iconfont icon-refresh"></i
></div>
<transition name="tips">
<span
v-if="tipWords"
class="verify-tips"
:class="passFlag ? 'suc-bg' : 'err-bg'"
>{{ tipWords }}</span
>
</transition>
</div>
</div>
<!-- 公共部分 -->
<div
class="verify-bar-area"
:style="{
'width': setSize.imgWidth,
'height': barSize.height,
'line-height': barSize.height,
}"
>
<span class="verify-msg" v-text="text"></span>
<div
class="verify-left-bar"
:style="{
'width': leftBarWidth !== undefined ? leftBarWidth : barSize.height,
'height': barSize.height,
'border-color': leftBarBorderColor,
'transaction': transitionWidth,
}"
>
<span class="verify-msg" v-text="finishText"></span>
<div
class="verify-move-block"
:style="{
'width': barSize.height,
'height': barSize.height,
'background-color': moveBlockBackgroundColor,
'left': moveBlockLeft,
'transition': transitionLeft,
}"
@touchstart="start"
@mousedown="start"
>
<i
:class="['verify-icon iconfont', iconClass]"
:style="{ color: iconColor }"
></i>
<div
v-if="type === '2'"
class="verify-sub-block"
:style="{
'width':
Math.floor((parseInt(setSize.imgWidth) * 47) / 310) + 'px',
'height': setSize.imgHeight,
'top': '-' + (parseInt(setSize.imgHeight) + vSpace) + 'px',
'background-size': setSize.imgWidth + ' ' + setSize.imgHeight,
}"
>
<img
:src="'data:image/png;base64,' + blockBackImgBase"
alt=""
style="
width: 100%;
height: 100%;
display: block;
-webkit-user-drag: none;
"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script type="text/babel">
import {
computed,
onMounted,
reactive,
ref,
watch,
nextTick,
toRefs,
getCurrentInstance,
} from 'vue';
import {
checkBehaviorCaptcha,
getBehaviorCaptcha,
} from '@/api/common/captcha';
import { aesEncrypt } from '../utils/ase';
import { resetSize } from '../utils/util';
export default {
name: 'VerifySlide',
props: {
captchaType: {
type: String,
},
type: {
type: String,
default: '1',
},
// popfixed
mode: {
type: String,
default: 'fixed',
},
vSpace: {
type: Number,
default: 5,
},
explain: {
type: String,
default: '向右滑动完成验证',
},
imgSize: {
type: Object,
default() {
return {
width: '310px',
height: '155px',
};
},
},
blockSize: {
type: Object,
default() {
return {
width: '50px',
height: '50px',
};
},
},
barSize: {
type: Object,
default() {
return {
width: '310px',
height: '40px',
};
},
},
},
setup(props) {
const { mode, captchaType, type, blockSize, explain } = toRefs(props);
const { proxy } = getCurrentInstance();
const secretKey = ref(''); // ase
const passFlag = ref(''); //
const backImgBase = ref(''); //
const blockBackImgBase = ref(''); //
const backToken = ref(''); // token
const startMoveTime = ref(''); //
const endMovetime = ref(''); //
const tipsBackColor = ref(''); //
const tipWords = ref('');
const text = ref('');
const finishText = ref('');
const setSize = reactive({
imgHeight: 0,
imgWidth: 0,
barHeight: 0,
barWidth: 0,
});
const top = ref(0);
const left = ref(0);
const moveBlockLeft = ref(undefined);
const leftBarWidth = ref(undefined);
//
const moveBlockBackgroundColor = ref(undefined);
const leftBarBorderColor = ref('#ddd');
const iconColor = ref(undefined);
const iconClass = ref('icon-right');
const status = ref(false); //
const isEnd = ref(false); //
const showRefresh = ref(true);
const transitionLeft = ref('');
const transitionWidth = ref('');
const startLeft = ref(0);
//
function getPictrue() {
const data = {
captchaType: captchaType.value,
};
getBehaviorCaptcha(data).then((res) => {
backImgBase.value = res.data.originalImageBase64;
blockBackImgBase.value = res.data.jigsawImageBase64;
backToken.value = res.data.token;
secretKey.value = res.data.secretKey;
});
}
const barArea = computed(() => {
return proxy.$el.querySelector('.verify-bar-area');
});
//
function move(e) {
e = e || window.event;
if (status.value && isEnd.value === false) {
let x;
if (!e.touches) {
// PC
x = e.clientX;
} else {
//
x = e.touches[0].pageX;
}
const bar_area_left = barArea.value.getBoundingClientRect().left;
let move_block_left = x - bar_area_left; // left
if (
move_block_left >=
barArea.value.offsetWidth -
parseInt(parseInt(blockSize.value.width, 10) / 2, 10) -
2
) {
move_block_left =
barArea.value.offsetWidth -
parseInt(parseInt(blockSize.value.width, 10) / 2, 10) -
2;
}
if (move_block_left <= 0) {
move_block_left = parseInt(
parseInt(blockSize.value.width, 10) / 2,
10,
);
}
// left
moveBlockLeft.value = `${move_block_left - startLeft.value}px`;
leftBarWidth.value = `${move_block_left - startLeft.value}px`;
}
}
const refresh = () => {
showRefresh.value = true;
finishText.value = '';
transitionLeft.value = 'left .3s';
moveBlockLeft.value = 0;
leftBarWidth.value = undefined;
transitionWidth.value = 'width .3s';
leftBarBorderColor.value = '#ddd';
moveBlockBackgroundColor.value = '#fff';
iconColor.value = '#000';
iconClass.value = 'icon-right';
isEnd.value = false;
getPictrue();
setTimeout(() => {
transitionWidth.value = '';
transitionLeft.value = '';
text.value = explain.value;
}, 300);
};
//
function end() {
endMovetime.value = +new Date();
//
if (status.value && isEnd.value === false) {
let moveLeftDistance = parseInt(
(moveBlockLeft.value || '').replace('px', ''),
10,
);
moveLeftDistance =
(moveLeftDistance * 310) / parseInt(setSize.imgWidth, 10);
const data = {
captchaType: captchaType.value,
pointJson: secretKey.value
? aesEncrypt(
JSON.stringify({ x: moveLeftDistance, y: 5.0 }),
secretKey.value,
)
: JSON.stringify({ x: moveLeftDistance, y: 5.0 }),
token: backToken.value,
};
checkBehaviorCaptcha(data).then((res) => {
if (res.success && res.data.repCode === '0000') {
moveBlockBackgroundColor.value = '#5cb85c';
leftBarBorderColor.value = '#5cb85c';
iconColor.value = '#fff';
iconClass.value = 'icon-check';
showRefresh.value = false;
isEnd.value = true;
if (mode.value === 'pop') {
setTimeout(() => {
proxy.$parent.clickShow = false;
refresh();
}, 1500);
}
passFlag.value = true;
tipWords.value = `${(
(endMovetime.value - startMoveTime.value) /
1000
).toFixed(2)}s验证成功`;
const captchaVerification = secretKey.value
? aesEncrypt(
`${backToken.value}---${JSON.stringify({
x: moveLeftDistance,
y: 5.0,
})}`,
secretKey.value,
)
: `${backToken.value}---${JSON.stringify({
x: moveLeftDistance,
y: 5.0,
})}`;
setTimeout(() => {
tipWords.value = '';
proxy.$parent.closeBox();
proxy.$parent.$emit('success', { captchaVerification });
}, 1000);
} else {
moveBlockBackgroundColor.value = '#d9534f';
leftBarBorderColor.value = '#d9534f';
iconColor.value = '#fff';
iconClass.value = 'icon-close';
passFlag.value = false;
setTimeout(function () {
refresh();
}, 1000);
proxy.$parent.$emit('error', proxy);
tipWords.value = res.data.repMsg;
setTimeout(() => {
tipWords.value = '';
}, 1000);
}
});
status.value = false;
}
}
function init() {
text.value = explain.value;
getPictrue();
nextTick(() => {
const { imgHeight, imgWidth, barHeight, barWidth } = resetSize(proxy);
setSize.imgHeight = imgHeight;
setSize.imgWidth = imgWidth;
setSize.barHeight = barHeight;
setSize.barWidth = barWidth;
proxy.$parent.$emit('ready', proxy);
});
window.removeEventListener('touchmove', function (e) {
move(e);
});
window.removeEventListener('mousemove', function (e) {
move(e);
});
//
window.removeEventListener('touchend', function () {
end();
});
window.removeEventListener('mouseup', function () {
end();
});
window.addEventListener('touchmove', function (e) {
move(e);
});
window.addEventListener('mousemove', function (e) {
move(e);
});
//
window.addEventListener('touchend', function () {
end();
});
window.addEventListener('mouseup', function () {
end();
});
}
watch(type, () => {
init();
});
onMounted(() => {
//
init();
proxy.$el.onselectstart = function () {
return false;
};
});
//
function start(e) {
e = e || window.event;
let x;
if (!e.touches) {
// PC
x = e.clientX;
} else {
//
x = e.touches[0].pageX;
}
startLeft.value = Math.floor(
x - barArea.value.getBoundingClientRect().left,
);
startMoveTime.value = +new Date(); //
if (isEnd.value === false) {
text.value = '';
moveBlockBackgroundColor.value = '#337ab7';
leftBarBorderColor.value = '#337AB7';
iconColor.value = '#fff';
e.stopPropagation();
status.value = true;
}
}
return {
secretKey, // ase
passFlag, //
backImgBase, //
blockBackImgBase, //
backToken, // token
startMoveTime, //
endMovetime, //
tipsBackColor, //
tipWords,
text,
finishText,
setSize,
top,
left,
moveBlockLeft,
leftBarWidth,
//
moveBlockBackgroundColor,
leftBarBorderColor,
iconColor,
iconClass,
status, //
isEnd, //
showRefresh,
transitionLeft,
transitionWidth,
barArea,
refresh,
start,
};
},
};
</script>

View File

@ -0,0 +1,14 @@
import CryptoJS from 'crypto-js';
/**
* @word 要加密的内容
* @keyWord String 服务器随机返回的关键字
* */
export function aesEncrypt(word, keyWord = 'XwKsGlMcdPMEhR1B') {
const key = CryptoJS.enc.Utf8.parse(keyWord);
const arcs = CryptoJS.enc.Utf8.parse(word);
const encrypted = CryptoJS.AES.encrypt(arcs, key, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.toString();
}

View File

@ -0,0 +1,39 @@
export function resetSize(vm) {
let img_width;
let img_height;
let bar_width;
let bar_height; // 图片的宽度、高度,移动条的宽度、高度
const parentWidth = vm.$el.parentNode.offsetWidth || window.offsetWidth;
const parentHeight = vm.$el.parentNode.offsetHeight || window.offsetHeight;
if (vm.imgSize.width.indexOf('%') !== -1) {
img_width = `${(parseInt(vm.imgSize.width, 10) / 100) * parentWidth}px`;
} else {
img_width = vm.imgSize.width;
}
if (vm.imgSize.height.indexOf('%') !== -1) {
img_height = `${(parseInt(vm.imgSize.height, 10) / 100) * parentHeight}px`;
} else {
img_height = vm.imgSize.height;
}
if (vm.barSize.width.indexOf('%') !== -1) {
bar_width = `${(parseInt(vm.barSize.width, 10) / 100) * parentWidth}px`;
} else {
bar_width = vm.barSize.width;
}
if (vm.barSize.height.indexOf('%') !== -1) {
bar_height = `${(parseInt(vm.barSize.height, 10) / 100) * parentHeight}px`;
} else {
bar_height = vm.barSize.height;
}
return {
imgWidth: img_width,
imgHeight: img_height,
barWidth: bar_width,
barHeight: bar_height,
};
}

View File

@ -29,7 +29,7 @@
class="captcha-btn" class="captcha-btn"
:loading="captchaLoading" :loading="captchaLoading"
:disabled="captchaDisable" :disabled="captchaDisable"
@click="handleSendCaptcha" @click="handleOpenBehaviorCaptcha"
> >
{{ captchaBtnName }} {{ captchaBtnName }}
</a-button> </a-button>
@ -43,6 +43,13 @@
>{{ $t('login.button') }}演示不开放 >{{ $t('login.button') }}演示不开放
</a-button> </a-button>
</a-form> </a-form>
<Verify
ref="verifyRef"
:mode="captchaMode"
:captcha-type="captchaType"
:img-size="{ width: '330px', height: '155px' }"
@success="handleSendCaptcha"
></Verify>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -52,7 +59,7 @@
import { ValidatedError } from '@arco-design/web-vue'; import { ValidatedError } from '@arco-design/web-vue';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import { PhoneLoginReq } from '@/api/auth'; import { PhoneLoginReq } from '@/api/auth';
import { getSmsCaptcha } from '@/api/common/captcha'; import { BehaviorCaptchaReq, getSmsCaptcha } from '@/api/common/captcha';
const { proxy } = getCurrentInstance() as any; const { proxy } = getCurrentInstance() as any;
const { t } = useI18n(); const { t } = useI18n();
@ -63,6 +70,8 @@
const captchaDisable = ref(true); const captchaDisable = ref(true);
const captchaTime = ref(60); const captchaTime = ref(60);
const captchaTimer = ref(); const captchaTimer = ref();
const captchaType = ref('blockPuzzle');
const captchaMode = ref('pop');
const captchaBtnNameKey = ref('login.captcha.get'); const captchaBtnNameKey = ref('login.captcha.get');
const captchaBtnName = computed(() => t(captchaBtnNameKey.value)); const captchaBtnName = computed(() => t(captchaBtnNameKey.value));
const data = reactive({ const data = reactive({
@ -85,6 +94,18 @@
}); });
const { form, rules } = toRefs(data); const { form, rules } = toRefs(data);
/**
* 弹出行为验证码
*/
const handleOpenBehaviorCaptcha = () => {
if (captchaLoading.value) return;
proxy.$refs.formRef.validateField('phone', (valid: any) => {
if (!valid) {
proxy.$refs.verifyRef.show();
}
});
};
/** /**
* 重置验证码 * 重置验证码
*/ */
@ -98,13 +119,13 @@
/** /**
* 发送验证码 * 发送验证码
*/ */
const handleSendCaptcha = () => { const handleSendCaptcha = (captchaParam: BehaviorCaptchaReq) => {
if (captchaLoading.value) return; if (captchaLoading.value) return;
proxy.$refs.formRef.validateField('phone', (valid: any) => { proxy.$refs.formRef.validateField('phone', (valid: any) => {
if (!valid) { if (!valid) {
captchaLoading.value = true; captchaLoading.value = true;
captchaBtnNameKey.value = 'login.captcha.ing'; captchaBtnNameKey.value = 'login.captcha.ing';
getSmsCaptcha(form.value.phone) getSmsCaptcha(form.value.phone, captchaParam)
.then((res) => { .then((res) => {
captchaLoading.value = false; captchaLoading.value = false;
captchaDisable.value = true; captchaDisable.value = true;

View File

@ -47,7 +47,7 @@
v-model="form.newPhone" v-model="form.newPhone"
:placeholder=" :placeholder="
$t( $t(
'userCenter.securitySettings.updatePhone.form.placeholder.newPhone' 'userCenter.securitySettings.updatePhone.form.placeholder.newPhone',
) )
" "
allow-clear allow-clear
@ -73,7 +73,7 @@
type="primary" type="primary"
:disabled="captchaDisable" :disabled="captchaDisable"
class="captcha-btn" class="captcha-btn"
@click="handleSendCaptcha" @click="handleOpenBehaviorCaptcha"
> >
{{ captchaBtnName }} {{ captchaBtnName }}
</a-button> </a-button>
@ -81,7 +81,7 @@
<a-form-item <a-form-item
:label=" :label="
$t( $t(
'userCenter.securitySettings.updatePhone.form.label.currentPassword' 'userCenter.securitySettings.updatePhone.form.label.currentPassword',
) )
" "
field="currentPassword" field="currentPassword"
@ -90,7 +90,7 @@
v-model="form.currentPassword" v-model="form.currentPassword"
:placeholder=" :placeholder="
$t( $t(
'userCenter.securitySettings.updatePhone.form.placeholder.currentPassword' 'userCenter.securitySettings.updatePhone.form.placeholder.currentPassword',
) )
" "
:max-length="32" :max-length="32"
@ -98,13 +98,20 @@
/> />
</a-form-item> </a-form-item>
</a-form> </a-form>
<Verify
ref="verifyRef"
:mode="captchaMode"
:captcha-type="captchaType"
:img-size="{ width: '330px', height: '155px' }"
@success="handleSendCaptcha"
></Verify>
</a-modal> </a-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { getCurrentInstance, ref, reactive, computed } from 'vue'; import { getCurrentInstance, ref, reactive, computed } from 'vue';
import { FieldRule } from '@arco-design/web-vue'; import { FieldRule } from '@arco-design/web-vue';
import { getSmsCaptcha } from '@/api/common/captcha'; import { BehaviorCaptchaReq, getSmsCaptcha } from '@/api/common/captcha';
import { UserPhoneUpdateReq, updatePhone } from '@/api/system/user-center'; import { UserPhoneUpdateReq, updatePhone } from '@/api/system/user-center';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
@ -117,6 +124,8 @@
const captchaTimer = ref(); const captchaTimer = ref();
const captchaLoading = ref(false); const captchaLoading = ref(false);
const captchaDisable = ref(true); const captchaDisable = ref(true);
const captchaType = ref('blockPuzzle');
const captchaMode = ref('pop');
const visible = ref(false); const visible = ref(false);
const captchaBtnNameKey = ref('userCenter.securitySettings.captcha.get'); const captchaBtnNameKey = ref('userCenter.securitySettings.captcha.get');
const captchaBtnName = computed(() => t(captchaBtnNameKey.value)); const captchaBtnName = computed(() => t(captchaBtnNameKey.value));
@ -134,13 +143,13 @@
{ {
required: true, required: true,
message: t( message: t(
'userCenter.securitySettings.updatePhone.form.error.required.newPhone' 'userCenter.securitySettings.updatePhone.form.error.required.newPhone',
), ),
}, },
{ {
match: /^1[3-9]\d{9}$/, match: /^1[3-9]\d{9}$/,
message: t( message: t(
'userCenter.securitySettings.updatePhone.form.error.match.newPhone' 'userCenter.securitySettings.updatePhone.form.error.match.newPhone',
), ),
}, },
], ],
@ -154,7 +163,7 @@
{ {
required: true, required: true,
message: t( message: t(
'userCenter.securitySettings.updatePhone.form.error.required.currentPassword' 'userCenter.securitySettings.updatePhone.form.error.required.currentPassword',
), ),
}, },
], ],
@ -171,26 +180,38 @@
captchaDisable.value = false; captchaDisable.value = false;
}; };
/**
* 弹出行为验证码
*/
const handleOpenBehaviorCaptcha = () => {
if (captchaLoading.value) return;
proxy.$refs.formRef.validateField('newPhone', (valid: any) => {
if (!valid) {
proxy.$refs.verifyRef.show();
}
});
};
/** /**
* 发送验证码 * 发送验证码
*/ */
const handleSendCaptcha = () => { const handleSendCaptcha = (captchaParam: BehaviorCaptchaReq) => {
if (captchaLoading.value) return; if (captchaLoading.value) return;
proxy.$refs.formRef.validateField('newPhone', (valid: any) => { proxy.$refs.formRef.validateField('newPhone', (valid: any) => {
if (!valid) { if (!valid) {
captchaLoading.value = true; captchaLoading.value = true;
captchaBtnNameKey.value = 'userCenter.securitySettings.captcha.ing'; captchaBtnNameKey.value = 'userCenter.securitySettings.captcha.ing';
getSmsCaptcha(form.newPhone) getSmsCaptcha(form.newPhone, captchaParam)
.then((res) => { .then((res) => {
captchaLoading.value = false; captchaLoading.value = false;
captchaDisable.value = true; captchaDisable.value = true;
captchaBtnNameKey.value = `${t( captchaBtnNameKey.value = `${t(
'userCenter.securitySettings.captcha.get' 'userCenter.securitySettings.captcha.get',
)}(${(captchaTime.value -= 1)}s)`; )}(${(captchaTime.value -= 1)}s)`;
captchaTimer.value = window.setInterval(() => { captchaTimer.value = window.setInterval(() => {
captchaTime.value -= 1; captchaTime.value -= 1;
captchaBtnNameKey.value = `${t( captchaBtnNameKey.value = `${t(
'userCenter.securitySettings.captcha.get' 'userCenter.securitySettings.captcha.get',
)}(${captchaTime.value}s)`; )}(${captchaTime.value}s)`;
if (captchaTime.value <= 0) { if (captchaTime.value <= 0) {
resetCaptcha(); resetCaptcha();

View File

@ -13,7 +13,8 @@
"@/*": ["src/*"] "@/*": ["src/*"]
}, },
"lib": ["es2020", "dom"], "lib": ["es2020", "dom"],
"skipLibCheck": true "skipLibCheck": true,
"allowJs": true
}, },
"include": ["src/**/*", "src/**/*.vue"], "include": ["src/**/*", "src/**/*.vue"],
"exclude": ["node_modules"] "exclude": ["node_modules"]

View File

@ -35,11 +35,14 @@ import org.dromara.sms4j.api.entity.SmsResponse;
import org.dromara.sms4j.comm.constant.SupplierConstant; import org.dromara.sms4j.comm.constant.SupplierConstant;
import org.dromara.sms4j.core.factory.SmsFactory; import org.dromara.sms4j.core.factory.SmsFactory;
import org.redisson.api.RateType; import org.redisson.api.RateType;
import org.springframework.http.HttpHeaders;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.anji.captcha.model.common.RepCodeEnum;
import com.anji.captcha.model.common.ResponseModel;
import com.anji.captcha.model.vo.CaptchaVO;
import com.anji.captcha.service.CaptchaService;
import com.wf.captcha.base.Captcha; import com.wf.captcha.base.Captcha;
import cn.dev33.satoken.annotation.SaIgnore; import cn.dev33.satoken.annotation.SaIgnore;
@ -58,6 +61,7 @@ import top.charles7c.continew.starter.captcha.graphic.autoconfigure.GraphicCaptc
import top.charles7c.continew.starter.core.autoconfigure.project.ProjectProperties; import top.charles7c.continew.starter.core.autoconfigure.project.ProjectProperties;
import top.charles7c.continew.starter.core.util.TemplateUtils; import top.charles7c.continew.starter.core.util.TemplateUtils;
import top.charles7c.continew.starter.core.util.validate.CheckUtils; import top.charles7c.continew.starter.core.util.validate.CheckUtils;
import top.charles7c.continew.starter.core.util.validate.ValidationUtils;
import top.charles7c.continew.starter.extension.crud.model.resp.R; import top.charles7c.continew.starter.extension.crud.model.resp.R;
import top.charles7c.continew.starter.messaging.mail.util.MailUtils; import top.charles7c.continew.starter.messaging.mail.util.MailUtils;
@ -72,13 +76,27 @@ import top.charles7c.continew.starter.messaging.mail.util.MailUtils;
@Validated @Validated
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/common/captcha") @RequestMapping("/captcha")
public class CaptchaController { public class CaptchaController {
private final CaptchaService captchaService;
private final CaptchaProperties captchaProperties; private final CaptchaProperties captchaProperties;
private final ProjectProperties projectProperties; private final ProjectProperties projectProperties;
private final GraphicCaptchaProperties graphicCaptchaProperties; private final GraphicCaptchaProperties graphicCaptchaProperties;
@Operation(summary = "获取行为验证码", description = "获取行为验证码Base64编码")
@GetMapping("/behavior")
public R<Object> getBehaviorCaptcha(CaptchaVO captchaReq, HttpServletRequest request) {
captchaReq.setBrowserInfo(JakartaServletUtil.getClientIP(request) + request.getHeader(HttpHeaders.USER_AGENT));
return R.ok(captchaService.get(captchaReq).getRepData());
}
@Operation(summary = "校验行为验证码", description = "校验行为验证码")
@PostMapping("/behavior")
public R<Object> checkBehaviorCaptcha(@RequestBody CaptchaVO captchaReq) {
return R.ok(captchaService.check(captchaReq));
}
@Operation(summary = "获取图片验证码", description = "获取图片验证码Base64编码带图片格式data:image/gif;base64") @Operation(summary = "获取图片验证码", description = "获取图片验证码Base64编码带图片格式data:image/gif;base64")
@GetMapping("/img") @GetMapping("/img")
public R<CaptchaResp> getImageCaptcha() { public R<CaptchaResp> getImageCaptcha() {
@ -118,7 +136,11 @@ public class CaptchaController {
@GetMapping("/sms") @GetMapping("/sms")
public R getSmsCaptcha( public R getSmsCaptcha(
@NotBlank(message = "手机号不能为空") @Pattern(regexp = RegexConstants.MOBILE, message = "手机号格式错误") String phone, @NotBlank(message = "手机号不能为空") @Pattern(regexp = RegexConstants.MOBILE, message = "手机号格式错误") String phone,
HttpServletRequest request) { CaptchaVO captchaReq, HttpServletRequest request) {
// 行为验证码校验
ResponseModel verificationRes = captchaService.verification(captchaReq);
ValidationUtils.throwIfNotEqual(verificationRes.getRepCode(), RepCodeEnum.SUCCESS.getCode(),
verificationRes.getRepMsg());
CaptchaProperties.CaptchaSms captchaSms = captchaProperties.getSms(); CaptchaProperties.CaptchaSms captchaSms = captchaProperties.getSms();
String templateId = captchaSms.getTemplateId(); String templateId = captchaSms.getTemplateId();
String limitKeyPrefix = CacheConstants.LIMIT_KEY_PREFIX; String limitKeyPrefix = CacheConstants.LIMIT_KEY_PREFIX;

View File

@ -42,7 +42,7 @@ import top.charles7c.continew.starter.log.common.annotation.Log;
/** /**
* 消息管理 API * 消息管理 API
* *
* @author BULL_BCLS * @author Bull-BCLS
* @since 2023/10/15 19:05 * @since 2023/10/15 19:05
*/ */
@Tag(name = "消息管理 API") @Tag(name = "消息管理 API")

View File

@ -88,8 +88,13 @@ spring.cache:
# 是否允许缓存空值(默认 true表示允许可以解决缓存穿透问题 # 是否允许缓存空值(默认 true表示允许可以解决缓存穿透问题
cache-null-values: true cache-null-values: true
--- ### 图形验证码配置 --- ### 验证码配置
continew-starter.captcha: continew-starter.captcha:
## 行为验证码配置
behavior:
enabled: true
cache-type: REDIS
water-mark: ${project.app-name}
## 图形验证码配置 ## 图形验证码配置
graphic: graphic:
enabled: true enabled: true

View File

@ -90,8 +90,13 @@ spring.cache:
# 是否允许缓存空值(默认 true表示允许可以解决缓存穿透问题 # 是否允许缓存空值(默认 true表示允许可以解决缓存穿透问题
cache-null-values: true cache-null-values: true
--- ### 图形验证码配置 --- ### 验证码配置
continew-starter.captcha: continew-starter.captcha:
## 行为验证码配置
behavior:
enabled: true
cache-type: REDIS
water-mark: ${project.app-name}
## 图形验证码配置 ## 图形验证码配置
graphic: graphic:
enabled: true enabled: true