简介这是一套基于以太坊的通用电子优惠券系统完整项目面向区块链方向的高校学生、课程设计与毕业设计开发者。项目采用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.xTruffle 5 最稳版本不对会出现一堆看不懂的 ABI 报错。npm install -g truffle5 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 是否匹配。链上循环每轮至少一次 SLOAD500 张券就是 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——每张券拥有一个唯一的 tokenIdowner 天然由合约管理用户之间可以直接转账券交易市场的挂单、撮合、上架逻辑都能复用现成轮子。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在私链上多跑了四个区块导致数据对不上后来强制规定所有事件监听必须显式指定起始区块测试脚本必须跑两遍完整生命周期。从那以后我每次接触新合约工程都先做这两步再进业务代码。希望帮到你。本文还有配套的精品资源点击获取