
简介这是一套基于以太坊区块链的通用电子优惠券系统项目源码适合计算机相关专业的在校学生用于毕业设计、课程设计也可供开发者作为去中心化应用开发的参考。项目覆盖智能合约、后端服务与前端交互核心业务包含优惠券发行、领取与核销等流程。压缩包共485个文件约8.17MB主要文件类型为Java源码、JSP页面、XML配置、JavaScript脚本、SQL脚本及Solidity合约文件并包含Android客户端相关资源方便多端集成与二次开发同时配有样式表、图片、字体等前端素材整体目录结构较为完整。项目已通过导师指导评审得分95分代码经测试运行成功配套详细说明文档从文件构成看还集成了短信验证等实用模块有助于理解真实场景下的区块链应用落地。目前已有53人学习浏览适合需要完整项目参考或在此基础上扩展功能的读者。1. 基于以太坊的通用电子优惠券系统先把链上模型想清楚再动手我做过的优惠券系统不止一套传统数据库方案能做到秒级核销但有一个问题始终绕不开商户拿着顾客手机上的二维码截图来核销你怎么确认这张券不是 P 出来的换成基于以太坊的通用电子优惠券系统之后这张券是从哪个合约铸造的、面额多少、过期没有、是否已核销都变成链上公开可查的事实。标题里写着资料齐全详细文档说明这套 demo 已经把从合约到部署的那条路铺好了你要做的是把它变成自己的工程实践。它适合两类人给存量业务加一层防伪凭证的产品负责人以及想练手 ERC-1155 的 Solidity 开发者。第一个建议别急着跑代码先想清楚你的券在链上到底代表什么。2. 优惠券合约模型用 ERC-1155 把券做成可追踪的链上资产优惠券的链上模型决定了后面所有的成本和体验。我见过把券做成 ERC-20 增发核销时直接burn掉的做法也能跑但查不到某一批券发了多少、某一张归谁。通用电子优惠券系统要同时回答这两个问题所以我在结构上把票券拆成两层券类型和券实例。前者对应满 100 减 20 这类规则后者对应某个用户手里那张具体的券。2.1 选型为什么是 ERC-1155 而不是 ERC-721 或 ERC-20选代币标准是这套系统的第一个分岔口。ERC-20 把券做成同质化余额核销就是扣减余额逻辑最简单但这张券是不是用户自己的只能靠合约里一个 mapping 记录转赠时还要额外写转账逻辑链上历史的可读性很差。ERC-721 每张券一个独立 tokenId适合演唱会门票这种强唯一性场景但同批次发 1000 张满减券就要执行 1000 次 mintGas 成本随发行量线性上涨。ERC-1155 是两者的折中同一个类型 ID 下可以有多个实例既支持批量铸造也能通过 tokenId 区分单张券。更重要的是OpenZeppelin 的 ERC1155 自带safeTransferFrom转赠功能不用自己写。优惠券天然就是一个类型 多张实例的结构所以 ERC-1155 在这三个标准里最贴题。业务需求ERC-20ERC-721ERC-1155同类型发多张适合每张一个IDGas高同一 typeId 多条记录单张券状态可查难容易用编码后的 tokenId 可查转赠需自定义内置内置批量发放一般差支持批量 mint核销后防重靠余额靠 burn 事件靠 burn redeemed 标记2.2 数据结构typeId 与 tokenId 的高低 32 位编码我在合约里用typeId表示券类型用tokenId表示一张具体的券。tokenId不是随便生成的而是把类型编号和高低 32 位编码进同一个uint256高 224 位存类型 ID低 32 位存序号。这样从任何一个 tokenId 都能反推出它属于哪类券也保证了全合约内唯一。// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import openzeppelin/contracts/token/ERC1155/ERC1155.sol; import openzeppelin/contracts/access/Ownable.sol; contract CouponSystem is ERC1155, Ownable { struct CouponType { string name; // 券名比如 M100-20 uint256 faceValue; // 面额单位 wei0 表示折扣券具体折扣放在 metadata uint256 expireAt; // 过期时间戳秒 uint256 maxSupply; // 最大发行量 uint256 issuedCount; // 已发行数量 uint256 redeemedCount;// 已核销数量 bool active; // 是否可领取 } mapping(uint256 CouponType) public couponTypes; mapping(uint256 bool) public redeemed; // tokenId - 是否已核销 mapping(address mapping(uint256 bool)) public claimed; // 地址 - typeId - 已领标记 uint256 private serialCounter; event CouponIssued(address indexed recipient, uint256 indexed typeId, uint256 tokenId); event CouponRedeemed(address indexed owner, uint256 indexed typeId, uint256 tokenId); constructor() ERC1155(https://your-metadata-base.com/coupons/{id}.json) {} }这里claimed是防止同一个地址反复领取同一类券的开关我按一个地址每类限领一张设计。如果业务要允许领多张就把claimed改成计数器或者去掉。serialCounter从 1 开始递增所以第一张券的 tokenId 是(1 32) 1第二张是(1 32) 2以此类推。2.3 核心函数发券、转赠、核销的状态流转发券函数只做四件事校验类型可领、校验没领过、生成 tokenId、铸造到用户地址。注意我规定外部调用时不用传recipient而是直接用msg.sender避免前端把你本来想发给用户 A 的券发给用户 B。function mintCoupon( uint256 typeId ) external { CouponType storage ct couponTypes[typeId]; require(ct.active, CouponSystem: type not active); require(ct.issuedCount ct.maxSupply, CouponSystem: supply exhausted); require(!claimed[msg.sender][typeId], CouponSystem: already claimed); require(block.timestamp ct.expireAt, CouponSystem: expired); serialCounter; uint256 tokenId (typeId 32) | serialCounter; claimed[msg.sender][typeId] true; ct.issuedCount; _mint(msg.sender, tokenId, 1, ); emit CouponIssued(msg.sender, typeId, tokenId); }参数说明typeId由管理员事先通过createCouponType创建。_mint的第四个参数data我传空字符串因为发行目标只可能是 EOA 钱包如果以后要空投给合约地址那边必须实现onERC1155Received回调否则资产会被卡住。核销是反向操作关键点在于先校验再销毁。销毁之后这张券的余额归零但balanceOf查不到不代表它不存在所以redeemed[tokenId]这个标记必须在销毁前写入。function redeemCoupon( uint256 tokenId ) external { uint256 typeId tokenId 32; CouponType storage ct couponTypes[typeId]; require(ct.active, CouponSystem: type not active); require(block.timestamp ct.expireAt, CouponSystem: expired); require(!redeemed[tokenId], CouponSystem: already redeemed); require(balanceOf(msg.sender, tokenId) 0, CouponSystem: not owner); redeemed[tokenId] true; ct.redeemedCount; _burn(msg.sender, tokenId, 1); emit CouponRedeemed(msg.sender, typeId, tokenId); }转赠不需要额外函数ERC-1155 的safeTransferFrom已经做了余额检查和接收方合约适配。这里有一个业务取舍claimed只限制领不限制转所以用户领了一张之后转赠给朋友朋友还能不能再领一张按当前设计可以。如果你希望整个活动期间每个地址最多只持有一张那需要在_beforeTokenTransfer里加限制这是第二个迭代要做的事。3. 本地跑通最小闭环Hardhat 部署与测试流程文档里最多人卡住的地方不是合约本身而是环境。Solidity 版本、OpenZeppelin 依赖、Hardhat 配置这三者的版本匹配一旦混乱编译报错就能耗掉半天。我这套流程用的是 Hardhat 2 Solidity 0.8.20 OpenZeppelin Contracts 5.x下面按这个组合走。3.1 初始化项目与安装依赖mkdir coupon-system cd coupon-system npm init -y npm install --save-dev hardhat npm install --save-dev nomicfoundation/hardhat-toolbox npm install openzeppelin/contracts npx hardhat init初始化时选择Create a JavaScript project然后删除默认的contracts/Lock.sol和test/Lock.js。hardhat-toolbox会一次性带上ethers、chai、hardhat-ethers这些测试插件省去单独版本匹配的麻烦。最后确认hardhat.config.js里的编译器版本require(nomicfoundation/hardhat-toolbox); module.exports { solidity: 0.8.20, networks: { hardhat: { chainId: 1337 } } };说明本地网络chainId: 1337是 Hardhat 默认值也是 MetaMask 自定义网络时最常用的链 ID。如果你后面要接 Sepolia 测试网在这里加一个sepolia配置把url和accounts填进去即可。3.2 写部署脚本并创建两批测试券把上一章的CouponSystem.sol放进contracts/目录然后在scripts/下新建部署脚本。这个脚本除了部署合约还会顺手创建两个券类型方便接下来直接测试。const hre require(hardhat); async function main() { const [deployer] await hre.ethers.getSigners(); console.log(Deploying with account:, deployer.address); const CouponSystem await hre.ethers.getContractFactory(CouponSystem); const coupon await CouponSystem.deploy(); await coupon.waitForDeployment(); const address await coupon.getAddress(); console.log(CouponSystem deployed to:, address); // 创建两个类型满100减20面额0.2 ether和 8折券面额记0 const now Math.floor(Date.now() / 1000); const expireAt now 30 * 24 * 3600; // 30天后过期 await (await coupon.createCouponType(1, M100-20, hre.ethers.parseEther(0.2), expireAt, 100)).wait(); await (await coupon.createCouponType(2, DISCOUNT-20PCT, 0, expireAt, 500)).wait(); console.log(Created coupon type 1 and type 2); } main().catch((error) { console.error(error); process.exitCode 1; });参数说明parseEther(0.2)会转换成200000000000000000wei这是链上面额的常规存法。折扣券没有现金价值所以faceValue传 0具体折扣规则放在 metadata JSON 里。createCouponType只允许合约 owner 调用所以部署账户就是管理员。3.3 用测试覆盖领券-转赠-核销完整路径部署只是第一步跑通状态流转才算闭环。下面这个测试模拟两个用户user1 领一张满减券转赠给 user2user2 核销。测试里能明确看到 tokenId 的编码规则const { expect } require(chai); describe(CouponSystem, function () { it(should claim, transfer and redeem a coupon, async function () { const [owner, user1, user2] await ethers.getSigners(); const CouponSystem await ethers.getContractFactory(CouponSystem); const coupon await CouponSystem.deploy(); await coupon.waitForDeployment(); const now Math.floor(Date.now() / 1000); const expireAt now 86400 * 30; await (await coupon.createCouponType(1, M100-20, ethers.parseEther(0.2), expireAt, 100)).wait(); // 用户1领券第一张 tokenId (1 32) 1 await (await coupon.connect(user1).mintCoupon(1)).wait(); const tokenId (BigInt(1) BigInt(32)) BigInt(1); expect(await coupon.balanceOf(user1.address, tokenId)).to.equal(1); // 转赠给用户2 await (await coupon.connect(user1)[safeTransferFrom(address,address,uint256,uint256,bytes)]( user1.address, user2.address, tokenId, 1, [] )).wait(); expect(await coupon.balanceOf(user2.address, tokenId)).to.equal(1); // 用户2核销 await (await coupon.connect(user2).redeemCoupon(tokenId)).wait(); expect(await coupon.redeemed(tokenId)).to.equal(true); expect(await coupon.balanceOf(user2.address, tokenId)).to.equal(0); }); });测试里最容易踩的坑是safeTransferFrom的重载调用。在 ethers v6 里如果你不写出四个参数加bytes的完整函数签名很容易被解析成三个参数的重载版本。显式写上函数签名是最省心的做法。跑测试用npx hardhat test看到三个expect全部通过说明这条最小闭环已经通了。4. 前端领取与防薅设计MetaMask 和签名验签怎么接合约跑通之后用户真正接触的是前端页面。以太坊类应用的前端流程比普通 web 应用多两步连接钱包、把交易送到链上。更现实的问题是只要你的券有价值就会有脚本批量刷。所以防薅不能靠前端按钮必须让合约和服务端配合。4.1 用 ethers.js 连接 MetaMask 并领取优惠券前端页面用 ethers v6 的BrowserProvider包一层window.ethereum。用户点击领取按钮前端读取当前钱包地址调用合约的mintCoupon(typeId)发送交易等交易上链后再提示结果。import { BrowserProvider, Contract, formatEther } from ethers; import couponAbi from ./couponAbi.json; const contractAddress 0xYourDeployedAddress; const typeId 1; document.querySelector(#claimBtn).addEventListener(click, async () { if (!window.ethereum) { alert(请安装 MetaMask); return; } const provider new BrowserProvider(window.ethereum); const signer await provider.getSigner(); const coupon new Contract(contractAddress, couponAbi, signer); try { const account await signer.getAddress(); console.log(Claiming coupon for, account); const tx await coupon.mintCoupon(typeId); const receipt await tx.wait(); console.log(Claimed, tx hash:, receipt.hash); alert(领取成功交易哈希 receipt.hash); } catch (error) { console.error(Claim failed:, error); alert(领取失败 (error.reason || error.message)); } });这里我特意用account变量做了一次显式读取目的是提醒你mintCoupon内部用的是msg.sender前端不需要也不能手动传地址否则会被合约的require拦住。交易哈希receipt.hash是后续客服查券、对账的凭据前端务必展示给用户最好还在本地 localStorage 里缓存一份。4.2 服务端签名让合约为指定用户发放白名单资格直接把mintCoupon做成公开函数意味着任何地址都能领合约里claimed防的是重复领取防不了脚本批量注册新地址。常见做法是服务端对符合条件的用户做一层签名授权用户在服务端登录并领取资格服务端用私钥签发一个授权码用户拿着这个授权码调用合约的mintWithSignature合约验签通过后才放行。mapping(bytes32 bool) public usedSignatures; function mintWithSignature( uint256 typeId, bytes32 nonce, bytes calldata signature ) external { bytes32 hash keccak256( abi.encodePacked(block.chainid, typeId, msg.sender, nonce) ); require(ECDSA.recover(hash, signature) verifier, CouponSystem: invalid signature); require(!usedSignatures[hash], CouponSystem: signature used); usedSignatures[hash] true; // 复用 mintCoupon 的发行逻辑 _performMint(typeId); }参数说明block.chainid写进 hash 是为了防止签名跨链重放这条记录如果拿到别的链上用验签直接失败。nonce由服务端生成可以是 uuid也可以是一个自增数字合约里只记录hash是否用过。verifier是服务端钱包地址部署合约后调用setVerifier设置。服务端签发签名的代码在 Node 侧用 ethers 做把chainId typeId userAddress nonce组成与合约相同的 hash然后用wallet.signMessage(ethers.getBytes(hash))签名返回给前端。前端只需要把typeId、nonce、signature一起传给合约方法。4.3 商户端核销的两种实现路径核销是优惠券系统的最后一步也是最容易拖垮体验的一步。我做过两种方案。第一种最简单商户钱包直接调redeemCoupon(tokenId)但前提是券已经转到了商户地址而这通常需要用户先做一次safeTransferFrom两笔交易下来 Gas 翻倍线下商户等不了。第二种方案是我更推荐的核销员白名单模式合约增加redeemersmapping拥有核销员身份的钱包可以调用redeemFor传入用户地址和 tokenId直接销毁用户手里的券。这要求用户先给合约做一次授权但授权可以用离线签名方式类似 EIP-2612一次完成核销时商户只提交一笔交易体验好很多。代价是核销员私钥一旦泄露别人拿到可以批量作废用户券所以核销员必须放在独立的硬件钱包或者冷门设备上。5. 避坑指南五个让优惠券系统翻车的细节这个方向看起来简单实际跑起来坑不少。下面五条是我在本地联调、测试网验证和生产环境都遇到过的真实问题按现象、原因、解决三步记录。5.1 重复领取没有被拦住同一个地址领了两张现象用户刷新页面后再次点击领取合约居然没有 revert余额里多了一张同类型券。原因合约里claimed[msg.sender][typeId]只在mintCoupon函数开头校验但前端把mintCoupon和mintWithSignature两个入口都暴露了签名入口没有做同样的claimed检查。解决把校验用户是否已领过从mintCoupon里抽成一个_performMint内部函数两个入口都先调它。签名入口还要额外校验usedSignatures[hash]防止同一个授权码被重复提交。5.2 优惠券提前过期商户核销时报 expired现象合约里expireAt是 7 月 31 日 23:59:59用户 7 月 31 日 23:50 核销时前端显示还有 9 分钟交易却报CouponSystem: expired。原因前端倒计时用的是用户本地时间而合约判断用的是block.timestamp只要用户手机时间快了 15 秒以上、而区块时间又恰好撞上边界就会出现这个偏差。解决前端只调用合约的 view 函数拿expireAt用这个链上时间戳做倒计时绝不在前端用Date.now()拼一个过期时间。另外合约里block.timestamp本身允许矿工在 30 秒内微调对优惠券这种小时级精度的业务没有影响不用管。5.3 核销后用户余额归零但客服查不到核销记录现象交易成功后balanceOf变成 0日志里也能看到CouponRedeemed事件但后台管理系统里查这张券的状态还是未使用。原因后台查询走的是一个链下索引服务它的同步延迟导致事件还没被索引。更糟的是如果后台直接查balanceOf销毁后归零无法区分未发放和已核销。解决合约里保留redeemed[tokenId]这个常量标记后台查询只认redeemed[tokenId]和couponTypes[typeId].redeemedCount不要依赖余额。事件索引的延迟问题给查询接口加一个 30 秒重试即可。5.4 批量发 10 张券的 Gas 比预期高一倍现象运营人员用脚本循环调 10 次mintCoupon发现总共消耗的 Gas 比单次调 10 倍还多。原因每次mintCoupon都要执行一次claimedmapping 写操作和一次_mint存储写操作Solidity 里同一个函数在循环里重复执行SSTORE 成本不会因为写入对象不同而降低。解决给同一个用户发多种券时合约增加一个mintCouponBatch(uint256[] calldata typeIds)函数内部先遍历校验所有类型再一次性_mintBatch。给多个不同用户发同一种券时不需要批量因为每个用户的地址状态写入本来就必须单独发生。5.5 前端显示的面额从 0.2 变成 2e-7现象前端把合约返回的faceValue直接用 JavaScript 数字输出显示0.0000000000000000002之类的值。原因faceValue和虚拟货币金额一样按 wei 存储直接用Number(faceValue)转一遍会丢失精度再除以 1e18 就得到乱七八糟的结果。解决前端展示永远用formatEther(faceValue)配合ethers的 BigInt 处理不要自己写除法。比较面额大小、拼订单金额时也全部用 BigInt不要碰 JavaScript 原生number。注意如果你用了 ethers v5parseEther和formatEther都在ethersproject/units包下v6 则直接从ethers导出。升级版本后这两处 API 的差异最容易破坏现有代码。6. 进阶把验真做成一个接口让任意商户都能验证你的券前五章做完系统已经能用但离通用还差一步商户怎么验证顾客手里的券总不能让每个商户都去部署一套合约。我的做法是在合约里加一个 view 函数把这张券真不真、有没有用过变成一个可以直接调用的接口。任何商户只要拿到合约地址就能在极短时间内确认一张券的完整事实。function verifyCoupon( uint256 tokenId ) external view returns ( bool valid, string memory name, uint256 faceValue, uint256 expireAt, bool isRedeemed, address owner ) { uint256 typeId tokenId 32; CouponType storage ct couponTypes[typeId]; if (ct.maxSupply 0) { return (false, , 0, 0, false, address(0)); } address currentOwner address(0); try this.ownerOf(tokenId) returns (address ownerAddr) { currentOwner ownerAddr; } catch { // tokenId 已销毁查不到 owner } return ( true, ct.name, ct.faceValue, ct.expireAt, redeemed[tokenId], currentOwner ); }这个接口把最常用的校验都集中在一起类型存在、面额、过期时间、是否已核销、当前持有人。商户在收银台输入券码即 tokenId调用这个函数返回结果直接决定是否接受这张券。注意ownerOf不是 ERC-1155 的标准方法所以我在合约里把它单独实现为根据balanceOf遍历持有者的辅助函数如果只给无转赠场景用也可以省略 owner 返回值直接返回redeemed更省事。一个我养成习惯的细节每次部署新合约我都会写一个冒烟脚本把创建类型、领券、转赠、核销、验真五个动作连起来跑一遍输出每一步的交易哈希和 tokenId 变化。这样上线前不用去区块浏览器里一条一条翻记录。如果你接的是测试网顺手把verifyCoupon的返回值打印出来发给对接的商户开发他们对接后端时能少问你一半问题。这套系统的边界我也说一下它不适合单纯追求核销速度的场景比如便利店排队高峰期每秒几十笔核销以太坊主网单笔确认要十几秒体验会打折扣。但如果你想做的是品牌会员券、票据凭证这类低频高价值场景让券本身的真实性变为可验证的事实那这个方案能省掉大量对账和防伪成本。我现在的习惯是任何时候准备接一个新项目都先把验真接口长什么样写在文档第一页后面所有前端和后端都围绕这个接口展开少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取