资讯动态

OTC承兑平台系统源码解析:从业务闭环到部署避坑

发布时间:2026/10/8 2:29:03 来源:尧图企业网站定制
简介这是一套基于Java实现的OTC承兑平台系统源码完整覆盖数字货币场外交易所需的Web端与Android移动端应用。项目定位明确既能作为计算机相关专业的毕业设计参考也适合希望深入交易系统开发的中高级开发者。压缩包共2000个文件总体积约24.62MB文件类型以PHP脚本、JavaScript脚本、LESS样式、日志、Markdown文档、HTML页面和JSON配置为主并混有数据库SQL脚本、Excel表格、Android安装包及图文资源基本涵盖了后端业务处理、前端交互页面、数据库初始化与部署配置等环节目录结构清晰便于按模块查询学习。目前已有632人学习浏览。该源码的实战价值在于可直接对照完整的用户认证、订单撮合、钱包管理、价格计算等模块理解OTC交易从挂单、承兑到结算的全流程通过Android与Web端资源还能掌握多端交互、接口定义和数据表设计要点。源码中携带的第三方组件与工具配置也可用于二次开发或作为毕业设计的基础代码。1. OTC承兑平台系统源码为什么毕业设计和快速交付都盯着它OTC承兑平台这几个字听着像币圈专属其实它抽掉外壳就是一个标准的资金撮合系统用户发起法币买入或卖出需求承兑商接单平台负责订单流转和资金担保。我第一次拆这套源码时最意外的是它没有做成前后端分离的教学残品而是把后端Java工程、数据库脚本、安卓端全部打在一个压缩包里解压后可以直接导入IDE跑通流程。对做Java后端毕业设计的人来说最难的不是CRUD而是订单-承兑商-资金流水-申诉这条闭环链路怎么设计而这份源码恰好把主链路实现了。它适合三类人一是准备Java毕设但时间紧的学生二是想快速搭一套场外交易Demo的中小团队三是想在安卓端加支付或钱包模块的开发者。接下来我会按业务场景→后端工程→多端部署→踩坑→扩展的顺序把它拆开讲透。2. 先从业务场景讲清承兑商与用户的交易闭环很多人拿到源码第一件事就是点开运行看页面结果越看越懵一会儿是挂单一会儿是承兑商一会儿又是申诉冻结。我建议先别碰代码花一点时间把交易闭环画出来整个系统的架构瞬间就清楚了。这章不做技术堆砌只讲清楚这套源码要解决什么问题、有哪些角色、订单从生到死走哪些状态。2.1 承兑平台的最小可用流程一个最简OTC承兑流程可以压缩成四步挂单、接单、付款、确认。我用一个具体例子说明用户A想在平台上用法币买1000 USDT于是发起一张买单系统生成一条待接单的挂单记录承兑商B看到这张单后接单把自己手里的USDT冻结用户A按照订单页面展示的收款方式把人民币转给承兑商BA点击确认已付款B验证到账后点击确认放币系统把USDT从B的账户划到A的账户。如果任何一方质疑订单进入申诉状态由管理员介入仲裁。这套流程看起来简单但落到代码里有三个绕不开的设计点。第一是资产冻结承兑商接单后他名下的USDT不能又被别人拿去消费所以必须有一条独立的冻结记录。第二是确认节点用户确认已付款之后订单不能直接被置为完成必须等承兑商确认收款否则会出现用户说付了、承兑商说没收到的死锁。第三是超时处理如果承兑商长时间不处理或者用户付款后不点确认得有定时任务兜底自动取消或转人工。我拆的这套源码里主流程是通的但超时和部分边界逻辑往往是简写的所以后面会有专门一章讲避坑。2.2 核心角色与权限模型这个系统里一共有三类角色普通用户、承兑商、平台管理员。普通用户只能做挂单、接单如果是卖币方、发起申诉承兑商除了能接单还要能管理自己的库存和收款方式管理员则拥有最高权限能审核承兑商、冻结账户、处理申诉。权限模型在代码里最直接的体现就是角色枚举和接口注解。举个典型场景一个普通用户如果调了承兑商接单的接口后端必须直接拒绝否则任何人注册个账号就能把别人的单子吃掉资金风险极大。我一般会建议源码里把权限校验放在两个层面第一层是Spring Security或拦截器对URL做角色匹配第二层是Service层再判断当前用户和订单的归属关系。比如接单操作要求当前用户角色是承兑商并且这张单的接单承兑商id字段必须为空下单操作要求当前用户的资产状态正常没有被冻结。权限这块这套源码通常不会做得特别细但基本角色的登录和接口过滤是有的二次开发时需要自己加细粒度控制。2.3 订单状态机的设计状态机是OTC系统的核心也是毕设答辩时导师最爱问的部分。我把这套源码里常见的订单状态整理成一张转换表见下面这张表。状态字段一般放在订单表里用tinyint或varchar存储代码里对应枚举类。当前状态触发事件下一状态数据变更待接单承兑商接单待付款冻结承兑商库存记录接单时间待付款用户点击已付款待确认记录付款时间、上传凭证待确认承兑商确认收款已完成划转资产扣除手续费待确认承兑商拒绝收款未到账申诉中双方进入申诉流程待付款超时未付款已取消解除冻结恢复库存申诉中管理员仲裁已完成/已取消按仲裁结果划转或退款这张表看着简单但实际开发中踩坑最多的是并发改状态。理论上待付款只能被用户确认已付款或系统超时取消但如果你用普通的update语句去改很可能在并发下两个操作同时读到旧状态一个改成了待确认另一个改成了已取消订单就出现两条分支。正确做法是在update语句里带上当前状态条件比如UPDATE order SET status 待确认 WHERE id ... AND status 待付款再用int rows db.update()判断受影响行数如果rows等于0说明状态已经被其他事务改了需要返回操作失败请刷新重试。这套源码里大部分状态切换用的是这种乐观锁更新但也有个别地方没加这也是后面避坑章节的一个重要关注点。设计状态机时还要考虑状态字段的命名。我习惯用英文小写加下划线比如pending_pay、paid、completed、canceled尽量避免中文状态值因为安卓端要做多语言或者调试过滤时英文状态字符串直接可读可查不容易出现编码问题。3. 拆解Java后端工程从Spring Boot到数据库持久层上一章把业务逻辑梳理清楚后再回来看这套源码就会有一种原来如此的感觉。这一章我们直接进入代码层面讲后端工程的结构、关键表设计和接口实现。不管你是拿它做毕设还是二次开发建议都按这个顺序去读先看目录结构再建表最后追接口。3.1 工程模块划分与目录结构我拆过的Java源码里OTC系统多数是单模块Spring Boot MyBatis Plus MySQL Redis的结构偶尔会拆成multi-module。单模块的好处是打包简单适合毕设答辩跑Demo缺点是如果项目要持续演进代码分层不够清晰。下面是一个典型的单模块目录结构你用Idea打开rar解压后的目录应该能看到类似的组织方式。otc-platform/ ├── src/main/java │ └── com/example/otc │ ├── common/ # 公共类统一返回结果、异常、常量 │ ├── config/ # Spring配置redis、mybatis、cors │ ├── controller/ # 接口层用户、承兑商、订单、资产 │ ├── service/ # 业务层订单、资金、申诉 │ │ └── impl/ # 业务实现 │ ├── mapper/ # MyBatis持久层接口 │ ├── entity/ # 数据库实体 │ ├── dto/ # 请求/响应参数对象 │ └── utils/ # JWT生成、MD5、金额计算等工具类 ├── src/main/resources │ ├── mapper/ # MyBatis的XML映射文件 │ ├── application.yml # 主配置 │ ├── application-dev.yml # 开发环境配置 │ └── sql/ │ └── otc_init.sql # 初始化数据库脚本 └── pom.xml这个结构的核心是service/和mapper/两个包。业务逻辑不要写在controller里这是很多毕设源码的通病但这份源码基本做到了分层。你在读代码时重点看OrderServiceImpl和AssetServiceImpl这两个类订单状态流转和资金划转都在这两个类里。common/里有个统一返回值类一般是ResultT包含code、message、data三个字段前后端约定用0表示成功非0表示业务错误码。这个设计在安卓端联调时很省心我们后面的章节会用到。3.2 关键表结构订单表、承兑商表、资金流水表数据库脚本通常叫otc_init.sql打开后会看到大概十几张表但对理解系统最核心的就是三张订单表、承兑商商户表、资金流水表。我把三张表的精简建表语句写出来并加上索引设计你可以直接对照源码里的实体类看。-- 订单表存储OTC挂单和接单记录 CREATE TABLE otc_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号业务上唯一, type TINYINT NOT NULL COMMENT 1买入法币2卖出法币, user_id BIGINT NOT NULL COMMENT 发单人用户id, acceptor_id BIGINT DEFAULT NULL COMMENT 承兑商用户id接单后写入, coin_code VARCHAR(16) NOT NULL COMMENT 币种如USDT, currency VARCHAR(16) NOT NULL COMMENT 法币如CNY, amount DECIMAL(18,6) NOT NULL COMMENT 数量, price DECIMAL(18,6) NOT NULL COMMENT 单价, fee_rate DECIMAL(6,4) DEFAULT 0.0010 COMMENT 手续费比例, status VARCHAR(16) NOT NULL DEFAULT pending_pay COMMENT 状态见状态机, pay_time DATETIME DEFAULT NULL COMMENT 用户确认付款时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_status (status), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 承兑商表展示商户收款信息和接单能力 CREATE TABLE otc_acceptor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 关联用户表, name VARCHAR(64) NOT NULL COMMENT 商户显示名称, pay_type VARCHAR(16) NOT NULL COMMENT 收款方式bank/wechat/alipay, pay_account VARCHAR(128) NOT NULL COMMENT 收款账号或二维码链接, stock DECIMAL(18,6) NOT NULL DEFAULT 0.000000 COMMENT 当前可用库存, freeze_stock DECIMAL(18,6) NOT NULL DEFAULT 0.000000 COMMENT 冻结库存, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用0禁用, KEY uk_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 资金流水表所有资产变动必须落流水 CREATE TABLE otc_cash_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, order_no VARCHAR(32) DEFAULT NULL COMMENT 关联订单号, change_type VARCHAR(32) NOT NULL COMMENT freeze/unfreeze/transfer/refund, coin_code VARCHAR(16) NOT NULL, amount DECIMAL(18,6) NOT NULL COMMENT 正数增加负数减少, balance_after DECIMAL(18,6) NOT NULL COMMENT 变动后余额, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_coin (user_id, coin_code), KEY idx_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;三张表的逻辑关系是订单表记录交易内容承兑商表显示当前库存和收款信息资金流水表是账本任何一笔资产变化都不能只改余额不记流水。这里最容易被忽略的是otc_cash_log里的balance_after字段。很多源码写着写着就只记amount不记变动后的余额结果对账时根本无法验证余额是否算对了只能靠猜。我建议你拿到这份源码后先检查生成流水的代码里是否有balance_after如果没有后面避坑章节会告诉你这个账最后会乱成什么样。3.3 接口层规范与权限控制接口层通常会用JWT做身份认证请求头带Authorization: Bearer token后端用拦截器解析出当前用户id放到ThreadLocal里。下面是一段典型的抢单接口Controller代码我加了注释你对照源码能快速理解流程。RestController RequestMapping(/api/order) public class OrderController { Autowired private OrderService orderService; /** * 承兑商接单 * param orderId 订单id * return 统一返回 */ PostMapping(/accept) public ResultString accept(RequestParam Long orderId, RequestHeader(Authorization) String token) { // 解析token得到当前用户id实际会封装在自定义注解里 Long currentUserId JwtUtil.getUserId(token.replace(Bearer , )); OrderAcceptDTO dto new OrderAcceptDTO(); dto.setOrderId(orderId); dto.setAcceptorId(currentUserId); // 核心业务逻辑在service里 orderService.accept(dto); return Result.success(接单成功); } }这段代码的要点在orderService.accept(dto)。真正有技术含量的不是Controller而是Service里必须同时做三件事更新订单状态、冻结承兑商库存、记一笔冻结流水。这三个操作要么全部成功要么全部失败所以需要事务控制常见做法是在accept方法上打Transactional。但要注意光是打Transactional还不够如果update语句用了行锁SELECT ... FOR UPDATE事务里锁住的资源要尽快释放不要在事务内调用远程接口或者做耗时的网络操作否则并发量一上来库存表就卡住了。我一般会把状态更新和库存冻结分开成两个短事务但那样又带来一致性窗口增大的问题需要引入消息补偿。这份源码大概率用的是单事务作为毕业设计完全够用生产环境则需要再细化。4. 把安卓端和Web端跑起来环境准备与本地部署拿到rar压缩包第一件事不是改代码而是把整套环境跑通。很多读者卡在部署上不是代码问题而是环境变量和工具链对不上。这一章手把手讲后端、安卓端、联调三步跑起来每一步都给出参数和排查思路。4.1 后端本地启动参数后端是一个标准的Spring Boot应用启动前要准备MySQL 5.7或8.0、Redis 6.x、JDK 1.8或11。先把sql/otc_init.sql导入数据库然后修改application-dev.yml里的连接信息。下面是最小配置示例。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/otc_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: 127.0.0.1 port: 6379 database: 0 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true jwt: secret: your-secret-key-your-secret-key-123456 expire-minutes: 120这里有几个参数容易让人翻车。serverTimezone必须写成Asia/Shanghai如果你用UTC订单时间会差8小时对账对到怀疑人生。map-underscore-to-camel-case必须开否则数据库里的order_no映射不到orderNo字段。jwt.secret至少32位太短容易被爆破但也不要改完忘记得重新登录。都改好后在项目根目录执行mvn spring-boot:run看到Started Application in X seconds就是启动成功。如果启动报错先看两样东西MySQL驱动版本和JDK版本有些源码是用Java 8写的放到Java 17环境会直接反射报错。4.2 安卓端编译与模拟器调试安卓端一般是独立的module或单独目录导入Android Studio时注意Gradle版本要和你的IDE匹配。打开build.gradle检查compileSdk和buildToolsVersion然后配置本地签名或直接跑debug。下面是一个常见的编译流程。# 在安卓工程根目录下 # 先构建debug包 ./gradlew assembleDebug # 如果提示SDK location找不到在local.properties里指明SDK路径 sdk.dir/Users/yourname/Library/Android/sdk # 构建成功后生成的apk在 app/build/outputs/apk/debug/app-debug.apk构建出来的APK装到模拟器或真机后第一件事是确认登录接口能通。模拟器访问宿主的后端地址有讲究Android Studio自带的模拟器里localhost指向模拟器自己想访问电脑上的8080端口要用10.0.2.2。所以源码里如果写死了http://127.0.0.1:8080/api你需要在模拟器里改成http://10.0.2.2:8080/api。真机调试则要和电脑在同一个局域网填电脑的局域网IP。我见过太多人卡在这一步手机端一直转圈其实是后端接口地址没配对。4.3 联调时的环境变量配置前后端联调阶段最常遇到的异常是返回码为401或403。401是token过期或无效检查系统时间是否正确JWT校验依赖时间戳手机时间差太多会导致签名失效。403是权限不足排查思路是先确认当前登录用户的角色再对照接口需要的角色。比如接单接口要求承兑商角色你拿普通用户登录去调自然被拦。为了方便切换后端地址我通常会建议在安卓源码里建一个Config.java统一放接口域名不要散落在各个Activity里。这样联调时只需要改一处打包前再改成正式域名。下面是一段示例。public class Config { // 开发环境指向本机真机改成局域网IP public static final String API_BASE http://10.0.2.2:8080/api; // 请求超时时间秒 public static final int HTTP_TIMEOUT 10; }改完地址重启App再走一遍挂单-接单-确认付款-完成流程如果每一步都对得上恭喜你这套源码的骨架你已经跑通了。此时再去看代码你会发现自己已经能分清哪些是核心逻辑哪些是锦上添花。5. OTC系统开发避坑刷单、风控与资金账务的五条血泪记录这个系统跑通Demo不难难的是上线后不被资金问题搞死。我拆完源码、也见过别人把这个系统拿去做生产环境总结出五条真实踩坑记录每条都是先讲现象再讲原因和解决。你在二次开发时务必对照检查。5.1 并发重复点击导致同一订单被抢两次现象两个承兑商几乎同时点接单结果数据库里同一张订单的acceptor_id被写入了两次订单状态变成待付款但库存冻结也执行了两次承兑商A和B都认为自己接到了单。原因接单的SQL是UPDATE otc_order SET acceptor_id #{id} WHERE id #{orderId}这个语句在并发下没有校验当前status是否为待接单也没有唯一性约束两个事务都能拿到同一行然后先后提交覆盖。解决把更新语句改成带状态条件的乐观锁写法并检查受影响行数。正确语句如下。UPDATE otc_order SET acceptor_id #{acceptorId}, status pending_pay, accepted_at NOW() WHERE id #{orderId} AND status pending_accept AND acceptor_id IS NULL同时还要注意即使更新成功了后续的库存冻结失败导致事务回滚那acceptor_id也会跟着回滚所以要先冻结库存再更新订单或者保证两个操作在同一个事务里。我在源码里见过反过来的顺序结果订单被占用、库存没冻上用户的币可以反复卖。5.2 法币入金回调丢失导致余额不符现象用户通过银行或第三方支付付款后平台的零钱或币种余额没有增加订单卡在待付款用户投诉钱扣了但资产没到账。原因OTC系统往往依赖支付回调来触发入金但如果回调接口没有做幂等或者回调时网络超时导致丢失系统就永远不会执行加余额操作。解决引入一个pay_callback_log表回调进来先查订单号是否已处理过没处理过才继续。同时后台提供手动补单按钮管理员输入订单号重新触发入金流程。我在自己的项目中强制要求回调处理逻辑必须用唯一订单号做幂等键而且入金和订单状态更新要放在同一个事务里。下面是一个典型的幂等校验代码逻辑。Transactional public void handlePayCallback(String orderNo, BigDecimal amount) { PayCallbackLog log payCallbackLogMapper.selectByOrderNo(orderNo); if (log ! null) { // 已处理过直接返回防止重复入金 return; } // 记录回调日志 payCallbackLogMapper.insert(orderNo); // 增加用户资产 assetService.increase(userId, coinCode, amount); // 更新订单状态 orderService.updateStatus(orderNo, paid); }这段代码里selectByOrderNo要命中唯一索引否则并发回调还是会插入两条日志。很多毕设源码会省略这个表我强烈建议你加上因为它还能当审计日志用。5.3 恶意用户超时申诉系统卡在已付款状态现象用户点击已付款后承兑商迟迟不确认用户超时后发起申诉但系统没有定时任务去处理订单就永远停在待确认挂在那里资金也冻着。原因源码里把超时处理写成了写死的定时任务但部署时没配置Scheduled触发的开关或者任务执行频率太低导致申诉状态没有被流转。解决第一确认SpringBootApplication上有没有EnableScheduling第二给订单加一个expire_time字段在用户确认付款时把超时时间设置为当前时间加30分钟第三定时任务每5分钟扫描一次把超过expire_time且状态为pending_confirm的订单自动转成appealing并通知管理员。这里要注意定时任务要加分布式锁否则部署多个实例时同一张订单会被处理两遍。我一般用Redis的setnx做简单锁key是订单号加任务名过期时间设为1分钟。5.4 数据库库存金额与资金流水表不一致现象系统跑了几天后对账发现承兑商表里的库存减去冻结库存跟流水表里计算出来的结余对不上差个一分几分。原因代码某处更新余额时没有记流水比如管理员手动调整库存或退款时直接改了stock字段没走流水表。还有一处是更新订单状态和更新库存不在一个事务里中间进程崩溃导致只改了订单没改库存。解决凡是涉及资产变化必须同时插入otc_cash_log。可以写一个统一的资产服务类所有加钱、扣钱、冻结都走这个类不要允许别的地方直接update otc_acceptor。另外每天做一个对账任务把otc_cash_log按用户和币种汇总对比总资产表的余额不一致就告警。这个对账任务在毕设里不做也许能蒙混过关但作为二次开发项目这是救命的基础设施。5.5 安卓端回调签名校验被绕过现象安卓端在收到服务器推送的通知时直接信任了数据没有校验签名。结果有人通过抓包修改通知内容把订单状态改成已完成骗过客户端展示。原因安卓端和后端通信虽然有HTTPS但App不具备双向验证客户端无法确认收到的消息真的来自自己的服务器。如果只是靠HTTP接口轮询还好但如果是WebSocket或推送没有签名就是空子。解决后端给推送消息加签名签名方式可以简单用HMAC-SHA256参与签名的字段包括订单号、状态、时间戳。安卓端收到通知后用内置的secretKey重新计算摘要不一致就丢弃。很多源码不会带这个逻辑你需要自己在网络层封装时加上。下面是一个检查签名的Java示例。public static boolean checkSign(String orderNo, String status, long timestamp, String sign) { String content orderNo | status | timestamp; String calcSign HmacSHA256(content, your_secret_here); return calcSign.equals(sign); }注意密钥不能写明文在客户端代码里否则反编译就泄露了。毕设可以不藏那么深但实践里至少要用NDK或加密配置混淆。6. 扩展与验证把OTC系统源码改造成带风控的可用项目如果你只是交毕设把源码跑通、写进论文就够了。但如果想把这个工程变成一块可展示的敲门砖我建议做三件事补验证脚本、加风控规则、加固账务一致性。这章不展开太多给几个具体方向。6.1 用现有源码做毕业设计答辩时的验证脚本答辩现场最怕的是点半天不出数据。我习惯准备一个Postman可导入的脚本把挂单、接单、确认付款、查询余额每一步的请求都保存好参数用环境变量管理。演示时按顺序发送每一步的响应里都带上订单号或余额变化导师一看就觉得系统是真实可跑的。如果安卓端来不及美化直接用接口文档配合Postman演示后端能力效果比模拟器里卡顿的App要好很多。6.2 给源码加一层简单的风控规则真正的OTC平台拼的不是功能而是风控你可以给源码加一个RiskCheckService在接单前检查该承兑商今日接单数是否超过上限、当前是否已有未完成订单、用户的注册时间是否少于某天。这些规则写在Service入口就行不必改动核心流程。加完之后系统的防御力立刻升级论文里也有内容可写。6.3 从源码到可用项目的最后一步最后一步是补对账和日志。你可以在Config里加一个开关开启后每天凌晨统计各用户资产与流水差异。我从拆这套源码开始只要涉及资金系统都会强制自己走一遍事务一致性检查每个业务操作至少找到一条对应的资金流水找不到就认为代码有bug。这个习惯帮我避开了至少三次上线事故希望也帮到你。如果你也把这套OTC源码改到了这一步它就不再是别人写的Demo而是一个有你自己设计印记的项目了。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑