撰文缘由
首先聊下我做这个功能的契机吧,博主本人是大二学生,然后同学是学校动漫社负责人,他在校外负责过一些大型漫展的各部门的工作,同时也独立组织过一些类似的活动,于是就意图在学校举办一场二次元展会。因为所在的校区偏文,所以技术含量几乎为零,于是找到了我这个半桶水去做一个检票系统。
(活动详情的图片(极狐那张)是网上找的,如有侵权马上d我,这边立马删掉哈)
背景介绍
为什么用小程序来做?
整个开发周期只有不到一个月,博主此前只有网站的开发经验,所以一开始想要做网站来实现(至少算是舒适区),但是因为成本太高而且对于目前移动端普及率极高的环境不太友好就放弃了。后来想过该做app,但是又担心兼容问题,于是最后选择了小程序。
小程序优点有哪些?
小程序的优点就在于轻便(宿主环境是微信,不用考虑是安卓还是苹果的兼容问题,而且不需要下载,有微信就能打开)以及安全(数据通过微信的服务器进行传输,不要另外考虑ssl证书之类的问题)以及低成本(包括时间成本和经济成本,任何具有前端工作经验的人员都可以较为轻易地上手小程序的开发,无论是原生js开发还是通过uniapp用vue来实现,还有就是确实便宜啊,除去给企鹅交的三十块保护费,还有腾讯云cos的存储费用,基本没啥开销了(云开发环境有新手免费试用一个月))
一点碎碎念(如果着急可以直接导航到“实现原理”)
说干就干,于是博主一人成团,整个项目从ui开发测试运维都是我一人实现,其中最为困扰的一个是UI(这是可以说是码农一生之敌了),另一个就是这个核心功能了:用户在平台报名了活动之后要生成对应的电子门票(存储用户的个人信息,同时也需要进行加密),并且在活动当天工作人员也能够去核销每名到场者的电子门票。UI我真没法子腆着脸分享经验,因为确实最后也是最弱一环(难绷),但是这个电子门票的功能我觉得还是值得写篇博客来分享一下的。
实现原理
简要描述
首先因为博主开的是个体小程序(30元版本),所以像常用的手机号验证(wx.getphonenumber)之类的很多接口都调用不了,于是我这里用户信息都要纯靠用户自主输入(当然前提是你要获取用户对于隐私信息被获取的同意),如果你担心手机号不对可以加个短信验证功能,每条短信五到六分钱,保守评估参展人数会达到三百人,如果我这里启用这个功能又要多花几百块了(我不信现在有谁能够牢牢记住密码不用手机号验证登录的),所以最终也弃用了(颇有创业者囊中羞涩的窘迫感)。获取了用户输入的个人信息(如图中的CN(cosername),手机号,电子邮箱)后,我们需要存储这些个人信息,同时也需要对这些信息进行加密,于是我选择了常用的AES算法来加密,然后将加密后的内容存储到云数据库中并用Qrcode库来生成二维码,告知用户这是电子门票,也是活动当天的入场凭证,成功报名之后,用户随时可以上来小程序通过手机号进行查询获取。工作人员这边登录管理员账号之后就可以使用核销功能,这里将调用手机摄像头识别二维码,并将获取内容解密,打印在工作人员设备的屏幕上,同时在数据库中搜索对应的记录,然后给remove掉,这样就成功核销了。
具体代码
获取用户同意
handleAgreePrivacyAuthorization() {
wx.authorizePrivacyContract({
success: () => {
getApp().globalData.hasAgreedPrivacy = true;
this.setData({ showPrivacyPopup: false });
},
fail: () => {
wx.showToast({ title: '请同意隐私协议', icon: 'none' });
}
});
}
收集用户信息
首先很常规的要做个表单啦,我把wxml也放上来吧(其实就是html,就是有一丢丢略微不同而已)
<view class="back" bind:tap="back" style="top: {{top}};">
<image src="/images/返回.png" style="height: {{height}}; width: {{height}}; padding-left: 1vh;"></image>
</view>
<view class="container {{theme}}">
<view class="title">报名表</view>
<form bindsubmit="formSubmit" bindreset="resetForm">
<view class="form-group">
<text class="label">CN</text>
<input
class="input"
name="name"
type="text"
placeholder="请填写您的CN~"
placeholder-style="color: #999"
/>
</view>
<view class="form-group">
<text class="label">手机号</text>
<input
class="input"
name="phone"
type="text"
placeholder="请填写您的手机号码~"
placeholder-style="color: #999"
/>
</view>
<view class="form-group">
<text class="label">电子邮箱</text>
<input
class="input"
name="email"
type="email"
placeholder="请填写您的电子邮箱~"
placeholder-style="color: #999"
/>
</view>
<button class="submit-btn" form-type="submit">提交报名</button>
<checkbox-group bindchange="handleAgreementChange">
<checkbox class="agreement" checked="{{isAgreed}}"></checkbox>
<text>勾选即为同意</text>
<navigator url="/pages/agreement/agreement" class="agreement-link">《隐私保护声明》</navigator>
</checkbox-group>
</form>
<view class="tips">Notice:请检查填写内容是否有误,报名成功后您可以进入“我的门票”进行票务查询~</view>
<view class="qrcode" wx:if="{{showcode}}">
<canvas canvas-id="myQrcode" style="width: 200px; height: 200px;"></canvas>
<text>请注意查收您的电子门票!(建议截图保存)</text>
</view>
</view>
<!-- 隐私弹窗 -->
<view class="privacy-popup" wx:if="{{showPrivacy}}">
<view class="privacy-content">
<view>隐私弹窗内容....</view>
<button bindtap="handleOpenPrivacyContract">查看隐私协议</button>
<button id="agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="handleAgreePrivacyAuthorization">同意</button>
</view>
</view>
然后是js部分:
formSubmit: function(e) {
const name = e.detail.value.name.trim();
const phone = e.detail.value.phone.trim();
const email = e.detail.value.email.trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRegex = /^\d{11}$/;
// 校验 CN
if (!name) {
wx.showToast({ title: 'CN 不能为空', icon: 'none', duration: 2000 });
return;
}
// 校验手机号
if (!phoneRegex.test(phone)) {
wx.showToast({ title: '手机号必须是11位数字', icon: 'none', duration: 2000 });
return;
}
// 校验邮箱
if (!emailRegex.test(email)) {
wx.showToast({ title: '请输入正确的邮箱格式', icon: 'none', duration: 2000 });
return;
}
// 验证同意协议
if (!this.data.isAgreed) {
wx.showToast({ title: '请先同意隐私协议', icon: 'none', duration: 2000 });
return;
}}
// 成功提示
showSuccess() {
wx.showToast({
title: '提交成功!',
icon: 'success',
duration: 2000,
});
},
resetForm() {
this.setData({
isAgreed: false,
name: "",
phone: "",
email: ""
});
}
用户信息加密
然后我们就要用到一个库crypto(CSDN站内就有教学,大伙可以自行查询,我这就不赘述了),通过AES算法对称加密用户的个人信息
const CryptoJS = require('../../utils/crypto-js.js');
const AES_KEY = '12345678'.padEnd(16, '0'); // 密钥(16/24/32 字符长度),如果你想不到16位数字那么多可以用padEnd方法在后边加0补齐
const AES_IV = '87654321'.padEnd(16, '0'); // 偏移量(16字符)
// AES 加密
encryptData: function(data) {
const ciphertext = CryptoJS.AES.encrypt(data, CryptoJS.enc.Utf8.parse(AES_KEY), {
iv: CryptoJS.enc.Utf8.parse(AES_IV),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}).toString();
return ciphertext;
}
this.setData({
name,
phone,
email,
encryptedData
});//将用户信息存进data
将加密内容生成为二维码并存储到数据库中
//生成二维码
QRCode({
width: 200,
height: 200,
canvasId: 'myQrcode',
text: encryptedData
});
const encrypteddata = this.data.encryptedData;
this.saveQRCode(encrypteddata)
// 保存二维码内容到数据库
saveQRCode: function(encrypteddata) {
wx.showLoading({ title: '提交中...', mask: true });
const db = wx.cloud.database();
const that = this
db.collection('registrations').add({
data: {
name: this.data.name,
phone: this.data.phone,
email: this.data.email,
agreed: this.data.isAgreed,
qrcodecontent: encrypteddata, // 保存加密后内容
createdAt: new Date()
},
success: (res) => {
wx.hideLoading();
this.showSuccess();
this.resetForm();
that.setData({showcode:true})
},
fail: (err) => {
wx.hideLoading();
wx.showToast({ title: '提交失败,请稍后再试!', icon: 'none' });
console.error('提交失败:', err);
}
});
}
用户查询电子门票
// AES 解密
decryptData: function(ciphertext) {
try {
const bytes = CryptoJS.AES.decrypt(ciphertext, CryptoJS.enc.Utf8.parse(AES_KEY), {
iv: CryptoJS.enc.Utf8.parse(AES_IV),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return bytes.toString(CryptoJS.enc.Utf8);
} catch (error) {
console.error('解密失败:', error);
return '解密失败';
}
},
// 输入手机号
handlePhoneInput(e) {
this.setData({ phone: e.detail.value });
},
// 查询二维码内容
searchQRCode() {
const { phone } = this.data;
const phoneRegex = /^\d{11}$/;
if (!phoneRegex.test(phone)) {
wx.showToast({ title: '请输入正确的手机号', icon: 'none', duration: 2000 });
return;
}
wx.showLoading({ title: '查询中...', mask: true });
const db = wx.cloud.database();
db.collection('registrations')
.where({ phone })
.get({
success: (res) => {
wx.hideLoading();
if (res.data.length === 0) {
wx.showToast({ title: '未找到匹配记录', icon: 'none', duration: 2000 });
return;
}
const record = res.data[0];
const decryptedContent = this.decryptData(record.qrcodecontent);
const shortcontent = decryptedContent.substring(0,5)+'**'
this.setData({
qrcodeContent: record.qrcodecontent,
decryptedContent,
shortcontent,
showResult: true
});
// 生成二维码
QRCode({
width: 200,
height: 200,
canvasId: 'resultQrcode',
text: record.qrcodecontent
});
},
fail: (err) => {
wx.hideLoading();
wx.showToast({ title: '查询失败,请稍后再试', icon: 'none' });
console.error('查询失败:', err);
}
});
}
工作人员核销电子门票
const app = getApp();
const CryptoJS = require('../../utils/crypto-js.js');
const AES_KEY = '12345678'.padEnd(16, '0');
const AES_IV = '87654321'.padEnd(16, '0');
Page({
data: {
content: '',
decryptedContent: '',
isButtonDisabled: true,
},
onLoad() {
wx.clearStorageSync();
this.checkLoginStatus();
},
onShow() {
this.checkLoginStatus();
},
checkLoginStatus() {
const isLoggedIn = app.globalData.isLoggedIn;
this.setData({ isButtonDisabled: !isLoggedIn });
},
scanQRCode() {
if (this.data.isButtonDisabled) {
wx.showToast({ title: '请先登录', icon: 'none' });
return;
}
const that = this;
wx.scanCode({
success(res) {
console.log('扫描结果(未解密):', res.result);
const decryptedData = that.decryptData(res.result);
if (decryptedData) {
that.setData({ decryptedContent: decryptedData, content: decryptedData });
console.log('扫描结果(已解密):', decryptedData);
const phoneMatch = decryptedData.match(/手机号码:(\d{11})/);
if (phoneMatch) {
const phone = phoneMatch[1].trim();
that.verifyAndDelete(phone);
} else {
wx.showToast({ title: '未找到有效手机号', icon: 'none' });
}
} else {
wx.showToast({ title: '解密失败,请检查二维码', icon: 'none' });
}
},
fail(error) {
wx.showToast({ title: '扫描失败,请重试', icon: 'none' });
console.error('扫描失败:', error);
}
});
},
decryptData(ciphertext) {
try {
const bytes = CryptoJS.AES.decrypt(ciphertext, CryptoJS.enc.Utf8.parse(AES_KEY), {
iv: CryptoJS.enc.Utf8.parse(AES_IV),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return bytes.toString(CryptoJS.enc.Utf8).trim();
} catch (error) {
console.error('解密失败:', error);
return '';
}
},
verifyAndDelete(phone) {
const db = wx.cloud.database();
const that = this;
wx.showLoading({ title: '核销中...', mask: true });
db.collection('registrations').where({ phone: String(phone) }).get({
success(res) {
if (res.data.length > 0) {
db.collection('registrations').where({ phone: String(phone) }).remove({
success: (removeRes) => {
wx.hideLoading();
wx.showToast({ title: '核销成功!', icon: 'success', duration: 2000 });
},
fail: (removeErr) => {
wx.hideLoading();
wx.showToast({ title: '核销失败,请重试', icon: 'none' });
console.error('删除失败:', removeErr);
}
});
} else {
wx.hideLoading();
wx.showToast({ title: '未找到匹配记录', icon: 'none' });
}
},
fail(err) {
wx.hideLoading();
wx.showToast({ title: '查询失败,请检查网络', icon: 'none' });
console.error('查询失败:', err);
}
});
},
back() {
wx.navigateBack({ delta: 1 });
}
});
结言
由于博主第一次写技术文章,所以肯定会有诸多不足,还恳请诸位大佬勘误指教,如果是小白萌新的同学有什么问题也可以留言,博主如果看到第一时间会进行答复,那大概就说这么多吧(我好啰嗦)......