简介:基于以太坊的通用电子优惠券系统,是一套面向区块链应用开发的完整项目资源,兼顾优惠券发行、领取、校验等通用业务流程,适合计算机相关专业的毕设/课设、项目演示及区块链技术学习。压缩包共485个文件,以Java源码与Android工程为主(173个java、107个xml),并包含JSP后端、SQL数据库、智能合约sol文件及界面切图资源,总大小约8.17MB,目录划分清晰,便于按模块查阅与二次开发。这套资料此前已有54人学习下载,项目代码经实际运行测试成功,且获得导师认可、答辩评审95分,另附详细开发文档,可帮助读者快速理解以太坊交互与优惠券系统设计思路,也能直接作为毕业设计或课程设计的基础版本,在此基础上修改扩展。整体适合具备一定编程基础、希望落地区块链应用的学习者。
1. 基于以太坊的通用电子优惠券系统,到底在解决什么
拿到这个标题时,我的第一反应不是什么“区块链赋能营销”,而是先问一句:普通优惠券系统用一张数据库表就能做,为什么要上以太坊?答案其实很现实——电子优惠券的三个老毛病在传统架构里很难根治:发行方无法自证没超发、商户之间不互信、消费者手里的券被平台悄悄作废也没人知道。把券的发行、流转、核销状态放到链上之后,这三件事都变成了可以公开查验的交易记录。这个压缩包里的“资料齐全+详细文档”,按我的经验,大概率是合约源码、部署脚本、ER 图和调用时序图几件套的组合。它适合两类人:一类是想给积分、卡券、票务系统做存证改造的后端工程师,另一类是刚接触以太坊、想找一个不那么虚的业务来练手的 Solidity 开发者。但先说清楚,这类 zip 包更像工程种子,不是解压即跑的傻瓜包,能不能用起来取决于你是否先想明白状态机和角色划分。
2. 先读懂优惠券的状态机与合约选型:为什么不推荐一张券一个 ERC-721
一上来就编译合约是新手最常见的动作,但我建议先把“券的一生”画出来。电子优惠券在链上不是一条静态数据,它是一组被合约强制约束的状态迁移。这个模型不立住,后面改代码、调订单、查核销记录都会很痛苦。
2.1 券的状态机:读懂了状态,你就读懂了系统为什么这么设计
一张通用电子优惠券至少经历四个状态:已发行、可流转、已核销、已过期。如果做成可退券场景,还要加上冻结和解冻。每个状态变更都要对应一次链上交易或一个被合约认可的事件,否则审计时说不清“这张券为什么没了”。
在这个系统里,角色一般分成三类:发行方(品牌方或商户平台)、持券人(消费者)、核销方(门店或收银端)。常见的误区是把发行方和核销方混成同一个账户,这在单商户 demo 里没问题,但一旦做多商户平台,权限就会变成黑匣子——谁都说不清某张券到底是谁核销的。所以在设计合约时,我一般会把“谁有权 mint”“谁有权核销”“谁能转让”拆成三个独立权限点。
状态机的实现核心在于:每个状态都用合约里的 mapping 或代币余额来体现,而状态迁移必须通过指定函数完成。比如已核销,不能用“转给黑洞地址”这种隐晦做法,而是要在合约里显式记录 redeemed 数量,并发一个CouponRedeemed事件。否则后端的对账脚本只能靠猜。
2.2 数据结构选型:自建映射、ERC-721 还是 ERC-1155
大多数资料包给你的方案会在这两者之间二选一:自建mapping(uint256 => CouponInfo)那套,或者一张券一个 ERC-721。我的建议是:如果目标是“通用”,优先用 ERC-1155,而不是 ERC-721。
原因很简单。ERC-721 强调唯一性,但优惠券的“唯一一张”意义很弱,真正需要的是“同一种券的多个实例”。用 ERC-1155 的话,一个typeId就是一类券(比如“满 100 减 20 的咖啡券”),amount才是某个人手里的数量。这样批量发行、批量核销、按类别查库存都非常自然。
| 方案 | 适用场景 | 缺陷 |
|---|---|---|
| 自建 mapping | 纯存证、没有转让需求 | 流转记录要自己维护,审计成本高 |
| ERC-721 | 每张券有独立编号、藏品属性强 | 批量发券要循环 mint,Gas 费高 |
| ERC-1155 | 多商户、多类别、半同质化 | 需要额外处理好 typeId 与实例的对应关系 |
自建 mapping 也不是不能用,但你要处理“券被转给别人后,原持有人的数据怎么作废”这类麻烦问题。ERC-1155 把余额和转账都交给 OpenZeppelin 标准实现,合约代码量直接减半,后端也不需要自己写转让逻辑。
2.3 一个可以直接上测试网的最小 Solidity 模型
下面这份代码是我对一个“通用优惠券”最小实现的习惯写法,核心就三件事:定义券类型、发行、核销。签名校验部分先留占位,第 4 章会补完整。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; contract CouponSystem is ERC1155, Ownable { struct CouponType { address issuer; // 发行方 address merchant; // 可核销方 string name; // 券名称,比如“满100减20咖啡券” uint256 value; // 面值,单位由业务约定 uint256 expiry; // 过期时间,Unix 时间戳 uint256 originalSupply; // 发行总量 uint256 redeemed; // 已核销数量 } mapping(uint256 => CouponType) public couponTypes; // typeId => 核销请求的唯一标识,用于幂等 mapping(uint256 => mapping(bytes32 => bool)) public redemptionUsed; event CouponIssued(uint256 indexed typeId, address indexed to, uint256 amount); event CouponRedeemed(uint256 indexed typeId, address indexed holder, uint256 amount, bytes32 requestId); constructor() ERC1155("https://example.com/coupon/{id}.json") {} function issueCoupon( uint256 typeId, address to, string calldata name, uint256 value, uint256 expiry, uint256 amount ) external onlyOwner { couponTypes[typeId] = CouponType({ issuer: msg.sender, merchant: msg.sender, name: name, value: value, expiry: expiry, originalSupply: amount, redeemed: 0 }); _mint(to, typeId, amount, ""); emit CouponIssued(typeId, to, amount); } function redeem( uint256 typeId, address holder, uint256 amount, bytes32 requestId ) external { CouponType storage ctype = couponTypes[typeId]; require(block.timestamp <= ctype.expiry, "CouponSystem: expired"); require(ctype.redeemed + amount <= ctype.originalSupply, "CouponSystem: over redeemed"); require(redemptionUsed[typeId][requestId] == false, "CouponSystem: request already used"); // 调用者必须是持券人,或已经获得持券人授权的商户 require( msg.sender == holder || isApprovedForAll(holder, msg.sender), "CouponSystem: not authorized" ); _burn(holder, typeId, amount); ctype.redeemed += amount; redemptionUsed[typeId][requestId] = true; emit CouponRedeemed(typeId, holder, amount, requestId); } }这段代码有个关键点:核销通过_burn把券从持券人余额里永久销毁,同时给redeemed计数。requestId是业务侧生成的幂等键,比如订单号,同一笔核销请求重复提交会被redemptionUsed挡住。isApprovedForAll是 ERC-1155 自带的授权机制,门店持有持券人的授权后就能代替消费者提交核销交易。
参数方面要注意:expiry用 Unix 时间戳,别用区块高度,因为优惠券的过期时间是业务语义,应该跟现实时钟对齐;typeId建议从 1 开始自增,不要用 0 做有效类别,方便后端判断“未初始化”和“已存在”;originalSupply和redeemed分开记录,这样链上能直接验证“有没有超发”。这段代码的issuer和merchant暂设为同一人,真正多商户化时要把这个赋值逻辑拆开。
3. 跑通最小系统:从解压 zip 到测试网完成发券与核销
拿到压缩包后最容易踩的坑是急着看代码。我的习惯是先按清单验收文件,再动手改配置。
3.1 先按“资料齐全”的清单验收压缩包
这类“资料齐全+详细文档”的 zip,解压后一般会包含:contracts/合约源码、scripts/部署与交互脚本、docs/文档、web/或frontend/前端 demo。我不关心文件数量,只看四样东西在不在:有没有说明券类型字段的 ER 图、有没有合约函数的调用时序图、有没有环境变量模板(比如.env.example)、有没有测试用例。四样里缺两样以上,这份资料的“详细文档”就要打个问号。
解压本身也有玄学,尤其是从网盘或者邮箱下载的 zip,经常遇到导入失败 caused by: invalid zip archive: could not find eocd。这不是密码问题,而是压缩包文件不完整,常见原因是传输中断。遇到这种情况先用zip -FF damaged.zip --out new.zip尝试修复,修复不了只能重新下载。文件很重的话,先用unzip -l file.zip查看内部列表,确认没有嵌套压缩包再解压。
3.2 用 Hardhat 把合约编译并部署到 Sepolia 测试网
我一般用 Hardhat 跑这类项目,配置文件如下。注意私钥一律从环境变量读,不要写死在代码里,更不要提交到压缩包——市面上很多模板泄漏私钥就是因为图省事。
// hardhat.config.js require("@nomicfoundation/hardhat-toolbox"); require("dotenv").config(); module.exports = { solidity: "0.8.17", networks: { sepolia: { url: process.env.SEPOLIA_RPC_URL, accounts: [process.env.DEPLOYER_PRIVATE_KEY] } } };这里的SEPOLIA_RPC_URL可以用公共 RPC 服务商,也可以自己用本地节点。DEPLOYER_PRIVATE_KEY用.env管理,启动前确认.env已经被写进.gitignore。部署脚本按 Hardhat 的惯例放在scripts/deploy.js:
const hre = require("hardhat"); async function main() { const CouponSystem = await hre.ethers.getContractFactory("CouponSystem"); const coupon = await CouponSystem.deploy(); await coupon.waitForDeployment(); console.log("CouponSystem deployed to:", await coupon.getAddress()); } main().catch((error) => { console.error(error); process.exitCode = 1; });这个脚本本身没什么复杂逻辑,真正要理解的是waitForDeployment():它等待合约部署交易被确认,拿到地址后才能做后续交互。部署后把合约地址写进一个deployments.json,方便下一步跑发券和核销。
3.3 用 Node 脚本完成一次发券和一次核销
下面这一段就是我前面反复强调的“全生命周期验证”,代码不长,但能验证合约基本逻辑是否可用。
const hre = require("hre"); async function demo() { const [issuer, holder, merchant] = await hre.ethers.getSigners(); const coupon = await hre.ethers.getContractAt( "CouponSystem", "0xYourDeployedAddress" ); // 发券:typeId=1,给 holder 10 张“满100减20”咖啡券 const issueTx = await coupon.connect(issuer).issueCoupon( 1, holder.address, "coffee_100_20", 100, Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, 10 ); const issueReceipt = await issueTx.wait(); console.log("issue status:", issueReceipt.status); // 持券人授权给商户,让门店可以代提交核销 await coupon.connect(holder).setApprovalForAll(merchant.address, true); // 商户核销 2 张,requestId 用订单号哈希 const requestId = hre.ethers.keccak256( hre.ethers.toUtf8Bytes("order-20240601-001") ); const redeemTx = await coupon.connect(merchant).redeem( 1, holder.address, 2, requestId ); const redeemReceipt = await redeemTx.wait(); console.log("redeem status:", redeemReceipt.status); } demo().catch(console.error);这里的connect(issuer)切换调用身份,setApprovalForAll把核销权委托给商户。参数上比较值得说的是requestId:它必须和商户订单系统能对应上,并且同一订单不要换,否则账就对不齐。核销成功后再查couponTypes[1].redeemed,应该等于 2,holder的余额应该是 8。
如果你只想在本地快速验证逻辑,把network改成hardhat内置网络,不需要真的上测试网。但最后验收还是建议过一遍 Sepolia,原因在第 4 章会提到——签名核销依赖 chainId。
4. 核销这步才是通用性的关键:EIP-712 离线签名与防重放
很多资料包把重点放在发行和转让上,但真正让优惠券系统“通用”起来的是核销环节。核销如果每次都要持券人自己发交易付 Gas,门店体验会很差;如果不经过链上,又回到传统系统互不信任的老路。EIP-712 离线签名正好卡在中间:商户对核销请求做签名背书,持券人或门店带着签名和券去链上兑现。
4.1 为什么核销要在链下先做一次签名验证
直接分析一下成本:一个核销动作在链上至少发生一次_burn和一次状态更新,按当前以太坊主网价格,这笔 Gas 对一张几十块的优惠券来说可能超过券本身面值。所以现实方案是“链下验证、链上结算”。门店收银系统拿到消费者的券 ID 和消费金额后,由商户后端用私钥对这笔核销请求签名,表示“这个门店承认这笔消费有效”,然后再把签名连同请求提交到合约。这样消费者不用为核销付 Gas,商户可以把多笔核销攒到一天集中上链。
安全性上,EIP-712 比普通的字符串签名更强,因为它把转账类型、字段名、合约地址和 chainId 全部绑死在签名内容里,签名在别的合约或别的链上无法重用。这对优惠券系统很重要——你不能让攻击者在 B 平台伪造一笔 A 平台的核销请求。
4.2 商户端离线签名与合约端校验的代码实现
先看商户后端怎么生成签名。这里用 ethers.js 的signTypedData,代码是标准写法。
const domain = { name: "CouponSystem", version: "1", chainId: Number((await hre.ethers.provider.getNetwork()).chainId), verifyingContract: "0xYourDeployedAddress" }; const types = { RedemptionRequest: [ { name: "typeId", type: "uint256" }, { name: "holder", type: "address" }, { name: "amount", type: "uint256" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" } ] }; const value = { typeId: 1, holder: "0xHOLDER_ADDRESS", amount: 2, nonce: 20240601, deadline: Math.floor(Date.now() / 1000) + 3600 }; const signature = await merchantSigner.signTypedData(domain, types, value);签名里的nonce不是链上交易 nonce,而是业务方自己的请求序号,每次核销递增,目的就是防重放。deadline表示该签名只在未来一小时内有效,过期后门店必须重新申请。把deadline设成 3600 秒是比较安全的默认值,太长容易被重放,太短会频繁请求签名。
合约端对应修改核销函数:
bytes32 public constant REDEMPTION_TYPEHASH = keccak256( "RedemptionRequest(uint256 typeId,address holder,uint256 amount,uint256 nonce,uint256 deadline)" ); function redeemWithSignature( uint256 typeId, address holder, uint256 amount, uint256 nonce, uint256 deadline, bytes calldata signature ) external { require(block.timestamp <= deadline, "CouponSystem: signature expired"); bytes32 structHash = keccak256(abi.encode( REDEMPTION_TYPEHASH, typeId, holder, amount, nonce, deadline )); bytes32 digest = keccak256(abi.encodePacked( "\x19\x01", DOMAIN_SEPARATOR, structHash )); address signer = ECDSA.recover(digest, signature); require(signer == couponTypes[typeId].merchant, "CouponSystem: invalid signer"); bytes32 requestId = keccak256(abi.encodePacked(typeId, holder, amount, nonce)); require(!redemptionUsed[typeId][requestId], "CouponSystem: already used"); redemptionUsed[typeId][requestId] = true; _burn(holder, typeId, amount); couponTypes[typeId].redeemed += amount; emit CouponRedeemed(typeId, holder, amount, requestId); }注意合约里的DOMAIN_SEPARATOR必须在构造函数里和前端domain保持一致。每次改动合约地址或 chainId,都重新生成一遍,否则验签永远失败。
4.3 防重放的三道防线:nonce、deadline 与 requestId
离线签名方案里最容易被攻击的就是重放。第一道防线是nonce,每次核销请求都用新的整数值,同一 nonce 的签名如果已经用掉,合约里可以维护usedNonce[merchant][nonce]标记。第二道防线是deadline,它把签名的有效窗口限制在一个小时或几分钟内,降低截获后延迟使用的风险。第三道防线是requestId幂等键,它把 typeId、holder、amount、nonce 绑定成一个业务唯一键,即使同一笔请求被并发提交或重复提交,也只有第一次能成功。
这里有一个大家容易翻车的地方:requestId只包含业务字段,不含签名本身。如果你想强行让同一笔业务只能用同一个签名,就必须把signature也放进哈希。但更常见、也更省 Gas 的做法是信任 deadline 和 nonce 的单调性,业务上让商户后端保证“同一 nonce 只签发一次”。两份承诺叠加,重放攻击的可行窗口已经很小了。
5. 跑通后最容易翻车的 5 个常见问题:现象、原因与处置
真正把系统部署到测试网甚至主网之后,坑才开始冒出来。下面这 5 条是我在类似项目里反复遇到的,每一条都按现象、原因、解决的顺序写,方便你直接对照排查。
5.1 Gas 费高到无法商用:每张券都单独发一笔交易
现象:在 Sepolia 上跑通后,换到主网前估算一下成本,发现发 1000 张券要 1000 次mint,每次交易都消耗 Gas,核销也要每笔单独上链,整体费用比优惠券面值还高。
原因:合约没问题,是用法不对。ERC-1155 本身支持批量 mint 和批量转账,很多模板没有把这一层封装进业务接口,于是一次循环发券就成为名副其实的“手续费黑洞”。
解决:发券时调用mintBatch,把多个持券地址和对应数量打包成一笔交易;核销端把当天所有小票攒成一个清单,由中台晚间集中执行,配合第 4 章的离线签名,把逐笔核销变成批量对账。
5.2 导入资料包后编译不过:Solidity 版本和依赖库都太老
现象:解压后按文档执行npx hardhat compile,报错ParserError: Source file requires different compiler version,或者提示某个 OpenZeppelin 函数找不到。有些项目还夹杂zip 解压之后文件损坏的报错,因为压缩包内嵌了子目录,解压工具没有正确处理。
原因:这类 zip 里的代码可能是 0.7.x 或 0.8.4 时代写的,用的是SafeMath和旧式constructor()写法,而新版本 Hardhat 默认编译器是 0.8.17+,两者不兼容。
解决:在hardhat.config.js里声明多个编译版本,让旧合约和依赖各用各的版本;或者把.add()、.sub()整体替换成原生+、-,把constructor关键字去掉。改完先跑npx hardhat clean再重新编译,避免缓存干扰。
5.3 测试网交易一直 pending,换哪个 RPC 都没用
现象:在 Sepolia 上部署或核销时,交易提交后迟迟不入块,后续所有交易全部排队,报nonce too low或者replacement transaction underpriced。
原因:一个地址被多个终端操作,或者之前有一笔低 Gas 交易卡在 pending 状态,节点把 nonce 锁住了。测试网经常出现这种“看上去是网络问题,实际是 nonce 问题”的假象。
解决:在脚本里显式设置 nonce,用provider.getTransactionCount(address, "pending")获取最新计数值;如果卡住,用相同 nonce 重发一笔带更高 Gas 费的交易去替换它。不要同时让两个脚本操作同一把私钥,这是血泪经验。
5.4 券被“复制”使用:签名和其他字段没有绑紧
现象:同一张券在多个订单里被核销成功,或者 A 签名的核销请求被拿到 B 的券上用。
原因:签名的 payload 里只有经销商和金额,没有把持券人、券类型、核销单号绑进去。合约只验证签名人合法,不验证“这份签名在替谁核销哪张券”,于是签名被复用。
解决:回归第 4 章代码。RedemptionRequest里的typeId、holder、amount、nonce、deadline五要素一个都不能少,合约里requestId也按同样字段生成。这样即使签名被截获,也无法在另一张券或另一个订单上重用。
5.5 核销失败却显示成功:前端只看事件不看收据
现象:门店前端提示“核销成功”,但后台账单里没有这笔记录,或者用户券的余额没有变化。翻日志发现合约实际 revert 了。
原因:前端监听了TransferSingle事件,而核销交易里_burn会触发这个事件;只要事件被推出来,页面就认为成功。但此时交易可能还在 pending,或者已经 revert。
解决:所有客户端判断都以tx.wait()返回的 receipt 为准,先检查receipt.status === 1,再读取事件。开发期间把 revert 原因完整打印出来,不必用require里的字符串做前端提示,那是给调试看的,不是给用户看的。
6. 让这份 zip 变成生产方案:三维验收与两个迭代方向
到这一步,合约部署、发券、离线验证、批量上链都已经能跑通。但我建议再做一轮完整验收,否则心里没底。
6.1 用三组用例覆盖正常、逆向与并发路径
第一组是正常路径:发行方发券,持券人授权,商户核销,断言CouponRedeemed事件触发、redeemed字段增加、持券人余额减少。第二组是逆向路径:过期券核销、非法签名、重复 nonce、超额核销,每一笔都应该 revert,并且不产生状态变化。第三组是并发路径:对同一张券同时提交两笔核销,必须且只能有一笔成功。
把这些断言写成npx hardhat test的测试用例,比手动点十次前端要有效得多。测试脚本里直接调用expect(...).to.be.revertedWith("CouponSystem: expired")这类断言,能把错误信息留在 CI 记录里。
6.2 两个值得做的迭代方向
第一个方向是把发券权限从全局onlyOwner改成每类券独立的issuer和merchant。这样多商户才能真正入驻,每个门店只能核销自己名下的券,平台不能任意干涉商户资产。第二个方向是把结算模式改成 T+1 批量核销:门店离线收集签名,晚上中台统一上链。Gas 成本会降到几乎可以忽略,核销的实时性则用第 4 章的签名时效来兜底。
我自己做这类系统最深的一个教训是:zip 里的源码只是契约模型,不是业务答案。把状态机先画出来、把签名绑定字段先定死,再动手改合约,比按文档走一遍要省力得多。每次看到“核销不成功但显示成功”这类问题,回溯到最后大多是状态机没理清。希望这份踩坑清单能帮你少走这段弯路,也希望你的第一张链上优惠券能稳稳地发出去、核销掉。
本文还有配套的精品资源,点击获取