简介:六爻排盘是传统文化数字化中颇具代表性的场景,其本质是将阴阳爻、五行生克等规则转化为可计算的数据模型。二进制表示卦象、随机数模拟铜钱正反,是程序实现起卦逻辑的基础。后端采用Spring Boot构建分层服务,通过静态映射表完成六十四卦、世应、六亲等复杂信息的快速检索,并以RESTful接口向前端输出标准化排盘结果。微信小程序负责摇卦交互动画与卦象渲染,前后端职责清晰。工程实践层面还涉及随机序列重复、日柱基准校验以及低版本iOS兼容性等问题的处理,为Java开发者及传统文化类小程序项目提供了一套从算法建模到部署落地的完整参考。 启动这个项目之前,我在技术社群里翻到好几条类似的提问:有没有 Java 写的六爻排盘开源项目?市面上的回答大多给的是 Python 脚本或者纯前端 Demo,能直接在微信小程序里落地、后端还能成套复用的,几乎没有。去年我花了一周时间,从起卦逻辑到排盘模块,再从 Spring Boot 接口写到小程序界面,把整套完整源码跑通了。今天这篇就把核心设计思路和关键实现拆开来讲,适合正在做传统文化类小程序、或者想用 Java 练手完整业务链的开发者参考。
1. 六爻起卦的逻辑模型:从三枚铜钱到二进制数据
1.1 爻是什么,卦又是什么
六爻占卜里最小的单位是“爻”,每个爻只表达两种状态:阴和阳。六个爻从下往上叠加,形成一个卦象。这个结构天然适合程序表达——阴是 0,阳是 1,一个卦就是一行六位二进制数字。
但六爻不止“静态卦象”这一层。传统起卦过程中,每一爻还会额外产生一个“动”或“静”的属性:老阴和老阳属于动爻,动爻会变,变完之后得到一个新的卦,叫变卦。所以一次完整的六爻起卦,数据上至少包含三块内容:
- 本卦:六个爻的阴阳组合
- 动爻:哪些位置发生了变化
- 变卦:本卦中动爻反转后得到的新卦
这一层拆清楚之后,后面的数据模型和接口设计都会顺畅很多。
1.2 三枚铜钱在程序里对应什么随机过程
传统摇卦用三枚铜钱,摇六次,每次得到一爻。三枚铜钱的正反面组合只有四种结果:
| 组合情况 | 点数 | 名称 | 动变属性 |
|---|---|---|---|
| 三个字(无背) | 6 | 老阴 | 动爻,变阳 |
| 一背两字 | 7 | 少阳 | 静爻 |
| 两背一字 | 8 | 少阴 | 静爻 |
| 三背(无字) | 9 | 老阳 | 动爻,变阴 |
从概率上看,7 和 8 的出现概率各为 3/8,6 和 9 各为 1/8,这正好对应传统六爻起卦的“阴阳平衡、动少静多”原则。程序里模拟一枚铜钱只需要生成一个 0 或 1 的随机数,然后统计三枚的总和,就可以映射到上表。
1.3 从六次结果组装本卦和变卦
第一次摇出来的爻放在最底下,第六次摇出来的爻放在最上面。Java 里可以用一个长度为 6 的整数数组来存六爻数据,数组下标 0 表示初爻,下标 5 表示上爻。
组装本卦的规则很简单:6 和 8 视为阴爻,记 0;7 和 9 视为阳爻,记 1。变卦则看动爻:值为 9 的阳爻变阴,值为 6 的阴爻变阳,等于做了一个异或操作。
这一步的核心代码很直白:
public class TossResult { private int[] originalValues = new int[6]; // 存 6/7/8/9 private int[] benGua = new int[6]; // 本卦,0阴1阳 private int[] bianGua = new int[6]; // 变卦,0阴1阳 private List<Integer> movingPositions = new ArrayList<>(); public void buildGua() { for (int i = 0; i < 6; i++) { int v = originalValues[i]; // 6老阴、7少阳为阴;8少阴、9老阳为阳 benGua[i] = (v == 6 || v == 8) ? 0 : 1; // 动爻反转得到变卦 if (v == 6 || v == 9) { bianGua[i] = benGua[i] == 0 ? 1 : 0; movingPositions.add(i); } else { bianGua[i] = benGua[i]; } } } }到这一步,起卦核心已经完成百分之四十了。接下来要处理的是“这个卦叫什么、五行属性是什么、世应落在哪里”,这些信息全都来自卦象数据本身。
2. 后端架构:Spring Boot 起卦引擎的分层设计与实现
2.1 小程序不能让 Java 直接跑,前后端职责怎么切
微信小程序的前端运行在 JavaScript 引擎里,没法直接执行 Java 代码。所以“Java 代码实现小程序”的准确含义是:Java 负责后端接口和排盘引擎,小程序负责交互和渲染。
我选择 Spring Boot 2.7 作为后端框架,原因很简单:
- 内置 Tomcat,打一个 jar 包就能部署
- Controller 层做接口转发非常省事
- 后续如果需要接数据库存历史记录,JPA 或 MyBatis 都能无缝整合
整个后端分为三层:
- Controller 层:接收小程序请求,校验参数,返回封装结果
- Service 层:起卦、排盘、计算的业务逻辑
- Model 层:爻、卦、排盘结果的数据模型
2.2 起卦引擎的接口设计
前端有两种起卦交互方式,一种是手动每次摇一爻,另一种是一次性自动起卦。我的后端接口同时支持这两种方式。
手动摇卦接口每次只接收一枚爻的结果,前端摇完六次后统一请求/api/paipan完成排盘。自动起卦则直接调/api/auto,后端一次性生成六爻并返回完整排盘数据。
@RestController @RequestMapping("/api/liuyao") public class DivinationController { @PostMapping("/toss") public Result<TossResponse> toss() { int value = DivinationUtil.tossThreeCoins(); return Result.success(new TossResponse(value)); } @PostMapping("/paipan") public Result<PaipanResult> paipan(@RequestBody List<Integer> values) { if (values == null || values.size() != 6) { return Result.fail("必须提供6个爻的数据"); } return Result.success(paipanService.buildFullResult(values)); } @PostMapping("/auto") public Result<PaipanResult> auto() { int[] values = DivinationUtil.tossSixTimes(); return Result.success(paipanService.buildFullResult(values)); } }这里注意一个设计细节:/toss接口每次返回的是 6 到 9 之间的整数,而不是前端直接算出阴阳,这样前端只负责展示动画,所有判定逻辑都收口在后端,避免因为客户端版本差异导致排盘结果不一致。
2.3 随机性实现:这题没你想的那么简单
用Math.random()生成三枚铜钱完全够用,但有两个细节会影响体验。
第一是线程安全。Math.random()内部用了Random的静态实例,并发场景下没问题。但如果用java.util.Random实例去并发调用,可能出现竞争问题。我的做法是用ThreadLocalRandom.current().nextInt(0, 2),性能和安全性都更好。
第二是随机序列的用户感知问题。六爻起卦一次生成 6 个爻,出现完全相同卦象的概率是 1/64,其实并不低。很多用户会在短时间内反复起卦测试,如果连续两次摇到同一个卦,就会觉得程序“有 bug”。我在前端做了提示,告诉用户连续摇到相同卦象是正常的随机结果,同时后端记录每一次起卦的完整参数,方便排查。
摇卦的核心实现:
public class DivinationUtil { public static int tossThreeCoins() { int yangCount = 0; for (int i = 0; i < 3; i++) { yangCount += ThreadLocalRandom.current().nextInt(0, 2); } // 0个阳面 => 6老阴;1个阳面 => 7少阳;2个阳面 => 8少阴;3个阳面 => 9老阳 int[] mapping = {6, 7, 8, 9}; return mapping[yangCount]; } public static int[] tossSixTimes() { int[] values = new int[6]; for (int i = 0; i < 6; i++) { values[i] = tossThreeCoins(); } return values; } }2.4 六十四卦映射表与卦辞数据
得到本卦的六个 0/1 数据之后,下一步要映射到具体的卦名。这一步我强烈建议用静态映射表,不要试图用算法去推算卦名。六十四卦的排列虽然有规律,但其中涉及八卦相叠的规则,运行时推算的代码量反而比查表更大。
映射表的结构:
public class GuaDict { // key: 从初爻到上爻的0/1字符串,value: 八卦信息字段 public static final Map<String, String[]> HEXAGRAM_INFO = new HashMap<>(); static { // 数据格式: {卦名, 所属宫, 五行属性, 世爻位置, 应爻位置} HEXAGRAM_INFO.put("111111", new String[]{"乾为天", "乾宫", "金", "5", "2"}); HEXAGRAM_INFO.put("000000", new String[]{"坤为地", "坤宫", "土", "5", "2"}); // 剩余62卦类似... } public static String getGuaName(String benGuaKey) { String[] info = HEXAGRAM_INFO.get(benGuaKey); return info == null ? "未知卦" : info[0]; } }查表的好处有三个:一是运行时性能最好;二是便于核对排盘结果正确性,遇到疑问直接对表检查;三是后续要扩展卦辞、爻辞内容,只需要在这张表里加字段即可。我在项目里把每个卦的卦名、卦辞、大象辞都存到了同一张表,避免散落多处。
3. 排盘不只是卦象:世应、六亲、六神的计算模块
3.1 世爻和应爻:根据八宫卦序定位
世应位置是排盘信息里最直观的两个标记,但它们的定位逻辑相对隐蔽。传统上,世应是根据“八宫卦序”来确定的,每个宫有八个卦,分别对应不同世位:
- 本宫卦(上世):世在第六爻
- 一世卦:世在第一爻
- 二世卦:世在第二爻
- 三世卦:世在第三爻
- 四世卦:世在第四爻
- 五世卦:世在第五爻
- 游魂卦:世在第四爻
- 归魂卦:世在第三爻
应爻位置和世爻位置之间隔两个爻位,也就是对应的规律:世在初爻则应四爻,世在二爻则应五爻,世在三爻则应上爻,以此类推。代码里计算应爻直接用公式(shi + 3) % 6。
用静态映射表存储世爻位置,通过查表获取:
public class PaipanService { public int getShiPosition(String benGuaKey) { String[] info = GuaDict.HEXAGRAM_INFO.get(benGuaKey); if (info == null) { return -1; } return Integer.parseInt(info[3]); } public int getYingPosition(int shiPosition) { return (shiPosition + 3) % 6; } }3.2 纳甲与五行:给每个爻配上地支
要算六亲,必须先知道每个爻的地支五行。这里的规则是“纳甲法”:不同的卦(准确说是不同宫),六个爻会配上特定的十天干和十二地支。
以乾宫八卦和坤宫八卦为例:
| 卦宫 | 内卦三爻(初、二、三) | 外卦三爻(四、五、上) |
|---|---|---|
| 乾宫 | 子、寅、辰 | 午、申、戌 |
| 坤宫 | 未、巳、卯 | 丑、亥、酉 |
| 震宫 | 子、寅、辰 | 午、申、戌 |
| 巽宫 | 丑、亥、酉 | 未、巳、卯 |
| 坎宫 | 寅、辰、午 | 申、戌、子 |
| 离宫 | 卯、丑、亥 | 酉、未、巳 |
| 艮宫 | 辰、午、申 | 戌、子、寅 |
| 兑宫 | 巳、卯、丑 | 亥、酉、未 |
地支五行属性是固定的:子水、丑土、寅木、卯木、辰土、巳火、午火、未土、申金、酉金、戌土、亥水。
这一块在项目里直接用两张静态映射表完成,不需要计算:
public class NaJiaUtil { private static final Map<String, String[]> GONG_ZHI = new HashMap<>(); static { // 乾宫: 从初爻到上爻对应的地支 GONG_ZHI.put("乾宫", new String[]{"子", "寅", "辰", "午", "申", "戌"}); GONG_ZHI.put("坤宫", new String[]{"未", "巳", "卯", "丑", "亥", "酉"}); // 其他宫的纳甲数据类似... } private static final Map<String, String> ZHI_WUXING = new HashMap<>(); static { ZHI_WUXING.put("子", "水"); ZHI_WUXING.put("丑", "土"); ZHI_WUXING.put("寅", "木"); ZHI_WUXING.put("卯", "木"); ZHI_WUXING.put("辰", "土"); ZHI_WUXING.put("巳", "火"); ZHI_WUXING.put("午", "火"); ZHI_WUXING.put("未", "土"); ZHI_WUXING.put("申", "金"); ZHI_WUXING.put("酉", "金"); ZHI_WUXING.put("戌", "土"); ZHI_WUXING.put("亥", "水"); } public static String getZhi(String gong, int position) { return GONG_ZHI.get(gong)[position]; } public static String getWuXing(String zhi) { return ZHI_WUXING.get(zhi); } }3.3 六亲计算:五行生克的四种关系
六亲是排盘结果里最常被用户关注的一栏,包括兄弟、父母、子孙、官鬼、妻财。它们的判断依据是本卦所属宫的五行和当前爻的地支五行之间的生克关系。
以宫五行为“我”:
- 与我同类:兄弟
- 生我者:父母
- 我生者:子孙
- 克我者:官鬼
- 我克者:妻财
五行生克关系表:
| 我方五行 | 生我 | 我生 | 克我 | 我克 |
|---|---|---|---|---|
| 金 | 土 | 水 | 火 | 木 |
| 木 | 水 | 火 | 金 | 土 |
| 水 | 金 | 木 | 土 | 火 |
| 火 | 木 | 土 | 水 | 金 |
| 土 | 火 | 金 | 木 | 水 |
Java 实现时可以写成一个五行关系映射,或者直接用一个二维数组判断:
public class LiuQinUtil { private static final Map<String, Integer> WUXING_INDEX = Map.of( "木", 0, "火", 1, "土", 2, "金", 3, "水", 4 ); // 五行相生:木生火、火生土、土生金、金生水、水生木 private static final int[] SHENG = {1, 2, 3, 4, 0}; // 五行相克:木克土、土克水、水克火、火克金、金克木 private static final int[] KE = {2, 4, 1, 3, 0}; public static String getLiuQin(String gongWuXing, String yaoWuXing) { int gongIdx = WUXING_INDEX.get(gongWuXing); int yaoIdx = WUXING_INDEX.get(yaoWuXing); if (gongIdx == yaoIdx) { return "兄弟"; } if (SHENG[gongIdx] == yaoIdx) { return "子孙"; } if (SHENG[yaoIdx] == gongIdx) { return "父母"; } if (KE[gongIdx] == yaoIdx) { return "妻财"; } if (KE[yaoIdx] == gongIdx) { return "官鬼"; } return "未知"; } }3.4 六神排布与日柱计算
六神的起始位置取决于起卦当天的日干,因此需要先算出日柱的天干。
六神从初爻到上爻依次排列,顺序是固定的:青龙、朱雀、勾陈、螣蛇、白虎、玄武。起始六神由日干决定:
| 日干 | 初爻六神 |
|---|---|
| 甲、乙 | 青龙 |
| 丙、丁 | 朱雀 |
| 戊 | 勾陈 |
| 己 | 螣蛇 |
| 庚、辛 | 白虎 |
| 壬、癸 | 玄武 |
日柱的计算我采用基准日思路:以 1900 年 1 月 1 日为已知基准(该日为甲戌日,干支序号第 11),计算目标日期与基准日的天数差,加上基准序号之后对 60 取模,得到目标日的干支序号。
public class GanZhiUtil { private static final LocalDate BASE_DATE = LocalDate.of(1900, 1, 1); private static final int BASE_GANZHI_INDEX = 11; // 甲戌日的六十甲子序号(甲子为1) public static int getDayGanZhiIndex(LocalDate date) { long days = ChronoUnit.DAYS.between(BASE_DATE, date); int index = (int) ((BASE_GANZHI_INDEX + days) % 60); if (index == 0) { index = 60; } return index; } public static String getDayGan(LocalDate date) { int index = getDayGanZhiIndex(date); String[] ganzhi = buildGanZhiArray(); return ganzhi[index - 1].substring(0, 1); } }需要提醒的是,这类基准日推算法依赖基准点的准确性,不同历法资料对同一公历日期的干支记录可能存在差异。实际项目里最好用国家标准的历法数据做一次校验,或者把日柱计算直接内置到后端,不允许前端传值,避免数据源不一致导致的排盘偏差。
4. 小程序前端:摇卦交互、卦象绘制与接口对接
4.1 摇卦页面:一次点击摇一爻
小程序端用的是原生框架,没有引入额外 UI 库。摇卦页的核心是管理“已摇次数”和“爻值列表”这两个状态。
页面交互逻辑:
- 用户点击铜钱区域,触发一次
wx.request调用/api/toss - 收到返回值后,把爻值追加到数组中,用 CSS 动画展示铜钱翻转效果
- 摇满六次之后,按钮从“摇卦中”变成“查看排盘结果”
Page({ data: { tossedCount: 0, values: [], canToss: true, }, tossCoin() { if (!this.data.canToss) return; if (this.data.tossedCount >= 6) return; wx.request({ url: 'https://your.domain.com/api/liuyao/toss', method: 'POST', success: (res) => { if (res.data.code === 0) { const value = res.data.data.value; this.setData({ values: [...this.data.values, value], tossedCount: this.data.tossedCount + 1, }); } }, }); }, goPaipan() { if (this.data.tossedCount < 6) return; wx.request({ url: 'https://your.domain.com/api/liuyao/paipan', method: 'POST', data: this.data.values, success: (res) => { const result = res.data.data; wx.navigateTo({ url: `/pages/result/result?data=${encodeURIComponent(JSON.stringify(result))}`, }); }, }); }, });4.2 结果页的卦象绘制
排盘结果的卦象可以用 CSS 绘制,也可以用 Canvas。我用的是普通 view 组件,因为阴阳爻就是一根直线和中间断开的直线,用两个圆角矩形就能拼出来。
绘制逻辑:
- 阳爻:一个横向长条
- 阴爻:左右两个短横条,中间留空
- 动爻:在爻的右侧加一个圆圈标记
<view class="gua-panel"> <view class="gua-column"> <view class="gua-title">本卦</view> <view class="yao-row" wx:for="{{benGua}}" wx:key="index"> <view class="yao {{item === 1 ? 'yang' : 'yin'}}"></view> <view wx:if="{{movingPositions.includes(index)}}" class="moving-mark"></view> </view> </view> <view class="gua-column"> <view class="gua-title">变卦</view> <view class="yao-row" wx:for="{{bianGua}}" wx:key="index"> <view class="yao {{item === 1 ? 'yang' : 'yin'}}"></view> </view> </view> </view>CSS 里对应:
.yao { width: 120px; height: 8px; background: #333; border-radius: 2px; } .yao.yin { width: 50px; margin-left: 35px; background: #333; }阴爻的左右两段做法:可以用外层容器宽度固定,内部用::before和::after生成两段短横条。不过小程序对伪元素的支持没问题,直接用伪元素就行。
4.3 排盘明细表的渲染
排盘结果页底部是一个六行表格,每行对应一个爻位,展示的信息包括:六神、六亲、地支五行、世应标记、爻性、变爻后六亲。
我给后端返回的数据结构预留了一个按爻位组装好的列表:
{ "position": 0, "liuShen": "青龙", "liuQin": "父母", "zhi": "子", "wuXing": "水", "shiYing": "世", "value": 9, "bianValue": 7, "bianLiuQin": "父母" }前端拿到之后直接wx:for渲染成一个表格,不需要再做任何逻辑计算。这样既能保证展示一致,也方便后续增加更多字段。
5. 实测踩坑:随机数分布、日柱基准与兼容性问题
5.1 摇卦接口连续出现相同卦象,用户以为程序坏了
联调阶段遇到一个很有意思的反馈:测试人员在手动摇卦时,连续两次摇到了同样的卦象,立刻提了 Bug——随机数是不是没用对?
从概率上讲,六十四卦中任意两次结果相同的概率是 1/64,算不上极低。但用户的直觉会放大这种巧合。而且如果用的是默认的Random种子,在某些 JVM 版本上启动初期确实可能出现短时间内的伪随机重复序列。
我做了两层处理:
- 后端改用
ThreadLocalRandom.current(),并且不手动设置种子,交给系统随机源 - 前端摇卦过程中加入 300ms 的最低网络过渡动画,让每次摇卦之间有时间差感
另外,在自动起卦接口里增加了“连续生成相同卦象则重新摇”的逻辑,最多重试三次,这样很少会让用户连续两次看到一模一样的结果,体验上舒服很多。
5.2 日柱推算结果和手机日历不一致
日柱计算用基准日偏移法省事,但有一个问题:基准日的干支必须可靠。我在初版代码里使用的是某网络博客提供的“1900 年 1 月 1 日为甲戌日”,后来对比手机自带日历发现差了一天。
排查过程很简单:取 2024 年 1 月 1 日的日柱,用基准日推算法算出结果,再和多个万年历 App 校对。发现确实偏移一位之后,我重新确认了标准历法数据,修正了基准干支序号。
这个坑要给所有做干支历法相关项目的开发者提个醒:不要盲目相信网上的基准日数据,至少要拿最近的 100 个日期去和多款权威日历对照。我当时写了一个单元测试,直接断言 2024 年的日柱序列,之后每次改动都能自动回归验证。
5.3 小程序端旧版本 iOS 的 flex 布局兼容问题
排盘结果页的卦象区用了display: flex横排本卦和变卦。在最新的安卓和 iOS 设备上都没问题,但在 iOS 14 以下的部分机型上,flex 容器里的width: 50%偶尔会不生效,导致两个卦叠在一起。
解决方式是给右侧变卦区域也加上flex-shrink: 0,并显式指定min-width。这类兼容性 bug 不好提前发现,建议在发布前用低版本 iOS 的模拟器或者真机样本过一遍核心页面。
注意:微信开发者工具里的渲染结果和真机有差异,尤其是 flex 布局和 CSS 伪元素。项目验收一定要以真机预览为准。
5.4 排盘数据正确性怎么验证
排盘模块最容易写错的是世应位置和六亲关系。我的经验是产出一张“测试卦例表”,至少覆盖八个宫、八种世位,每个宫取一个典型卦,人工标好预期结果,然后跑单元测试进行断言。
比如乾宫的“天风姤”,世在一爻、应在四爻,宫五行金,初爻子水为子孙,二爻寅木为妻财,三爻辰土为父母,四爻午火为官鬼,五爻申金为兄弟,上爻戌土为父母。我把这类用例直接写进测试代码,每次改了排盘模块就全量跑一遍。
刚开始做这类项目时,很容易陷入“把功能跑通就行”的思维,但排盘这种确定性计算,数据表一旦写错,用户看到的整个盘面都是歪的,而且外行根本看不出来。自动化测试是这里最值得花时间的环节。
5.5 数据表维护的顺带建议
六十四卦的静态映射表很庞大,手工录入容易出错。我用的方法是先写一个临时 Python 脚本从权威资料里提取数据,生成 Java 代码里的三维数组字符串,再贴到项目里。不要手动逐行 copy,尤其是世应位置和纳甲地支,错一个后面排查要花非常多的时间。
如果后面要给项目加功能,比如保存起卦历史、按卦名搜索,建议把这份静态表迁移到数据库里。接口层不用变,只是把 GuaDict 的读取改成从缓存或 DB 加载,业务逻辑完全解耦,这是一条非常舒服的演进路径。
从起卦引擎到小程序渲染,这套源码的完整链路不算复杂,但每一步都踩过实实在在的坑。尤其是排盘这类文化知识密集型的模块,除了编程能力,更需要耐心核对传统规则。好在这些规则都能用数据和表格固化下来,一旦理清,后续扩展的空间非常大。
本文还有配套的精品资源,点击获取