简介:这是一套基于以太坊的通用电子优惠券系统完整项目,面向区块链方向的高校学生、课程设计与毕业设计开发者。项目采用Java与JSP构建业务层,合约部分包含Solidity源码,并配有SQL数据库脚本及短信SDK接入文件,可实现优惠券发放、核销与链上存证等典型业务闭环。资源包共485个文件,约8.17MB,以Java、XML、JSP、JS等代码文件为主,辅以Gradle构建配置、属性配置、动态库及前端样式页面,目录结构清晰,便于按模块阅读与二次开发。项目已通过导师指导认可,答辩评审分达95分,代码经测试运行正常,配套资料与文档齐全,可直接用于毕业设计、课程设计或项目初期演示,适合进一步扩展其他应用场景。目前已有54人学习下载,对想快速上手以太坊应用开发或完成优惠券类课题的读者具有较高参考价值。
1. 基于以太坊的电子优惠券:把券从黑匣子变成链上资产
优惠券在线下零售里一直是个“黑匣子”:后台说发了十万张,财务对账时多出两万;顾客拿着一张被复制过的纸质券,收银员肉眼根本分不出真假。基于以太坊的通用电子优惠券系统,做的就是把这个黑匣子换成一条人人可查的链——券模板、发券、领券、核销、作废全部写成智能合约,存证在以太坊上,任何人都能查区块对上账,商户和平台之间不用再互相猜。这个资源自带完整的 Solidity 合约、Truffle 工程、bootstrap 管理端和 Android 短信验证模块,毕设、课设、初期项目立项都能直接用。下面按合约设计—部署联调—移动端接入—避坑的顺序把它拆开,最后再聊一个把券升级成 NFT 的进阶玩法。
2. 智能合约数据模型:模板与实例分离,省 gas 且能追溯
2.1 数据模型:券模板与券实例为什么要分开
很多新手第一版合约会把“满 100 减 30”“满 50 减 10”这类公共字段直接复制到每一张券里,发一万张券,链上就存一万份商户名、面额、有效期。按以太坊当时的存储成本算,每多一个字段就是一笔额外的 SSTORE 开销,发行成本成倍往上翻,而且后续改模板属性得遍历所有券,数据结构基本锁死。
这个项目里用的是“模板 + 实例”的两层结构:CouponTemplate 存公共属性,Coupon 只存与单张券相关的 ID、归属、状态和券码哈希。每张券通过 templateId 指向模板,查详情时再关联读取,链上存储量小,对账时也清晰。
pragma solidity ^0.5.0; contract CouponManager { // 券模板:公共属性,多个券实例共享 struct CouponTemplate { string name; // 券名称,如“满100减30” uint256 faceValue; // 面额,单位 wei uint256 total; // 发行总量 uint256 issued; // 已发行数 uint256 expireAt; // 过期时间戳 bool active; // 模板是否启用 } // 券实例:只存差异字段 struct Coupon { uint256 templateId; // 指向模板 address owner; // 当前持有者 uint8 status; // 0=未领取 1=已领取 2=已核销 3=已作废 bytes32 codeHash; // 券码哈希,线下核销时比对 } mapping(uint256 => CouponTemplate) public templates; mapping(uint256 => Coupon) public coupons; uint256 public templateCount; uint256 public couponCount; }Coupon 里的 codeHash 存的是 keccak256 后的哈希,不是明文券码。明文放在链上等于告诉全网的人“拿着这串码就能冒领”,哈希则让券码只在核销那一刻以明文形式出现,链上始终只有指纹。status 字段是状态机的核心,0→1→2 是正常路径,3 是运营作废路径,后面所有核心函数都围绕这个状态迁移来写。
2.2 核心方法:发券、领券、验券的合约实现
三个核心函数分成两组权限:发券是运营/商户行为,领取是用户行为,验券是商户行为。合约里通过 onlyOwner 和 onlyMerchant 这类 modifier 做权限控制,仓库里的实现也是这个套路。
// 发券:运营方调用,生成一张新券,归属为空 function issueCoupon(uint256 _templateId, bytes32 _codeHash) external onlyOwner returns (uint256) { CouponTemplate storage t = templates[_templateId]; require(t.active, "template not active"); require(t.issued < t.total, "sold out"); t.issued += 1; couponCount += 1; coupons[couponCount] = Coupon({ templateId: _templateId, owner: address(0), status: 0, codeHash: _codeHash }); emit Issued(couponCount, _templateId, _codeHash); return couponCount; } // 领券:用户调用,将未领取的券绑定到自己的地址 function claimCoupon(uint256 _couponId) external { Coupon storage c = coupons[_couponId]; require(c.status == 0, "coupon not claimable"); require(block.timestamp <= templates[c.templateId].expireAt, "expired"); c.owner = msg.sender; c.status = 1; emit Claimed(_couponId, msg.sender); } // 验券:商户调用,校验券码并置为已核销 function verifyCoupon(uint256 _couponId, string memory _plainCode) external onlyMerchant returns (bool) { Coupon storage c = coupons[_couponId]; require(c.status == 1, "coupon must be claimed"); require(keccak256(abi.encodePacked(_plainCode)) == c.codeHash, "code mismatch"); require(block.timestamp <= templates[c.templateId].expireAt, "expired"); c.status = 2; emit Verified(_couponId, msg.sender, block.timestamp); return true; }issueCoupon 里先查模板是否启用、是否还有余量,再把已发行数加一,这一步防止超发。claimCoupon 的关键在 status == 0 的判断——只有未领取的券才能被认领,已核销或已作废的券直接抛异常。verifyCoupon 是整条链上“临门一脚”,把用户输入的明文券码做 keccak256 后与 codeHash 对比,匹配且未过期才允许置为已核销;细心的读者会注意到这里没有把“已核销”改成“已作废”,因为核销是终态,不允许再次流转。
2.3 事件日志:链上对账的真正依据
合约里三个事件是整个系统的审计线索。每一次发券、领券、核销都产生一条 Event Log,前端页面和财务对账脚本只需要从区块里按事件过滤,就能还原全部券生命周期,不需要遍历 mapping。
event Issued(uint256 indexed couponId, uint256 indexed templateId, bytes32 codeHash); event Claimed(uint256 indexed couponId, address indexed owner); event Verified(uint256 indexed couponId, address indexed merchant, uint256 timestamp);这里的 indexed 参数会被写进区块日志的 topic 区,支持按 couponId 或 owner 地址高效过滤。我见过有人把对账做成定时任务去遍历 coupons mapping,区块高度一大就把节点内存打爆,而事件日志方案是增量拉取,性能压力小得多。项目里的 bootstrap 管理后台能实时显示领券/核销记录,靠的就是监听这些事件后写入本地数据库,做展示层的缓存。
3. 部署与联调:Truffle + Ganache 跑通发券到验券全链路
3.1 工程环境与编译
解压 zip 后看到的是标准的 Truffle 工程:contracts/ 放 Solidity 源码,migrations/ 放部署脚本,src/ 里是 bootstrap 管理端页面,根目录的 gradlew.bat 属于 Android 子工程。bootstrap.css 和 bootstrap.min.css 是页面样式,后端管理界面直接拿来渲染表格和按钮,没有额外引入重型 UI 框架。
编译合约前先确认 Node 环境,然后装 Truffle。老项目如果 Solidity 版本是 0.5.x,Truffle 5 最稳,版本不对会出现一堆看不懂的 ABI 报错。
npm install -g truffle@5 npm install truffle compile编译通过后,build/contracts/ 下会生成 CouponManager.json,里面包含 ABI 和 bytecode。ABI 是前端和移动端调用合约的“接口说明书”,后面 web3.js 和 Android 工程都要引用它,所以这个文件不要随意删。如果 compile 报错,优先看 pragma 版本和当前 Truffle 默认编译器版本是否匹配,0.5.0 的合约用 0.8.x 编译器经常会在构造函数可见性上报错。
3.2 迁移脚本与本地链配置
Truffle 的部署逻辑写在 migrations/ 目录,文件名按数字前缀排序执行。这里只需要部署一个 CouponManager,所以迁移脚本很短,但注意部署账号就是合约的 owner,所有发券操作都从它发起。
// migrations/2_deploy_coupon_manager.js const CouponManager = artifacts.require("CouponManager"); module.exports = function (deployer, network, accounts) { deployer.deploy( CouponManager, { from: accounts[0], gas: 3000000 } ); };from 指定 accounts[0] 为合约所有者,gas 给 300 万是为保险起见——早期合约如果构造函数里有较多状态初始化,默认 gas 可能不够。本地的 Ganache 配置在 truffle-config.js 里写好,端口默认 7545,链 ID 不必手工指定,Ganache 会自动分配。
// truffle-config.js module.exports = { networks: { development: { host: "127.0.0.1", port: 7545, network_id: "*" } }, compilers: { solc: { version: "0.5.16" } } };这里 network_id 用*通配,是为了在 Ganache 和测试网之间切换时不改配置。实际部署到以太坊测试网时,network_id 必须写死,否则 Truffle 可能误判链。
3.3 编写测试用例验证全流程
部署完不要直接写前端,先用 Truffle Test 把发券、领券、验券这条主链路跑通。测试本质上就是一次“彩排”,能暴露大部分合约逻辑问题,比在浏览器里点半天界面高效得多。
// test/coupon_flow_test.js const CouponManager = artifacts.require("CouponManager"); contract("CouponManager", (accounts) => { const [owner, merchant, user] = accounts; it("发券/领券/验券全流程", async () => { const cm = await CouponManager.deployed(); // 1. 创建券模板 const createReceipt = await cm.createTemplate( "满100减30", web3.utils.toWei("30", "ether"), 1000, Math.floor(Date.now() / 1000) + 3600 * 24 * 7, { from: owner } ); const templateId = createReceipt.logs[0].args.templateId.toNumber(); // 2. 发一张券 const codeHash = web3.utils.sha3("OFF2024-0001"); await cm.issueCoupon(templateId, codeHash, { from: owner }); // 3. 用户领券 await cm.claimCoupon(1, { from: user }); // 4. 商户验券 const verifyReceipt = await cm.verifyCoupon(1, "OFF2024-0001", { from: merchant }); assert.equal(verifyReceipt.receipt.status, true); // 5. 重复验券必须失败 try { await cm.verifyCoupon(1, "OFF2024-0001", { from: merchant }); assert.fail("should have reverted"); } catch (e) { assert(e.message.includes("revert")); } }); });这段测试踩了所有核心状态:模板创建、发券、领券、核销、重复核销失败。web3.utils.sha3 生成的哈希和合约里 keccak256(abi.encodePacked() ) 是同一套算法,前端以后也要统一用这个函数生成哈希,不要自己拼字符串。repeat 验券那段是刻意验证状态机,经验是这一条测试能挡掉至少一半的“状态没更新”类 bug。
3.4 bootstrap 管理端接入 web3.js
后台管理页面用的是 bootstrap 布局,核心逻辑是 web3.js 调合约方法、渲染事件日志。所有操作按钮都走 Metamask 弹窗签名,页面本身不持有私钥。
// src/js/coupon-admin.js async function verifyCoupon(couponId, plainCode) { const accounts = await web3.eth.getAccounts(); const cm = new web3.eth.Contract(CouponManagerAbi, contractAddress); try { await cm.methods.verifyCoupon(couponId, plainCode) .send({ from: accounts[0], gas: 150000 }); alert("核销成功"); } catch (e) { console.error("核销失败", e.message); } }gas 给 150000 是我习惯的起步值,合约逻辑简单时通常足够;如果报 out of gas,优先排查是不是 verifyCoupon 里有数组遍历或额外写操作。页面监听合约事件时,一定要指定 fromBlock,否则每次刷新都从头扫块,区块一多页面卡死。管理后台一般监听“当前区块最近 1000 个区块”即可,历史对账交给脚本处理。
4. Android 端与短信验证:SMSSDK 接入和私钥落地的细节
4.1 集成 SMSSDK:为什么领券要先过短信
资源里有两个 aar——SMSSDKGUI-2.1.3.aar 和 SMSSDK-2.1.3.aar,它们是 MobTech 的短信验证码 SDK。这套系统里短信验证的作用是“领券前先确认手机号真实存在”,防止脚本批量注册撸券。链上合约不做短信校验,因为链上无法安全地验证中心化服务的结果,所以短信是前端准入,领券动作是链上交易,两者解耦。
Android 工程采用本地 aar 依赖,在 app/build.gradle 里加 flatDir 仓库和 implementation 声明。
// app/build.gradle android { repositories { flatDir { dirs 'libs' } } } dependencies { implementation(name: 'SMSSDKGUI-2.1.3', ext: 'aar') implementation(name: 'SMSSDK-2.1.3', ext: 'aar') }SMSSDKGUI 是带界面的验证码 UI 库,SMSSDK 是核心逻辑库,两个都必须引入。Gradle 3.0 以上建议用implementation替代compile,否则会报 deprecation 警告。aar 文件不能直接双击打开看源码,调试时如果怀疑 SDK 内部问题,去 MobTech 官方文档查对应版本的行为,不要反编译 aar,浪费的时间足够重写一遍业务流程。
4.2 验证码流程:先短信后领券
发送验证码和提交验证码这两个动作,在 Android 端的标准写法如下。关键点是提交验证码成功后再调链上合约的 claimCoupon,不能把“短信通过”和“链上领券成功”混为一谈。
// CouponClaimActivity.java SMSSDK.getVerificationCode("86", phone, new SmartSMSListener() { @Override public void onEvent(int action, Object data) { if (action == SmartSMSListener.ACTION_GET_CODE_SUCCESS) { runOnUiThread(() -> toast("验证码已发送")); } } }); SMSSDK.submitVerificationCode("86", phone, inputCode, new SmartSMSListener() { @Override public void onEvent(int action, Object data) { if (action == SmartSMSListener.ACTION_SUBMIT_CODE_SUCCESS) { // 短信验证通过,此时才发起链上领券交易 claimCouponOnChain(couponId, phone); } } });我之前接过一个翻车案例:开发者把短信验证结果直接当成领券成功的凭证,前端显示“已领取”,链上交易实际没发送。用户拿着券去门店核销,链上根本查不到。正确的做法是短信验证只作为操作门槛,真正的领取以交易落块为准,UI 上要展示交易 hash 和等待确认的状态。
4.3 私钥管理:keystore 留在用户手里
Android 端领券需要用户签名交易,私钥不能放在自己的服务器上。常见做法是首次登录时生成密钥对,将 keystore JSON 加密后存本地,用户输入密码时解密签名。
// WalletManager.java String keystoreJson = createKeystore(password); // 生成新钱包 // 签名交易 Credentials credentials = Credentials.create(password, keystoreJson); RawTransaction tx = RawTransaction.createTransaction( nonce, gasPrice, gasLimit, contractAddress, data, BigInteger.ZERO ); byte[] signedMessage = TransactionEncoder.signMessage(tx, credentials);这里 gasPrice 和 nonce 必须来自链上实时数据,不能写死。Android 端网络切换时,nonce 复用会导致交易被 pending,用户反复点击领券按钮产生多笔重复交易,这是我碰到最多的问题——解决办法是在点击领券后立即把按钮置灰,等待交易确认回调再恢复。
5. 避坑指南:优惠券上链最容易翻车的五个现场
5.1 验券码对不上:字符串编码不一致
现象:线下扫码验券,合约一直返回 “code mismatch”,换了几张券都一样。 原因:前端生成券码时带上了换行符或空格,或者用了keccak256(parseInt(code))之类的错误转换,和合约侧keccak256(abi.encodePacked(_plainCode))算出来的哈希不同。 解决:统一用web3.utils.sha3(code.trim())生成哈希;合约侧不要对字符串做任何隐式转换,传什么就 pack 什么。代码里、数据库里、扫码枪里,券码的编码格式必须完全一致,建议在生成券码时直接约束为[A-Z0-9-]字符集,从源头避免编码歧义。
5.2 一个券码能被复制到多个模板
现象:同一串券码在 A 模板核销后,换到 B 模板又能核销一次。 原因:verifyCoupon 里只校验了 codeHash 本身,没有检查这张券属于哪个模板,运营把相同券码发到了多个模板下。 解决:在 verifyCoupon 里加一层require(c.templateId == _templateId),或者在生成 codeHash 时把 templateId 拼进去,例如sha3(templateId + code),这样一张券码天然只能在一个模板下生效。我一般两种都做,链上多一层保险,前端生成时也多一道约束。
5.3 领券时漏掉状态检查,已核销券被二次领取
现象:用户 A 领了券并核销后,用户 B 用同一个 couponId 调用 claimCoupon,竟然能把券领走。 原因:claimCoupon 只判断了owner == 0,没有检查status。一张已核销的券 owner 字段已经被清空,或初始状态被误写成 0。 解决:状态机要明确——只有 status == 0 才能领取,领取后立即置为 1,核销后置为 2,任何跳状态的操作都直接 revert。写合约时把状态流转画成一张表:0→1、1→2、1→3,其他路径一律禁止。
5.4 遍历数组找未核销券,gas 直接爆掉
现象:用户持有超过 500 张券后,前端“我的券”页面调用了getUserCoupons(),交易一直 out of gas。 原因:合约里用循环遍历全部券 ID,再判断 owner 是否匹配。链上循环每轮至少一次 SLOAD,500 张券就是 500 次存储读取,gas 成倍上涨,区块 gas 上限兜不住。 解决:把券 ID 的索引关系存到 mapping 里,比如mapping(address => uint256[]) userCoupons,领券时 push,核销时按需过滤。前端展示只读页面时,优先用事件日志查询而不是调合约方法,性能差距是数量级的。
5.5 时间戳断言在测试网和本地链上偶发失败
现象:在 Ganache 上跑测试一切正常,切到测试网后验券偶尔报 “expired”。 原因:合约里用 block.timestamp 判断过期,测试网节点的时间戳可能与本地时钟偏差较大,尤其是私有测试网节点没有做时间同步。 解决:测试网部署时把 expireAt 设置得宽松一些,至少留一天缓冲;调试时间相关逻辑时用 Ganache 的evm_increaseTime命令手动控制链上时间,不要依赖本地系统时间。生产环境里过期判断是硬逻辑,但运营侧建议提前 24 小时做下架处理,不要把状态卡在链上时间戳的临界点上。
6. 进阶:把券升级成 ERC-721,再聊聊离线核销
6.1 用 ERC-721 让每张券都成为独立资产
项目里的券是普通合约数据结构,升级空间其实很大。如果你想把优惠券做成可转让、可交易的数字资产,最简单的改造是让 Coupon 合约继承 OpenZeppelin 的 ERC721——每张券拥有一个唯一的 tokenId,owner 天然由合约管理,用户之间可以直接转账券,交易市场的挂单、撮合、上架逻辑都能复用现成轮子。
import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; contract CouponNFT is ERC721 { uint256 public nextTokenId; mapping(uint256 => uint256) public tokenTemplateId; function issueCouponNFT(uint256 _templateId, address _to) external onlyOwner returns (uint256) { uint256 tokenId = nextTokenId; _mint(_to, tokenId); tokenTemplateId[tokenId] = _templateId; nextTokenId++; return tokenId; } }6.2 离线核销:把签名放到链下
如果门店核销频率高,gas 是个绕不开的成本。一个常见优化是“先签名,后校验”——商户私钥对核销结果做链下签名,用户或系统凭签名去链上兑换积分,合约只验证签名是否来自授权商户,核销状态批量更新。这套方案省掉了每次核销的链上写入,逻辑更复杂但更适合高频场景。
验收这类合约有个动作我每次都做:把 Ganache 重置到初始区块,重新跑一遍全部测试,再清掉前端本地缓存重新联调。那个期末验收前夜,我因为事件监听没定位 fromBlock,在私链上多跑了四个区块导致数据对不上,后来强制规定所有事件监听必须显式指定起始区块,测试脚本必须跑两遍完整生命周期。从那以后我每次接触新合约工程都先做这两步,再进业务代码。希望帮到你。
本文还有配套的精品资源,点击获取