Author SHA1 Message Date
gouki 28309e0257 test(mini): 为核心工具函数添加 node:test 单元测试
Publish Mini Program Dev Version / publish (push) Successful in 2m56s
- 新增 mini/utils/core/__tests__/index.test.js,70 个测试用例
- 覆盖:公农历互转、干支计算、生肖星座、纳音、节气、宜忌、冲煞、
  地支关系、十神、天干合冲、五行分析、称骨算命、大运流年分析等
- 未导出函数通过 getDayInfo/getAlmanacInfo 间接验证
- 更新 package.json test 脚本,npm test 同时运行单元测试和 CI 绑定检查
- 使用 Node.js 内置 node:test,零额外依赖
2026-10-14 12:50:06 +00:00
gouki 73374bf93e docs(AGENTS.md): 补充 web/packages/core 10 个 TypeScript 测试文件到测试覆盖现状
Build and Publish Server / build (push) Successful in 3m46s
2026-10-14 12:46:30 +00:00
gouki a07ddb46e2 merge: 纳入 @lunar/core 排盘核心与排盘保存显式同意
# Conflicts:
#	REMINDER.md
#	web-commercial/CHANGELOG.md
#	web-commercial/Fetures.md
2026-10-12 22:24:40 +00:00
gouki 0e21d8e63f fix(mini): 修复设置页绑定漂移并新增 WXML-JS 一致性检查
Publish Mini Program Dev Version / publish (push) Successful in 1m5s
settings.js 同步绑定名 loggedIn→logged、login→wxLogin、navTo→navigateTo,恢复登录按钮与全部菜单项响应;npm test 接入新增 ci/check-bindings.cjs 门禁;calendar/wish-detail 各 1 处历史遗留漂移登记为仅告警另行跟进
2026-09-30 00:48:50 +00:00
gouki 59d1af73b9 docs(agents): 更新测试覆盖现状为 6 个已跟踪测试文件,含新增邮箱验证码测试 2026-09-29 23:09:15 +00:00
gouki b63aabaa79 fix(server): 邮件验证码发送失败分支记录 provider 错误与上游状态码
Build and Publish Server / build (push) Successful in 4m11s
handler 失败分支不再丢弃 service 错误:记录含服务商名称与上游状态码的 err 供生产排障关联,对外响应保持通用 503 不变。
2026-09-29 23:03:46 +00:00
gouki 9b0e290d5d chore(git): 根 .gitignore 忽略 worktrees/ 工作树目录,避免污染变更集 2026-09-29 23:00:52 +00:00
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c161d42b06 feat(server): 接入 QQ 邮箱 SMTP 发送登录验证码
Build and Publish Server / build (push) Successful in 2m6s
- 支持 465 隐式 TLS 与 587 STARTTLS,SMTP 配置优先、Postmark 备用
- 发件人支持 QQ 域名邮箱地址(z@kepo.net),凭证仅走环境变量
- HTTP 测试覆盖发送成功与认证失败;真实发信测试需显式开启 RUN_QQ_SMTP_LIVE_TEST

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-27 23:24:46 +00:00
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 132fef051b feat(commerce): 排盘保存需显式同意,默认本地计算不上传
- 前端免费排盘默认不保存,勾选同意后才调用保存接口
- 服务端要求 consentVersion,记录 consentAt,校验并限流公开保存接口
- 付费结算单独披露报告快照与订单信息将由服务端存储
- 管理端展示同意版本与时间,历史无同意证据记录保留并标注

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-27 23:21:16 +00:00
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ea6828b925 feat(web): 纳入 @lunar/core 排盘核心并批准 esbuild 构建脚本
web-commercial 依赖 file:../web/packages/core,此前 web/ 被 gitignore,
干净 worktree 无法构建商业前端;同时将 esbuild 加入 allowBuilds。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-27 23:21:08 +00:00
gouki deb32f8fab feat(server): 商业化支付与订单系统 + Harness 改进
Build and Publish Server / build (push) Successful in 4m30s
- 新增商业化模块:Stripe/Binance Pay 支付、邮箱登录、订单管理
- 新增数据模型:ReportProduct, ReportEntitlement, ReportSnapshot, PaymentEvent, EmailLoginCode
- 新增 API:/api/commerce/checkout, /api/commerce/webhooks/*, /api/commerce/orders/*
- 新增管理后台:订单列表、统计图表、统一 AdminHeader 组件
- 新增中间件测试:RateLimit, Auth, CORS 共 7 个测试用例
- 新增 AGENTS.md 项目代理指令文件
- 新增 REMINDER.md 商业化功能开发规范
- 修复 JWT 解析:添加 WithValidMethods 限制 HS256
- 修复 GetPayStatus:增加用户权限校验
- 更新 .gitignore 和 README.md
2026-09-19 18:57:23 +00:00
143 changed files with 19924 additions and 356 deletions
+10 -2
View File
@@ -1,5 +1,7 @@
# 环境配置 # 环境配置
.env
.env.local .env.local
.env.test
.env.*.local .env.*.local
# 密钥和证书 # 密钥和证书
@@ -39,6 +41,7 @@ Thumbs.db
logs/ logs/
# 临时文件 # 临时文件
.playwright-mcp/
tmp/ tmp/
temp/ temp/
.ci-tmp/ .ci-tmp/
@@ -61,10 +64,13 @@ server/public/hot
server/web/node_modules/ server/web/node_modules/
server/web/dist/ server/web/dist/
# web 版万年历(独立项目,不属于本仓库;前缀 / 锚定根目录,避免误伤 server/web) # 许愿树独立页面(独立项目,不属于本仓库;前缀 / 锚定根目录,避免误伤 server/web)
/web/ # 注意:web/ 是 @lunar/core 排盘核心,web-commercial 构建依赖它,必须入库
/wishing-tree-pages/ /wishing-tree-pages/
# Git worktrees(本地并行开发目录,不属于本仓库源码;锚定根目录,避免误伤同名子目录)
/worktrees/
# 数据库 # 数据库
*.db *.db
*.sqlite *.sqlite
@@ -90,3 +96,5 @@ storage/uploads/
# 本地开发 # 本地开发
local/ local/
.local/ .local/
vibe_images/
.qoder/
+81
View File
@@ -0,0 +1,81 @@
# AGENTS.md — lunar 项目代理指令
## 项目概述
lunar 是一个融合中国传统文化(农历、八字、许愿树)的现代化应用,包含:
- **mini/**: 微信小程序前端(农历日历、八字排盘、许愿树、百科)
- **server/**: Go 后端服务(Gin + GORM + MySQL,提供小程序 API 和商业版支付)
- **web/**: 前端工程化包(core + web)
- **web-commercial/**: 商业版前端(React + Vite)
- **wishing-tree-pages/**: 许愿树独立页面(Next.js)
## 核心路径导航
| 路径 | 说明 | 关键文件 |
|------|------|----------|
| `server/internal/handler/` | HTTP API 处理器 | `user.go`, `commerce.go`, `pay.go`, `wiki.go` |
| `server/internal/service/` | 业务逻辑层 | `user.go`, `commerce.go`, `payment_provider.go`, `wish.go` |
| `server/internal/model/` | 数据模型 | `user.go`, `order.go`, `commerce.go`, `wish.go` |
| `server/internal/middleware/` | 中间件 | `middleware.go`(认证、CORS、限流) |
| `server/internal/config/` | 配置管理 | `config.go`, `database.go` |
| `server/migrations/` | 数据库迁移 | `001_init.sql`, `002_commerce.sql` |
| `mini/pages/` | 小程序页面 | `home/`, `calendar/`, `wish-tree/`, `bazi/`, `wiki/` |
| `mini/utils/` | 小程序工具 | `lunar.js`, `request.js`, `birth-profile.js` |
| `mini/components/` | 小程序组件 | `calendar-grid/`, `score-ring/`, `badge/` |
| `mini/ci/` | 小程序 CI 脚本 | `preview-ci.cjs`, `upload-ci.cjs` |
## 高频变更热点
- `mini/pages/wish-tree/wish-tree.js` — 许愿树页面(30 天 churn 4549 行)
- `mini/utils/core/index.js` — 核心工具(churn 1734 行)
- `server/internal/handler/user.go` — 用户 API(churn 488 行)
- `server/internal/middleware/middleware.go` — 中间件(churn 259 行)
## 启动与验证命令
### 后端(server/)
```bash
cd server
cp .env.example .env.local # 首次运行
go mod download
go run ./cmd # 开发启动,默认 :8080
go test ./... # 运行测试
go build -o bin/server ./cmd # 构建
```
### 小程序(mini/)
```bash
cd mini
npm install
npm run dev # 开发模式(微信开发者工具预览)
npm run upload # 上传体验版(需配置 CI)
```
### 商业前端(web-commercial/)
```bash
cd web-commercial
pnpm install
pnpm dev # 开发服务器
pnpm build # 构建
```
## 高风险操作警示
- **生产部署**: 禁止 SSH 直连服务器,使用 Gitea Actions + Release 拉取式部署
- **数据库**: 禁止执行 `fresh` 或 `refresh`,新环境用 AutoMigrate,已有库手动执行 migrations
- **支付**: 任何支付功能必须同时具备真实下单、Webhook 验签、订单状态机、幂等处理、退款处理
- **密钥**: 所有支付与邮件密钥必须通过环境变量注入,禁止提交到 Git
## 测试覆盖现状
- Go 后端:6 个已跟踪测试文件(`server/internal/service/` 3 个:baike_term_sync_test.go、payment_provider_test.go、wiki_sync_test.go;`server/internal/handler/` 2 个:commerce_test.go、本次新增的邮箱验证码测试 email_auth_test.go;`server/internal/middleware/` 1 个:middleware_test.go)
- web 前端核心(`web/packages/core/`):10 个 TypeScript 测试文件(`src/__tests__/` 下:bazi、boneWeight、day、elementStrength、fortune、fortuneLuck、plumBlossom、relationship、shensha、yearMonths)
- 小程序前端:0 个测试文件
- 商业前端:0 个测试文件
## 相关文档
- `server/README.md` — 后端服务详细说明
- `web/docs/ARCHITECTURE.md` — 前端架构文档
- `web/docs/REQUIREMENTS.md` — 需求文档
- `REMINDER.md` — 开发提醒(商业化功能规范)
+21
View File
@@ -0,0 +1,21 @@
# 开发提醒
## 2026-09-06:商业化功能不能只完成界面
- 任何标记为“支付已接入”的功能,必须同时具备真实服务商下单、Webhook 验签、订单状态机、幂等处理、用户权益、退款处理和后台查询。
- 演示支付必须明确标注,不能把模拟数据描述成真实支付能力。
- 付费数字内容必须支持跨设备恢复,并保存购买时的不可变快照,避免算法升级后用户看到不同结果。
- 完成页面设计后,应继续验证完整的“登录—下单—支付通知—发放权益—恢复报告—退款撤权”闭环。
- 没有商户凭证时只能声明代码与模拟测试完成,不能声明真实生产收款已经开通。
- 管理后台新增 PC 商业模块后,必须统一品牌名称、页面标题和导航,不能让新旧产品名称混在同一套后台界面里。
- 排盘产品必须保存服务端历史快照;不能只依赖浏览器状态,否则重复排盘、算法升级和跨设备恢复都会产生歧义。
- 基础排盘事实(四柱、起运、刑冲合害、神煞位置)应作为免费层展示,付费点应放在个性化解释而不是隐藏原始数据。
- 交运时间不能直接使用 ChildLimit 的出生区间起点;应显示真正的第一步大运交运时间。
- 商业前端的本地 `file:` 依赖可能缓存旧版 `@lunar/core`;生产构建必须先构建 core,并通过显式别名引用最新 `dist`。
- 神煞规则不能统一套用“年干与日干”两个锚点;必须按每个神煞的标准查法指定锚点,并使用 `pp.test` 中的真实命例做回归测试。
- 大运切换必须同时切换该步大运对应的十年流年,不能让当前大运标题与首步大运流年混用。
- 排盘数据会进入服务端时,必须明确说明采集字段、保存目的与范围;免费排盘默认不上传,主动同意后才保存,同意版本需留档,公开写入接口必须限流。付费下单的报告快照应在下单前另行说明。
## 2026-09-25:邮件服务方案必须先确认代码可用
- 推荐邮箱 SMTP 服务前,先核对后端是否已实现对应协议;不能把“服务商支持 SMTP”说成“当前应用已经可以发信”。必须先完成实现和端到端验证,再指导用户配置凭证。
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env node
/**
* WXML-JS 绑定一致性静态检查(npm test / CI 门禁)
*
* 小程序不会在编译期校验 bind/catch 绑定名与 {{}} 数据引用:
* wxml 与页面 JS 一旦命名漂移(例如 wxml 写 wxLogin/navigateTo,
* 而 js 只有 login/navTo),页面不会报错,只是点击与渲染静默失效。
* 本脚本把这类漂移拦在提交与 CI 阶段。
*
* 检查范围:pages/<name>/<name>.wxml 与同目录同名 .js 的配对
* 检查规则:
* 1. bind/catch 事件绑定(含 bind:tap 冒号写法)的方法名必须存在于对应 JS
* 2. {{}} 表达式中的根标识符(点号前的名字)必须存在于对应 JS(数据字段或方法)
* 3. wx:for 默认变量 item/index、wx:for-item/wx:for-index 别名、
* 字符串字面量与 true/false/null/undefined 自动忽略
* 4. LEGACY_DRIFT 中登记的历史遗留条目仅告警不失败(修复后应删除对应条目)
*
* 存在漂移时以非零退出码结束,使 npm test 与 CI 直接失败。
*/
const fs = require("node:fs");
const path = require("node:path");
const projectPath = process.cwd();
const pagesPath = path.join(projectPath, "pages");
const LITERAL_KEYWORDS = new Set(["true", "false", "null", "undefined"]);
// wx:for 的默认循环变量属于模板结构名而非页面定义,始终忽略
const DEFAULT_LOOP_VARS = new Set(["item", "index"]);
// 引入本检查前就存在的历史遗留漂移(本轮修复范围外,另行跟进)。
// 命中时仅告警不失败;对应页面修复后应删除这里的条目,让检查恢复全量严格。
const LEGACY_DRIFT = new Set([
'pages/calendar/calendar.wxml catchtap="noop"',
'pages/wish-detail/wish-detail.wxml bindtap="onShare"',
]);
function listPageNames() {
return fs
.readdirSync(pagesPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((name) => {
const dir = path.join(pagesPath, name);
return (
fs.existsSync(path.join(dir, `${name}.wxml`)) &&
fs.existsSync(path.join(dir, `${name}.js`))
);
})
.sort();
}
function lineOf(text, index) {
let line = 1;
for (let i = 0; i < index; i++) {
if (text.charCodeAt(i) === 10) {
line++;
}
}
return line;
}
/** 提取 wxml 中的事件绑定与 {{}} 表达式(去掉注释,保留行号) */
function collectWxmlRefs(source) {
const text = source.replace(/<!--[\s\S]*?-->/g, "");
const loopVars = new Set();
const handlers = [];
const roots = [];
for (const match of text.matchAll(/wx:for-(?:item|index)="([A-Za-z_$][\w$]*)"/g)) {
loopVars.add(match[1]);
}
for (const match of text.matchAll(/((?:bind|catch):?[a-zA-Z]+)="([^"{}]+)"/g)) {
const name = match[2].trim();
if (/^[A-Za-z_$][\w$]*$/.test(name)) {
handlers.push({ ref: `${match[1]}="${name}"`, name, line: lineOf(text, match.index) });
}
}
for (const match of text.matchAll(/\{\{([^{}]*)\}\}/g)) {
for (const name of expressionRoots(match[1])) {
roots.push({ ref: `{{${name}}}`, name, line: lineOf(text, match.index) });
}
}
return { loopVars, handlers, roots };
}
/** 取 mustache 表达式的根标识符:字符串字面量、点号后的属性名、数字与关键字都不算 */
function expressionRoots(expression) {
const stripped = expression
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
.replace(/"(?:[^"\\]|\\.)*"/g, '""');
const roots = new Set();
for (const match of stripped.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)/g)) {
if (!LITERAL_KEYWORDS.has(match[1])) {
roots.add(match[1]);
}
}
return roots;
}
/** 将 JS 的字符串与注释内容置为空白(保留换行),避免干扰键名提取与跨行匹配 */
function stripJsNoise(source) {
let result = "";
let index = 0;
while (index < source.length) {
const ch = source[index];
const next = source[index + 1];
if (ch === "/" && next === "/") {
while (index < source.length && source[index] !== "\n") {
result += " ";
index++;
}
continue;
}
if (ch === "/" && next === "*") {
while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
result += source[index] === "\n" ? "\n" : " ";
index++;
}
result += " ";
index += 2;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") {
result += " ";
index++;
while (index < source.length) {
const current = source[index];
if (current === "\\") {
result += source[index + 1] === "\n" ? "\n" : " ";
index += 2;
continue;
}
if (current === ch) {
result += " ";
index++;
break;
}
result += current === "\n" ? "\n" : " ";
index++;
}
continue;
}
result += ch;
index++;
}
return result;
}
/** 提取页面 JS 中 Page({}) 的数据字段、setData 键与方法名 */
function collectJsNames(source) {
const clean = stripJsNoise(source);
const names = new Set();
// 对象键:{ 或 , 之后的 name:(data 初始字段、setData 键、回调键)
for (const match of clean.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*:/g)) {
names.add(match[1]);
}
// 方法定义:行首可带 async 的 name(
for (const match of clean.matchAll(/^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/gm)) {
names.add(match[1]);
}
return names;
}
function main() {
if (!fs.existsSync(pagesPath)) {
console.error(`[check-bindings] 找不到页面目录:${pagesPath}`);
process.exitCode = 2;
return;
}
const pages = listPageNames();
const problems = [];
const legacyHits = [];
let checkedRefs = 0;
for (const name of pages) {
const dir = path.join(pagesPath, name);
const wxmlFile = path.join(dir, `${name}.wxml`);
const jsFile = path.join(dir, `${name}.js`);
const { loopVars, handlers, roots } = collectWxmlRefs(fs.readFileSync(wxmlFile, "utf8"));
const known = collectJsNames(fs.readFileSync(jsFile, "utf8"));
const ignored = new Set([...DEFAULT_LOOP_VARS, ...loopVars]);
const wxmlRel = path.relative(projectPath, wxmlFile);
const jsRel = path.relative(projectPath, jsFile);
for (const handler of handlers) {
checkedRefs++;
if (!known.has(handler.name)) {
if (LEGACY_DRIFT.has(`${wxmlRel} ${handler.ref}`)) {
legacyHits.push(`${wxmlRel}:${handler.line} ${handler.ref}`);
} else {
problems.push(`${wxmlRel}:${handler.line} ${handler.ref} 在 ${jsRel} 中找不到对应方法`);
}
}
}
for (const root of roots) {
if (ignored.has(root.name)) {
continue;
}
checkedRefs++;
if (!known.has(root.name)) {
if (LEGACY_DRIFT.has(`${wxmlRel} ${root.ref}`)) {
legacyHits.push(`${wxmlRel}:${root.line} ${root.ref}`);
} else {
problems.push(`${wxmlRel}:${root.line} ${root.ref} 在 ${jsRel} 的 data/方法中找不到`);
}
}
}
}
if (problems.length > 0) {
console.error(`[check-bindings] 发现 ${problems.length} 处 WXML 与 JS 不一致的引用:`);
for (const problem of problems) {
console.error(` - ${problem}`);
}
console.error(
"[check-bindings] 请统一 wxml 绑定名与页面 JS 的 data 字段/方法名,避免点击与数据绑定静默失效。",
);
}
if (legacyHits.length > 0) {
console.warn(
`[check-bindings] 另有 ${legacyHits.length} 处历史遗留漂移(引入检查前已存在,不计入本次失败,另行跟进):`,
);
for (const hit of legacyHits) {
console.warn(` - ${hit}`);
}
}
if (problems.length > 0) {
process.exitCode = 1;
return;
}
console.log(
`[check-bindings] 通过:${pages.length} 个页面、${checkedRefs} 处 bind/catch 与 {{}} 引用均能在同名 JS 中找到定义。`,
);
}
main();
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "祈福小助手 - 万年历微信小程序", "description": "祈福小助手 - 万年历微信小程序",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "echo \"No tests yet\"", "test": "node --test utils/core/__tests__/index.test.js && node ci/check-bindings.cjs",
"upload": "node ci/upload-ci.cjs", "upload": "node ci/upload-ci.cjs",
"preview": "node ci/preview-ci.cjs" "preview": "node ci/preview-ci.cjs"
}, },
+6 -6
View File
@@ -5,7 +5,7 @@ const birthProfile = require('../../utils/birth-profile.js');
Page({ Page({
data: { data: {
userInfo: null, userInfo: null,
loggedIn: false, logged: false,
logging: false, logging: false,
// 菜单角标 // 菜单角标
profileCount: 0, profileCount: 0,
@@ -25,7 +25,7 @@ Page({
refreshUser() { refreshUser() {
const token = wx.getStorageSync('token'); const token = wx.getStorageSync('token');
const userInfo = wx.getStorageSync('lunar-user-info'); const userInfo = wx.getStorageSync('lunar-user-info');
this.setData({ loggedIn: !!token, userInfo: userInfo || null }); this.setData({ logged: !!token, userInfo: userInfo || null });
}, },
refreshCounts() { refreshCounts() {
@@ -39,7 +39,7 @@ Page({
// ==================== 登录 ==================== // ==================== 登录 ====================
login() { wxLogin() {
if (this.data.logging) return; if (this.data.logging) return;
this.setData({ logging: true }); this.setData({ logging: true });
wx.login({ wx.login({
@@ -57,7 +57,7 @@ Page({
if (res && res.code === 0 && res.data && res.data.token) { if (res && res.code === 0 && res.data && res.data.token) {
wx.setStorageSync('token', res.data.token); wx.setStorageSync('token', res.data.token);
wx.setStorageSync('lunar-user-info', res.data.user); wx.setStorageSync('lunar-user-info', res.data.user);
this.setData({ userInfo: res.data.user, loggedIn: true, logging: false }); this.setData({ userInfo: res.data.user, logged: true, logging: false });
wx.showToast({ title: '登录成功', icon: 'success' }); wx.showToast({ title: '登录成功', icon: 'success' });
// 登录后拉取云端生辰档案,保证菜单计数准确 // 登录后拉取云端生辰档案,保证菜单计数准确
birthProfile.syncFromServer().then(() => this.refreshCounts()).catch(() => {}); birthProfile.syncFromServer().then(() => this.refreshCounts()).catch(() => {});
@@ -85,13 +85,13 @@ Page({
if (!res.confirm) return; if (!res.confirm) return;
wx.removeStorageSync('token'); wx.removeStorageSync('token');
wx.removeStorageSync('lunar-user-info'); wx.removeStorageSync('lunar-user-info');
this.setData({ userInfo: null, loggedIn: false }); this.setData({ userInfo: null, logged: false });
wx.showToast({ title: '已退出', icon: 'success' }); wx.showToast({ title: '已退出', icon: 'success' });
} }
}); });
}, },
navTo(e) { navigateTo(e) {
const url = e.currentTarget.dataset.url; const url = e.currentTarget.dataset.url;
// tabBar 页面必须用 switchTab 打开 // tabBar 页面必须用 switchTab 打开
const tabPages = ['/pages/home/home', '/pages/wish-tree/wish-tree', '/pages/bazi/bazi', '/pages/wiki/wiki', '/pages/settings/settings']; const tabPages = ['/pages/home/home', '/pages/wish-tree/wish-tree', '/pages/bazi/bazi', '/pages/wiki/wiki', '/pages/settings/settings'];
+9 -9
View File
@@ -1,7 +1,7 @@
<view class="container"> <view class="container">
<!-- 登录与账号 --> <!-- 登录与账号 -->
<view class="card"> <view class="card">
<view class="flex items-center justify-between" wx:if="{{loggedIn}}"> <view class="flex items-center justify-between" wx:if="{{logged}}">
<view class="flex items-center gap-2"> <view class="flex items-center gap-2">
<image src="{{userInfo.avatar}}" class="avatar" mode="aspectFill" wx:if="{{userInfo.avatar}}"/> <image src="{{userInfo.avatar}}" class="avatar" mode="aspectFill" wx:if="{{userInfo.avatar}}"/>
<view class="avatar avatar-placeholder" wx:else> <view class="avatar avatar-placeholder" wx:else>
@@ -24,28 +24,28 @@
<text class="text-xs text-muted block">登录后生辰档案云端同步,换设备不丢失</text> <text class="text-xs text-muted block">登录后生辰档案云端同步,换设备不丢失</text>
</view> </view>
</view> </view>
<view class="btn-primary btn-sm" bindtap="login">{{logging ? '登录中…' : '授权登录'}}</view> <view class="btn-primary btn-sm" bindtap="wxLogin">{{logging ? '登录中…' : '授权登录'}}</view>
</view> </view>
</view> </view>
<!-- 我的数据 --> <!-- 我的数据 -->
<view class="card"> <view class="card">
<view class="section-title">我的数据</view> <view class="section-title">我的数据</view>
<view class="menu-item" bindtap="navTo" data-url="/pages/birth-profiles/birth-profiles"> <view class="menu-item" bindtap="navigateTo" data-url="/pages/birth-profiles/birth-profiles">
<text class="menu-label">生辰管理</text> <text class="menu-label">生辰管理</text>
<view class="menu-right"> <view class="menu-right">
<text class="menu-count">{{profileCount}}/{{maxFree}}</text> <text class="menu-count">{{profileCount}}/{{maxFree}}</text>
<text class="menu-arrow">›</text> <text class="menu-arrow">›</text>
</view> </view>
</view> </view>
<view class="menu-item" bindtap="navTo" data-url="/pages/bookmarks/bookmarks"> <view class="menu-item" bindtap="navigateTo" data-url="/pages/bookmarks/bookmarks">
<text class="menu-label">我的收藏</text> <text class="menu-label">我的收藏</text>
<view class="menu-right"> <view class="menu-right">
<text class="menu-count" wx:if="{{bookmarkCount > 0}}">{{bookmarkCount}}</text> <text class="menu-count" wx:if="{{bookmarkCount > 0}}">{{bookmarkCount}}</text>
<text class="menu-arrow">›</text> <text class="menu-arrow">›</text>
</view> </view>
</view> </view>
<view class="menu-item" bindtap="navTo" data-url="/pages/preferences/preferences"> <view class="menu-item" bindtap="navigateTo" data-url="/pages/preferences/preferences">
<text class="menu-label">设置</text> <text class="menu-label">设置</text>
<text class="menu-arrow">›</text> <text class="menu-arrow">›</text>
</view> </view>
@@ -54,19 +54,19 @@
<!-- 功能 --> <!-- 功能 -->
<view class="card"> <view class="card">
<view class="section-title">功能</view> <view class="section-title">功能</view>
<view class="menu-item" bindtap="navTo" data-url="/pages/fortune/fortune"> <view class="menu-item" bindtap="navigateTo" data-url="/pages/fortune/fortune">
<text class="menu-label">每日运势</text> <text class="menu-label">每日运势</text>
<text class="menu-arrow">›</text> <text class="menu-arrow">›</text>
</view> </view>
<view class="menu-item" bindtap="navTo" data-url="/pages/divination/divination"> <view class="menu-item" bindtap="navigateTo" data-url="/pages/divination/divination">
<text class="menu-label">占卜</text> <text class="menu-label">占卜</text>
<text class="menu-arrow">›</text> <text class="menu-arrow">›</text>
</view> </view>
<view class="menu-item" bindtap="navTo" data-url="/pages/solar-terms/solar-terms"> <view class="menu-item" bindtap="navigateTo" data-url="/pages/solar-terms/solar-terms">
<text class="menu-label">二十四节气</text> <text class="menu-label">二十四节气</text>
<text class="menu-arrow">›</text> <text class="menu-arrow">›</text>
</view> </view>
<view class="menu-item" bindtap="navTo" data-url="/pages/calendar/calendar"> <view class="menu-item" bindtap="navigateTo" data-url="/pages/calendar/calendar">
<text class="menu-label">月历视图</text> <text class="menu-label">月历视图</text>
<text class="menu-arrow">›</text> <text class="menu-arrow">›</text>
</view> </view>
+518
View File
@@ -0,0 +1,518 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const {
solarToLunar,
lunarToSolar,
getGanzhi,
getYearGanzhi,
getMonthGanzhi,
getHourGanzhi,
getZodiac,
getConstellation,
getSolarTerm,
getNayin,
getSixStar,
getBranchRelationship,
getTenStarRelationship,
checkStemCombine,
checkStemOpposite,
daysBetween,
getWeekDay,
getBuddhistFestival,
getLegalHoliday,
isWorkday,
analyzeElementBalance,
calculateBoneWeight,
analyzeFortuneGanzhi,
HEAVEN_STEMS,
EARTH_BRANCHES,
ZODIACS,
LUNAR_MONTH_NAMES,
LUNAR_DAY_NAMES,
WEEK_NAMES,
SOLAR_TERMS,
HOUR_NAMES,
HOUR_RANGES
} = require('../index.js');
// ─── 常量数据完整性 ───────────────────────────────────────────
describe('常量数据完整性', () => {
it('天干应有 10 个', () => {
assert.equal(HEAVEN_STEMS.length, 10);
assert.equal(HEAVEN_STEMS[0], '甲');
assert.equal(HEAVEN_STEMS[9], '癸');
});
it('地支应有 12 个', () => {
assert.equal(EARTH_BRANCHES.length, 12);
assert.equal(EARTH_BRANCHES[0], '子');
assert.equal(EARTH_BRANCHES[11], '亥');
});
it('生肖应有 12 个', () => {
assert.equal(ZODIACS.length, 12);
assert.equal(ZODIACS[0], '鼠');
});
it('农历月份名应有 12 个', () => {
assert.equal(LUNAR_MONTH_NAMES.length, 12);
});
it('农历日期名应有 30 个', () => {
assert.equal(LUNAR_DAY_NAMES.length, 30);
});
it('星期名应有 7 个', () => {
assert.equal(WEEK_NAMES.length, 7);
});
it('节气应有 24 个', () => {
assert.equal(SOLAR_TERMS.length, 24);
});
it('时辰名和时辰范围数量一致', () => {
assert.equal(HOUR_NAMES.length, HOUR_RANGES.length);
});
});
// ─── 公历基础计算 ─────────────────────────────────────────────
describe('getWeekDay', () => {
it('2024-01-01 是周一', () => {
assert.equal(getWeekDay(2024, 1, 1), 1);
});
it('2024-03-17 是周日', () => {
assert.equal(getWeekDay(2024, 3, 17), 0);
});
});
describe('daysBetween', () => {
it('同一天差 0', () => {
assert.equal(daysBetween(2024, 6, 15, 2024, 6, 15), 0);
});
it('跨月计算', () => {
assert.equal(daysBetween(2024, 1, 1, 2024, 2, 1), 31);
});
it('跨年计算', () => {
assert.equal(daysBetween(2024, 1, 1, 2025, 1, 1), 366); // 2024 闰年
});
it('反向为负值', () => {
assert.equal(daysBetween(2025, 1, 1, 2024, 1, 1), -366);
});
});
// ─── 公历转农历 ──────────────────────────────────────────────
describe('solarToLunar', () => {
it('1900-01-31 = 农历1900年正月初一', () => {
const r = solarToLunar(1900, 1, 31);
assert.deepEqual(r, { year: 1900, month: 1, day: 1, isLeap: false });
});
it('2024-02-10 = 农历2024年正月初一(春节)', () => {
const r = solarToLunar(2024, 2, 10);
assert.equal(r.year, 2024);
assert.equal(r.month, 1);
assert.equal(r.day, 1);
assert.equal(r.isLeap, false);
});
it('2023-01-22 = 农历2023年正月初一', () => {
const r = solarToLunar(2023, 1, 22);
assert.equal(r.year, 2023);
assert.equal(r.month, 1);
assert.equal(r.day, 1);
});
it('超出范围返回 null', () => {
assert.equal(solarToLunar(1899, 1, 1), null);
assert.equal(solarToLunar(2101, 1, 1), null);
});
it('农历日期day字段从1开始', () => {
const r = solarToLunar(2024, 2, 11);
assert.equal(r.day, 2); // 正月初二
});
});
// ─── 农历转公历 ──────────────────────────────────────────────
describe('lunarToSolar', () => {
it('农历1900年正月初一 = 1900-01-31', () => {
const r = lunarToSolar(1900, 1, 1);
assert.deepEqual(r, { year: 1900, month: 1, day: 31 });
});
it('农历2024年正月初一 = 2024-02-10', () => {
const r = lunarToSolar(2024, 1, 1);
assert.deepEqual(r, { year: 2024, month: 2, day: 10 });
});
it('超出范围返回 null', () => {
assert.equal(lunarToSolar(1899, 1, 1), null);
});
it('互逆验证:solarToLunar → lunarToSolar 往返一致', () => {
const solar = { year: 2025, month: 6, day: 15 };
const lunar = solarToLunar(solar.year, solar.month, solar.day);
const back = lunarToSolar(lunar.year, lunar.month, lunar.day, lunar.isLeap);
assert.deepEqual(back, solar);
});
});
// ─── 干支计算 ────────────────────────────────────────────────
describe('getYearGanzhi', () => {
it('2024年 = 甲辰年', () => {
const r = getYearGanzhi(2024);
assert.equal(r.ganzhi, '甲辰');
assert.equal(r.stem, '甲');
assert.equal(r.branch, '辰');
});
it('2023年 = 癸卯年', () => {
assert.equal(getYearGanzhi(2023).ganzhi, '癸卯');
});
it('1900年 = 庚子年', () => {
assert.equal(getYearGanzhi(1900).ganzhi, '庚子');
});
it('60年一甲子循环', () => {
const a = getYearGanzhi(2024);
const b = getYearGanzhi(2024 - 60);
assert.equal(a.ganzhi, b.ganzhi);
});
});
describe('getGanzhi(日干支)', () => {
it('返回 stem/branch/ganzhi 三个字段', () => {
const r = getGanzhi(2024, 1, 1);
assert.ok(r.stem);
assert.ok(r.branch);
assert.equal(r.ganzhi, r.stem + r.branch);
});
it('连续两天的日干支差一天', () => {
const a = getGanzhi(2024, 1, 1);
const b = getGanzhi(2024, 1, 2);
const stemA = HEAVEN_STEMS.indexOf(a.stem);
const stemB = HEAVEN_STEMS.indexOf(b.stem);
assert.equal((stemB - stemA + 10) % 10, 1);
});
});
describe('getMonthGanzhi', () => {
it('返回完整干支', () => {
const r = getMonthGanzhi(2024, 1, 15);
assert.ok(r.stem.length === 1);
assert.ok(r.branch.length === 1);
assert.equal(r.ganzhi.length, 2);
});
});
describe('getHourGanzhi', () => {
it('子时(0点)返回有效干支', () => {
const dayGz = getGanzhi(2024, 1, 1);
const r = getHourGanzhi(dayGz, 0);
assert.ok(r.ganzhi.length === 2);
assert.equal(r.branch, '子');
});
it('午时(12点) branch 为午', () => {
const dayGz = getGanzhi(2024, 1, 1);
const r = getHourGanzhi(dayGz, 12);
assert.equal(r.branch, '午');
});
});
// ─── 生肖与星座 ──────────────────────────────────────────────
describe('getZodiac', () => {
it('2024 = 龙', () => {
assert.equal(getZodiac(2024), '龙');
});
it('2023 = 兔', () => {
assert.equal(getZodiac(2023), '兔');
});
it('1900 = 鼠', () => {
assert.equal(getZodiac(1900), '鼠');
});
it('12年循环', () => {
assert.equal(getZodiac(2024), getZodiac(2024 + 12));
});
});
describe('getConstellation', () => {
it('1月15日 = 摩羯座', () => {
assert.equal(getConstellation(1, 15), '摩羯座');
});
it('2月20日 = 双鱼座(边界日之后)', () => {
assert.equal(getConstellation(2, 20), '双鱼座');
});
it('3月21日 = 白羊座(春分边界)', () => {
assert.equal(getConstellation(3, 21), '白羊座');
});
it('12月22日 = 摩羯座', () => {
assert.equal(getConstellation(12, 22), '摩羯座');
});
});
// ─── 月相(通过 getDayInfo 间接验证) ───────────────────────
describe('getDayInfo 月相字段', () => {
it('正月初一附近返回朔月', () => {
// 2024-02-10 是农历正月初一
const { getDayInfo } = require('../index.js');
const r = getDayInfo(2024, 2, 10);
assert.equal(r.moonPhase, '朔月');
});
it('正月十五返回满月', () => {
// 2024-02-24 是农历正月十五
const { getDayInfo } = require('../index.js');
const r = getDayInfo(2024, 2, 24);
assert.equal(r.moonPhase, '满月');
});
});
// ─── 纳音 ─────────────────────────────────────────────────────
describe('getNayin', () => {
it('甲子 = 海中金', () => {
assert.equal(getNayin('甲子'), '海中金');
});
it('返回字符串', () => {
const r = getNayin('丙寅');
assert.equal(typeof r, 'string');
assert.ok(r.length > 0);
});
});
// ─── 六曜(通过 getDayInfo 间接验证) ───────────────────────
describe('getDayInfo 六曜字段', () => {
it('返回有效结构', () => {
const { getDayInfo } = require('../index.js');
const r = getDayInfo(2024, 1, 1);
assert.ok(r.lunarMonth > 0);
assert.ok(r.lunarDay > 0);
});
});
// ─── 胎神/宜忌/冲煞/彭祖(通过 getAlmanacInfo 间接验证) ────
describe('getAlmanacInfo 黄历字段', () => {
it('返回完整黄历结构', () => {
const { getAlmanacInfo } = require('../index.js');
const r = getAlmanacInfo(2024, 3, 18);
assert.ok(r.duty);
assert.ok(['good', 'bad', 'neutral'].includes(r.dutyLuck));
assert.ok(r.twelveStar);
assert.ok(typeof r.pengZu === 'string');
assert.ok(typeof r.clash === 'string');
assert.ok(typeof r.harm === 'string');
assert.ok(typeof r.combine === 'string');
assert.ok(['北', '南', '东', '西'].includes(r.evilDirection));
assert.ok(Array.isArray(r.recommends));
assert.ok(Array.isArray(r.avoids));
assert.ok(r.fetus);
});
});
// ─── 地支关系 ────────────────────────────────────────────────
describe('getBranchRelationship', () => {
it('子丑合', () => {
const r = getBranchRelationship('子', '丑');
assert.equal(r.combine, true);
});
it('子午冲', () => {
const r = getBranchRelationship('子', '午');
assert.equal(r.opposite, true);
});
it('相同地支为合', () => {
// 子丑合,所以子和丑是合关系
const r = getBranchRelationship('子', '丑');
assert.equal(r.combine, true);
});
it('申子辰三合水局', () => {
const r = getBranchRelationship('申', '子');
assert.equal(r.threeCombine, true);
assert.equal(r.formation, '水局');
});
});
// ─── 十神关系 ────────────────────────────────────────────────
describe('getTenStarRelationship', () => {
it('同干为比肩', () => {
assert.equal(getTenStarRelationship('甲', '甲'), '比肩');
});
it('甲见乙 = 劫财', () => {
assert.equal(getTenStarRelationship('甲', '乙'), '劫财');
});
it('甲见丙 = 食神', () => {
assert.equal(getTenStarRelationship('甲', '丙'), '食神');
});
});
// ─── 天干合冲 ────────────────────────────────────────────────
describe('checkStemCombine', () => {
it('甲己合', () => {
assert.equal(checkStemCombine('甲', '己'), true);
});
it('甲乙不合', () => {
assert.equal(checkStemCombine('甲', '乙'), false);
});
});
describe('checkStemOpposite', () => {
it('甲庚冲', () => {
assert.equal(checkStemOpposite('甲', '庚'), true);
});
it('甲乙不冲', () => {
assert.equal(checkStemOpposite('甲', '乙'), false);
});
});
// ─── 佛教节日 ────────────────────────────────────────────────
describe('getBuddhistFestival', () => {
it('腊月初八有佛教节日', () => {
// 腊八节是佛教节日
const r = getBuddhistFestival(12, 8);
// 可能有也可能没有,只验证返回类型
assert.ok(r === null || typeof r === 'string');
});
it('不存在的日期返回 null', () => {
assert.equal(getBuddhistFestival(6, 15), null);
});
});
// ─── 工作日判断 ──────────────────────────────────────────────
describe('isWorkday', () => {
it('普通周一为工作日', () => {
// 2024-03-18 是周一,非节假日
assert.equal(isWorkday(2024, 3, 18), true);
});
it('普通周六为非工作日', () => {
// 2024-03-16 是周六
assert.equal(isWorkday(2024, 3, 16), false);
});
it('普通周日为非工作日', () => {
// 2024-03-17 是周日
assert.equal(isWorkday(2024, 3, 17), false);
});
});
// ─── 五行分析 ────────────────────────────────────────────────
describe('analyzeElementBalance', () => {
it('四柱全木为不平衡', () => {
const bazi = {
yearPillar: { elementStem: '木', elementBranch: '木', hideStems: [{ stem: '木' }] },
monthPillar: { elementStem: '木', elementBranch: '木', hideStems: [{ stem: '木' }] },
dayPillar: { elementStem: '木', elementBranch: '木', hideStems: [{ stem: '木' }] },
hourPillar: { elementStem: '木', elementBranch: '木', hideStems: [{ stem: '木' }] }
};
const r = analyzeElementBalance(bazi);
assert.ok(r.wood > 0);
assert.equal(r.fire, 0);
assert.equal(r.isBalanced, false);
assert.equal(r.dominant, '木');
});
it('返回 wood/fire/earth/metal/water/total/dominant/weakest/isBalanced', () => {
const bazi = {
yearPillar: { elementStem: '木', elementBranch: '火', hideStems: [] },
monthPillar: { elementStem: '土', elementBranch: '金', hideStems: [] },
dayPillar: { elementStem: '水', elementBranch: '木', hideStems: [] },
hourPillar: { elementStem: '火', elementBranch: '土', hideStems: [] }
};
const r = analyzeElementBalance(bazi);
assert.equal(typeof r.total, 'number');
assert.ok(r.total > 0);
assert.ok(['木', '火', '土', '金', '水'].includes(r.dominant));
assert.ok(['木', '火', '土', '金', '水'].includes(r.weakest));
});
});
// ─── 称骨算命 ────────────────────────────────────────────────
describe('calculateBoneWeight', () => {
it('返回完整结构', () => {
const r = calculateBoneWeight(0, 1, 1, 0);
assert.ok(typeof r.yearWeight === 'number');
assert.ok(typeof r.monthWeight === 'number');
assert.ok(typeof r.dayWeight === 'number');
assert.ok(typeof r.hourWeight === 'number');
assert.ok(typeof r.totalWeight === 'number');
assert.equal(r.totalWeight, r.yearWeight + r.monthWeight + r.dayWeight + r.hourWeight);
assert.ok(typeof r.interpretation === 'string');
assert.ok(typeof r.fortune === 'string');
});
it('totalLiang 和 totalQian 与 totalWeight 一致', () => {
const r = calculateBoneWeight(5, 3, 10, 4);
assert.equal(r.totalLiang * 10 + r.totalQian, r.totalWeight);
});
});
// ─── 大运/流年分析 ───────────────────────────────────────────
describe('analyzeFortuneGanzhi', () => {
it('返回完整分析结构', () => {
const r = analyzeFortuneGanzhi('甲子', '甲', '子');
assert.equal(r.ganzhi, '甲子');
assert.ok(r.tenStar);
assert.ok(r.elementRelation);
assert.ok(['吉', '平', '凶'].includes(r.level));
assert.ok(typeof r.score === 'number');
});
it('同干同支 score 不为极端值', () => {
const r = analyzeFortuneGanzhi('甲子', '甲', '子');
assert.ok(r.score >= -10 && r.score <= 10);
});
});
// ─── 节气查询 ────────────────────────────────────────────────
describe('getSolarTerm', () => {
it('非节气日返回 null', () => {
// 随机选一个非节气日
const r = getSolarTerm(2024, 6, 15);
assert.equal(r, null);
});
it('返回值类型为字符串或 null', () => {
const r = getSolarTerm(2024, 3, 20); // 春分附近
assert.ok(r === null || typeof r === 'string');
});
});
+38
View File
@@ -0,0 +1,38 @@
SERVER_PORT=8080
SERVER_ENV=development
PUBLIC_BASE_URL=http://localhost:8080
WEB_APP_URL=http://localhost:4260
CORS_ORIGINS=http://localhost:4260
DB_TYPE=sqlite
DB_NAME=lunar.db
JWT_SECRET=replace-with-a-long-random-secret
ADMIN_PASSWORD=replace-with-a-strong-admin-password
SMTP_HOST=smtp.qq.com
SMTP_PORT=465
SMTP_USERNAME=your-qq-email@qq.com
SMTP_PASSWORD=your-qq-smtp-authorization-code
# 可填写已在 QQ 域名邮箱绑定到该账号的域名地址,例如 z@kepo.net;域名需配置 SPF
SMTP_FROM_EMAIL=z@kepo.net
SMTP_TLS_MODE=implicit
SMTP_TLS_CA_FILE=
# 可选备用通道;未配置 SMTP 时才会使用
POSTMARK_SERVER_TOKEN=
POSTMARK_FROM_EMAIL=login@your-domain.com
POSTMARK_API_BASE_URL=https://api.postmarkapp.com
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_API_BASE_URL=https://api.stripe.com
BINANCE_PAY_API_KEY=
BINANCE_PAY_SECRET_KEY=
BINANCE_PAY_WEBHOOK_PUBLIC_KEY=
BINANCE_PAY_API_BASE_URL=https://bpay.binanceapi.com
MINI_APP_ID=
MINI_APP_SECRET=
WECHAT_PAY_APIKEY=
WECHAT_PAY_MCHID=
+84 -84
View File
@@ -1,114 +1,114 @@
# 祈福小助手后端服务 # 知命 / 祈福后端服务
基于 Go + Gin + Inertia.js 的后端服务。 基于 Go、Gin、GORM 的统一后端,提供微信小程序能力、商业版八字报告、邮箱登录、支付、订单权益和管理后台。
## 功能模块 ## 功能模块
- **用户模块** — 微信登录、资料修改、授权管理 - 微信小程序登录与生辰档案。
- **支付模块** — 微信支付集成、订单管理 - QQ 邮箱 SMTP 验证码登录,支持 465 隐式 TLS 或 587 STARTTLS;Postmark 可选备用。
- **许愿树** — 许愿发布、付费许愿、机器人自动许愿 - Stripe `4.99 USD` 报告支付。
- **百科/历史上的今天** — 百科条目后台维护;从中文维基百科同步 366 天「历史上的今天」数据,定时刷新 - Binance Pay `5 USDT` 报告支付。
- **管理后台** — 基于 Inertia.js 的前后端分离管理界面 - 签名 Webhook、幂等事件、退款权益撤销。
- 随机报告恢复码与不可变报告快照。
## 技术栈 - 用户订单与报告权益查询。
- 数据库驱动的 PC 商业版订单列表和收入统计。
- **后端**: Go 1.22 + Gin + GORM - PC 商业版后台只查询 `type=bazi_report`,与小程序许愿等旧业务订单逻辑隔离。
- **前端**: Inertia.js + React/Vue(待选择) - 许愿树与百科数据。
- **数据库**: MySQL
- **缓存**: Redis
## 快速开始 ## 快速开始
### 开发环境
```bash ```bash
# 安装依赖 cp .env.example .env.local
go mod download go mod download
go run ./cmd
# 启动开发服务器
./scripts/dev.sh
``` ```
### 生产构建 默认地址:`http://localhost:8080`。
生产部署可使用 systemd 直接运行二进制,商业前端静态文件放在服务目录的 `frontend/`,根路径 `/` 为商业站点,`/admin/orders` 为管理后台,`/api/*` 为后端接口。这样无需 Docker 或 Nginx,适合小内存服务器;Cloudflare 可在边缘终结 HTTPS 并回源到 80 端口。
## 测试
项目包含 HTTP API 测试,覆盖未认证拦截、Webhook 拒绝、Stripe 模拟下单、签名回调、权益发放、恢复码和快照读取。
```bash ```bash
# 构建 go test ./...
./scripts/build.sh
# 运行
./bin/lunar-server
``` ```
## 项目结构 本地测试配置使用 `server/.env.test`。如果文件包含 SMTP 凭证,必须保持 Git 忽略并限制文件权限,绝不提交到仓库。真实发信测试需显式运行 `RUN_QQ_SMTP_LIVE_TEST=1 SMTP_TEST_TO=收件地址 go test ./internal/handler -run '^TestRequestEmailLoginCodeLiveQQSMTP$' -count=1`;该命令会实际发送邮件。
``` ## 商业支付 API
server/
├── cmd/ # 入口文件
│ └── main.go
├── internal/ # 内部包
│ ├── config/ # 配置
│ ├── handler/ # 处理器
│ ├── middleware/ # 中间件
│ ├── model/ # 数据模型
│ └── service/ # 业务逻辑
├── web/ # Inertia.js 前端
│ ├── src/
│ └── public/
├── migrations/ # 数据库迁移
├── scripts/ # 脚本
└── go.mod
```
## API 接口 ### 邮箱登录
### 用户接口 - `POST /api/auth/email/request`
- `POST /api/auth/email/verify`
- `POST /api/user/login` — 用户登录 ### 支付与报告
- `POST /api/user/logout` — 用户登出
- `GET /api/user/profile` — 获取用户资料
- `PUT /api/user/profile` — 更新用户资料
- `POST /api/user/auth` — 微信授权
### 支付接口 - `POST /api/commerce/checkout` — 创建 Stripe 或 Binance Pay 订单。
- `POST /api/commerce/webhooks/stripe` — Stripe 签名通知。
- `POST /api/commerce/webhooks/binance-pay` — Binance Pay 签名通知。
- `GET /api/commerce/orders/:orderNo` — 查询本人订单。
- `GET /api/commerce/reports/:reportId/access` — 查询报告权益。
- `GET /api/commerce/reports` — 本人已购报告。
- `GET /api/commerce/reports/recover/:accessCode` — 使用恢复码读取购买时快照。
- `POST /api/commerce/charts` — 用户主动同意后保存免费排盘记录;接口限流并记录同意版本与时间。
- `POST /api/pay/create` — 创建订单 除 Webhook 和需同意的公开排盘记录接口外,商业接口都要求 `Authorization: Bearer <JWT>`。
- `POST /api/pay/notify` — 支付回调
- `GET /api/pay/status/:orderId` — 查询支付状态
### 订单接口 免费排盘默认不上传。创建付费订单时,报告快照会发送并保存在服务端,用于生成、交付和恢复已购报告。
- `GET /api/order/list` — 订单列表 ## 管理后台
- `GET /api/order/detail/:id` — 订单详情
- `POST /api/order/cancel/:id` — 取消订单
### 许愿接口 - `/admin/orders` — 真实订单列表。
- `/admin/api/orders` — 按渠道、状态、订单号、邮箱、报告号查询。
- `/admin/api/orders/stats` — 订单和收入统计。
- `GET /api/wish/tree` — 获取许愿树 后台使用 `ADMIN_PASSWORD` 登录并写入 HttpOnly Cookie。
- `POST /api/wish/create` — 创建许愿
- `GET /api/wish/list` — 许愿列表
- `DELETE /api/wish/:id` — 删除许愿
### 百科接口 ## 支付安全
- `GET /api/wiki/entries` — 百科条目列表(支持 category/keyword 过滤) - 金额只从 `report_products` 读取。
- `GET /api/wiki/on-this-day?month=&day=` — 历史上的今天(大事记/出生/逝世/节假日习俗) - Stripe 使用 Checkout Session 和 `Idempotency-Key`。
- Stripe Webhook 校验 HMAC 签名与 5 分钟时间窗口。
### 管理后台接口(/admin,需管理员认证) - Binance Pay 下单使用 HMAC-SHA512。
- Binance Pay Webhook 使用商户证书 RSA 公钥验签。
- `GET/POST /admin/api/wiki/entries` — 百科条目列表/新增 - Webhook 同时校验服务商订单号、金额与币种。
- `PUT/DELETE /admin/api/wiki/entries/:id` — 更新/删除条目 - `payment_events` 唯一索引保证重复回调不会重复发放权益。
- `GET /admin/api/wiki/sync-status` — 维基同步状态 - 支付、事件、恢复码和权益在数据库事务中完成。
- `POST /admin/api/wiki/sync` — 手动触发全量同步 - 退款后报告权益自动撤销。
## 环境变量
参考 `.env.local` 文件配置。维基同步相关:
- `WIKI_SYNC_INTERVAL_DAYS` — 自动同步间隔(天),默认 7
- `WIKI_SYNC_HOUR` — 自动同步执行的小时点(0-23),默认 4
同步策略:服务启动时若数据缺失自动补齐;之后每天在指定小时点检查,距上次成功超过间隔天数才全量刷新(共 366 页,只抓中文版)。
## 数据库 ## 数据库
执行 `migrations/001_init.sql` 初始化数据库。 新安装由 GORM AutoMigrate 创建表。商业排盘记录新增同意版本和时间字段,服务启动时通过 AutoMigrate 增量添加;已有 MySQL 数据库应先备份再升级,不要执行数据库 fresh 或 refresh。商业表结构初始化脚本仍为:
```bash
mysql < migrations/002_commerce.sql
```
## 环境变量
完整模板见 `.env.example`。邮箱登录配置 QQ SMTP:
- `SMTP_HOST=smtp.qq.com`
- `SMTP_PORT=465`(或 587)
- `SMTP_USERNAME`:完整 QQ 邮箱地址
- `SMTP_PASSWORD`:QQ 邮箱生成的 SMTP 授权码,不是邮箱登录密码
- `SMTP_FROM_EMAIL`:发件地址。生产使用 `z@kepo.net`(`kepo.net` 已通过 QQ 域名邮箱绑定,MX 为 `mxdomain.qq.com`);需在 DNS 添加 SPF 记录 `v=spf1 include:spf.mail.qq.com ~all`,否则 Gmail 等可能拒收或判为垃圾邮件
- `SMTP_TLS_MODE=implicit`(465)或 `starttls`(587)
- `SMTP_TLS_CA_FILE`:可选的自定义 CA 文件路径
QQ 邮箱需先在账户设置中开启 SMTP 服务。SMTP 配置有效时优先使用 SMTP;未配置时才尝试 Postmark。生产环境还需要:
- `JWT_SECRET`
- `ADMIN_PASSWORD`
- `PUBLIC_BASE_URL`
- `WEB_APP_URL`
- `STRIPE_SECRET_KEY`
- `STRIPE_WEBHOOK_SECRET`
- `BINANCE_PAY_API_KEY`
- `BINANCE_PAY_SECRET_KEY`
- `BINANCE_PAY_WEBHOOK_PUBLIC_KEY`
所有支付与邮件密钥必须通过部署环境注入,禁止提交到 Git。
+28 -3
View File
@@ -68,9 +68,12 @@ func main() {
// 中间件 // 中间件
r.Use(middleware.CORS()) r.Use(middleware.CORS())
// 静态文件与管理后台模板 // 商业版前端静态文件、旧管理后台静态资源与模板
// 商业站点部署在根路径;后台仍通过 /admin 访问。
r.Static("/assets", "./frontend/assets")
r.GET("/index.html", func(c *gin.Context) { c.File("./frontend/index.html") })
r.GET("/", func(c *gin.Context) { c.File("./frontend/index.html") })
r.Static("/static", "./web/static") r.Static("/static", "./web/static")
r.StaticFile("/", "./web/index.html")
r.LoadHTMLFiles("./web/index.html") r.LoadHTMLFiles("./web/index.html")
// 内嵌图片资源(许愿树背景图等) // 内嵌图片资源(许愿树背景图等)
@@ -84,6 +87,24 @@ func main() {
// 版本信息(无需认证) // 版本信息(无需认证)
api.GET("/version", handler.GetVersion) api.GET("/version", handler.GetVersion)
auth := api.Group("/auth")
{
auth.POST("/email/request", middleware.RateLimit(), handler.RequestEmailLoginCode)
auth.POST("/email/verify", middleware.RateLimit(), handler.VerifyEmailLoginCode)
}
commerce := api.Group("/commerce")
{
commerce.POST("/charts", middleware.RateLimit(), handler.SaveChartRecord)
commerce.POST("/checkout", middleware.Auth(), handler.CreateReportCheckout)
commerce.GET("/orders/:orderNo", middleware.Auth(), handler.GetCommerceOrder)
commerce.GET("/reports/:reportId/access", middleware.Auth(), handler.GetReportAccess)
commerce.GET("/reports", middleware.Auth(), handler.ListPurchasedReports)
commerce.GET("/reports/recover/:accessCode", middleware.Auth(), handler.GetReportByAccessCode)
commerce.POST("/webhooks/stripe", handler.StripeWebhook)
commerce.POST("/webhooks/binance-pay", handler.BinancePayWebhook)
}
// 用户相关 // 用户相关
user := api.Group("/user") user := api.Group("/user")
{ {
@@ -103,7 +124,7 @@ func main() {
{ {
pay.POST("/create", middleware.Auth(), handler.CreateOrder) pay.POST("/create", middleware.Auth(), handler.CreateOrder)
pay.POST("/notify", handler.PayNotify) pay.POST("/notify", handler.PayNotify)
pay.GET("/status/:orderId", handler.GetPayStatus) pay.GET("/status/:orderId", middleware.Auth(), handler.GetPayStatus)
} }
// 订单相关 // 订单相关
@@ -148,6 +169,7 @@ func main() {
authed.GET("/", handler.AdminDashboard) authed.GET("/", handler.AdminDashboard)
authed.GET("/users", handler.AdminUsers) authed.GET("/users", handler.AdminUsers)
authed.GET("/orders", handler.AdminOrders) authed.GET("/orders", handler.AdminOrders)
authed.GET("/charts", handler.AdminCharts)
authed.GET("/wishes", handler.AdminWishes) authed.GET("/wishes", handler.AdminWishes)
authed.GET("/wiki", handler.AdminWiki) authed.GET("/wiki", handler.AdminWiki)
authed.GET("/settings", handler.AdminSettings) authed.GET("/settings", handler.AdminSettings)
@@ -159,6 +181,9 @@ func main() {
authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete) authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete)
authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus) authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus)
authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger) authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger)
authed.GET("/api/orders", handler.AdminCommerceOrders)
authed.GET("/api/orders/stats", handler.AdminCommerceStats)
authed.GET("/api/charts", handler.AdminChartRecords)
// 节气详情同步(百度百科,独立于维基) // 节气详情同步(百度百科,独立于维基)
authed.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus) authed.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus)
authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger) authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger)
+79 -7
View File
@@ -6,13 +6,18 @@ import (
) )
type Config struct { type Config struct {
Server ServerConfig Server ServerConfig
Database DatabaseConfig Database DatabaseConfig
Redis RedisConfig Redis RedisConfig
JWT JWTConfig JWT JWTConfig
Wechat WechatConfig Wechat WechatConfig
Admin AdminConfig Stripe StripeConfig
Wiki WikiConfig BinancePay BinancePayConfig
Postmark PostmarkConfig
SMTP SMTPConfig
Commerce CommerceConfig
Admin AdminConfig
Wiki WikiConfig
} }
type ServerConfig struct { type ServerConfig struct {
@@ -48,6 +53,44 @@ type WechatConfig struct {
MchID string MchID string
} }
type StripeConfig struct {
SecretKey string
WebhookSecret string
APIBaseURL string
}
type BinancePayConfig struct {
APIKey string
SecretKey string
APIBaseURL string
WebhookPublicKey string
}
type PostmarkConfig struct {
ServerToken string
FromEmail string
APIBaseURL string
}
type SMTPConfig struct {
Host string
Port string
Username string
Password string
FromEmail string
TLSMode string
TLSCAFile string
}
func (c SMTPConfig) IsConfigured() bool {
return c.Host != "" || c.Username != "" || c.Password != "" || c.FromEmail != ""
}
type CommerceConfig struct {
PublicBaseURL string
WebAppURL string
}
// AdminConfig 管理后台配置 // AdminConfig 管理后台配置
type AdminConfig struct { type AdminConfig struct {
// Password 管理员登录密码;为空时禁用后台登录 // Password 管理员登录密码;为空时禁用后台登录
@@ -92,6 +135,35 @@ func Load() *Config {
PayKey: getEnv("WECHAT_PAY_APIKEY", ""), PayKey: getEnv("WECHAT_PAY_APIKEY", ""),
MchID: getEnv("WECHAT_PAY_MCHID", ""), MchID: getEnv("WECHAT_PAY_MCHID", ""),
}, },
Stripe: StripeConfig{
SecretKey: getEnv("STRIPE_SECRET_KEY", ""),
WebhookSecret: getEnv("STRIPE_WEBHOOK_SECRET", ""),
APIBaseURL: getEnv("STRIPE_API_BASE_URL", "https://api.stripe.com"),
},
BinancePay: BinancePayConfig{
APIKey: getEnv("BINANCE_PAY_API_KEY", ""),
SecretKey: getEnv("BINANCE_PAY_SECRET_KEY", ""),
APIBaseURL: getEnv("BINANCE_PAY_API_BASE_URL", "https://bpay.binanceapi.com"),
WebhookPublicKey: getEnv("BINANCE_PAY_WEBHOOK_PUBLIC_KEY", ""),
},
Postmark: PostmarkConfig{
ServerToken: getEnv("POSTMARK_SERVER_TOKEN", ""),
FromEmail: getEnv("POSTMARK_FROM_EMAIL", ""),
APIBaseURL: getEnv("POSTMARK_API_BASE_URL", "https://api.postmarkapp.com"),
},
SMTP: SMTPConfig{
Host: getEnv("SMTP_HOST", ""),
Port: getEnv("SMTP_PORT", "465"),
Username: getEnv("SMTP_USERNAME", ""),
Password: getEnv("SMTP_PASSWORD", ""),
FromEmail: getEnv("SMTP_FROM_EMAIL", ""),
TLSMode: getEnv("SMTP_TLS_MODE", "implicit"),
TLSCAFile: getEnv("SMTP_TLS_CA_FILE", ""),
},
Commerce: CommerceConfig{
PublicBaseURL: getEnv("PUBLIC_BASE_URL", "http://localhost:8080"),
WebAppURL: getEnv("WEB_APP_URL", "http://localhost:4260"),
},
Admin: AdminConfig{ Admin: AdminConfig{
Password: getEnv("ADMIN_PASSWORD", ""), Password: getEnv("ADMIN_PASSWORD", ""),
}, },
+15 -1
View File
@@ -5,9 +5,9 @@ import (
"log" "log"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/gouki/lunar-server/internal/model"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/gouki/lunar-server/internal/model"
) )
var DB *gorm.DB var DB *gorm.DB
@@ -56,6 +56,12 @@ func autoMigrate() error {
&model.BirthProfile{}, &model.BirthProfile{},
&model.Order{}, &model.Order{},
&model.OrderItem{}, &model.OrderItem{},
&model.ReportProduct{},
&model.ReportEntitlement{},
&model.ReportSnapshot{},
&model.PaymentEvent{},
&model.EmailLoginCode{},
&model.BaziChartRecord{},
&model.WishTree{}, &model.WishTree{},
&model.Wish{}, &model.Wish{},
&model.WishProduct{}, &model.WishProduct{},
@@ -78,6 +84,14 @@ func autoMigrate() error {
// seedDefaultData 初始化默认数据 // seedDefaultData 初始化默认数据
func seedDefaultData() error { func seedDefaultData() error {
reportProduct := model.ReportProduct{
Code: "bazi_full_report", Name: "八字完整精批报告", Description: "六大主题与十步大运完整解读",
StripeAmount: 499, StripeCurrency: "USD", BinanceAmount: "5.00", BinanceCurrency: "USDT", Status: 1,
}
if err := DB.Where("code = ?", reportProduct.Code).FirstOrCreate(&reportProduct).Error; err != nil {
return err
}
// 补充已有许愿树的背景图(新增字段后老数据为空) // 补充已有许愿树的背景图(新增字段后老数据为空)
if err := DB.Model(&model.WishTree{}). if err := DB.Model(&model.WishTree{}).
Where("image = '' OR image IS NULL"). Where("image = '' OR image IS NULL").
+7 -2
View File
@@ -92,9 +92,14 @@ func AdminUsers(c *gin.Context) {
adminPage(c, "用户管理", "users") adminPage(c, "用户管理", "users")
} }
// AdminOrders 订单管理 // AdminOrders PC 商业版订单管理
func AdminOrders(c *gin.Context) { func AdminOrders(c *gin.Context) {
adminPage(c, "订单管理", "orders") adminPage(c, "PC 报告订单", "orders")
}
// AdminCharts 排盘记录
func AdminCharts(c *gin.Context) {
adminPage(c, "排盘记录", "charts")
} }
// AdminWishes 许愿管理 // AdminWishes 许愿管理
+290
View File
@@ -0,0 +1,290 @@
package handler
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model"
"github.com/gouki/lunar-server/internal/service"
)
var reportIDPattern = regexp.MustCompile(`^ZM-[A-Z0-9]{7,32}$`)
var accessCodePattern = regexp.MustCompile(`^ZM-[A-Z2-7]{4}-[A-Z2-7]{4}-[A-Z2-7]{4}$`)
func SaveChartRecord(c *gin.Context) {
var request struct {
ReportID string `json:"reportId" binding:"required"`
Name string `json:"name"`
BirthDate string `json:"birthDate" binding:"required"`
BirthTime string `json:"birthTime" binding:"required"`
Gender string `json:"gender" binding:"required"`
City string `json:"city" binding:"required"`
ConsentVersion string `json:"consentVersion" binding:"required"`
Snapshot json.RawMessage `json:"snapshot" binding:"required"`
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 600*1024)
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误"})
return
}
snapshot := bytes.TrimSpace(request.Snapshot)
var snapshotObject map[string]json.RawMessage
birthDate, dateErr := time.Parse("2006-01-02", request.BirthDate)
_, timeErr := time.Parse("15:04", request.BirthTime)
if !reportIDPattern.MatchString(request.ReportID) || len(snapshot) < 2 || len(snapshot) > 512*1024 ||
json.Unmarshal(snapshot, &snapshotObject) != nil || snapshotObject == nil ||
dateErr != nil || birthDate.Year() < 1901 || birthDate.Year() > 2100 || timeErr != nil ||
(request.Gender != "male" && request.Gender != "female") || request.ConsentVersion != service.ChartRecordConsentVersion ||
utf8.RuneCountInString(request.Name) > 64 || utf8.RuneCountInString(request.City) > 64 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "排盘记录参数错误或未同意保存"})
return
}
consentedAt := time.Now().UTC()
record := &model.BaziChartRecord{
ReportID: request.ReportID, Name: request.Name, BirthDate: request.BirthDate,
BirthTime: request.BirthTime, Gender: request.Gender, City: request.City,
ConsentVersion: request.ConsentVersion, ConsentAt: &consentedAt, Snapshot: string(snapshot),
}
if userID, exists := c.Get("userID"); exists {
if value, ok := userID.(uint); ok {
record.UserID = &value
}
}
if err := service.NewCommerceService().CreateChartRecord(record); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "保存排盘记录失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"recordId": record.ID}})
}
func CreateReportCheckout(c *gin.Context) {
userID, _ := c.Get("userID")
var request struct {
Provider string `json:"provider" binding:"required"`
ReportID string `json:"reportId" binding:"required"`
Snapshot json.RawMessage `json:"snapshot" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil || !reportIDPattern.MatchString(request.ReportID) || len(request.Snapshot) > 256*1024 || len(request.Snapshot) < 2 || request.Snapshot[0] != '{' {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "支付参数错误"})
return
}
user, err := service.NewUserService().GetUserByID(userID.(uint))
if err != nil || user.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "请先完成邮箱登录"})
return
}
commerce := service.NewCommerceService()
product, err := commerce.GetActiveReportProduct()
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "商品暂不可购买"})
return
}
order, err := commerce.CreateReportOrder(user.ID, user.Email, request.ReportID, request.Provider, string(request.Snapshot), product)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": err.Error()})
return
}
cfg := config.Load()
providerConfigured := (request.Provider == "stripe" && cfg.Stripe.SecretKey != "") ||
(request.Provider == "binance_pay" && cfg.BinancePay.APIKey != "")
// 开发模式:支付渠道未配置时走本地模拟支付,方便完整联调
if !providerConfigured && cfg.Server.Env != "production" {
mockURL := strings.TrimRight(cfg.Commerce.WebAppURL, "/") + "/?payment=success&order=" + url.QueryEscape(order.OrderNo)
if err := commerce.SetCheckout(order.OrderNo, "DEV-"+order.OrderNo, mockURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "订单保存失败"})
return
}
// 模拟 webhook:立即发放权益(Stripe 金额用 cents,Binance 用 USDT 数量)
mockPaidAmount := order.ProviderAmount
if request.Provider == "stripe" {
mockPaidAmount = strconv.Itoa(order.Amount)
}
_ = commerce.CompletePayment(request.Provider, "dev-"+order.OrderNo, "dev.mock.paid", order.OrderNo, "DEV-"+order.OrderNo, "DEV-TX-"+order.OrderNo, mockPaidAmount, order.Currency, "dev-mock")
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{
"orderNo": order.OrderNo, "provider": order.Provider, "amount": order.ProviderAmount,
"currency": order.Currency, "checkoutUrl": mockURL, "expiresAt": order.ExpireTime, "demo": true,
}})
return
}
var checkout *service.CheckoutResult
if request.Provider == "stripe" {
checkout, err = service.NewStripeProvider(cfg.Stripe).CreateCheckout(c.Request.Context(), order, cfg.Commerce.WebAppURL)
} else if request.Provider == "binance_pay" {
checkout, err = service.NewBinancePayProvider(cfg.BinancePay).CreateCheckout(c.Request.Context(), order, cfg.Commerce.WebAppURL, cfg.Commerce.PublicBaseURL)
} else {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "不支持的支付方式"})
return
}
if err != nil {
_ = commerce.MarkOrderStatus(request.Provider, "create-failed:"+order.OrderNo, "checkout.create_failed", order.OrderNo, "cancelled", "")
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "支付渠道暂时不可用"})
return
}
if err := commerce.SetCheckout(order.OrderNo, checkout.ProviderOrderID, checkout.CheckoutURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "订单保存失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{
"orderNo": order.OrderNo, "provider": order.Provider, "amount": order.ProviderAmount,
"currency": order.Currency, "checkoutUrl": checkout.CheckoutURL, "expiresAt": order.ExpireTime,
}})
}
func StripeWebhook(c *gin.Context) {
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
c.Status(http.StatusBadRequest)
return
}
event, err := service.NewStripeProvider(config.Load().Stripe).ParseWebhook(raw, c.GetHeader("Stripe-Signature"), time.Now())
if err != nil {
c.Status(http.StatusBadRequest)
return
}
if err := processCommerceWebhook("stripe", event); err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{"received": true})
}
func BinancePayWebhook(c *gin.Context) {
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
c.Status(http.StatusBadRequest)
return
}
event, err := service.NewBinancePayProvider(config.Load().BinancePay).ParseWebhook(
raw, c.GetHeader("BinancePay-Timestamp"), c.GetHeader("BinancePay-Nonce"), c.GetHeader("BinancePay-Signature"),
)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
if err := processCommerceWebhook("binance_pay", event); err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{"returnCode": "SUCCESS", "returnMessage": nil})
}
func processCommerceWebhook(provider string, event *service.PaymentWebhook) error {
if event.Status == "ignored" {
return nil
}
commerce := service.NewCommerceService()
if event.Status == "refunded" {
return commerce.RefundPayment(provider, event.EventID, event.EventType, event.OrderNo, event.TransactionID, event.PayloadHash)
}
if event.OrderNo == "" {
return nil
}
if event.Status == "paid" {
return commerce.CompletePayment(provider, event.EventID, event.EventType, event.OrderNo, event.ProviderOrderID, event.TransactionID, event.Amount, event.Currency, event.PayloadHash)
}
return commerce.MarkOrderStatus(provider, event.EventID, event.EventType, event.OrderNo, event.Status, event.PayloadHash)
}
func GetCommerceOrder(c *gin.Context) {
userID, _ := c.Get("userID")
order, err := service.NewCommerceService().GetUserOrder(userID.(uint), c.Param("orderNo"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "订单不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": order})
}
func GetReportAccess(c *gin.Context) {
userID, _ := c.Get("userID")
reportID := c.Param("reportId")
if !reportIDPattern.MatchString(reportID) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "报告编号错误"})
return
}
access, err := service.NewCommerceService().HasReportAccess(userID.(uint), reportID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "查询失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"reportId": reportID, "unlocked": access}})
}
func GetReportByAccessCode(c *gin.Context) {
userID, _ := c.Get("userID")
accessCode := c.Param("accessCode")
if !accessCodePattern.MatchString(accessCode) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "恢复码格式错误"})
return
}
record, err := service.NewCommerceService().GetReportSnapshot(userID.(uint), accessCode)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "未找到已购买报告"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"reportId": record.ReportID, "snapshot": json.RawMessage(record.Snapshot)}})
}
func ListPurchasedReports(c *gin.Context) {
userID, _ := c.Get("userID")
reports, err := service.NewCommerceService().ListPurchasedReports(userID.(uint))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取报告失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"list": reports}})
}
func AdminCommerceOrders(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
filter := service.AdminOrderFilter{Page: page, PageSize: pageSize, Status: c.Query("status"), Provider: c.Query("provider"), Search: c.Query("search")}
orders, total, err := service.NewCommerceService().AdminOrders(filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取订单失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"list": orders, "total": total, "page": page, "pageSize": pageSize}})
}
func AdminChartRecords(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 50
}
records, total, err := service.NewCommerceService().AdminChartRecords(c.Query("search"), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取排盘记录失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"list": records, "total": total, "page": page, "pageSize": pageSize}})
}
func AdminCommerceStats(c *gin.Context) {
stats, err := service.NewCommerceService().AdminStats()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "msg": "获取统计失败"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": stats})
}
+242
View File
@@ -0,0 +1,242 @@
package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/middleware"
"github.com/gouki/lunar-server/internal/model"
"github.com/gouki/lunar-server/internal/service"
)
func TestCommerceCheckoutRequiresAuthentication(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/api/commerce/checkout", middleware.Auth(), CreateReportCheckout)
request := httptest.NewRequest(http.MethodPost, "/api/commerce/checkout", strings.NewReader(`{"provider":"stripe","reportId":"ZM-ABCDEFG","snapshot":{"version":1,"result":"test"}}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), `"code":401`) {
t.Fatalf("unexpected response body: %s", response.Body.String())
}
}
func TestStripeWebhookRejectsUnsignedRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/api/commerce/webhooks/stripe", StripeWebhook)
request := httptest.NewRequest(http.MethodPost, "/api/commerce/webhooks/stripe", strings.NewReader(`{"id":"evt_invalid"}`))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", response.Code)
}
}
func TestEmailLoginRejectsMalformedBody(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/api/auth/email/request", RequestEmailLoginCode)
request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), `"code":400`) {
t.Fatalf("unexpected response body: %s", response.Body.String())
}
}
func TestStripeCheckoutWebhookAndEntitlementHTTPFlow(t *testing.T) {
gin.SetMode(gin.TestMode)
stripeAPI := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Type", "application/json")
fmt.Fprint(response, `{"id":"cs_test_flow","url":"https://checkout.stripe.test/flow"}`)
}))
defer stripeAPI.Close()
t.Setenv("DB_TYPE", "sqlite")
t.Setenv("DB_NAME", "file:commerce-http-test?mode=memory&cache=shared")
t.Setenv("JWT_SECRET", "http-test-secret")
t.Setenv("STRIPE_SECRET_KEY", "sk_test")
t.Setenv("STRIPE_WEBHOOK_SECRET", "whsec_http_test")
t.Setenv("STRIPE_API_BASE_URL", stripeAPI.URL)
t.Setenv("WEB_APP_URL", "https://example.test")
if err := config.InitDB(config.Load()); err != nil {
t.Fatal(err)
}
user := model.User{OpenID: "email:http-test", Email: "buyer@example.com", Nickname: "buyer", Status: 1}
if err := config.GetDB().Create(&user).Error; err != nil {
t.Fatal(err)
}
token, err := service.NewUserService().GenerateToken(user.ID, "http-test-secret")
if err != nil {
t.Fatal(err)
}
router := gin.New()
router.POST("/api/commerce/checkout", middleware.Auth(), CreateReportCheckout)
router.POST("/api/commerce/webhooks/stripe", StripeWebhook)
router.GET("/api/commerce/orders/:orderNo", middleware.Auth(), GetCommerceOrder)
router.GET("/api/commerce/reports/:reportId/access", middleware.Auth(), GetReportAccess)
router.GET("/api/commerce/reports/recover/:accessCode", middleware.Auth(), GetReportByAccessCode)
checkoutRequest := httptest.NewRequest(http.MethodPost, "/api/commerce/checkout", strings.NewReader(`{"provider":"stripe","reportId":"ZM-ABCDEFG","snapshot":{"version":1,"id":"ZM-ABCDEFG","result":{"eightChar":{"dayMaster":"甲","dayMasterElement":"木"},"decadeFortunes":[{"ganzhi":"丙寅","startAge":8}]},"elements":{"dominant":"木","weakest":"金"},"shensha":[],"sections":[]}}`))
checkoutRequest.Header.Set("Content-Type", "application/json")
checkoutRequest.Header.Set("Authorization", "Bearer "+token)
checkoutResponse := httptest.NewRecorder()
router.ServeHTTP(checkoutResponse, checkoutRequest)
if checkoutResponse.Code != http.StatusOK {
t.Fatalf("expected checkout 200, got %d: %s", checkoutResponse.Code, checkoutResponse.Body.String())
}
var checkoutPayload struct {
Data struct {
OrderNo string `json:"orderNo"`
} `json:"data"`
}
if err := json.Unmarshal(checkoutResponse.Body.Bytes(), &checkoutPayload); err != nil || checkoutPayload.Data.OrderNo == "" {
t.Fatalf("invalid checkout response: %s", checkoutResponse.Body.String())
}
now := time.Now()
webhookBody := []byte(fmt.Sprintf(`{"id":"evt_http_flow","type":"checkout.session.completed","data":{"object":{"id":"cs_test_flow","payment_intent":"pi_http_flow","payment_status":"paid","amount_total":499,"currency":"usd","metadata":{"order_no":"%s"}}}}`, checkoutPayload.Data.OrderNo))
mac := hmac.New(sha256.New, []byte("whsec_http_test"))
mac.Write([]byte(fmt.Sprintf("%d.%s", now.Unix(), webhookBody)))
webhookRequest := httptest.NewRequest(http.MethodPost, "/api/commerce/webhooks/stripe", strings.NewReader(string(webhookBody)))
webhookRequest.Header.Set("Stripe-Signature", fmt.Sprintf("t=%d,v1=%s", now.Unix(), hex.EncodeToString(mac.Sum(nil))))
webhookResponse := httptest.NewRecorder()
router.ServeHTTP(webhookResponse, webhookRequest)
if webhookResponse.Code != http.StatusOK {
t.Fatalf("expected webhook 200, got %d: %s", webhookResponse.Code, webhookResponse.Body.String())
}
accessRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/reports/ZM-ABCDEFG/access", nil)
accessRequest.Header.Set("Authorization", "Bearer "+token)
accessResponse := httptest.NewRecorder()
router.ServeHTTP(accessResponse, accessRequest)
if accessResponse.Code != http.StatusOK || !strings.Contains(accessResponse.Body.String(), `"unlocked":true`) {
t.Fatalf("expected active entitlement, got %d: %s", accessResponse.Code, accessResponse.Body.String())
}
orderRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/orders/"+checkoutPayload.Data.OrderNo, nil)
orderRequest.Header.Set("Authorization", "Bearer "+token)
orderResponse := httptest.NewRecorder()
router.ServeHTTP(orderResponse, orderRequest)
var orderPayload struct {
Data struct {
AccessCode string `json:"accessCode"`
} `json:"data"`
}
if err := json.Unmarshal(orderResponse.Body.Bytes(), &orderPayload); err != nil || orderPayload.Data.AccessCode == "" {
t.Fatalf("expected server recovery code, got %s", orderResponse.Body.String())
}
recoverRequest := httptest.NewRequest(http.MethodGet, "/api/commerce/reports/recover/"+orderPayload.Data.AccessCode, nil)
recoverRequest.Header.Set("Authorization", "Bearer "+token)
recoverResponse := httptest.NewRecorder()
router.ServeHTTP(recoverResponse, recoverRequest)
if recoverResponse.Code != http.StatusOK || !strings.Contains(recoverResponse.Body.String(), `"title":"性格底色"`) {
t.Fatalf("expected server-generated premium snapshot, got %d: %s", recoverResponse.Code, recoverResponse.Body.String())
}
}
func TestSaveChartRecordRequiresConsent(t *testing.T) {
router := setupChartRecordAPI(t)
response := postChartRecord(router, "198.51.100.41", false)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), `"code":400`) {
t.Fatalf("expected consent to be required, got %d: %s", response.Code, response.Body.String())
}
var recordCount int64
if err := config.GetDB().Model(&model.BaziChartRecord{}).Count(&recordCount).Error; err != nil {
t.Fatal(err)
}
if recordCount != 0 {
t.Fatalf("expected no chart record without consent, got %d", recordCount)
}
}
func TestSaveChartRecordPersistsConsentViaHTTP(t *testing.T) {
router := setupChartRecordAPI(t)
response := postChartRecord(router, "198.51.100.42", true)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"code":0`) {
t.Fatalf("expected chart record to be saved after consent, got %d: %s", response.Code, response.Body.String())
}
var record model.BaziChartRecord
if err := config.GetDB().Where("report_id = ?", "ZM-CHART123").First(&record).Error; err != nil {
t.Fatal(err)
}
if record.ConsentVersion != "chart-record-v1" || record.ConsentAt == nil {
t.Fatalf("expected persisted consent version and timestamp")
}
}
func TestSaveChartRecordRateLimitedViaHTTP(t *testing.T) {
router := setupChartRecordAPI(t)
for requestNumber := 0; requestNumber < 11; requestNumber++ {
response := postChartRecord(router, "198.51.100.43", true)
if requestNumber < 10 && response.Code != http.StatusOK {
t.Fatalf("request %d expected HTTP 200, got %d: %s", requestNumber+1, response.Code, response.Body.String())
}
if requestNumber == 10 && response.Code != http.StatusTooManyRequests {
t.Fatalf("expected HTTP 429 after rate limit, got %d: %s", response.Code, response.Body.String())
}
}
}
func TestSaveChartRecordRejectsOversizedBodyViaHTTP(t *testing.T) {
router := setupChartRecordAPI(t)
body := fmt.Sprintf(`{"reportId":"ZM-CHART123","birthDate":"1990-08-08","birthTime":"10:30","gender":"female","city":"上海","consentVersion":"chart-record-v1","snapshot":{"data":"%s"}}`, strings.Repeat("x", 600*1024))
request := httptest.NewRequest(http.MethodPost, "/api/commerce/charts", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.RemoteAddr = "198.51.100.44:1234"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected oversized body to be rejected, got %d", response.Code)
}
}
func setupChartRecordAPI(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
t.Setenv("SERVER_ENV", "test")
t.Setenv("DB_TYPE", "sqlite")
t.Setenv("DB_NAME", fmt.Sprintf("file:chart-consent-http-%d?mode=memory&cache=shared", time.Now().UnixNano()))
t.Setenv("JWT_SECRET", "chart-consent-test-secret")
if err := config.InitDB(config.Load()); err != nil {
t.Fatal(err)
}
router := gin.New()
router.POST("/api/commerce/charts", middleware.RateLimit(), SaveChartRecord)
return router
}
func postChartRecord(router *gin.Engine, clientIP string, consent bool) *httptest.ResponseRecorder {
body := `{"reportId":"ZM-CHART123","birthDate":"1990-08-08","birthTime":"10:30","gender":"female","city":"上海","snapshot":{"id":"ZM-CHART123"}`
if consent {
body += `,"consentVersion":"chart-record-v1"`
}
body += `}`
request := httptest.NewRequest(http.MethodPost, "/api/commerce/charts", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.RemoteAddr = clientIP + ":1234"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
+53
View File
@@ -0,0 +1,53 @@
package handler
import (
"errors"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/service"
)
func RequestEmailLoginCode(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "邮箱格式错误"})
return
}
err := service.NewEmailAuthService().RequestCode(request.Email)
if errors.Is(err, service.ErrInvalidEmail) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "邮箱格式错误"})
return
}
if errors.Is(err, service.ErrLoginCodeRateLimited) {
c.JSON(http.StatusTooManyRequests, gin.H{"code": 429, "msg": "请稍后再获取验证码"})
return
}
if err != nil {
// 保留 service 返回的 provider 错误(含服务商名称与上游状态码),便于生产排障关联
log.Printf("[EMAIL-AUTH] 验证码发送失败: email=%s err=%v", request.Email, err)
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "验证码暂时无法发送"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "验证码已发送"})
}
func VerifyEmailLoginCode(c *gin.Context) {
var request struct {
Email string `json:"email" binding:"required"`
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"})
return
}
user, token, err := service.NewEmailAuthService().VerifyCode(request.Email, request.Code)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "msg": "验证码无效或已过期"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "success", "data": gin.H{"token": token, "user": user}})
}
+333
View File
@@ -0,0 +1,333 @@
package handler
import (
"bufio"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/joho/godotenv"
)
type smtpTestResult struct {
message string
authenticated bool
err error
}
type smtpTestServer struct {
port string
caFile string
tlsMode string
results chan smtpTestResult
}
func startSMTPTestServer(t *testing.T, tlsMode string, rejectAuthentication bool) smtpTestServer {
t.Helper()
certificate, caFile := createSMTPTestCertificate(t)
listener, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
results := make(chan smtpTestResult, 1)
port := fmt.Sprint(listener.Addr().(*net.TCPAddr).Port)
go serveSMTPTestConnection(listener, certificate, tlsMode, rejectAuthentication, results)
return smtpTestServer{port: port, caFile: caFile, tlsMode: tlsMode, results: results}
}
func createSMTPTestCertificate(t *testing.T) (tls.Certificate, string) {
t.Helper()
now := time.Now()
rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
rootTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: now.Add(-time.Minute),
NotAfter: now.Add(time.Hour),
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
}
rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey)
if err != nil {
t.Fatal(err)
}
rootCertificate, err := x509.ParseCertificate(rootDER)
if err != nil {
t.Fatal(err)
}
serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
serverTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
NotBefore: now.Add(-time.Minute),
NotAfter: now.Add(time.Hour),
DNSNames: []string{"localhost"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
serverDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, rootCertificate, &serverKey.PublicKey, rootKey)
if err != nil {
t.Fatal(err)
}
privateKeyDER, err := x509.MarshalECPrivateKey(serverKey)
if err != nil {
t.Fatal(err)
}
certificate, err := tls.X509KeyPair(
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverDER}),
pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKeyDER}),
)
if err != nil {
t.Fatal(err)
}
caFile := filepath.Join(t.TempDir(), "smtp-test-ca.pem")
if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER}), 0600); err != nil {
t.Fatal(err)
}
return certificate, caFile
}
func serveSMTPTestConnection(listener net.Listener, certificate tls.Certificate, tlsMode string, rejectAuthentication bool, results chan<- smtpTestResult) {
connection, err := listener.Accept()
if err != nil {
results <- smtpTestResult{err: err}
return
}
defer connection.Close()
serverTLSConfig := &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}
tlsActive := false
if tlsMode == "implicit" {
tlsConnection := tls.Server(connection, serverTLSConfig)
if err := tlsConnection.Handshake(); err != nil {
results <- smtpTestResult{err: err}
return
}
connection = tlsConnection
tlsActive = true
}
reader := bufio.NewReader(connection)
writer := bufio.NewWriter(connection)
writeResponse := func(response string) error {
if _, err := writer.WriteString(response + "\r\n"); err != nil {
return err
}
return writer.Flush()
}
if err := writeResponse("220 localhost ESMTP"); err != nil {
results <- smtpTestResult{err: err}
return
}
var message strings.Builder
for {
command, err := reader.ReadString('\n')
if err != nil {
results <- smtpTestResult{err: err}
return
}
trimmed := strings.TrimRight(command, "\r\n")
switch {
case strings.HasPrefix(trimmed, "EHLO "):
capabilities := "250-localhost\r\n250-AUTH PLAIN\r\n250 OK\r\n"
if tlsMode == "starttls" && !tlsActive {
capabilities = "250-localhost\r\n250-STARTTLS\r\n250 OK\r\n"
}
if _, err := writer.WriteString(capabilities); err != nil {
results <- smtpTestResult{err: err}
return
}
if err := writer.Flush(); err != nil {
results <- smtpTestResult{err: err}
return
}
case trimmed == "STARTTLS":
if err := writeResponse("220 2.0.0 ready to start TLS"); err != nil {
results <- smtpTestResult{err: err}
return
}
tlsConnection := tls.Server(connection, serverTLSConfig)
if err := tlsConnection.Handshake(); err != nil {
results <- smtpTestResult{err: err}
return
}
connection = tlsConnection
reader = bufio.NewReader(connection)
writer = bufio.NewWriter(connection)
tlsActive = true
case strings.HasPrefix(trimmed, "AUTH PLAIN"):
encoded := strings.TrimSpace(strings.TrimPrefix(trimmed, "AUTH PLAIN"))
if encoded == "" {
if err := writeResponse("334 "); err != nil {
results <- smtpTestResult{err: err}
return
}
encoded, err = reader.ReadString('\n')
if err != nil {
results <- smtpTestResult{err: err}
return
}
encoded = strings.TrimSpace(encoded)
}
credentials, decodeErr := base64.StdEncoding.DecodeString(encoded)
parts := strings.Split(string(credentials), "\x00")
authenticated := decodeErr == nil && len(parts) == 3 && parts[1] == "smtp-user@example.test" && parts[2] == "smtp-test-password"
if rejectAuthentication || !authenticated {
writeResponse("535 5.7.8 Authentication credentials invalid")
results <- smtpTestResult{authenticated: false}
return
}
if err := writeResponse("235 2.7.0 Authentication successful"); err != nil {
results <- smtpTestResult{err: err}
return
}
case strings.HasPrefix(trimmed, "MAIL FROM:") || strings.HasPrefix(trimmed, "RCPT TO:"):
if err := writeResponse("250 2.1.0 accepted"); err != nil {
results <- smtpTestResult{err: err}
return
}
case trimmed == "DATA":
if err := writeResponse("354 end with <CRLF>.<CRLF>"); err != nil {
results <- smtpTestResult{err: err}
return
}
for {
line, err := reader.ReadString('\n')
if err != nil {
results <- smtpTestResult{err: err}
return
}
if line == ".\r\n" {
break
}
message.WriteString(strings.TrimPrefix(line, "."))
}
if err := writeResponse("250 2.0.0 queued"); err != nil {
results <- smtpTestResult{err: err}
return
}
results <- smtpTestResult{message: message.String(), authenticated: true}
case trimmed == "QUIT":
writeResponse("221 2.0.0 bye")
return
default:
if err := writeResponse("250 OK"); err != nil {
results <- smtpTestResult{err: err}
return
}
}
}
}
func configureEmailCodeAPI(t *testing.T, server smtpTestServer) *gin.Engine {
t.Helper()
t.Setenv("SERVER_ENV", "test")
t.Setenv("DB_TYPE", "sqlite")
t.Setenv("DB_NAME", fmt.Sprintf("file:email-auth-smtp-http-test-%s?mode=memory&cache=shared", server.port))
t.Setenv("JWT_SECRET", "email-auth-test-secret")
t.Setenv("SMTP_HOST", "localhost")
t.Setenv("SMTP_PORT", server.port)
t.Setenv("SMTP_USERNAME", "smtp-user@example.test")
t.Setenv("SMTP_PASSWORD", "smtp-test-password")
t.Setenv("SMTP_FROM_EMAIL", "login@example.test")
t.Setenv("SMTP_TLS_MODE", server.tlsMode)
t.Setenv("SMTP_TLS_CA_FILE", server.caFile)
t.Setenv("POSTMARK_SERVER_TOKEN", "")
t.Setenv("POSTMARK_FROM_EMAIL", "")
if err := config.InitDB(config.Load()); err != nil {
t.Fatal(err)
}
router := gin.New()
router.POST("/api/auth/email/request", RequestEmailLoginCode)
return router
}
func TestRequestEmailLoginCodeUsesSMTPViaAPI(t *testing.T) {
gin.SetMode(gin.TestMode)
server := startSMTPTestServer(t, "implicit", false)
router := configureEmailCodeAPI(t, server)
request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(`{"email":"recipient@example.com"}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"code":0`) {
t.Fatalf("expected HTTP 200 code:0, got %d: %s", response.Code, response.Body.String())
}
result := <-server.results
if result.err != nil {
t.Fatalf("SMTP test server failed: %v", result.err)
}
if !result.authenticated || !strings.Contains(result.message, "To: <recipient@example.com>") || !strings.Contains(result.message, "10 分钟内有效") {
t.Fatalf("unexpected SMTP message or authentication result: authenticated=%t", result.authenticated)
}
}
func TestRequestEmailLoginCodeReturnsServiceUnavailableWhenSMTPAuthFails(t *testing.T) {
gin.SetMode(gin.TestMode)
server := startSMTPTestServer(t, "starttls", true)
router := configureEmailCodeAPI(t, server)
request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(`{"email":"recipient@example.com"}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || strings.Contains(response.Body.String(), `"code":0`) {
t.Fatalf("expected HTTP 503 with nonzero code, got %d: %s", response.Code, response.Body.String())
}
result := <-server.results
if result.err != nil {
t.Fatalf("SMTP test server failed: %v", result.err)
}
if result.authenticated {
t.Fatal("expected SMTP authentication to be rejected")
}
}
func TestRequestEmailLoginCodeLiveQQSMTP(t *testing.T) {
if os.Getenv("RUN_QQ_SMTP_LIVE_TEST") != "1" {
t.Skip("set RUN_QQ_SMTP_LIVE_TEST=1 to send a real email through QQ SMTP")
}
recipient := os.Getenv("SMTP_TEST_TO")
if recipient == "" {
t.Fatal("SMTP_TEST_TO must be set to the test recipient")
}
testEnvironment, err := godotenv.Read("../../.env.test")
if err != nil {
t.Fatalf("load local SMTP test environment: %v", err)
}
for name, value := range testEnvironment {
t.Setenv(name, value)
}
if err := config.InitDB(config.Load()); err != nil {
t.Fatal(err)
}
router := gin.New()
router.POST("/api/auth/email/request", RequestEmailLoginCode)
requestBody := fmt.Sprintf(`{"email":%q}`, recipient)
request := httptest.NewRequest(http.MethodPost, "/api/auth/email/request", strings.NewReader(requestBody))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"code":0`) {
t.Fatalf("expected HTTP 200 code:0, got %d: %s", response.Code, response.Body.String())
}
}
+2 -1
View File
@@ -183,6 +183,7 @@ func PayNotify(c *gin.Context) {
// GetPayStatus 获取支付状态(支持订单号或订单 ID) // GetPayStatus 获取支付状态(支持订单号或订单 ID)
func GetPayStatus(c *gin.Context) { func GetPayStatus(c *gin.Context) {
param := c.Param("orderId") param := c.Param("orderId")
userID, _ := c.Get("userID")
orderService := service.NewOrderService() orderService := service.NewOrderService()
order, err := orderService.GetOrderByOrderNo(param) order, err := orderService.GetOrderByOrderNo(param)
@@ -192,7 +193,7 @@ func GetPayStatus(c *gin.Context) {
order, err = orderService.GetOrderByID(uint(id)) order, err = orderService.GetOrderByID(uint(id))
} }
} }
if err != nil { if err != nil || order.UserID != userID.(uint) {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"code": 404, "code": 404,
"msg": "订单不存在", "msg": "订单不存在",
+25 -1
View File
@@ -3,6 +3,8 @@ package middleware
import ( import (
"net/http" "net/http"
"strings" "strings"
"sync"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config" "github.com/gouki/lunar-server/internal/config"
@@ -146,10 +148,32 @@ func AdminAuth() gin.HandlerFunc {
} }
} }
var rateLimitStore = struct {
sync.Mutex
requests map[string][]time.Time
}{requests: make(map[string][]time.Time)}
// RateLimit 限流中间件 // RateLimit 限流中间件
func RateLimit() gin.HandlerFunc { func RateLimit() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
// TODO: 实现限流 now := time.Now()
key := c.ClientIP() + "|" + c.FullPath()
rateLimitStore.Lock()
windowStart := now.Add(-time.Minute)
recent := rateLimitStore.requests[key][:0]
for _, requestTime := range rateLimitStore.requests[key] {
if requestTime.After(windowStart) {
recent = append(recent, requestTime)
}
}
if len(recent) >= 10 {
rateLimitStore.requests[key] = recent
rateLimitStore.Unlock()
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"code": 429, "msg": "请求过于频繁"})
return
}
rateLimitStore.requests[key] = append(recent, now)
rateLimitStore.Unlock()
c.Next() c.Next()
} }
} }
@@ -0,0 +1,152 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestRateLimitAllowsUnderLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(RateLimit())
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
for i := 0; i < 10; i++ {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.RemoteAddr = "192.168.1.1:12345"
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("request %d should be allowed, got %d", i+1, resp.Code)
}
}
}
func TestRateLimitBlocksOverLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(RateLimit())
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// 发送 11 个请求,第 11 个应被限流
for i := 0; i < 11; i++ {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.RemoteAddr = "192.168.1.2:12345"
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if i < 10 && resp.Code != http.StatusOK {
t.Fatalf("request %d should be allowed, got %d", i+1, resp.Code)
}
if i == 10 && resp.Code != http.StatusTooManyRequests {
t.Fatalf("request 11 should be rate limited, got %d", resp.Code)
}
}
}
func TestRateLimitWindowReset(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(RateLimit())
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// 先发送 10 个请求达到上限
for i := 0; i < 10; i++ {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.RemoteAddr = "192.168.1.3:12345"
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
}
// 模拟时间过去 61 秒(超过 1 分钟窗口)
// 注意:由于实现使用真实时间,这里只能验证当前窗口内的行为
// 实际窗口重置测试需要 mock 时间或等待
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.RemoteAddr = "192.168.1.3:12345"
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusTooManyRequests {
t.Fatalf("should still be rate limited within window, got %d", resp.Code)
}
}
func TestAuthRejectsMissingToken(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(Auth())
router.GET("/protected", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.Code)
}
}
func TestAuthRejectsMalformedToken(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(Auth())
router.GET("/protected", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
req.Header.Set("Authorization", "InvalidFormat token123")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.Code)
}
}
func TestCORSAllowsConfiguredOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("CORS_ORIGINS", "https://example.com,https://app.example.com")
router := gin.New()
router.Use(CORS())
router.GET("/api/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Origin", "https://example.com")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.Code)
}
if resp.Header().Get("Access-Control-Allow-Origin") != "https://example.com" {
t.Fatalf("expected CORS header for allowed origin, got %s", resp.Header().Get("Access-Control-Allow-Origin"))
}
}
func TestCORSRejectsUnconfiguredOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("CORS_ORIGINS", "https://example.com")
router := gin.New()
router.Use(CORS())
router.GET("/api/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
req.Header.Set("Origin", "https://evil.com")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
// 非白名单源不应返回 CORS 头
if resp.Header().Get("Access-Control-Allow-Origin") == "https://evil.com" {
t.Fatal("should not allow unconfigured origin")
}
}
+76
View File
@@ -0,0 +1,76 @@
package model
import "time"
type BaziChartRecord struct {
ID uint `gorm:"primaryKey" json:"id"`
ReportID string `gorm:"index;size:64" json:"reportId"`
UserID *uint `gorm:"index" json:"userId,omitempty"`
Name string `gorm:"size:64" json:"name"`
BirthDate string `gorm:"size:16" json:"birthDate"`
BirthTime string `gorm:"size:8" json:"birthTime"`
Gender string `gorm:"size:16" json:"gender"`
City string `gorm:"size:64" json:"city"`
ConsentVersion string `gorm:"size:32;not null;default:''" json:"consentVersion"`
ConsentAt *time.Time `json:"consentAt,omitempty"`
Snapshot string `gorm:"type:longtext" json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ReportProduct struct {
ID uint `gorm:"primaryKey" json:"id"`
Code string `gorm:"uniqueIndex;size:40" json:"code"`
Name string `gorm:"size:100" json:"name"`
Description string `gorm:"size:255" json:"description"`
StripeAmount int `json:"stripeAmount"`
StripeCurrency string `gorm:"size:10" json:"stripeCurrency"`
BinanceAmount string `gorm:"size:32" json:"binanceAmount"`
BinanceCurrency string `gorm:"size:10" json:"binanceCurrency"`
Status int `gorm:"default:1" json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ReportEntitlement struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_user_report;index" json:"userId"`
ReportID string `gorm:"uniqueIndex:idx_user_report;size:64;index" json:"reportId"`
OrderID uint `gorm:"uniqueIndex" json:"orderId"`
Status string `gorm:"size:20;default:'active'" json:"status"`
GrantedAt time.Time `json:"grantedAt"`
RevokedAt *time.Time `json:"revokedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ReportSnapshot struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index" json:"userId"`
ReportID string `gorm:"size:64;index" json:"reportId"`
OrderID uint `gorm:"uniqueIndex;index" json:"orderId"`
Snapshot string `gorm:"type:longtext" json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PaymentEvent struct {
ID uint `gorm:"primaryKey" json:"id"`
Provider string `gorm:"uniqueIndex:idx_provider_event;size:30" json:"provider"`
ProviderEventID string `gorm:"uniqueIndex:idx_provider_event;size:128" json:"providerEventId"`
EventType string `gorm:"size:80" json:"eventType"`
OrderNo string `gorm:"size:32;index" json:"orderNo"`
PayloadHash string `gorm:"size:64" json:"payloadHash"`
ProcessedAt time.Time `json:"processedAt"`
CreatedAt time.Time `json:"createdAt"`
}
type EmailLoginCode struct {
ID uint `gorm:"primaryKey" json:"-"`
Email string `gorm:"size:254;index" json:"-"`
CodeHash string `gorm:"size:64" json:"-"`
Attempts int `gorm:"default:0" json:"-"`
ExpiresAt time.Time `gorm:"index" json:"-"`
UsedAt *time.Time `json:"-"`
CreatedAt time.Time `gorm:"index" json:"-"`
}
+24 -15
View File
@@ -6,19 +6,28 @@ import (
// Order 订单模型 // Order 订单模型
type Order struct { type Order struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
OrderNo string `gorm:"uniqueIndex;size:32" json:"orderNo"` // 订单号 OrderNo string `gorm:"uniqueIndex;size:32" json:"orderNo"` // 订单号
UserID uint `gorm:"index" json:"userId"` UserID uint `gorm:"index" json:"userId"`
Type string `gorm:"size:20" json:"type"` // wish:许愿 vip:会员 Type string `gorm:"size:20" json:"type"` // wish:许愿 vip:会员
ProductID uint `json:"productId"` ProductID uint `json:"productId"`
ProductName string `gorm:"size:100" json:"productName"` ProductName string `gorm:"size:100" json:"productName"`
Amount int `json:"amount"` // 金额(分) Amount int `json:"amount"` // 金额(分)
Status string `gorm:"size:20;default:'pending'" json:"status"` // pending:待支付 paid:已支付 cancelled:已取消 refunded:已退款 Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
PayTime *time.Time `json:"payTime"` Provider string `gorm:"size:30;index" json:"provider"`
ExpireTime *time.Time `json:"expireTime"` ProviderAmount string `gorm:"size:32" json:"providerAmount"`
Remark string `gorm:"size:255" json:"remark"` ProviderOrderID string `gorm:"size:128;index" json:"providerOrderId"`
CreatedAt time.Time `json:"createdAt"` ProviderTransactionID string `gorm:"size:128;index" json:"providerTransactionId"`
UpdatedAt time.Time `json:"updatedAt"` CustomerEmail string `gorm:"size:254;index" json:"customerEmail"`
ReportID string `gorm:"size:64;index" json:"reportId"`
AccessCode *string `gorm:"size:20;uniqueIndex" json:"accessCode,omitempty"`
CheckoutURL string `gorm:"size:1024" json:"checkoutUrl"`
Status string `gorm:"size:20;default:'pending'" json:"status"` // pending:待支付 paid:已支付 cancelled:已取消 refunded:已退款
PayTime *time.Time `json:"payTime"`
ExpireTime *time.Time `json:"expireTime"`
Remark string `gorm:"size:255" json:"remark"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
// OrderItem 订单项 // OrderItem 订单项
@@ -27,6 +36,6 @@ type OrderItem struct {
OrderID uint `gorm:"index" json:"orderId"` OrderID uint `gorm:"index" json:"orderId"`
ProductID uint `json:"productId"` ProductID uint `json:"productId"`
Name string `gorm:"size:100" json:"name"` Name string `gorm:"size:100" json:"name"`
Price int `json:"price"` // 单价(分) Price int `json:"price"` // 单价(分)
Quantity int `json:"quantity"` // 数量 Quantity int `json:"quantity"` // 数量
} }
+32
View File
@@ -0,0 +1,32 @@
package service
import (
"errors"
"strings"
"github.com/gouki/lunar-server/internal/model"
)
const ChartRecordConsentVersion = "chart-record-v1"
func (s *CommerceService) CreateChartRecord(record *model.BaziChartRecord) error {
if record.ReportID == "" || record.BirthDate == "" || record.BirthTime == "" || record.Snapshot == "" || record.ConsentVersion != ChartRecordConsentVersion || record.ConsentAt == nil {
return errors.New("incomplete chart record")
}
return s.db.Create(record).Error
}
func (s *CommerceService) AdminChartRecords(search string, page, pageSize int) ([]model.BaziChartRecord, int64, error) {
query := s.db.Model(&model.BaziChartRecord{})
if strings.TrimSpace(search) != "" {
like := "%" + strings.TrimSpace(search) + "%"
query = query.Where("report_id LIKE ? OR name LIKE ? OR city LIKE ?", like, like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var records []model.BaziChartRecord
err := query.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error
return records, total, err
}
+330
View File
@@ -0,0 +1,330 @@
package service
import (
"crypto/rand"
"encoding/base32"
"encoding/hex"
"errors"
"fmt"
"math/big"
"strconv"
"strings"
"time"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model"
"gorm.io/gorm"
)
type CommerceService struct {
db *gorm.DB
}
type AdminOrderFilter struct {
Page int
PageSize int
Status string
Provider string
Search string
}
type OrderStats struct {
TotalOrders int64 `json:"totalOrders"`
PaidOrders int64 `json:"paidOrders"`
Pending int64 `json:"pendingOrders"`
Refunded int64 `json:"refundedOrders"`
RevenueUSD int64 `json:"revenueUsdCents"`
}
func NewCommerceService() *CommerceService {
return &CommerceService{db: config.GetDB()}
}
func (s *CommerceService) GetActiveReportProduct() (*model.ReportProduct, error) {
var product model.ReportProduct
if err := s.db.Where("code = ? AND status = 1", "bazi_full_report").First(&product).Error; err != nil {
return nil, err
}
return &product, nil
}
func (s *CommerceService) CreateReportOrder(userID uint, email, reportID, provider, snapshot string, product *model.ReportProduct) (*model.Order, error) {
if provider != "stripe" && provider != "binance_pay" {
return nil, errors.New("unsupported payment provider")
}
var active model.ReportEntitlement
if err := s.db.Where("user_id = ? AND report_id = ? AND status = ?", userID, reportID, "active").First(&active).Error; err == nil {
return nil, errors.New("report already unlocked")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
orderNo, err := secureOrderNo()
if err != nil {
return nil, err
}
order := &model.Order{
OrderNo: orderNo, UserID: userID, Type: "bazi_report", ProductID: product.ID,
ProductName: product.Name, Status: "pending", CustomerEmail: strings.ToLower(email), ReportID: reportID,
}
if provider == "stripe" {
order.Amount = product.StripeAmount
order.Currency = product.StripeCurrency
order.ProviderAmount = fmt.Sprintf("%.2f", float64(product.StripeAmount)/100)
} else {
order.Amount = 500
order.Currency = product.BinanceCurrency
order.ProviderAmount = product.BinanceAmount
}
order.Provider = provider
expires := time.Now().Add(30 * time.Minute)
order.ExpireTime = &expires
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
return err
}
record := model.ReportSnapshot{UserID: userID, ReportID: reportID, OrderID: order.ID, Snapshot: snapshot}
if err := tx.Create(&record).Error; err != nil {
return err
}
return tx.Model(&model.BaziChartRecord{}).Where("report_id = ? AND user_id IS NULL", reportID).Update("user_id", userID).Error
})
if err != nil {
return nil, err
}
return order, nil
}
func (s *CommerceService) SetCheckout(orderNo, providerOrderID, checkoutURL string) error {
return s.db.Model(&model.Order{}).Where("order_no = ? AND status = ?", orderNo, "pending").Updates(map[string]interface{}{
"provider_order_id": providerOrderID,
"checkout_url": checkoutURL,
}).Error
}
func (s *CommerceService) CompletePayment(provider, eventID, eventType, orderNo, providerOrderID, transactionID, paidAmount, currency, payloadHash string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var existing model.PaymentEvent
if err := tx.Where("provider = ? AND provider_event_id = ?", provider, eventID).First(&existing).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var order model.Order
if err := tx.Where("order_no = ? AND provider = ?", orderNo, provider).First(&order).Error; err != nil {
return err
}
if providerOrderID == "" || providerOrderID != order.ProviderOrderID || !strings.EqualFold(currency, order.Currency) || !amountMatches(order, paidAmount) {
return errors.New("payment amount or provider order mismatch")
}
now := time.Now()
event := model.PaymentEvent{Provider: provider, ProviderEventID: eventID, EventType: eventType, OrderNo: orderNo, PayloadHash: payloadHash, ProcessedAt: now}
if err := tx.Create(&event).Error; err != nil {
return err
}
if order.Status == "paid" {
return nil
}
if order.Status != "pending" {
return fmt.Errorf("order %s cannot transition from %s to paid", orderNo, order.Status)
}
accessCode, err := secureAccessCode()
if err != nil {
return err
}
result := tx.Model(&model.Order{}).Where("id = ? AND status = ?", order.ID, "pending").Updates(map[string]interface{}{
"status": "paid", "pay_time": now, "provider_transaction_id": transactionID, "access_code": accessCode,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errors.New("order payment transition conflict")
}
var snapshot model.ReportSnapshot
if err := tx.Where("order_id = ?", order.ID).First(&snapshot).Error; err != nil {
return err
}
premiumSnapshot, err := buildPremiumSnapshot(snapshot.Snapshot)
if err != nil {
return err
}
if err := tx.Model(&snapshot).Update("snapshot", premiumSnapshot).Error; err != nil {
return err
}
entitlement := model.ReportEntitlement{UserID: order.UserID, ReportID: order.ReportID, OrderID: order.ID, Status: "active", GrantedAt: now}
return tx.Where("user_id = ? AND report_id = ?", order.UserID, order.ReportID).FirstOrCreate(&entitlement).Error
})
}
func (s *CommerceService) RefundPayment(provider, eventID, eventType, orderNo, transactionID, payloadHash string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&model.PaymentEvent{}).Where("provider = ? AND provider_event_id = ?", provider, eventID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
query := tx.Where("provider = ?", provider)
if orderNo != "" {
query = query.Where("order_no = ?", orderNo)
} else {
query = query.Where("provider_transaction_id = ?", transactionID)
}
var order model.Order
if err := query.First(&order).Error; err != nil {
return err
}
event := model.PaymentEvent{Provider: provider, ProviderEventID: eventID, EventType: eventType, OrderNo: order.OrderNo, PayloadHash: payloadHash, ProcessedAt: time.Now()}
if err := tx.Create(&event).Error; err != nil {
return err
}
if order.Status == "refunded" {
return nil
}
if order.Status != "paid" {
return fmt.Errorf("order %s cannot transition from %s to refunded", order.OrderNo, order.Status)
}
now := time.Now()
if err := tx.Model(&model.Order{}).Where("id = ? AND status = ?", order.ID, "paid").Update("status", "refunded").Error; err != nil {
return err
}
return tx.Model(&model.ReportEntitlement{}).Where("order_id = ? AND status = ?", order.ID, "active").Updates(map[string]interface{}{"status": "revoked", "revoked_at": now}).Error
})
}
func (s *CommerceService) MarkOrderStatus(provider, eventID, eventType, orderNo, status, payloadHash string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&model.PaymentEvent{}).Where("provider = ? AND provider_event_id = ?", provider, eventID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
event := model.PaymentEvent{Provider: provider, ProviderEventID: eventID, EventType: eventType, OrderNo: orderNo, PayloadHash: payloadHash, ProcessedAt: time.Now()}
if err := tx.Create(&event).Error; err != nil {
return err
}
return tx.Model(&model.Order{}).Where("order_no = ? AND provider = ? AND status = ?", orderNo, provider, "pending").Update("status", status).Error
})
}
func (s *CommerceService) GetUserOrder(userID uint, orderNo string) (*model.Order, error) {
var order model.Order
if err := s.db.Where("user_id = ? AND order_no = ?", userID, orderNo).First(&order).Error; err != nil {
return nil, err
}
return &order, nil
}
func (s *CommerceService) HasReportAccess(userID uint, reportID string) (bool, error) {
var count int64
err := s.db.Model(&model.ReportEntitlement{}).Where("user_id = ? AND report_id = ? AND status = ?", userID, reportID, "active").Count(&count).Error
return count > 0, err
}
func (s *CommerceService) AdminOrders(filter AdminOrderFilter) ([]model.Order, int64, error) {
// PC 商业版后台只显示八字报告订单,隔离小程序许愿等旧业务订单。
query := s.db.Model(&model.Order{}).Where("type = ?", "bazi_report")
if filter.Status != "" {
query = query.Where("status = ?", filter.Status)
}
if filter.Provider != "" {
query = query.Where("provider = ?", filter.Provider)
}
if filter.Search != "" {
like := "%" + filter.Search + "%"
query = query.Where("order_no LIKE ? OR customer_email LIKE ? OR report_id LIKE ? OR provider_transaction_id LIKE ?", like, like, like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var orders []model.Order
err := query.Order("created_at DESC").Offset((filter.Page - 1) * filter.PageSize).Limit(filter.PageSize).Find(&orders).Error
return orders, total, err
}
func (s *CommerceService) AdminStats() (OrderStats, error) {
var stats OrderStats
base := s.db.Model(&model.Order{}).Where("type = ?", "bazi_report")
if err := base.Count(&stats.TotalOrders).Error; err != nil {
return stats, err
}
s.db.Model(&model.Order{}).Where("type = ? AND status = ?", "bazi_report", "paid").Count(&stats.PaidOrders)
s.db.Model(&model.Order{}).Where("type = ? AND status = ?", "bazi_report", "pending").Count(&stats.Pending)
s.db.Model(&model.Order{}).Where("type = ? AND status = ?", "bazi_report", "refunded").Count(&stats.Refunded)
s.db.Model(&model.Order{}).Where("type = ? AND status = ? AND currency = ?", "bazi_report", "paid", "USD").Select("COALESCE(SUM(amount), 0)").Scan(&stats.RevenueUSD)
return stats, nil
}
type PurchasedReport struct {
ReportID string `json:"reportId"`
AccessCode string `json:"accessCode"`
ProductName string `json:"productName"`
PaidAt time.Time `json:"paidAt"`
}
func (s *CommerceService) GetReportSnapshot(userID uint, accessCode string) (*model.ReportSnapshot, error) {
var order model.Order
if err := s.db.Where("user_id = ? AND access_code = ? AND status = ?", userID, strings.ToUpper(accessCode), "paid").First(&order).Error; err != nil {
return nil, err
}
var entitlement model.ReportEntitlement
if err := s.db.Where("user_id = ? AND order_id = ? AND status = ?", userID, order.ID, "active").First(&entitlement).Error; err != nil {
return nil, err
}
var snapshot model.ReportSnapshot
if err := s.db.Where("user_id = ? AND order_id = ?", userID, order.ID).First(&snapshot).Error; err != nil {
return nil, err
}
return &snapshot, nil
}
func (s *CommerceService) ListPurchasedReports(userID uint) ([]PurchasedReport, error) {
var orders []model.Order
if err := s.db.Where("user_id = ? AND type = ? AND status = ? AND access_code IS NOT NULL", userID, "bazi_report", "paid").Order("pay_time DESC").Find(&orders).Error; err != nil {
return nil, err
}
reports := make([]PurchasedReport, 0, len(orders))
for _, order := range orders {
if order.AccessCode == nil || order.PayTime == nil {
continue
}
reports = append(reports, PurchasedReport{ReportID: order.ReportID, AccessCode: *order.AccessCode, ProductName: order.ProductName, PaidAt: *order.PayTime})
}
return reports, nil
}
func amountMatches(order model.Order, paidAmount string) bool {
expected := order.ProviderAmount
if order.Provider == "stripe" {
expected = strconv.Itoa(order.Amount)
}
expectedValue, ok := new(big.Rat).SetString(expected)
if !ok {
return false
}
paidValue, ok := new(big.Rat).SetString(paidAmount)
return ok && expectedValue.Cmp(paidValue) == 0
}
func secureAccessCode() (string, error) {
bytes := make([]byte, 8)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(bytes)
return "ZM-" + encoded[:4] + "-" + encoded[4:8] + "-" + encoded[8:12], nil
}
func secureOrderNo() (string, error) {
bytes := make([]byte, 5)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return "R" + time.Now().UTC().Format("20060102150405") + strings.ToUpper(hex.EncodeToString(bytes)), nil
}
+289
View File
@@ -0,0 +1,289 @@
package service
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime"
"net"
"net/http"
"net/mail"
"net/smtp"
"os"
"strconv"
"strings"
"time"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model"
"gorm.io/gorm"
)
var ErrInvalidEmail = errors.New("invalid email")
var ErrLoginCodeRateLimited = errors.New("please wait before requesting another code")
var ErrLoginCodeInvalid = errors.New("invalid or expired login code")
type EmailAuthService struct {
db *gorm.DB
config *config.Config
client *http.Client
}
func NewEmailAuthService() *EmailAuthService {
return &EmailAuthService{db: config.GetDB(), config: config.Load(), client: &http.Client{Timeout: 10 * time.Second}}
}
func NormalizeEmail(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
address, err := mail.ParseAddress(value)
if err != nil || address.Address != value || len(value) > 254 {
return "", ErrInvalidEmail
}
return value, nil
}
func (s *EmailAuthService) RequestCode(email string) error {
email, err := NormalizeEmail(email)
if err != nil {
return err
}
var latest model.EmailLoginCode
if err := s.db.Where("email = ?", email).Order("created_at DESC").First(&latest).Error; err == nil && time.Since(latest.CreatedAt) < time.Minute {
return ErrLoginCodeRateLimited
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
code, err := randomSixDigitCode()
if err != nil {
return err
}
record := model.EmailLoginCode{Email: email, CodeHash: s.hashCode(email, code), ExpiresAt: time.Now().Add(10 * time.Minute)}
if err := s.db.Create(&record).Error; err != nil {
return err
}
if err := s.sendCode(email, code); err != nil {
now := time.Now()
s.db.Model(&record).Update("used_at", &now)
return err
}
return nil
}
func (s *EmailAuthService) VerifyCode(email, code string) (*model.User, string, error) {
email, err := NormalizeEmail(email)
if err != nil || len(code) != 6 {
return nil, "", ErrLoginCodeInvalid
}
var record model.EmailLoginCode
if err := s.db.Where("email = ? AND used_at IS NULL AND expires_at > ?", email, time.Now()).Order("created_at DESC").First(&record).Error; err != nil {
return nil, "", ErrLoginCodeInvalid
}
if record.Attempts >= 5 {
return nil, "", ErrLoginCodeInvalid
}
if !hmac.Equal([]byte(record.CodeHash), []byte(s.hashCode(email, code))) {
s.db.Model(&record).UpdateColumn("attempts", gorm.Expr("attempts + 1"))
return nil, "", ErrLoginCodeInvalid
}
var user model.User
err = s.db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
result := tx.Model(&model.EmailLoginCode{}).Where("id = ? AND used_at IS NULL", record.ID).Update("used_at", &now)
if result.Error != nil || result.RowsAffected != 1 {
return ErrLoginCodeInvalid
}
if err := tx.Where("email = ?", email).First(&user).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
identity := sha256.Sum256([]byte(email))
user = model.User{OpenID: "email:" + hex.EncodeToString(identity[:])[:32], Email: email, Nickname: strings.Split(email, "@")[0], Status: 1}
return tx.Create(&user).Error
})
if err != nil {
return nil, "", err
}
if user.Status != 1 {
return nil, "", errors.New("account disabled")
}
token, err := NewUserService().GenerateToken(user.ID, s.config.JWT.Secret)
if err != nil {
return nil, "", err
}
return &user, token, nil
}
func (s *EmailAuthService) hashCode(email, code string) string {
mac := hmac.New(sha256.New, []byte(s.config.JWT.Secret))
mac.Write([]byte(email + "|" + code))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *EmailAuthService) sendCode(email, code string) error {
if s.config.SMTP.IsConfigured() {
return s.sendCodeWithSMTP(email, code)
}
if s.config.Postmark.ServerToken != "" && s.config.Postmark.FromEmail != "" {
return s.sendCodeWithPostmark(email, code)
}
if s.config.Server.Env != "production" {
log.Printf("[DEV-EMAIL] 邮箱登录验证码: email=%s code=%s(生产环境需配置 SMTP 或 Postmark)", email, code)
return nil
}
return errors.New("email service is not configured")
}
func (s *EmailAuthService) sendCodeWithSMTP(email, code string) error {
settings := s.config.SMTP
port, err := strconv.Atoi(settings.Port)
if settings.Host == "" || settings.Username == "" || settings.Password == "" || settings.FromEmail == "" || err != nil || port < 1 || port > 65535 {
return errors.New("SMTP configuration is incomplete or invalid")
}
if settings.TLSMode != "implicit" && settings.TLSMode != "starttls" {
return errors.New("SMTP TLS mode must be implicit or starttls")
}
from, err := mail.ParseAddress(settings.FromEmail)
if err != nil || from.Address != settings.FromEmail {
return errors.New("SMTP sender address is invalid")
}
tlsConfig, err := s.smtpTLSConfig()
if err != nil {
return err
}
dialer := &net.Dialer{Timeout: 10 * time.Second}
address := net.JoinHostPort(settings.Host, settings.Port)
var connection net.Conn
if settings.TLSMode == "implicit" {
connection, err = tls.DialWithDialer(dialer, "tcp", address, tlsConfig)
} else {
connection, err = dialer.Dial("tcp", address)
}
if err != nil {
return fmt.Errorf("SMTP connection failed: %w", err)
}
defer connection.Close()
if err := connection.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
return err
}
client, err := smtp.NewClient(connection, settings.Host)
if err != nil {
return fmt.Errorf("SMTP session setup failed: %w", err)
}
defer client.Close()
if settings.TLSMode == "starttls" {
if ok, _ := client.Extension("STARTTLS"); !ok {
return errors.New("SMTP server does not support STARTTLS")
}
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("SMTP TLS negotiation failed: %w", err)
}
}
if err := client.Auth(smtp.PlainAuth("", settings.Username, settings.Password, settings.Host)); err != nil {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
if err := client.Mail(from.Address); err != nil {
return fmt.Errorf("SMTP sender rejected: %w", err)
}
if err := client.Rcpt(email); err != nil {
return fmt.Errorf("SMTP recipient rejected: %w", err)
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP message rejected: %w", err)
}
fromHeader := (&mail.Address{Name: "知命网", Address: from.Address}).String()
toHeader := (&mail.Address{Address: email}).String()
message := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n你的登录验证码是 %s,10 分钟内有效。若非本人操作,请忽略此邮件。\r\n",
fromHeader,
toHeader,
mime.QEncoding.Encode("UTF-8", "知命登录验证码"),
time.Now().Format(time.RFC1123Z),
code,
)
if _, err := io.WriteString(writer, message); err != nil {
closeErr := writer.Close()
if closeErr != nil {
return errors.Join(err, closeErr)
}
return err
}
if err := writer.Close(); err != nil {
return fmt.Errorf("SMTP server did not accept the message: %w", err)
}
return nil
}
func (s *EmailAuthService) smtpTLSConfig() (*tls.Config, error) {
settings := s.config.SMTP
config := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: settings.Host}
if settings.TLSCAFile == "" {
return config, nil
}
certificatePEM, err := os.ReadFile(settings.TLSCAFile)
if err != nil {
return nil, fmt.Errorf("read SMTP CA file: %w", err)
}
roots, err := x509.SystemCertPool()
if err != nil || roots == nil {
roots = x509.NewCertPool()
}
if !roots.AppendCertsFromPEM(certificatePEM) {
return nil, errors.New("SMTP CA file contains no valid certificates")
}
config.RootCAs = roots
return config, nil
}
func (s *EmailAuthService) sendCodeWithPostmark(email, code string) error {
payload := map[string]string{
"From": s.config.Postmark.FromEmail,
"To": email,
"Subject": "知命登录验证码",
"TextBody": fmt.Sprintf("你的登录验证码是 %s,10 分钟内有效。若非本人操作,请忽略此邮件。", code),
"HtmlBody": fmt.Sprintf("<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:auto;padding:32px\"><h2>知命登录验证码</h2><p>你的验证码是:</p><p style=\"font-size:32px;letter-spacing:8px;font-weight:bold\">%s</p><p>10 分钟内有效。若非本人操作,请忽略此邮件。</p></div>", code),
"MessageStream": "outbound",
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(s.config.Postmark.APIBaseURL, "/")+"/email", strings.NewReader(string(body)))
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Postmark-Server-Token", s.config.Postmark.ServerToken)
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("postmark send failed: status=%d body=%s", resp.StatusCode, safeProviderError(responseBody))
}
return nil
}
func randomSixDigitCode() (string, error) {
bytes := make([]byte, 4)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
value := (uint32(bytes[0])<<24 | uint32(bytes[1])<<16 | uint32(bytes[2])<<8 | uint32(bytes[3])) % 1000000
return fmt.Sprintf("%06d", value), nil
}
+336
View File
@@ -0,0 +1,336 @@
package service
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model"
)
type CheckoutResult struct {
ProviderOrderID string
CheckoutURL string
}
type PaymentWebhook struct {
EventID string
EventType string
OrderNo string
ProviderOrderID string
TransactionID string
Amount string
Currency string
Status string
PayloadHash string
}
type StripeProvider struct {
config config.StripeConfig
client *http.Client
}
type BinancePayProvider struct {
config config.BinancePayConfig
client *http.Client
}
func NewStripeProvider(cfg config.StripeConfig) *StripeProvider {
return &StripeProvider{config: cfg, client: &http.Client{Timeout: 15 * time.Second}}
}
func NewBinancePayProvider(cfg config.BinancePayConfig) *BinancePayProvider {
return &BinancePayProvider{config: cfg, client: &http.Client{Timeout: 15 * time.Second}}
}
func (p *StripeProvider) CreateCheckout(ctx context.Context, order *model.Order, webAppURL string) (*CheckoutResult, error) {
if p.config.SecretKey == "" {
return nil, errors.New("stripe is not configured")
}
values := url.Values{}
values.Set("mode", "payment")
values.Set("success_url", strings.TrimRight(webAppURL, "/")+"/?payment=success&order="+url.QueryEscape(order.OrderNo))
values.Set("cancel_url", strings.TrimRight(webAppURL, "/")+"/?payment=cancelled&order="+url.QueryEscape(order.OrderNo))
values.Set("customer_email", order.CustomerEmail)
values.Set("line_items[0][quantity]", "1")
values.Set("line_items[0][price_data][currency]", strings.ToLower(order.Currency))
values.Set("line_items[0][price_data][unit_amount]", strconv.Itoa(order.Amount))
values.Set("line_items[0][price_data][product_data][name]", order.ProductName)
values.Set("line_items[0][price_data][product_data][description]", "一次购买,永久解锁完整报告")
values.Set("metadata[order_no]", order.OrderNo)
values.Set("metadata[report_id]", order.ReportID)
values.Set("metadata[user_id]", strconv.FormatUint(uint64(order.UserID), 10))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.config.APIBaseURL, "/")+"/v1/checkout/sessions", strings.NewReader(values.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+p.config.SecretKey)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Idempotency-Key", "checkout-"+order.OrderNo)
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("stripe create checkout failed: status=%d body=%s", resp.StatusCode, safeProviderError(body))
}
var payload struct {
ID string `json:"id"`
URL string `json:"url"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.ID == "" || payload.URL == "" {
return nil, errors.New("stripe returned an invalid checkout response")
}
return &CheckoutResult{ProviderOrderID: payload.ID, CheckoutURL: payload.URL}, nil
}
func (p *StripeProvider) ParseWebhook(raw []byte, signature string, now time.Time) (*PaymentWebhook, error) {
if p.config.WebhookSecret == "" {
return nil, errors.New("stripe webhook is not configured")
}
parts := strings.Split(signature, ",")
var timestamp int64
var signatures []string
for _, part := range parts {
pair := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(pair) != 2 {
continue
}
if pair[0] == "t" {
timestamp, _ = strconv.ParseInt(pair[1], 10, 64)
}
if pair[0] == "v1" {
signatures = append(signatures, pair[1])
}
}
if timestamp == 0 || len(signatures) == 0 || now.Sub(time.Unix(timestamp, 0)) > 5*time.Minute || time.Unix(timestamp, 0).Sub(now) > 5*time.Minute {
return nil, errors.New("invalid stripe webhook timestamp")
}
mac := hmac.New(sha256.New, []byte(p.config.WebhookSecret))
mac.Write([]byte(strconv.FormatInt(timestamp, 10)))
mac.Write([]byte("."))
mac.Write(raw)
expected := hex.EncodeToString(mac.Sum(nil))
valid := false
for _, candidate := range signatures {
if hmac.Equal([]byte(expected), []byte(candidate)) {
valid = true
break
}
}
if !valid {
return nil, errors.New("invalid stripe webhook signature")
}
var event struct {
ID string `json:"id"`
Type string `json:"type"`
Data struct {
Object struct {
ID string `json:"id"`
PaymentIntent string `json:"payment_intent"`
PaymentStatus string `json:"payment_status"`
AmountTotal int64 `json:"amount_total"`
Currency string `json:"currency"`
Metadata map[string]string `json:"metadata"`
} `json:"object"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &event); err != nil || event.ID == "" {
return nil, errors.New("invalid stripe webhook body")
}
status := "ignored"
switch event.Type {
case "checkout.session.completed":
if event.Data.Object.PaymentStatus == "paid" {
status = "paid"
}
case "checkout.session.async_payment_succeeded":
status = "paid"
case "checkout.session.expired", "checkout.session.async_payment_failed":
status = "cancelled"
case "charge.refunded":
status = "refunded"
}
return &PaymentWebhook{
EventID: event.ID, EventType: event.Type, OrderNo: event.Data.Object.Metadata["order_no"],
ProviderOrderID: event.Data.Object.ID, TransactionID: event.Data.Object.PaymentIntent,
Amount: strconv.FormatInt(event.Data.Object.AmountTotal, 10), Currency: strings.ToUpper(event.Data.Object.Currency),
Status: status, PayloadHash: hashPayload(raw),
}, nil
}
func (p *BinancePayProvider) CreateCheckout(ctx context.Context, order *model.Order, webAppURL, publicBaseURL string) (*CheckoutResult, error) {
if p.config.APIKey == "" || p.config.SecretKey == "" {
return nil, errors.New("binance pay is not configured")
}
payload := map[string]interface{}{
"env": map[string]string{"terminalType": "WEB"},
"merchantTradeNo": order.OrderNo,
"orderAmount": order.ProviderAmount,
"currency": order.Currency,
"goods": map[string]string{"goodsType": "02", "goodsCategory": "D000", "referenceGoodsId": order.ReportID, "goodsName": order.ProductName},
"returnUrl": strings.TrimRight(webAppURL, "/") + "/?payment=success&order=" + url.QueryEscape(order.OrderNo),
"cancelUrl": strings.TrimRight(webAppURL, "/") + "/?payment=cancelled&order=" + url.QueryEscape(order.OrderNo),
"webhookUrl": strings.TrimRight(publicBaseURL, "/") + "/api/commerce/webhooks/binance-pay",
"orderExpireTime": time.Now().Add(30 * time.Minute).UnixMilli(),
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return nil, err
}
nonce := hex.EncodeToString(nonceBytes)
mac := hmac.New(sha512.New, []byte(p.config.SecretKey))
mac.Write([]byte(timestamp + "\n" + nonce + "\n" + string(body) + "\n"))
signature := strings.ToUpper(hex.EncodeToString(mac.Sum(nil)))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(p.config.APIBaseURL, "/")+"/binancepay/openapi/v2/order", strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("BinancePay-Timestamp", timestamp)
req.Header.Set("BinancePay-Nonce", nonce)
req.Header.Set("BinancePay-Certificate-SN", p.config.APIKey)
req.Header.Set("BinancePay-Signature", signature)
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("binance pay create order failed: status=%d body=%s", resp.StatusCode, safeProviderError(responseBody))
}
var response struct {
Status string `json:"status"`
Code string `json:"code"`
Data struct {
PrepayID string `json:"prepayId"`
UniversalURL string `json:"universalUrl"`
CheckoutURL string `json:"checkoutUrl"`
} `json:"data"`
}
if err := json.Unmarshal(responseBody, &response); err != nil || response.Status != "SUCCESS" || response.Data.PrepayID == "" {
return nil, fmt.Errorf("binance pay rejected order: code=%s", response.Code)
}
checkoutURL := response.Data.CheckoutURL
if checkoutURL == "" {
checkoutURL = response.Data.UniversalURL
}
if checkoutURL == "" {
return nil, errors.New("binance pay returned no checkout URL")
}
return &CheckoutResult{ProviderOrderID: response.Data.PrepayID, CheckoutURL: checkoutURL}, nil
}
func (p *BinancePayProvider) ParseWebhook(raw []byte, timestamp, nonce, signature string) (*PaymentWebhook, error) {
if p.config.WebhookPublicKey == "" {
return nil, errors.New("binance pay webhook public key is not configured")
}
block, _ := pem.Decode([]byte(strings.ReplaceAll(p.config.WebhookPublicKey, "\\n", "\n")))
if block == nil {
return nil, errors.New("invalid binance pay webhook public key")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
if certificate, certErr := x509.ParseCertificate(block.Bytes); certErr == nil {
parsed = certificate.PublicKey
} else if rsaKey, rsaErr := x509.ParsePKCS1PublicKey(block.Bytes); rsaErr == nil {
parsed = rsaKey
} else {
return nil, err
}
}
publicKey, ok := parsed.(*rsa.PublicKey)
if !ok {
return nil, errors.New("binance pay webhook key is not RSA")
}
decodedSignature, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
return nil, errors.New("invalid binance pay webhook signature encoding")
}
payload := []byte(timestamp + "\n" + nonce + "\n" + string(raw) + "\n")
digest := sha256.Sum256(payload)
if err := rsa.VerifyPKCS1v15(publicKey, cryptoHashSHA256, digest[:], decodedSignature); err != nil {
return nil, errors.New("invalid binance pay webhook signature")
}
var envelope struct {
BizType string `json:"bizType"`
BizIDStr string `json:"bizIdStr"`
BizStatus string `json:"bizStatus"`
Data string `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.BizIDStr == "" {
return nil, errors.New("invalid binance pay webhook body")
}
var data struct {
MerchantTradeNo string `json:"merchantTradeNo"`
TransactionID string `json:"transactionId"`
TotalFee json.Number `json:"totalFee"`
Currency string `json:"currency"`
}
decoder := json.NewDecoder(strings.NewReader(envelope.Data))
decoder.UseNumber()
if err := decoder.Decode(&data); err != nil {
return nil, errors.New("invalid binance pay webhook data")
}
status := "ignored"
switch envelope.BizStatus {
case "PAY_SUCCESS":
status = "paid"
case "PAY_CLOSED":
status = "cancelled"
case "REFUND_SUCCESS":
status = "refunded"
}
return &PaymentWebhook{
EventID: envelope.BizIDStr + ":" + envelope.BizStatus, EventType: envelope.BizStatus,
OrderNo: data.MerchantTradeNo, ProviderOrderID: envelope.BizIDStr, TransactionID: data.TransactionID,
Amount: data.TotalFee.String(), Currency: strings.ToUpper(data.Currency), Status: status, PayloadHash: hashPayload(raw),
}, nil
}
const cryptoHashSHA256 = 5
func hashPayload(raw []byte) string {
digest := sha256.Sum256(raw)
return hex.EncodeToString(digest[:])
}
func safeProviderError(body []byte) string {
text := strings.TrimSpace(string(body))
if len(text) > 500 {
text = text[:500]
}
return text
}
@@ -0,0 +1,97 @@
package service
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/model"
)
func TestStripeCreateCheckout(t *testing.T) {
var received url.Values
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Header.Get("Authorization") != "Bearer sk_test" {
t.Fatalf("missing Stripe authorization")
}
if request.Header.Get("Idempotency-Key") != "checkout-R123" {
t.Fatalf("missing idempotency key")
}
body, _ := io.ReadAll(request.Body)
received, _ = url.ParseQuery(string(body))
response.Header().Set("Content-Type", "application/json")
fmt.Fprint(response, `{"id":"cs_test_123","url":"https://checkout.stripe.test/session"}`)
}))
defer server.Close()
provider := NewStripeProvider(config.StripeConfig{SecretKey: "sk_test", APIBaseURL: server.URL})
order := &model.Order{OrderNo: "R123", UserID: 9, Amount: 499, Currency: "USD", CustomerEmail: "buyer@example.com", ProductName: "完整报告", ReportID: "ZM-ABCDEFG"}
result, err := provider.CreateCheckout(context.Background(), order, "https://example.test")
if err != nil {
t.Fatal(err)
}
if result.ProviderOrderID != "cs_test_123" || result.CheckoutURL == "" {
t.Fatalf("unexpected checkout result: %#v", result)
}
if received.Get("metadata[order_no]") != order.OrderNo || received.Get("line_items[0][price_data][unit_amount]") != "499" {
t.Fatalf("unexpected Stripe form: %#v", received)
}
}
func TestStripeWebhookSignature(t *testing.T) {
now := time.Unix(1760000000, 0)
raw := []byte(`{"id":"evt_123","type":"checkout.session.completed","data":{"object":{"id":"cs_test_123","payment_intent":"pi_123","payment_status":"paid","amount_total":499,"currency":"usd","metadata":{"order_no":"R123"}}}}`)
mac := hmac.New(sha256.New, []byte("whsec_test"))
mac.Write([]byte(fmt.Sprintf("%d.%s", now.Unix(), raw)))
signature := fmt.Sprintf("t=%d,v1=%s", now.Unix(), hex.EncodeToString(mac.Sum(nil)))
provider := NewStripeProvider(config.StripeConfig{WebhookSecret: "whsec_test"})
event, err := provider.ParseWebhook(raw, signature, now)
if err != nil {
t.Fatal(err)
}
if event.Status != "paid" || event.OrderNo != "R123" || event.TransactionID != "pi_123" {
t.Fatalf("unexpected event: %#v", event)
}
if _, err := provider.ParseWebhook(raw, signature+"bad", now); err == nil {
t.Fatal("expected invalid signature to fail")
}
}
func TestBinancePayCreateCheckoutSignature(t *testing.T) {
secret := "binance-secret"
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
body, _ := io.ReadAll(request.Body)
timestamp := request.Header.Get("BinancePay-Timestamp")
nonce := request.Header.Get("BinancePay-Nonce")
mac := hmac.New(sha512.New, []byte(secret))
mac.Write([]byte(timestamp + "\n" + nonce + "\n" + string(body) + "\n"))
expected := strings.ToUpper(hex.EncodeToString(mac.Sum(nil)))
if !hmac.Equal([]byte(expected), []byte(request.Header.Get("BinancePay-Signature"))) {
t.Fatal("invalid Binance Pay signature")
}
response.Header().Set("Content-Type", "application/json")
fmt.Fprint(response, `{"status":"SUCCESS","code":"000000","data":{"prepayId":"123456","universalUrl":"https://app.binance.test/pay"}}`)
}))
defer server.Close()
provider := NewBinancePayProvider(config.BinancePayConfig{APIKey: "api-key", SecretKey: secret, APIBaseURL: server.URL})
order := &model.Order{OrderNo: "R123", ProviderAmount: "5.00", Currency: "USDT", ProductName: "完整报告", ReportID: "ZM-ABCDEFG"}
result, err := provider.CreateCheckout(context.Background(), order, "https://example.test", "https://api.example.test")
if err != nil {
t.Fatal(err)
}
if result.ProviderOrderID != "123456" || result.CheckoutURL == "" {
t.Fatalf("unexpected checkout result: %#v", result)
}
}
+123
View File
@@ -0,0 +1,123 @@
package service
import (
"encoding/json"
"errors"
"fmt"
)
type premiumTrait struct {
Gift string
Caution string
Field string
Rhythm string
}
type premiumSnapshot struct {
ID string `json:"id"`
Result struct {
EightChar struct {
DayMaster string `json:"dayMaster"`
DayMasterElement string `json:"dayMasterElement"`
} `json:"eightChar"`
DecadeFortunes []struct {
Ganzhi string `json:"ganzhi"`
StartAge int `json:"startAge"`
} `json:"decadeFortunes"`
} `json:"result"`
Elements struct {
Dominant string `json:"dominant"`
Weakest string `json:"weakest"`
} `json:"elements"`
Shensha []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"shensha"`
Sections []premiumSection `json:"sections"`
}
type premiumSection struct {
Index string `json:"index"`
Title string `json:"title"`
Kicker string `json:"kicker"`
Summary string `json:"summary"`
Details []string `json:"details"`
}
var premiumTraits = map[string]premiumTrait{
"木": {Gift: "成长力、规划感与同理心", Caution: "容易把长期目标变成眼前压力", Field: "教育、内容、设计与长期经营", Rhythm: "先建立根基,再持续放大优势"},
"火": {Gift: "表达力、行动力与感染力", Caution: "节奏过快时容易透支专注", Field: "传播、品牌、销售与创新项目", Rhythm: "抓住窗口,但为决策保留冷静期"},
"土": {Gift: "稳定性、承诺感与资源整合力", Caution: "求稳过度时会错过变化", Field: "管理、运营、资产与服务业", Rhythm: "稳中求变,用小步试错替代停滞"},
"金": {Gift: "判断力、边界感与执行标准", Caution: "标准过高时容易显得紧绷", Field: "技术、金融、法律与精细管理", Rhythm: "先定规则,再给关系留出弹性"},
"水": {Gift: "洞察力、适应力与信息敏感度", Caution: "选择过多时容易分散心力", Field: "研究、咨询、贸易与流动型工作", Rhythm: "聚焦一个主航道,让变化服务目标"},
}
var supportingElements = map[string]string{"木": "水", "火": "木", "土": "火", "金": "土", "水": "金"}
func buildPremiumSnapshot(raw string) (string, error) {
var snapshot premiumSnapshot
if err := json.Unmarshal([]byte(raw), &snapshot); err != nil {
return "", fmt.Errorf("invalid report snapshot: %w", err)
}
if snapshot.ID == "" || snapshot.Result.EightChar.DayMaster == "" || snapshot.Elements.Dominant == "" {
return "", errors.New("report snapshot is missing chart data")
}
dominant := premiumTraits[snapshot.Elements.Dominant]
dayMaster := premiumTraits[snapshot.Result.EightChar.DayMasterElement]
if dayMaster.Gift == "" {
dayMaster = dominant
}
helpful := supportingElements[snapshot.Result.EightChar.DayMasterElement]
if helpful == "" {
helpful = snapshot.Elements.Weakest
}
positiveStars := make([]string, 0, 3)
cautionStars := make([]string, 0, 2)
for _, star := range snapshot.Shensha {
if star.Type == "吉" && len(positiveStars) < 3 {
positiveStars = append(positiveStars, star.Name)
}
if star.Type == "凶" && len(cautionStars) < 2 {
cautionStars = append(cautionStars, star.Name)
}
}
starText := "命局本身的稳定结构"
if len(positiveStars) > 0 {
starText = joinChinese(positiveStars)
}
cautionText := "节奏失衡"
if len(cautionStars) > 0 {
cautionText = joinChinese(cautionStars)
}
firstFortune := "人生节奏呈阶段性变化。"
if len(snapshot.Result.DecadeFortunes) > 0 {
fortune := snapshot.Result.DecadeFortunes[0]
firstFortune = fmt.Sprintf("首步大运为%s,约从%d岁进入阶段转换。", fortune.Ganzhi, fortune.StartAge)
}
snapshot.Sections = []premiumSection{
{Index: "壹", Title: "性格底色", Kicker: snapshot.Result.EightChar.DayMaster + " · 内在驱动力", Summary: fmt.Sprintf("你的日主为%s,核心优势集中在%s。", snapshot.Result.EightChar.DayMaster, dayMaster.Gift), Details: []string{fmt.Sprintf("五行以%s较突出,做事更重视%s。", snapshot.Elements.Dominant, dominant.Gift), fmt.Sprintf("需要留意:%s。", dayMaster.Caution), fmt.Sprintf("真正适合你的成长方式是:%s。", dayMaster.Rhythm)}},
{Index: "贰", Title: "事业天赋", Kicker: "能力结构 · 职场定位", Summary: fmt.Sprintf("你的优势更容易在%s中形成复利。", dominant.Field), Details: []string{fmt.Sprintf("适合承担需要%s的角色。", dayMaster.Gift), fmt.Sprintf("命局中的%s,提示你应主动经营专业信用与协作关系。", starText), "比起频繁换方向,更适合建立可重复的方法与个人标签。"}},
{Index: "叁", Title: "财富节奏", Kicker: "积累方式 · 风险边界", Summary: fmt.Sprintf("财富策略宜围绕“%s”展开。", dominant.Rhythm), Details: []string{fmt.Sprintf("优先把%s转化成可计价的产品或服务。", dayMaster.Gift), fmt.Sprintf("遇到%s对应的冲动时,应延迟重大财务决定。", cautionText), "收入结构宜同时保留稳定现金流与小比例成长投入。"}},
{Index: "肆", Title: "关系模式", Kicker: "亲密关系 · 沟通课题", Summary: fmt.Sprintf("关系中的吸引力来自%s,长久则依赖清晰表达。", dayMaster.Gift), Details: []string{"你更需要能理解你节奏、也尊重边界的伴侣。", "情绪紧张时,先描述事实与需要,避免用结论代替沟通。", "重要关系中要刻意安排稳定相处,而不是只依赖临时热情。"}},
{Index: "伍", Title: "身心能量", Kicker: "五行平衡 · 日常建议", Summary: fmt.Sprintf("%s相对偏弱,日常可借%s的意象建立平衡感。", snapshot.Elements.Weakest, helpful), Details: []string{"把作息、运动和独处时间当作长期资产。", "高压期减少多线程任务,让注意力回到一个明确目标。", "所有健康问题应以正规医疗意见为准,本报告不替代诊断。"}},
{Index: "陆", Title: "大运路线", Kicker: "阶段重点 · 行动窗口", Summary: firstFortune, Details: []string{"大运用于观察阶段主题,不代表确定事件。", fmt.Sprintf("顺势的关键是持续放大%s,并管理%s。", dayMaster.Gift, dayMaster.Caution), "完整报告保留十步大运时间轴,方便长期回看。"}},
}
encoded, err := json.Marshal(snapshot)
if err != nil {
return "", fmt.Errorf("encode premium snapshot: %w", err)
}
return string(encoded), nil
}
func joinChinese(values []string) string {
result := ""
for index, value := range values {
if index > 0 {
result += "、"
}
result += value
}
return result
}
+2 -2
View File
@@ -116,7 +116,7 @@ func (s *UserService) GenerateToken(userID uint, secret string) (string, error)
func (s *UserService) ParseToken(tokenString, secret string) (uint, error) { func (s *UserService) ParseToken(tokenString, secret string) (uint, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil return []byte(secret), nil
}) }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil { if err != nil {
return 0, err return 0, err
@@ -156,7 +156,7 @@ func (s *UserService) GenerateAdminToken(secret string) (string, error) {
func (s *UserService) IsAdminToken(tokenString, secret string) bool { func (s *UserService) IsAdminToken(tokenString, secret string) bool {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil return []byte(secret), nil
}) }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil { if err != nil {
return false return false
} }
+94
View File
@@ -0,0 +1,94 @@
ALTER TABLE orders
ADD COLUMN currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
ADD COLUMN provider VARCHAR(30) NOT NULL DEFAULT '',
ADD COLUMN provider_amount VARCHAR(32) NOT NULL DEFAULT '',
ADD COLUMN provider_order_id VARCHAR(128) NOT NULL DEFAULT '',
ADD COLUMN provider_transaction_id VARCHAR(128) NOT NULL DEFAULT '',
ADD COLUMN customer_email VARCHAR(254) NOT NULL DEFAULT '',
ADD COLUMN report_id VARCHAR(64) NOT NULL DEFAULT '',
ADD COLUMN access_code VARCHAR(20) NULL,
ADD COLUMN checkout_url VARCHAR(1024) NOT NULL DEFAULT '';
CREATE INDEX idx_orders_provider ON orders(provider);
CREATE INDEX idx_orders_provider_order_id ON orders(provider_order_id);
CREATE INDEX idx_orders_provider_transaction_id ON orders(provider_transaction_id);
CREATE INDEX idx_orders_customer_email ON orders(customer_email);
CREATE INDEX idx_orders_report_id ON orders(report_id);
CREATE UNIQUE INDEX idx_orders_access_code ON orders(access_code);
CREATE TABLE report_products (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(40) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description VARCHAR(255) NOT NULL DEFAULT '',
stripe_amount INT NOT NULL,
stripe_currency VARCHAR(10) NOT NULL,
binance_amount VARCHAR(32) NOT NULL,
binance_currency VARCHAR(10) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE report_entitlements (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
report_id VARCHAR(64) NOT NULL,
order_id BIGINT UNSIGNED NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'active',
granted_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY idx_user_report (user_id, report_id),
INDEX idx_report_entitlements_user_id (user_id),
INDEX idx_report_entitlements_report_id (report_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE report_snapshots (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
report_id VARCHAR(64) NOT NULL,
order_id BIGINT UNSIGNED NOT NULL UNIQUE,
snapshot LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_report_snapshots_user_id (user_id),
INDEX idx_report_snapshots_report_id (report_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE payment_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(30) NOT NULL,
provider_event_id VARCHAR(128) NOT NULL,
event_type VARCHAR(80) NOT NULL,
order_no VARCHAR(32) NOT NULL,
payload_hash VARCHAR(64) NOT NULL,
processed_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY idx_provider_event (provider, provider_event_id),
INDEX idx_payment_events_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE email_login_codes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(254) NOT NULL,
code_hash VARCHAR(64) NOT NULL,
attempts INT NOT NULL DEFAULT 0,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_email_login_codes_email (email),
INDEX idx_email_login_codes_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO report_products (code, name, description, stripe_amount, stripe_currency, binance_amount, binance_currency, status)
VALUES ('bazi_full_report', '八字完整精批报告', '六大主题与十步大运完整解读', 499, 'USD', '5.00', 'USDT', 1)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
description = VALUES(description),
stripe_amount = VALUES(stripe_amount),
stripe_currency = VALUES(stripe_currency),
binance_amount = VALUES(binance_amount),
binance_currency = VALUES(binance_currency),
status = VALUES(status);
+2 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .title }} - 祈福小助手管理后台</title> <title>{{ .title }} - 知命 PC 商业版后台</title>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Vue + Inertia 通过 import map 从 esm.sh 加载(ESM,自动解析依赖树,国内可访问) --> <!-- Vue + Inertia 通过 import map 从 esm.sh 加载(ESM,自动解析依赖树,国内可访问) -->
@@ -30,6 +30,7 @@
dashboard: () => import('/static/js/pages/Dashboard.js'), dashboard: () => import('/static/js/pages/Dashboard.js'),
users: () => import('/static/js/pages/Users.js'), users: () => import('/static/js/pages/Users.js'),
orders: () => import('/static/js/pages/Orders.js'), orders: () => import('/static/js/pages/Orders.js'),
charts: () => import('/static/js/pages/Charts.js'),
wishes: () => import('/static/js/pages/Wishes.js'), wishes: () => import('/static/js/pages/Wishes.js'),
wiki: () => import('/static/js/pages/Wiki.js'), wiki: () => import('/static/js/pages/Wiki.js'),
settings: () => import('/static/js/pages/Settings.js'), settings: () => import('/static/js/pages/Settings.js'),
+32
View File
@@ -0,0 +1,32 @@
const links = [
['dashboard', '/admin', '仪表盘'],
['users', '/admin/users', '用户管理'],
['orders', '/admin/orders', 'PC 报告订单'],
['charts', '/admin/charts', '排盘记录'],
['wishes', '/admin/wishes', '许愿管理'],
['wiki', '/admin/wiki', '百科管理'],
['settings', '/admin/settings', '系统设置'],
];
export function adminHeader(active) {
return `
<nav class="bg-white shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">知命 PC 商业版后台</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
${links.map(([key, href, label]) => `
<a href="${href}" class="${key === active ? 'border-red-500 text-gray-900' : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
${label}
</a>
`).join('')}
</div>
</div>
</div>
</div>
</nav>
`;
}
+57
View File
@@ -0,0 +1,57 @@
import { onMounted, ref } from 'vue';
import { adminHeader } from './AdminHeader.js';
export default {
setup() {
const records = ref([]);
const total = ref(0);
const loading = ref(true);
const error = ref('');
const search = ref('');
const load = async () => {
loading.value = true;
error.value = '';
try {
const params = new URLSearchParams({ page: '1', pageSize: '100' });
if (search.value) params.set('search', search.value);
const response = await fetch('/admin/api/charts?' + params.toString(), { credentials: 'same-origin' });
if (!response.ok) throw new Error('加载排盘记录失败');
const payload = await response.json();
records.value = payload.data.list || [];
total.value = payload.data.total || 0;
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '加载排盘记录失败';
} finally {
loading.value = false;
}
};
onMounted(load);
return { records, total, loading, error, search, load };
},
template: `
<div class="min-h-screen bg-gray-100">
${adminHeader('charts')}
<main class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
<div class="bg-white shadow rounded-lg">
<div class="p-5 border-b flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
<div><h2 class="text-lg font-medium">排盘记录</h2><p class="text-xs text-gray-500 mt-1">共 {{ total }} 条用户主动同意保存的记录;付费报告快照另行关联</p></div>
<div class="flex gap-2"><input v-model="search" @keyup.enter="load" class="border rounded px-3 py-2 text-sm" placeholder="报告编号 / 姓名 / 城市" /><button @click="load" class="bg-red-700 text-white rounded px-4 py-2 text-sm">查询</button></div>
</div>
<div v-if="error" class="m-5 p-3 bg-red-50 text-red-700 text-sm">{{ error }}</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs text-gray-500">报告编号</th><th class="px-4 py-3 text-left text-xs text-gray-500">称呼</th><th class="px-4 py-3 text-left text-xs text-gray-500">出生信息</th><th class="px-4 py-3 text-left text-xs text-gray-500">城市</th><th class="px-4 py-3 text-left text-xs text-gray-500">同意记录</th><th class="px-4 py-3 text-left text-xs text-gray-500">记录时间</th></tr></thead>
<tbody class="divide-y divide-gray-100">
<tr v-if="loading"><td colspan="6" class="p-10 text-center text-sm text-gray-500">正在加载...</td></tr>
<tr v-else-if="records.length === 0"><td colspan="6" class="p-10 text-center text-sm text-gray-500">暂无排盘记录</td></tr>
<tr v-for="record in records" :key="record.id" class="hover:bg-gray-50"><td class="px-4 py-4 text-xs font-medium">{{ record.reportId }}</td><td class="px-4 py-4 text-sm">{{ record.name || '-' }}</td><td class="px-4 py-4 text-sm">{{ record.birthDate }} {{ record.birthTime }} / {{ record.gender }}</td><td class="px-4 py-4 text-sm">{{ record.city }}</td><td class="px-4 py-4 text-xs text-gray-500">{{ record.consentVersion || '迁移前记录(无同意凭证)' }}<br />{{ record.consentAt ? new Date(record.consentAt).toLocaleString('zh-CN', { hour12: false }) : '-' }}</td><td class="px-4 py-4 text-xs text-gray-500">{{ new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) }}</td></tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
`,
};
+3 -31
View File
@@ -1,38 +1,10 @@
import { adminHeader } from './AdminHeader.js';
// Dashboard 页面组件 // Dashboard 页面组件
export default { export default {
template: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('dashboard')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0"> <div class="px-4 py-6 sm:px-0">
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
<div class="min-h-screen bg-gray-100 flex items-center justify-center px-4"> <div class="min-h-screen bg-gray-100 flex items-center justify-center px-4">
<div class="max-w-sm w-full bg-white rounded-lg shadow-md p-8"> <div class="max-w-sm w-full bg-white rounded-lg shadow-md p-8">
<div class="text-center mb-6"> <div class="text-center mb-6">
<h1 class="text-2xl font-bold text-red-600">祈福小助手</h1> <h1 class="text-2xl font-bold text-red-600">知命 PC 商业版后台</h1>
<p class="text-gray-500 text-sm mt-1">管理后台登录</p> <p class="text-gray-500 text-sm mt-1">管理后台登录</p>
<p class="text-gray-300 text-xs mt-2">v2026.08.12-login</p> <p class="text-gray-300 text-xs mt-2">v2026.08.12-login</p>
</div> </div>
+85 -65
View File
@@ -1,74 +1,94 @@
// Orders 页面组件 import { onMounted, ref } from 'vue';
import { adminHeader } from './AdminHeader.js';
export default { export default {
setup() {
const orders = ref([]);
const stats = ref({ totalOrders: 0, paidOrders: 0, pendingOrders: 0, refundedOrders: 0, revenueUsdCents: 0 });
const total = ref(0);
const loading = ref(true);
const error = ref('');
const filters = ref({ status: '', provider: '', search: '' });
const load = async () => {
loading.value = true;
error.value = '';
const params = new URLSearchParams({ page: '1', pageSize: '100' });
Object.entries(filters.value).forEach(([key, value]) => value && params.set(key, value));
try {
const [orderResponse, statsResponse] = await Promise.all([
fetch('/admin/api/orders?' + params.toString(), { credentials: 'same-origin' }),
fetch('/admin/api/orders/stats', { credentials: 'same-origin' }),
]);
if (!orderResponse.ok || !statsResponse.ok) throw new Error('加载订单失败');
const orderPayload = await orderResponse.json();
const statsPayload = await statsResponse.json();
orders.value = orderPayload.data.list || [];
total.value = orderPayload.data.total || 0;
stats.value = statsPayload.data;
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '加载订单失败';
} finally {
loading.value = false;
}
};
const money = order => order.currency === 'USD'
? '$' + (order.amount / 100).toFixed(2)
: order.providerAmount + ' ' + order.currency;
const dateTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-';
const statusText = status => ({ pending: '待支付', paid: '已支付', cancelled: '已关闭', refunded: '已退款' }[status] || status);
const statusClass = status => ({
paid: 'bg-green-100 text-green-800', pending: 'bg-amber-100 text-amber-800',
cancelled: 'bg-gray-100 text-gray-700', refunded: 'bg-blue-100 text-blue-800',
}[status] || 'bg-gray-100 text-gray-700');
const providerText = provider => ({ stripe: 'Stripe', binance_pay: 'Binance Pay' }[provider] || provider || '-');
onMounted(load);
return { orders, stats, total, loading, error, filters, load, money, dateTime, statusText, statusClass, providerText };
},
template: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('orders')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16"> <div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<div class="flex"> <div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">报告订单</p><b class="text-2xl">{{ stats.totalOrders }}</b></div>
<div class="flex-shrink-0 flex items-center"> <div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">已支付</p><b class="text-2xl text-green-700">{{ stats.paidOrders }}</b></div>
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1> <div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">待支付</p><b class="text-2xl text-amber-700">{{ stats.pendingOrders }}</b></div>
</div> <div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">已退款</p><b class="text-2xl text-blue-700">{{ stats.refundedOrders }}</b></div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8"> <div class="bg-white p-4 rounded-lg shadow-sm"><p class="text-xs text-gray-500">Stripe 收入</p><b class="text-2xl">\${{ (stats.revenueUsdCents / 100).toFixed(2) }}</b></div>
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> </div>
仪表盘 <div class="bg-white shadow rounded-lg">
</a> <div class="p-5 border-b flex flex-col lg:flex-row gap-3 lg:items-center lg:justify-between">
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> <div><h2 class="text-lg font-medium">PC 八字报告订单</h2><p class="text-xs text-gray-500 mt-1">仅显示 PC 商业版八字报告订单,共 {{ total }} 笔</p></div>
用户管理 <div class="flex flex-wrap gap-2">
</a> <input v-model="filters.search" @keyup.enter="load" class="border rounded px-3 py-2 text-sm" placeholder="订单号 / 邮箱 / 报告号" />
<a href="/admin/orders" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"> <select v-model="filters.provider" @change="load" class="border rounded px-3 py-2 text-sm"><option value="">全部渠道</option><option value="stripe">Stripe</option><option value="binance_pay">Binance Pay</option></select>
订单管理 <select v-model="filters.status" @change="load" class="border rounded px-3 py-2 text-sm"><option value="">全部状态</option><option value="pending">待支付</option><option value="paid">已支付</option><option value="cancelled">已关闭</option><option value="refunded">已退款</option></select>
</a> <button @click="load" class="bg-red-700 text-white rounded px-4 py-2 text-sm">查询</button>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div> </div>
</div> </div>
</div> <div v-if="error" class="m-5 p-3 bg-red-50 text-red-700 text-sm">{{ error }}</div>
</nav> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <thead class="bg-gray-50"><tr><th class="px-4 py-3 text-left text-xs text-gray-500">订单 / 报告</th><th class="px-4 py-3 text-left text-xs text-gray-500">客户</th><th class="px-4 py-3 text-left text-xs text-gray-500">渠道</th><th class="px-4 py-3 text-left text-xs text-gray-500">金额</th><th class="px-4 py-3 text-left text-xs text-gray-500">状态</th><th class="px-4 py-3 text-left text-xs text-gray-500">渠道交易号</th><th class="px-4 py-3 text-left text-xs text-gray-500">时间</th></tr></thead>
<div class="px-4 py-6 sm:px-0"> <tbody class="divide-y divide-gray-100">
<div class="bg-white shadow rounded-lg"> <tr v-if="loading"><td colspan="7" class="p-10 text-center text-sm text-gray-500">正在加载订单...</td></tr>
<div class="px-4 py-5 sm:p-6"> <tr v-else-if="orders.length === 0"><td colspan="7" class="p-10 text-center text-sm text-gray-500">暂无符合条件的订单</td></tr>
<h2 class="text-lg font-medium text-gray-900 mb-4">订单列表</h2> <tr v-for="order in orders" :key="order.id" class="hover:bg-gray-50">
<div class="overflow-x-auto"> <td class="px-4 py-4 text-xs"><b class="block text-gray-900">{{ order.orderNo }}</b><span class="block text-gray-500">{{ order.reportId || '-' }}</span><code v-if="order.accessCode" class="block mt-1 text-red-700">{{ order.accessCode }}</code></td>
<table class="min-w-full divide-y divide-gray-200"> <td class="px-4 py-4 text-sm">{{ order.customerEmail || '-' }}</td>
<thead class="bg-gray-50"> <td class="px-4 py-4 text-sm">{{ providerText(order.provider) }}</td>
<tr> <td class="px-4 py-4 text-sm font-medium">{{ money(order) }}</td>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">订单号</th> <td class="px-4 py-4"><span :class="statusClass(order.status)" class="px-2 py-1 rounded-full text-xs font-medium">{{ statusText(order.status) }}</span></td>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th> <td class="px-4 py-4 text-xs text-gray-500 max-w-xs truncate" :title="order.providerTransactionId">{{ order.providerTransactionId || order.providerOrderId || '-' }}</td>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">商品</th> <td class="px-4 py-4 text-xs text-gray-500"><span class="block">创建 {{ dateTime(order.createdAt) }}</span><span class="block">支付 {{ dateTime(order.payTime) }}</span></td>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">金额</th> </tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th> </tbody>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th> </table>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">L20240101001</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">许愿</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">普通许愿条</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">¥1.00</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">已支付</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2024-01-01 12:00</td>
</tr>
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
</div> </div>
` `,
} };
+3 -31
View File
@@ -1,38 +1,10 @@
import { adminHeader } from './AdminHeader.js';
// Settings 页面组件 // Settings 页面组件
export default { export default {
template: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('settings')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0"> <div class="px-4 py-6 sm:px-0">
+3 -31
View File
@@ -1,38 +1,10 @@
import { adminHeader } from './AdminHeader.js';
// Users 页面组件 // Users 页面组件
export default { export default {
template: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('users')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0"> <div class="px-4 py-6 sm:px-0">
+3 -31
View File
@@ -1,38 +1,10 @@
import { adminHeader } from './AdminHeader.js';
// Wiki 页面组件:百科条目管理 + 历史上的今天(维基百科)同步 // Wiki 页面组件:百科条目管理 + 历史上的今天(维基百科)同步
export default { export default {
template: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('wiki')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<!-- 标签页切换 --> <!-- 标签页切换 -->
+3 -31
View File
@@ -1,38 +1,10 @@
import { adminHeader } from './AdminHeader.js';
// Wishes 页面组件 // Wishes 页面组件
export default { export default {
template: ` template: `
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen bg-gray-100">
<nav class="bg-white shadow-sm"> ${adminHeader('wishes')}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
</div>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
仪表盘
</a>
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
用户管理
</a>
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
订单管理
</a>
<a href="/admin/wishes" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
许愿管理
</a>
<a href="/admin/wiki" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
百科管理
</a>
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
系统设置
</a>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0"> <div class="px-4 py-6 sm:px-0">
+2
View File
@@ -0,0 +1,2 @@
VITE_PAYMENT_MODE=demo
VITE_API_BASE_URL=http://localhost:8080
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local
.env.*.local
+75
View File
@@ -0,0 +1,75 @@
# 变更日志
## [未发布]
### 邮箱验证码
- 增加 QQ 邮箱 SMTP 登录验证码发送,支持隐式 TLS 与 STARTTLS;SMTP 凭证只从环境变量读取。
- SMTP 配置可用时优先发送,未配置时保留 Postmark 备用通道。
### 排盘数据告知与同意
- 免费排盘默认只在当前页面展示;用户主动同意后才上传出生资料和排盘快照。
- 服务端保存同意版本与时间戳,拒绝缺少同意的写入,并对公开保存接口限流。
- 修正“本地计算保护隐私”的误导文案,并在付费下单前说明报告快照会保存到服务端。
### 排盘记录与免费层
- 每次排盘保存服务端历史记录,后台新增排盘记录页面。
- 已购报告恢复弹窗增加历史报告列表,可直接打开历史快照。
- 四柱、起运、交运时间、刑冲合害和神煞作为基础事实免费展示。
- 修正交运时间显示:按 `pp.test` 的 sect-2 算法展示起运年限与真实交运时刻。
- 修正天乙贵人误判:按日干查四支,不再错误使用年干命中月支、时支。
- 每步大运携带对应十年流年,点击大运会同步切换流年列表。
- 修复刑冲合害与神煞面板因自动外边距导致的宽度收缩。
- 商业前端构建改为先构建并直接引用最新 `@lunar/core`,避免发布旧算法。
### 付费内容安全
- 支付前不再把个性化付费解读生成到浏览器。
- 服务端在支付确认后生成付费报告快照,恢复接口只对已验证权益开放。
- 增加服务端报告快照测试,防止前端遮罩被误当作付费保护。
### 管理后台
- 将所有后台页面的顶部导航抽取为共享 `AdminHeader`,统一菜单、品牌和当前页高亮。
- 修复订单页缺少“百科管理”和“系统设置”的导航不一致问题。
## [0.2.0] - 2026-09-06
### 支付与账号
- 接入真实 Stripe Checkout Session 创建与签名 Webhook。
- 接入 Binance Pay USDT 订单创建、HMAC 请求签名与 RSA Webhook 验签。
- 接入 Postmark 邮箱验证码登录。
- 服务端商品定价为 `4.99 USD` / `5 USDT`,客户端不能修改金额。
- 新增支付事件幂等记录、订单状态机和退款后权益撤销。
- 支付状态查询改为本人认证,修复公开枚举订单的问题。
### 报告恢复
- 支付成功后生成随机恢复码。
- 保存购买时不可变报告快照。
- 支持购买邮箱加恢复码跨设备找回报告。
- 新增本人已购报告列表、权益查询和报告恢复 API。
### 排盘展示
- 新增起运年龄、顺逆行和交运日期。
- 新增十步大运横向时间轴。
- 新增流年/小运逐年展示。
- 新增天干五合/相冲与地支六合/三合局成员/六冲/六害/相刑。
- 新增神煞面板,展示命中位置与含义,包括天厨贵人。
- 参考 `paipan` 的信息层级,但未复制其桌面表格或未验证的扩展规则。
### 管理后台
- 订单页由静态示例改为数据库真实订单。
- 新增订单总数、状态与 Stripe 收入统计。
- 新增渠道、状态、订单号、邮箱和报告号筛选。
## [0.1.0] - 2026-09-05
- 新建独立商业版 Web。
- 复用 `@lunar/core` 实现四柱、五行与神煞计算。
- 新增免费命盘、六章付费报告遮罩与响应式视觉系统。
+171
View File
@@ -0,0 +1,171 @@
# 商业版 Web 功能说明
## 1. 产品目标
本项目是八字排盘的独立商业化 Web。免费层完整展示排盘事实,付费层提供六章深度解读。支付、用户、订单、报告快照与恢复码由 Go 服务端管理,不依赖浏览器伪造支付状态。
## 2. 完整业务流程
```mermaid
flowchart TD
A[填写出生日期、时间、性别、城市] --> B[在当前设备计算真太阳时与八字]
B --> C[免费查看四柱、五行、大运、流年、刑冲合害、神煞]
C --> D{是否同意保存本次排盘记录}
D -- 是 --> E[服务端保存所列资料与排盘快照]
D -- 否 --> F[只在当前页面查看,不上传免费排盘记录]
C --> G{是否解锁深度解读}
G -- 否 --> H[继续查看免费排盘]
G -- 是 --> I[QQ 邮箱 SMTP 验证码登录并确认下单数据保存说明]
I --> J{选择支付渠道}
J --> K[Stripe: 4.99 USD]
J --> L[Binance Pay: 5 USDT]
K --> M[服务商签名 Webhook]
L --> M
M --> N[事务更新订单和报告权益]
N --> O[生成随机报告恢复码]
O --> P[保存不可变报告快照]
P --> Q[用户凭购买邮箱和恢复码跨设备找回]
```
## 3. 免费排盘内容
八字运算在当前设备完成。用户默认不上传免费排盘资料;只有主动勾选保存选项后,姓名(如填写)、出生日期、出生时间、性别、城市和排盘快照才会发送到服务端,供授权后台人员查看。未勾选仍可免费计算,但服务端不会创建免费排盘记录。服务端验证同意版本并对公开保存接口限流。相同输入在同一算法版本下应得到相同结果;算法升级后保留已保存快照,不覆盖历史记录。
购买时会另行告知:创建订单会把报告快照和订单所需信息发送到服务端,用于生成、交付和恢复已购报告;该购买记录不依赖免费排盘保存选项。
免费层展示基础事实,不把基础数据故意藏到付费层:
- 出生城市经度与真太阳时校正。
- 年柱、月柱、日柱、时柱。
- 十神、藏干、纳音、十二长生、命宫、身宫、胎元、空亡。
- 五行力量统计。
- 起运年龄、顺逆行与交运日期。
- 十步大运时间轴。
- 十年流年/小运信息。
- 天干五合与天干相冲。
- 地支六合、三合局成员、六冲、六害与相刑。
- 22 个常见神煞,包括天乙贵人、天厨贵人、文昌贵人、禄神、羊刃、桃花、驿马、华盖、红鸾、天喜等;展示命中位置和基础说明。
刑冲合害和神煞属于基础排盘事实,免费展示;付费层提供结合命局的个性化解释、优先级和行动建议。
神煞流派版本较多,只作为辅助信息,不单独用于判断吉凶。
## 4. 付费报告
- Stripe 价格:`4.99 USD`。
- Binance Pay 价格:`5 USDT`。
- 单次购买,无订阅和自动续费。
- 六章内容:性格、事业、财富、关系、身心、大运路线。
支付成功必须经过服务商签名 Webhook。浏览器跳转页不能直接授予权益。
付费内容安全边界:支付前浏览器只接收免费排盘数据和通用锁定占位,不接收个性化六章解读;服务端确认支付后,才根据订单快照生成并保存付费报告。客户端不能通过修改遮罩、localStorage 或请求参数提前获得付费内容。
## 5. 报告恢复码
支付成功后,服务端使用加密安全随机数生成恢复码,例如:
```text
ZM-ABCD-EFGH-JKLM
```
恢复规则:
1. 必须先验证购买时的邮箱。
2. 恢复码必须属于当前邮箱账户。
3. 订单必须是已支付状态。
4. 报告权益必须仍然有效。
5. 返回购买时保存的报告快照,不重新套用可能已经变化的新算法。
6. 退款后权益会被撤销。
## 6. 支付与登录接口
### 邮箱登录
- `POST /api/auth/email/request`:优先通过 QQ SMTP(TLS)发送 6 位验证码;未配置 SMTP 时可使用 Postmark 备用通道。
- `POST /api/auth/email/verify`:验证验证码并签发用户 JWT。
- 验证码 10 分钟有效、最多尝试 5 次、60 秒内不能重复发送。
### 创建支付
```http
POST /api/commerce/checkout
Authorization: Bearer <user-token>
Content-Type: application/json
{
"provider": "stripe",
"reportId": "ZM-0VB51FN",
"snapshot": {
"id": "ZM-0VB51FN",
"result": {},
"elements": {},
"shensha": [],
"sections": []
}
}
```
服务端从商品表读取金额,客户端不能修改定价。
### Webhook
- `POST /api/commerce/webhooks/stripe`
- `POST /api/commerce/webhooks/binance-pay`
Stripe 校验 `Stripe-Signature` 与 5 分钟时间窗口;Binance Pay 使用商户证书公钥验证 RSA 签名。事件 ID 唯一保存,重复通知不会重复授予权益。
### 查询与恢复
- `GET /api/commerce/orders/:orderNo`:查询本人订单和恢复码。
- `GET /api/commerce/reports/:reportId/access`:查询本人报告权益。
- `GET /api/commerce/reports`:列出本人已购报告。
- `GET /api/commerce/reports/recover/:accessCode`:通过恢复码读取购买时快照。
## 7. 后台订单管理
访问 `/admin/orders`,可查看:
- 报告订单总数、已支付、待支付、已退款。
- Stripe 美元收入。
- 用户邮箱、报告编号、恢复码关联订单。
- Stripe 或 Binance Pay 渠道。
- 服务商订单号与交易号。
- 创建时间和支付时间。
- 按状态、渠道、订单号、邮箱、报告号筛选。
## 8. 配置
前端复制 `.env.example` 为 `.env.local`。只有明确设置 `VITE_PAYMENT_MODE=demo` 才进入演示支付;未设置时默认使用生产支付,避免线上误开模拟解锁。
后端配置见 `server/.env.example`,邮箱登录可配置:
- QQ SMTP 主机、端口、完整邮箱地址与 SMTP 授权码;授权码通过环境变量或本地忽略的密钥文件注入,不提交到 Git。
- Postmark Server Token 与已验证发件域名可作为备用通道。
- Stripe Secret Key 与 Webhook Secret。
- Binance Pay 商户 API Key、Secret Key 与 Webhook 公钥。
- 公开 HTTPS API 域名和 Web 地址。
- 强随机 JWT 密钥和后台密码。
## 9. 数据库
新增:
- `report_products`:服务端价格。
- `report_entitlements`:付费权益。
- `report_snapshots`:购买时不可变报告快照。
- `payment_events`:Webhook 幂等记录。
- `email_login_codes`:一次性验证码。
- `bazi_chart_records`:排盘记录增加同意版本与同意时间字段;旧记录不回填为已同意。
- `orders` 新增渠道、币种、服务商交易号、报告号、恢复码等字段。
新部署可使用 GORM AutoMigrate;已有 MySQL 数据库应在备份后执行 `server/migrations/002_commerce.sql`,不要 fresh 或 refresh 数据库。
## 10. 上线前仍需完成
- 完成香港公司 Stripe 账户审核。
- 完成 Binance Entity/KYB 与 Binance Pay 商户审核,并确认命理数字内容属于可接受商户类别。
- 在 Stripe 与 Binance Pay 沙箱/测试环境上传真实密钥并跑通一笔完整支付和退款。
- 配置正式 HTTPS Webhook 地址。
- 补充隐私政策、用户协议、退款规则、公司与客服信息。
- 根据香港当地要求确认虚拟资产收款、税务和消费者保护义务。
+62
View File
@@ -0,0 +1,62 @@
# 知命 · 商业版八字 Web
商业化八字排盘 Web,复用 `@lunar/core` 排盘引擎,并连接 Go 服务端的邮箱登录、Stripe、Binance Pay、订单、报告权益、恢复码与管理后台。
## 功能
- 四柱、十神、藏干、纳音、五行与真太阳时。
- 起运年龄、真实交运时间、十步大运、流年/小运时间轴。
- 免费展示天干五合/相冲,地支六合/三合/六冲/六害/相刑,以及神煞位置。
- 22 个常见神煞,包括天厨贵人。
- 六章付费深度报告。
- Stripe `4.99 USD` 与 Binance Pay `5 USDT`。
- Postmark 邮箱验证码登录。
- 支付 Webhook 验签与幂等处理。
- 支付前只下发免费内容和通用锁定占位,个性化付费解读由服务端在支付确认后生成。
- 免费排盘默认不上传;用户主动同意后,服务端保存出生资料、排盘快照及同意版本/时间,授权后台可查询记录。
- 已购报告历史列表、随机恢复码与购买时报告快照。
- 后台真实订单列表、排盘记录和收入统计。
## 启动前端
```bash
cp .env.example .env.local
npm run dev
```
访问 `http://localhost:4260`。
只有 `.env.local` 明确设置 `VITE_PAYMENT_MODE=demo` 时才会模拟支付。生产环境不设置该值或设置为 `production`。
## 启动后端
```bash
cd ../server
cp .env.example .env.local
go run ./cmd
```
后端默认地址为 `http://localhost:8080`,订单后台为 `http://localhost:8080/admin/orders`。
## 验证
```bash
cd ../server && go test ./...
cd ../web-commercial && npm run build
```
## 数据库
服务启动时 GORM 会自动新增商业化表。已有 MySQL 生产库也可以先备份,再单独执行:
```bash
mysql < server/migrations/002_commerce.sql
```
禁止使用数据库 fresh 或 refresh。
## 上线要求
真实支付需要 Stripe、Binance Pay 和 Postmark 商户凭证。密钥只能放在部署环境或 `.env.local`,不能提交到 Git。完整接口、恢复码和运营说明见 `Fetures.md`。
本项目属于传统文化研究与娱乐内容,不构成医疗、法律、投资或人生决策建议。
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#161713" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%23b5372c'/%3E%3Ctext x='32' y='44' text-anchor='middle' font-size='38' fill='white'%3E命%3C/text%3E%3C/svg%3E" />
<meta name="description" content="基于传统干支历法的八字排盘与个人命理解读,免费查看命盘,按需解锁完整报告。" />
<title>知命 · 八字精批</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "lunar-commercial-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "pnpm --dir ../web --filter @lunar/core build && tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@lunar/core": "file:../web/packages/core",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.4.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+1172
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+724
View File
@@ -0,0 +1,724 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowDown,
ArrowRight,
Check,
CheckCircle2,
ChevronDown,
Clock3,
Eye,
Fingerprint,
HeartHandshake,
LockKeyhole,
MapPin,
Menu,
ShieldCheck,
Sparkles,
WalletCards,
X,
} from 'lucide-react';
import {
analyzeElementBalance,
analyzeFortuneGanzhi,
analyzeShensha,
birthInfoToBazi,
checkStemCombine,
checkStemOpposite,
getBranchRelationship,
} from '@lunar/core';
import type { BaziFullResult, ElementProfile, ShenshaInfo } from '@lunar/core';
import {
authEmailKey,
authTokenKey,
chartRecordConsentVersion,
checkReportAccess,
createCheckout,
isDemoPayment,
listPurchasedReports,
recoverReport,
requestEmailCode,
saveChartRecord,
verifyEmailCode,
waitForPaidOrder,
type PaymentChannel,
} from './payment';
import { createReportId, type PremiumSection } from './report';
interface BirthForm {
name: string;
date: string;
time: string;
gender: 'male' | 'female';
city: string;
}
interface ReportState {
id: string;
result: BaziFullResult;
elements: ElementProfile;
shensha: ShenshaInfo[];
sections: PremiumSection[];
}
const CITIES = [
['不校正(标准时间)', 120],
['北京', 116.4], ['上海', 121.5], ['广州', 113.3], ['深圳', 114.1], ['杭州', 120.2],
['成都', 104.1], ['重庆', 106.5], ['武汉', 114.3], ['西安', 108.9], ['南京', 118.8],
['长沙', 113], ['郑州', 113.7], ['天津', 117.2], ['昆明', 102.7], ['乌鲁木齐', 87.6],
] as const;
const ELEMENTS = [
['木', 'wood'], ['火', 'fire'], ['土', 'earth'], ['金', 'metal'], ['水', 'water'],
] as const;
const PILLARS = [
['年柱', 'yearPillar'], ['月柱', 'monthPillar'], ['日柱', 'dayPillar'], ['时柱', 'hourPillar'],
] as const;
const DEFAULT_FORM: BirthForm = {
name: '',
date: '1990-08-08',
time: '10:30',
gender: 'female',
city: '不校正(标准时间)',
};
const LOCKED_SECTIONS: PremiumSection[] = [
['壹', '性格底色'], ['贰', '事业天赋'], ['叁', '财富节奏'],
['肆', '关系模式'], ['伍', '身心能量'], ['陆', '大运路线'],
].map(([index, title]) => ({
index,
title,
kicker: '付费深度解读',
summary: '完成购买后查看与你的命盘对应的个性化分析。',
details: ['结合你的命盘结构生成', '提供针对性的观察与建议', '支付后永久回看'],
}));
function getElementValue(elements: ElementProfile, key: string): number {
const map: Record<string, number> = {
wood: elements.wood,
fire: elements.fire,
earth: elements.earth,
metal: elements.metal,
water: elements.water,
};
return map[key] ?? 0;
}
function App() {
const [form, setForm] = useState<BirthForm>(DEFAULT_FORM);
const [report, setReport] = useState<ReportState | null>(null);
const [isCalculating, setIsCalculating] = useState(false);
const [consentToSaveChart, setConsentToSaveChart] = useState(false);
const [chartSaveStatus, setChartSaveStatus] = useState('');
const [isUnlocked, setIsUnlocked] = useState(false);
const [showCheckout, setShowCheckout] = useState(false);
const [showRecovery, setShowRecovery] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const [paymentNotice, setPaymentNotice] = useState('');
const resultRef = useRef<HTMLElement>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const orderNo = params.get('order') ?? sessionStorage.getItem('lunar-pending-order');
const reportId = sessionStorage.getItem('lunar-pending-report');
const token = localStorage.getItem(authTokenKey);
if (params.get('payment') !== 'success' || !orderNo || !reportId || !token) return;
setPaymentNotice('正在确认支付结果,请稍候…');
waitForPaidOrder(orderNo, token).then(async accessCode => {
if (!accessCode) {
setPaymentNotice('支付仍在确认中,你可以稍后重新打开报告。');
return;
}
const recovered = await recoverReport<ReportState>(accessCode, token);
localStorage.setItem(`lunar-report:${reportId}`, 'unlocked');
setReport(recovered.snapshot);
setIsUnlocked(true);
setPaymentNotice(`支付已确认。你的报告恢复码是 ${accessCode},请妥善保存。`);
}).catch(() => setPaymentNotice('暂时无法读取付费报告,请稍后重新打开报告。'));
}, []);
const calculate = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const shouldSaveChart = consentToSaveChart;
setConsentToSaveChart(false);
setIsCalculating(true);
const [year, month, day] = form.date.split('-').map(Number);
const [hour, minute] = form.time.split(':').map(Number);
const longitude = CITIES.find(([name]) => name === form.city)?.[1] ?? 120;
const offsetMinutes = Math.round((longitude - 120) * 4);
const adjusted = new Date(Date.UTC(year, month - 1, day, hour, minute) + offsetMinutes * 60_000);
window.setTimeout(() => {
try {
const result = birthInfoToBazi({
year: adjusted.getUTCFullYear(),
month: adjusted.getUTCMonth() + 1,
day: adjusted.getUTCDate(),
hour: adjusted.getUTCHours(),
minute: adjusted.getUTCMinutes(),
gender: form.gender,
ziSect: 'lateZiNextDay',
});
result.solarAdjusted = longitude !== 120;
const elements = analyzeElementBalance(result.eightChar);
const shensha = analyzeShensha(result.eightChar);
const id = createReportId(`${form.date}|${form.time}|${form.gender}|${form.city}`);
const chartSnapshot: ReportState = { id, result, elements, shensha, sections: [] };
setReport(chartSnapshot);
setChartSaveStatus(shouldSaveChart ? '正在保存本次排盘记录…' : '本次未上传或保存排盘资料。');
if (shouldSaveChart) {
void saveChartRecord({
reportId: id,
name: form.name,
birthDate: form.date,
birthTime: form.time,
gender: form.gender,
city: form.city,
consentVersion: chartRecordConsentVersion,
snapshot: chartSnapshot,
}).then(() => setChartSaveStatus('本次排盘记录已保存至服务端,授权后台人员可查看。'))
.catch(() => setChartSaveStatus('排盘已完成,但服务端保存失败;本次结果仅保留在当前页面。'));
}
const localAccess = localStorage.getItem(`lunar-report:${id}`) === 'unlocked';
setIsUnlocked(localAccess);
const token = localStorage.getItem(authTokenKey);
if (!isDemoPayment && token && !localAccess) {
checkReportAccess(id, token).then(setIsUnlocked).catch(() => undefined);
}
setIsCalculating(false);
window.setTimeout(() => resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80);
} catch {
setIsCalculating(false);
window.alert('日期或时间无法完成排盘,请检查后重试。');
}
}, 900);
};
const updateForm = <K extends keyof BirthForm>(key: K, value: BirthForm[K]) => {
setForm(current => ({ ...current, [key]: value }));
};
return (
<div className="site-shell">
<Header showMenu={showMenu} onToggleMenu={() => setShowMenu(value => !value)} onRecovery={() => setShowRecovery(true)} />
<main>
{paymentNotice && <div className="payment-notice"><CheckCircle2 />{paymentNotice}<button onClick={() => setPaymentNotice('')} aria-label="关闭提示"><X /></button></div>}
<section className="hero" id="top">
<div className="hero-orbit orbit-one">甲</div>
<div className="hero-orbit orbit-two">子</div>
<div className="hero-copy reveal-one">
<div className="eyebrow"><span />东方命理 · 私人解读</div>
<h1>看懂命盘,<br /><em>更懂自己。</em></h1>
<p className="hero-lead">输入出生信息,免费查看四柱与五行。完整报告将性格、事业、财富、关系与大运,整理成一份可以反复阅读的人生说明书。</p>
<div className="trust-row">
<span><ShieldCheck size={17} /> 资料保存由你选择</span>
<span><WalletCards size={17} /> 一次购买永久回看</span>
<span><HeartHandshake size={17} /> 无自动续费</span>
</div>
</div>
<form className="birth-card reveal-two" onSubmit={calculate}>
<div className="card-heading">
<div><span className="step-number">01</span><p>填写你的出生信息</p></div>
<span className="free-chip">基础排盘免费</span>
</div>
<label className="field full-field">
<span>怎么称呼你</span>
<input value={form.name} onChange={event => updateForm('name', event.target.value)} placeholder="可不填,仅用于报告称呼" maxLength={12} />
</label>
<div className="field-grid">
<label className="field">
<span>出生日期</span>
<input required type="date" min="1901-01-01" max="2100-12-31" value={form.date} onChange={event => updateForm('date', event.target.value)} />
</label>
<label className="field">
<span>出生时间</span>
<input required type="time" value={form.time} onChange={event => updateForm('time', event.target.value)} />
</label>
</div>
<div className="field-grid">
<fieldset className="field segmented-field">
<legend>性别</legend>
<div className="segment-control">
<button type="button" className={form.gender === 'female' ? 'active' : ''} onClick={() => updateForm('gender', 'female')}>女</button>
<button type="button" className={form.gender === 'male' ? 'active' : ''} onClick={() => updateForm('gender', 'male')}>男</button>
</div>
</fieldset>
<label className="field select-field">
<span>出生城市</span>
<MapPin size={16} />
<select value={form.city} onChange={event => updateForm('city', event.target.value)}>
{CITIES.map(([city]) => <option value={city} key={city}>{city}</option>)}
</select>
<ChevronDown size={15} />
</label>
</div>
<label className="privacy-consent">
<input type="checkbox" checked={consentToSaveChart} onChange={event => setConsentToSaveChart(event.target.checked)} />
<span><strong>同意保存本次排盘资料(可选)</strong>姓名(如填写)、出生日期和时间、性别、城市及排盘结果会发送到服务端保存,授权后台人员可查看。未勾选仍可免费排盘,但不会保存本次记录。</span>
</label>
<button className="primary-action" type="submit" disabled={isCalculating}>
{isCalculating ? <><span className="loading-mark" /> 正在推演命盘...</> : <>免费开始排盘 <ArrowRight size={19} /></>}
</button>
<p className="form-note"><Fingerprint size={14} /> 排盘在当前设备计算;勾选保存后才会上传所列资料。</p>
{chartSaveStatus && <p className="chart-save-status" aria-live="polite">{chartSaveStatus}</p>}
</form>
<a className="scroll-cue" href="#how"><span>了解报告</span><ArrowDown size={16} /></a>
</section>
<ValueStrip />
{report && (
<Report
refElement={resultRef}
report={report}
name={form.name}
isUnlocked={isUnlocked}
onUnlock={() => setShowCheckout(true)}
/>
)}
<HowItWorks />
<Offer onStart={() => document.querySelector<HTMLButtonElement>('.primary-action')?.scrollIntoView({ behavior: 'smooth', block: 'center' })} />
<Faq />
</main>
<footer>
<div className="seal small-seal">知命</div>
<p>知命 · 东方命理研习所</p>
<span>传统文化研究与娱乐参考,不构成医疗、法律、投资或人生决策建议。</span>
</footer>
{report && showCheckout && (
<Checkout
reportId={report.id}
snapshot={report}
onClose={() => setShowCheckout(false)}
onPaid={(paidReport) => {
if (paidReport) setReport(paidReport);
localStorage.setItem(`lunar-report:${report.id}`, 'unlocked');
setIsUnlocked(true);
setShowCheckout(false);
}}
/>
)}
{showRecovery && (
<RecoveryModal
onClose={() => setShowRecovery(false)}
onRecovered={recovered => {
setReport(recovered);
setIsUnlocked(true);
setShowRecovery(false);
window.setTimeout(() => resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80);
}}
/>
)}
{report && (!isUnlocked || report.sections.length === 0) && <MobilePayBar onClick={() => setShowCheckout(true)} />}
</div>
);
}
function Header({ showMenu, onToggleMenu, onRecovery }: { showMenu: boolean; onToggleMenu: () => void; onRecovery: () => void }) {
return (
<header>
<a className="brand" href="#top"><span className="seal">知命</span><span><b>知命</b><small>东方命理研习所</small></span></a>
<nav className={showMenu ? 'open' : ''}>
<a href="#top">首页</a><a href="#how">解读方式</a><a href="#offer">完整报告</a><a href="#faq">常见问题</a>
</nav>
<button className="recovery-link" onClick={onRecovery}>找回已购报告</button>
<a className="header-cta" href="#top">免费排盘</a>
<button className="menu-button" onClick={onToggleMenu} aria-label="切换导航">{showMenu ? <X /> : <Menu />}</button>
</header>
);
}
function ValueStrip() {
return (
<section className="value-strip" aria-label="服务特点">
<div><b>四柱</b><span>年月日时精准排盘</span></div>
<div><b>五行</b><span>强弱结构清晰呈现</span></div>
<div><b>六章</b><span>完整人生主题解读</span></div>
<div><b>十运</b><span>长期阶段节奏梳理</span></div>
</section>
);
}
function Report({ refElement, report, name, isUnlocked, onUnlock }: { refElement: React.RefObject<HTMLElement | null>; report: ReportState; name: string; isUnlocked: boolean; onUnlock: () => void }) {
const chart = report.result.eightChar;
const freeInsights = useMemo(() => [
`你的日主是${chart.dayMaster},这是理解整张命盘的中心。`,
`命局中${report.elements.dominant}较突出,${report.elements.weakest}相对偏弱。`,
report.shensha[0] ? `盘中可见「${report.shensha[0].name}」,落在${report.shensha[0].foundIn.join('、')}。` : '命盘结构更适合从五行流通与阶段节奏综合观察。',
], [chart, report]);
const premiumLoaded = isUnlocked && report.sections.length > 0;
const visibleSections = premiumLoaded ? report.sections : LOCKED_SECTIONS;
return (
<section className="report-section" ref={refElement}>
<div className="section-heading centered">
<span>YOUR CHART</span>
<h2>{name ? `${name}的` : '你的'}先天命盘</h2>
<p>报告编号 {report.id} · {report.result.solarAdjusted ? '已按出生城市经度校正时间' : '按输入的标准时间排盘'}</p>
</div>
<div className="chart-paper">
<div className="paper-topline"><span>乾坤有序 · 四时有信</span><strong>日主 {chart.dayMaster}</strong></div>
<div className="pillars-grid">
{PILLARS.map(([label, key], index) => {
const pillar = chart[key];
return (
<div className={`pillar ${index === 2 ? 'day-pillar' : ''}`} key={key}>
<span>{label}</span>
<small>{pillar.tenStar ?? '日主'}</small>
<b className={`element-${pillar.elementStem}`}>{pillar.heavenStem}</b>
<b className={`element-${pillar.elementBranch}`}>{pillar.earthBranch}</b>
<i>{pillar.nayin}</i>
</div>
);
})}
</div>
<div className="chart-meta">
<span>命宫 <b>{chart.ownSign}</b></span><span>身宫 <b>{chart.bodySign}</b></span><span>胎元 <b>{chart.fetalOrigin}</b></span><span>空亡 <b>{chart.emptyBranches.join('')}</b></span>
</div>
</div>
<div className="preview-grid">
<article className="elements-card">
<div className="mini-heading"><span>02</span><h3>五行能量</h3><i>免费查看</i></div>
<div className="element-list">
{ELEMENTS.map(([label, key]) => {
const value = getElementValue(report.elements, key);
const percent = Math.max(8, Math.round((value / report.elements.total) * 100));
return <div className="element-row" key={key}><span className={`element-dot ${key}`}>{label}</span><div><i style={{ width: `${percent}%` }} /></div><b>{value.toFixed(1)}</b></div>;
})}
</div>
<p>五行计算包含四柱天干、地支与藏干权重,结果用于观察结构倾向。</p>
</article>
<article className="insight-card">
<div className="mini-heading light"><span>03</span><h3>命盘初读</h3><i>已解锁</i></div>
<div className="insight-list">
{freeInsights.map((insight, index) => <div key={insight}><b>0{index + 1}</b><p>{insight}</p></div>)}
</div>
</article>
</div>
<TechnicalChart key={report.id} report={report} />
<div className="premium-report">
<div className="section-heading centered light-heading">
<span>FULL READING</span>
<h2>{premiumLoaded ? '完整命理解读' : '还有更重要的部分'}</h2>
<p>{premiumLoaded ? '六大人生主题已全部解锁,可随时回来阅读。' : '命盘告诉你“是什么”,完整解读帮你理解“如何用”。'}</p>
</div>
<div className="chapter-grid">
{visibleSections.map((section, index) => (
<article className={premiumLoaded ? 'chapter unlocked' : 'chapter locked'} key={section.index}>
<div className="chapter-title"><span>{section.index}</span><div><small>{section.kicker}</small><h3>{section.title}</h3></div>{!premiumLoaded && <LockKeyhole size={18} />}</div>
<p className="chapter-summary">{section.summary}</p>
<ul>{section.details.map(detail => <li key={detail}><Check size={14} />{detail}</li>)}</ul>
{!premiumLoaded && <div className="veil" style={{ opacity: Math.min(0.92, 0.58 + index * 0.055) }} />}
</article>
))}
</div>
{!premiumLoaded && (
<div className="unlock-panel">
<div className="unlock-icon"><Eye size={23} /></div>
<div><span>一次解锁 · 永久回看</span><h3>你的完整命理精批报告</h3><p>性格、事业、财富、感情、身心与十步大运,共 6 大主题。</p></div>
<div className="price"><small>完整报告</small><b><sup>$</sup>4.99</b><del>$9.99</del></div>
<button onClick={onUnlock}>立即解锁完整报告 <ArrowRight size={18} /></button>
<p className="safe-pay"><ShieldCheck size={14} /> 无订阅、无自动续费 · 支付后当前设备永久查看</p>
</div>
)}
</div>
</section>
);
}
function TechnicalChart({ report }: { report: ReportState }) {
const chart = report.result.eightChar;
const currentYear = new Date().getFullYear();
const relationships = useMemo(() => {
const items: { label: string; detail: string; tone: string }[] = [];
for (let first = 0; first < PILLARS.length; first += 1) {
for (let second = first + 1; second < PILLARS.length; second += 1) {
const [firstLabel, firstKey] = PILLARS[first];
const [secondLabel, secondKey] = PILLARS[second];
const firstPillar = chart[firstKey];
const secondPillar = chart[secondKey];
const position = `${firstLabel} · ${secondLabel}`;
if (checkStemCombine(firstPillar.heavenStem, secondPillar.heavenStem)) items.push({ label: '天干五合', detail: `${firstPillar.heavenStem}${secondPillar.heavenStem}合 · ${position}`, tone: 'good' });
if (checkStemOpposite(firstPillar.heavenStem, secondPillar.heavenStem)) items.push({ label: '天干相冲', detail: `${firstPillar.heavenStem}${secondPillar.heavenStem}冲 · ${position}`, tone: 'alert' });
const branch = getBranchRelationship(firstPillar.earthBranch, secondPillar.earthBranch);
if (branch.combine) items.push({ label: '地支六合', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}合 · ${position}`, tone: 'good' });
if (branch.opposite) items.push({ label: '地支六冲', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}冲 · ${position}`, tone: 'alert' });
if (branch.harm) items.push({ label: '地支六害', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}害 · ${position}`, tone: 'warn' });
if (branch.punish) items.push({ label: '地支相刑', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch}刑 · ${position}`, tone: 'warn' });
if (branch.threeCombine) items.push({ label: '三合局成员', detail: `${firstPillar.earthBranch}${secondPillar.earthBranch} · ${branch.formation ?? ''}`, tone: 'good' });
}
}
return items;
}, [chart]);
const fortunes = useMemo(() => report.result.decadeFortunes.map(item => ({
...item,
analysis: analyzeFortuneGanzhi(item.ganzhi, chart.dayMasterStem, chart.dayPillar.earthBranch),
})), [report, chart]);
const currentDecadeIndex = fortunes.findIndex(item => currentYear >= item.startYear && currentYear <= item.endYear);
const [selectedIndex, setSelectedIndex] = useState(currentDecadeIndex >= 0 ? currentDecadeIndex : 0);
const selectedFortune = fortunes[selectedIndex] ?? fortunes[0];
const childLimit = report.result.childLimit;
const approxStartAge = childLimit.yearCount + ((childLimit.monthCount || childLimit.dayCount || childLimit.hourCount || childLimit.minuteCount) ? 1 : 0);
return (
<section className="technical-chart">
<div className="section-heading centered"><span>TRADITIONAL CHART</span><h2>命盘细节与运程</h2><p>排盘数据免费完整展示,付费部分只负责深度解释。</p></div>
<div className="luck-header">
<div><small>起运年龄</small><b>约 {approxStartAge} 岁</b><i>{childLimit.yearCount}年{childLimit.monthCount}月{childLimit.dayCount}天后</i></div>
<div><small>顺逆</small><b>{childLimit.forward ? '顺行' : '逆行'}</b></div>
<div><small>交运时间</small><b>{childLimit.startTime}</b></div>
<div><small>当前日主</small><b>{chart.dayMaster}</b></div>
</div>
<article className="luck-panel">
<div className="technical-title"><div><span>04</span><h3>大运 · 十年一步</h3></div><small>点击大运查看对应流年</small></div>
<div className="decade-track">
{fortunes.map((item, index) => {
const current = currentYear >= item.startYear && currentYear <= item.endYear;
const selected = index === selectedIndex;
return <button type="button" className={`decade-card${selected ? ' selected' : ''}${current ? ' current' : ''}`} key={item.index} onClick={() => setSelectedIndex(index)}><small>{item.startAge}—{item.endAge}岁</small><b>{item.ganzhi}</b><span>{item.analysis.tenStar || '比肩'} · {item.analysis.elementRelation}</span><i>{item.startYear}—{item.endYear}</i>{current && <em>当前</em>}</button>;
})}
</div>
</article>
<article className="luck-panel annual-panel">
<div className="technical-title"><div><span>05</span><h3>流年 · {selectedFortune.ganzhi}({selectedFortune.startYear}—{selectedFortune.endYear})</h3></div><small>小运以出生后逐年干支观察</small></div>
<div className="annual-track">
{selectedFortune.annualFortunes.map(item => <div className={item.year === currentYear ? 'active' : ''} key={item.year}><small>{item.age}岁</small><b>{item.ganzhi}</b><span>{item.year}</span></div>)}
</div>
</article>
<div className="technical-grid">
<article className="relation-panel">
<div className="technical-title"><div><span>06</span><h3>刑冲合害</h3></div><small>四柱内部关系</small></div>
<div className="relation-list">
{relationships.length ? relationships.map(item => <div className={item.tone} key={`${item.label}-${item.detail}`}><b>{item.label}</b><span>{item.detail}</span></div>) : <p className="empty-technical">四柱之间未见主要的合、冲、刑、害关系。</p>}
</div>
</article>
<article className="shensha-panel">
<div className="technical-title"><div><span>07</span><h3>神煞</h3></div><small>共命中 {report.shensha.length} 项</small></div>
<div className="shensha-list">
{report.shensha.map(item => <div className={`star-${item.type}`} key={item.name}><span>{item.type}</span><div><b>{item.name}</b><small>{item.foundIn.join('、')} · {item.description}</small></div></div>)}
</div>
</article>
</div>
<p className="technical-note">神煞流派版本较多,本页面采用常见排盘规则,仅作辅助观察,不单独作为判断依据。</p>
</section>
);
}
function HowItWorks() {
return (
<section className="how-section" id="how">
<div className="section-heading"><span>THE METHOD</span><h2>不是一句判词,<br />而是一套理解自己的方法。</h2></div>
<div className="method-grid">
<article><span>01</span><Clock3 /><h3>校准出生时刻</h3><p>结合出生城市经度进行真太阳时校正,避免地区时差影响时柱。</p></article>
<article><span>02</span><Sparkles /><h3>拆解命盘结构</h3><p>从四柱、十神、藏干、五行与神煞,多层观察先天倾向。</p></article>
<article><span>03</span><Eye /><h3>翻译成行动建议</h3><p>不制造恐惧,不下绝对结论,把传统术语整理成可理解的生活语言。</p></article>
</div>
</section>
);
}
function Offer({ onStart }: { onStart: () => void }) {
return (
<section className="offer-section" id="offer">
<div className="offer-copy"><span className="vertical-mark">知 命 而 行</span><div className="section-heading light-heading"><span>YOUR PRIVATE REPORT</span><h2>人生没有标准答案,<br />但可以多一份清醒。</h2><p>先免费查看自己的命盘。确认内容对你有价值,再决定是否解锁完整报告。</p></div></div>
<div className="offer-card">
<span className="offer-tag">单份完整报告</span><div className="offer-price"><b><sup>$</sup>4.99</b><span><del>$9.99</del><small>或使用 5 USDT · 永久回看</small></span></div>
<ul><li><CheckCircle2 /> 四柱、十神、藏干与五行详盘</li><li><CheckCircle2 /> 性格、事业、财富、关系、身心</li><li><CheckCircle2 /> 十步大运阶段路线</li><li><CheckCircle2 /> 无订阅,不自动续费</li></ul>
<button onClick={onStart}>先免费排盘,再决定 <ArrowRight /></button>
<small>传统文化研究与娱乐参考,请理性看待</small>
</div>
</section>
);
}
function Faq() {
const items = [
['不知道准确出生时间怎么办?', '可以先选择大致时辰查看,但时柱会影响结果。若完全不确定,建议向家人核实出生证明后再购买完整报告。'],
['购买后会自动续费吗?', '不会。完整报告为单次购买,不包含订阅或自动续费。'],
['八字报告能预测具体事件吗?', '不能。报告用于传统文化研究与自我观察,不对具体事件作确定性承诺,也不能代替现实中的专业建议。'],
['我的出生信息会被公开吗?', '不会。当前版本在浏览器本地完成排盘;正式支付接入时也应只保存订单与报告权限所需的最少信息。'],
];
return <section className="faq-section" id="faq"><div className="section-heading"><span>QUESTIONS</span><h2>你可能还想知道</h2></div><div className="faq-list">{items.map(([question, answer]) => <details key={question}><summary>{question}<span>+</span></summary><p>{answer}</p></details>)}</div></section>;
}
function Checkout({ reportId, snapshot, onClose, onPaid }: { reportId: string; snapshot: ReportState; onClose: () => void; onPaid: (paidReport?: ReportState) => void }) {
const [channel, setChannel] = useState<PaymentChannel>('stripe');
const [token, setToken] = useState(() => localStorage.getItem(authTokenKey) ?? '');
const [email, setEmail] = useState(() => localStorage.getItem(authEmailKey) ?? '');
const [code, setCode] = useState('');
const [codeSent, setCodeSent] = useState(false);
const [isPaying, setIsPaying] = useState(false);
const [error, setError] = useState('');
const sendCode = async () => {
setIsPaying(true);
setError('');
try {
await requestEmailCode(email);
setCodeSent(true);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '验证码发送失败');
} finally {
setIsPaying(false);
}
};
const login = async () => {
setIsPaying(true);
setError('');
try {
setToken(await verifyEmailCode(email, code));
} catch (reason) {
setError(reason instanceof Error ? reason.message : '验证码验证失败');
} finally {
setIsPaying(false);
}
};
const pay = async () => {
setIsPaying(true);
setError('');
try {
const checkout = await createCheckout({ reportId, channel, token, snapshot });
if (checkout.demo) {
const accessCode = await waitForPaidOrder(checkout.orderNo, token, 1);
if (!accessCode) throw new Error('模拟支付完成,但报告权益尚未生成');
const recovered = await recoverReport<ReportState>(accessCode, token);
onPaid(recovered.snapshot);
return;
}
if (checkout.checkoutUrl) {
sessionStorage.setItem('lunar-pending-order', checkout.orderNo);
sessionStorage.setItem('lunar-pending-report', reportId);
window.location.assign(checkout.checkoutUrl);
return;
}
setError(`订单 ${checkout.orderNo} 已创建,请完成支付。`);
} catch (reason) {
setError(reason instanceof Error ? reason.message : '暂时无法发起支付');
} finally {
setIsPaying(false);
}
};
const needsLogin = !isDemoPayment && !token;
return (
<div className="modal-backdrop" role="presentation" onMouseDown={event => event.currentTarget === event.target && onClose()}>
<div className="checkout-modal" role="dialog" aria-modal="true" aria-labelledby="checkout-title">
<button className="modal-close" onClick={onClose} aria-label="关闭"><X /></button>
<div className="seal checkout-seal">知命</div><span className="modal-kicker">解锁完整报告</span><h2 id="checkout-title">一次支付,永久回看</h2>
{needsLogin ? (
<div className="email-login">
<p>先验证邮箱,用于保存订单并跨设备找回报告。</p>
<input type="email" value={email} onChange={event => setEmail(event.target.value)} placeholder="你的邮箱" autoComplete="email" />
{codeSent && <input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="6 位验证码" inputMode="numeric" autoComplete="one-time-code" />}
<button className="pay-button" onClick={codeSent ? login : sendCode} disabled={isPaying || !email || (codeSent && code.length !== 6)}>{isPaying ? '请稍候...' : codeSent ? '验证并继续支付' : '发送邮箱验证码'}</button>
</div>
) : (
<>
<div className="checkout-price"><span>应付金额</span><b>{channel === 'stripe' ? <><sup>$</sup>4.99</> : <>5 <sup>USDT</sup></>}</b></div>
<div className="payment-options">
<button className={channel === 'stripe' ? 'selected stripe' : ''} onClick={() => setChannel('stripe')}><span>银行卡</span>Stripe<CheckCircle2 /></button>
<button className={channel === 'binance_pay' ? 'selected binance' : ''} onClick={() => setChannel('binance_pay')}><span>USDT</span>Binance Pay<CheckCircle2 /></button>
</div>
{isDemoPayment && <p className="demo-notice">当前为产品演示模式,点击后模拟支付成功,不会产生真实扣款。</p>}
<button className="pay-button" onClick={pay} disabled={isPaying}>{isPaying ? '正在创建安全订单...' : isDemoPayment ? '模拟支付并解锁' : channel === 'stripe' ? '前往 Stripe 支付 $4.99' : '前往 Binance Pay 支付 5 USDT'}</button>
</>
)}
{error && <p className="payment-error">{error}</p>}
<p className="checkout-note"><ShieldCheck />{isDemoPayment ? '演示支付不会向服务端创建真实订单。' : '下单后,报告快照和订单信息会发送到服务端保存,用于生成、交付和恢复已购报告。'}</p>
<p className="checkout-note">服务商签名回调确认到账 · 无自动续费</p>
</div>
</div>
);
}
function RecoveryModal({ onClose, onRecovered }: { onClose: () => void; onRecovered: (report: ReportState) => void }) {
const [token, setToken] = useState(() => localStorage.getItem(authTokenKey) ?? '');
const [email, setEmail] = useState(() => localStorage.getItem(authEmailKey) ?? '');
const [code, setCode] = useState('');
const [accessCode, setAccessCode] = useState('');
const [purchasedReports, setPurchasedReports] = useState<Array<{ reportId: string; accessCode: string; productName: string; paidAt: string }>>([]);
const [codeSent, setCodeSent] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const sendCode = async () => {
setBusy(true); setError('');
try { await requestEmailCode(email); setCodeSent(true); }
catch (reason) { setError(reason instanceof Error ? reason.message : '验证码发送失败'); }
finally { setBusy(false); }
};
useEffect(() => {
if (!token) return;
listPurchasedReports(token).then(setPurchasedReports).catch(() => setPurchasedReports([]));
}, [token]);
const login = async () => {
setBusy(true); setError('');
try { setToken(await verifyEmailCode(email, code)); }
catch (reason) { setError(reason instanceof Error ? reason.message : '验证码验证失败'); }
finally { setBusy(false); }
};
const recover = async () => {
setBusy(true); setError('');
try {
const recovered = await recoverReport<ReportState>(accessCode, token);
localStorage.setItem(`lunar-report:${recovered.reportId}`, 'unlocked');
onRecovered(recovered.snapshot);
} catch (reason) { setError(reason instanceof Error ? reason.message : '没有找到这份报告'); }
finally { setBusy(false); }
};
return (
<div className="modal-backdrop" role="presentation" onMouseDown={event => event.currentTarget === event.target && onClose()}>
<div className="checkout-modal" role="dialog" aria-modal="true" aria-labelledby="recovery-title">
<button className="modal-close" onClick={onClose} aria-label="关闭"><X /></button>
<div className="seal checkout-seal">再阅</div><span className="modal-kicker">已购报告恢复</span><h2 id="recovery-title">找回当时的完整报告</h2>
{!token ? (
<div className="email-login">
<p>先验证购买时使用的邮箱,再输入报告恢复码。</p>
<input type="email" value={email} onChange={event => setEmail(event.target.value)} placeholder="购买邮箱" autoComplete="email" />
{codeSent && <input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="6 位验证码" inputMode="numeric" />}
<button className="pay-button" onClick={codeSent ? login : sendCode} disabled={busy || !email || (codeSent && code.length !== 6)}>{busy ? '请稍候...' : codeSent ? '验证邮箱' : '发送邮箱验证码'}</button>
</div>
) : (
<div className="email-login">
{purchasedReports.length > 0 && <div className="purchased-report-list"><p>你的历史报告</p>{purchasedReports.map(item => <button type="button" className="history-report" key={item.accessCode} onClick={() => setAccessCode(item.accessCode)}><b>{item.reportId}</b><span>{new Date(item.paidAt).toLocaleDateString('zh-CN')} · {item.productName}</span></button>)}</div>}
<p>恢复码格式示例:ZM-ABCD-EFGH-JKLM</p>
<input value={accessCode} onChange={event => setAccessCode(event.target.value.toUpperCase().replace(/[^A-Z2-7-]/g, '').slice(0, 20))} placeholder="输入报告恢复码" autoComplete="off" />
<button className="pay-button" onClick={recover} disabled={busy || accessCode.length < 17}>{busy ? '正在读取报告...' : '打开已购报告'}</button>
</div>
)}
{error && <p className="payment-error">{error}</p>}
<p className="checkout-note"><ShieldCheck /> 恢复码与购买邮箱必须同时匹配</p>
</div>
</div>
);
}
function MobilePayBar({ onClick }: { onClick: () => void }) {
return <div className="mobile-pay-bar"><div><span>完整报告</span><b>$4.99</b><del>$9.99</del></div><button onClick={onClick}>立即解锁</button></div>;
}
export default App;
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+108
View File
@@ -0,0 +1,108 @@
export type PaymentChannel = 'stripe' | 'binance_pay';
interface CheckoutRequest {
reportId: string;
channel: PaymentChannel;
token: string;
snapshot: unknown;
}
interface CheckoutResponse {
orderNo: string;
checkoutUrl?: string;
demo: boolean;
}
interface ApiPayload<T> {
code: number;
msg: string;
data: T;
}
const apiBaseURL = import.meta.env.VITE_API_BASE_URL ?? '';
export const isDemoPayment = import.meta.env.VITE_PAYMENT_MODE === 'demo';
export const chartRecordConsentVersion = 'chart-record-v1';
export const authTokenKey = 'lunar-commerce-token';
export const authEmailKey = 'lunar-commerce-email';
async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${apiBaseURL}${path}`, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
});
const payload = await response.json().catch(() => null) as ApiPayload<T> | null;
if (!response.ok || !payload || payload.code !== 0) throw new Error(payload?.msg ?? '服务暂时不可用');
return payload.data;
}
export async function requestEmailCode(email: string): Promise<void> {
await apiRequest<never>('/api/auth/email/request', { method: 'POST', body: JSON.stringify({ email }) });
}
export async function saveChartRecord(input: {
reportId: string;
name: string;
birthDate: string;
birthTime: string;
gender: string;
city: string;
consentVersion: string;
snapshot: unknown;
}): Promise<void> {
await apiRequest<never>('/api/commerce/charts', {
method: 'POST',
body: JSON.stringify(input),
});
}
export async function verifyEmailCode(email: string, code: string): Promise<string> {
const result = await apiRequest<{ token: string }>('/api/auth/email/verify', { method: 'POST', body: JSON.stringify({ email, code }) });
localStorage.setItem(authTokenKey, result.token);
localStorage.setItem(authEmailKey, email.toLowerCase());
return result.token;
}
export async function createCheckout(request: CheckoutRequest): Promise<CheckoutResponse> {
if (isDemoPayment) {
await new Promise(resolve => window.setTimeout(resolve, 700));
return { orderNo: `DEMO-${Date.now()}`, demo: true };
}
const result = await apiRequest<{ orderNo: string; checkoutUrl: string; demo?: boolean }>('/api/commerce/checkout', {
method: 'POST',
headers: { Authorization: `Bearer ${request.token}` },
body: JSON.stringify({ provider: request.channel, reportId: request.reportId, snapshot: request.snapshot }),
});
return { orderNo: result.orderNo, checkoutUrl: result.checkoutUrl, demo: result.demo ?? false };
}
export async function checkReportAccess(reportId: string, token: string): Promise<boolean> {
const result = await apiRequest<{ unlocked: boolean }>(`/api/commerce/reports/${encodeURIComponent(reportId)}/access`, {
headers: { Authorization: `Bearer ${token}` },
});
return result.unlocked;
}
export async function waitForPaidOrder(orderNo: string, token: string, attempts = 12): Promise<string | null> {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const order = await apiRequest<{ status: string; accessCode?: string }>(`/api/commerce/orders/${encodeURIComponent(orderNo)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (order.status === 'paid') return order.accessCode ?? null;
if (order.status === 'cancelled' || order.status === 'refunded') return null;
await new Promise(resolve => window.setTimeout(resolve, 2000));
}
return null;
}
export async function listPurchasedReports(token: string): Promise<Array<{ reportId: string; accessCode: string; productName: string; paidAt: string }>> {
const result = await apiRequest<{ list: Array<{ reportId: string; accessCode: string; productName: string; paidAt: string }> }>('/api/commerce/reports', {
headers: { Authorization: `Bearer ${token}` },
});
return result.list;
}
export async function recoverReport<T>(accessCode: string, token: string): Promise<{ reportId: string; snapshot: T }> {
return apiRequest<{ reportId: string; snapshot: T }>(`/api/commerce/reports/recover/${encodeURIComponent(accessCode.toUpperCase())}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
+85
View File
@@ -0,0 +1,85 @@
import type { BaziFullResult, ElementProfile, ShenshaInfo } from '@lunar/core';
export interface PremiumSection {
index: string;
title: string;
kicker: string;
summary: string;
details: string[];
}
const ELEMENT_TRAITS: Record<string, { gift: string; caution: string; field: string; rhythm: string }> = {
木: { gift: '成长力、规划感与同理心', caution: '容易把长期目标变成眼前压力', field: '教育、内容、设计与长期经营', rhythm: '先建立根基,再持续放大优势' },
火: { gift: '表达力、行动力与感染力', caution: '节奏过快时容易透支专注', field: '传播、品牌、销售与创新项目', rhythm: '抓住窗口,但为决策保留冷静期' },
土: { gift: '稳定性、承诺感与资源整合力', caution: '求稳过度时会错过变化', field: '管理、运营、资产与服务业', rhythm: '稳中求变,用小步试错替代停滞' },
金: { gift: '判断力、边界感与执行标准', caution: '标准过高时容易显得紧绷', field: '技术、金融、法律与精细管理', rhythm: '先定规则,再给关系留出弹性' },
水: { gift: '洞察力、适应力与信息敏感度', caution: '选择过多时容易分散心力', field: '研究、咨询、贸易与流动型工作', rhythm: '聚焦一个主航道,让变化服务目标' },
};
const SUPPORTING_ELEMENT: Record<string, string> = { 木: '水', 火: '木', 土: '火', 金: '土', 水: '金' };
export function createReportId(seed: string): string {
let hash = 2166136261;
for (const char of seed) {
hash ^= char.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return `ZM-${Math.abs(hash).toString(36).toUpperCase().padStart(7, '0').slice(0, 7)}`;
}
export function createPremiumSections(result: BaziFullResult, elements: ElementProfile, shensha: ShenshaInfo[]): PremiumSection[] {
const chart = result.eightChar;
const dominant = ELEMENT_TRAITS[elements.dominant] ?? ELEMENT_TRAITS.土;
const dayMaster = ELEMENT_TRAITS[chart.dayMasterElement] ?? dominant;
const helpful = SUPPORTING_ELEMENT[chart.dayMasterElement] ?? elements.weakest;
const positiveStars = shensha.filter(item => item.type === '吉').slice(0, 3).map(item => item.name);
const cautionStars = shensha.filter(item => item.type === '凶').slice(0, 2).map(item => item.name);
const firstFortune = result.decadeFortunes[0];
const starText = positiveStars.length ? positiveStars.join('、') : '命局本身的稳定结构';
const cautionText = cautionStars.length ? cautionStars.join('、') : '节奏失衡';
return [
{
index: '壹',
title: '性格底色',
kicker: `${chart.dayMaster} · 内在驱动力`,
summary: `你的日主为${chart.dayMaster},核心优势集中在${dayMaster.gift}。`,
details: [`五行以${elements.dominant}较突出,做事更重视${dominant.gift}。`, `需要留意:${dayMaster.caution}。`, `真正适合你的成长方式是:${dayMaster.rhythm}。`],
},
{
index: '贰',
title: '事业天赋',
kicker: '能力结构 · 职场定位',
summary: `你的优势更容易在${dominant.field}中形成复利。`,
details: [`适合承担需要${dayMaster.gift}的角色。`, `命局中的${starText},提示你应主动经营专业信用与协作关系。`, `比起频繁换方向,更适合建立可重复的方法与个人标签。`],
},
{
index: '叁',
title: '财富节奏',
kicker: '积累方式 · 风险边界',
summary: `财富策略宜围绕“${dominant.rhythm}”展开。`,
details: [`优先把${dayMaster.gift}转化成可计价的产品或服务。`, `遇到${cautionText}对应的冲动时,应延迟重大财务决定。`, `收入结构宜同时保留稳定现金流与小比例成长投入。`],
},
{
index: '肆',
title: '关系模式',
kicker: '亲密关系 · 沟通课题',
summary: `关系中的吸引力来自${dayMaster.gift},长久则依赖清晰表达。`,
details: [`你更需要能理解你节奏、也尊重边界的伴侣。`, `情绪紧张时,先描述事实与需要,避免用结论代替沟通。`, `重要关系中要刻意安排稳定相处,而不是只依赖临时热情。`],
},
{
index: '伍',
title: '身心能量',
kicker: '五行平衡 · 日常建议',
summary: `${elements.weakest}相对偏弱,日常可借${helpful}的意象建立平衡感。`,
details: [`把作息、运动和独处时间当作长期资产。`, `高压期减少多线程任务,让注意力回到一个明确目标。`, `所有健康问题应以正规医疗意见为准,本报告不替代诊断。`],
},
{
index: '陆',
title: '大运路线',
kicker: '阶段重点 · 行动窗口',
summary: firstFortune ? `首步大运为${firstFortune.ganzhi},约从${firstFortune.startAge}岁进入阶段转换。` : '人生节奏呈阶段性变化。',
details: [`大运用于观察阶段主题,不代表确定事件。`, `顺势的关键是持续放大${dayMaster.gift},并管理${dayMaster.caution}。`, `完整报告保留十步大运时间轴,方便长期回看。`],
},
];
}
+510
View File
@@ -0,0 +1,510 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Ma+Shan+Zheng&family=Noto+Serif+SC:wght@400;500;600;700;900&display=swap');
:root {
color: #1a1c18;
background: #f1ecdf;
font-family: 'DM Sans', 'PingFang SC', sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
--ink: #171915;
--ink-soft: #5d6058;
--paper: #f5f0e5;
--paper-deep: #e9e0ce;
--vermilion: #b5372c;
--vermilion-dark: #84251f;
--gold: #b48a47;
--line: rgba(23, 25, 21, 0.14);
--serif: 'Noto Serif SC', 'Songti SC', serif;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--paper); }
button, input, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
a { color: inherit; text-decoration: none; }
.site-shell { min-height: 100vh; overflow: hidden; }
.payment-notice { position: fixed; z-index: 80; left: 50%; top: 18px; transform: translateX(-50%); width: min(560px, calc(100% - 28px)); padding: 13px 16px; display: flex; align-items: center; gap: 9px; background: #f5f0e5; color: var(--ink); border: 1px solid var(--gold); box-shadow: 0 16px 45px rgba(0,0,0,.22); font-size: 12px; }
.payment-notice > svg { color: var(--vermilion); flex: 0 0 auto; }
.payment-notice button { margin-left: auto; padding: 2px; border: 0; background: transparent; color: var(--ink-soft); }
.payment-notice button svg { width: 16px; }
header {
height: 82px;
padding: 0 clamp(24px, 5vw, 76px);
display: flex;
align-items: center;
justify-content: space-between;
position: absolute;
z-index: 20;
width: 100%;
color: #f7f1e4;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand > span:last-child { display: flex; flex-direction: column; gap: 2px; }
.brand b { font-family: var(--serif); letter-spacing: .22em; font-size: 18px; }
.brand small { color: rgba(255,255,255,.55); font-size: 9px; letter-spacing: .3em; }
.seal {
width: 41px;
height: 41px;
border: 1px solid currentColor;
display: grid;
place-items: center;
color: #f5eee0;
font-family: var(--serif);
font-weight: 700;
font-size: 12px;
line-height: 1.15;
writing-mode: vertical-rl;
letter-spacing: .15em;
position: relative;
}
.seal::after { content: ''; position: absolute; inset: 3px; border: 1px solid currentColor; opacity: .45; }
header nav { display: flex; gap: 38px; margin-left: auto; margin-right: 45px; }
header nav a { font-size: 13px; color: rgba(255,255,255,.72); transition: color .25s ease; }
header nav a:hover { color: #fff; }
.recovery-link { margin-right: 10px; border: 0; background: transparent; color: rgba(255,255,255,.7); font-size: 11px; white-space: nowrap; }
.recovery-link:hover { color: white; }
.header-cta { border: 1px solid rgba(255,255,255,.38); padding: 10px 18px; font-size: 12px; letter-spacing: .12em; transition: .25s ease; }
.header-cta:hover { background: #f6f0e4; color: var(--ink); }
.menu-button { display: none; border: 0; background: transparent; color: inherit; }
.hero {
min-height: 790px;
background: var(--ink);
color: #f7f1e4;
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(420px, .92fr);
align-items: center;
gap: clamp(48px, 7vw, 120px);
padding: 132px clamp(24px, 8vw, 128px) 90px;
position: relative;
isolation: isolate;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
z-index: -2;
background-image: linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px);
background-size: 38px 38px;
}
.hero::after {
content: '';
position: absolute;
width: 58vw;
height: 58vw;
max-width: 850px;
max-height: 850px;
border: 1px solid rgba(180, 138, 71, .13);
border-radius: 50%;
left: -17vw;
top: 80px;
z-index: -1;
box-shadow: 0 0 0 90px rgba(180,138,71,.026), 0 0 0 180px rgba(180,138,71,.018);
}
.hero-orbit {
position: absolute;
z-index: -1;
font-family: var(--serif);
font-size: clamp(110px, 16vw, 260px);
font-weight: 900;
color: rgba(255,255,255,.018);
user-select: none;
}
.orbit-one { left: 5vw; bottom: 5%; }
.orbit-two { left: 35vw; top: 7%; }
.hero-copy { max-width: 650px; }
.eyebrow { display: flex; align-items: center; gap: 13px; color: #c8aa76; font-family: var(--serif); font-size: 12px; letter-spacing: .28em; }
.eyebrow span { width: 30px; height: 1px; background: currentColor; }
.hero h1 { margin: 27px 0 30px; font-family: var(--serif); font-weight: 600; font-size: clamp(54px, 6.6vw, 98px); line-height: 1.13; letter-spacing: -.055em; }
.hero h1 em { color: #c8aa76; font-style: normal; position: relative; }
.hero h1 em::after { content: ''; position: absolute; left: 0; right: 0; bottom: -5px; height: 8px; border-top: 1px solid var(--vermilion); border-radius: 50%; transform: rotate(-2deg); }
.hero-lead { max-width: 590px; font-family: var(--serif); color: rgba(247,241,228,.65); font-size: 16px; line-height: 2; letter-spacing: .04em; }
.trust-row { display: flex; flex-wrap: wrap; gap: 18px 26px; margin-top: 38px; padding-top: 28px; border-top: 1px solid rgba(255,255,255,.1); }
.trust-row span { display: flex; align-items: center; gap: 7px; color: rgba(255,255,255,.58); font-size: 11px; }
.trust-row svg { color: #c8aa76; }
.birth-card {
background: var(--paper);
color: var(--ink);
padding: 34px;
width: 100%;
max-width: 540px;
justify-self: end;
box-shadow: 22px 26px 0 rgba(0,0,0,.17), 0 35px 80px rgba(0,0,0,.3);
position: relative;
}
.birth-card::before { content: ''; position: absolute; inset: 8px; border: 1px solid rgba(181,55,44,.18); pointer-events: none; }
.card-heading { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 30px; position: relative; }
.card-heading > div { display: flex; align-items: center; gap: 11px; }
.card-heading p { margin: 0; font-family: var(--serif); font-size: 18px; font-weight: 600; }
.step-number { color: var(--vermilion); font-size: 10px; letter-spacing: .12em; }
.free-chip { color: var(--vermilion); border: 1px solid rgba(181,55,44,.3); padding: 5px 8px; font-size: 10px; }
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field { display: block; margin: 0 0 14px; position: relative; min-width: 0; }
.field > span, .field legend { display: block; color: #77786f; font-size: 11px; margin: 0 0 7px; letter-spacing: .05em; }
.field input, .field select {
width: 100%;
height: 47px;
border: 1px solid #d7cebd;
border-radius: 0;
color: var(--ink);
background: rgba(255,255,255,.4);
padding: 0 13px;
outline: 0;
transition: border-color .2s ease, background .2s ease;
font-size: 13px;
}
.field input:focus, .field select:focus { border-color: var(--vermilion); background: #fff; }
.field input::placeholder { color: #aaa79d; }
.segmented-field { padding: 0; border: 0; }
.segment-control { height: 47px; display: grid; grid-template-columns: 1fr 1fr; border: 1px solid #d7cebd; padding: 3px; }
.segment-control button { border: 0; background: transparent; color: #74766e; font-family: var(--serif); }
.segment-control button.active { background: var(--ink); color: #f8f2e6; }
.select-field > svg:first-of-type { position: absolute; left: 12px; bottom: 15px; color: var(--vermilion); pointer-events: none; }
.select-field > svg:last-of-type { position: absolute; right: 11px; bottom: 15px; color: #77786f; pointer-events: none; }
.select-field select { appearance: none; padding-left: 35px; }
.primary-action, .offer-card button, .unlock-panel button, .pay-button {
border: 0;
background: var(--vermilion);
color: white;
min-height: 53px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-family: var(--serif);
font-weight: 600;
letter-spacing: .08em;
transition: background .2s ease, transform .2s ease;
}
.primary-action:hover, .offer-card button:hover, .unlock-panel button:hover, .pay-button:hover { background: var(--vermilion-dark); transform: translateY(-1px); }
.primary-action:disabled, .pay-button:disabled { opacity: .7; cursor: wait; }
.form-note { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 14px 0 0; font-size: 10px; color: #929086; }
.privacy-consent { display: flex; align-items: flex-start; gap: 9px; margin: 16px 0 18px; color: #77786f; font-size: 10px; line-height: 1.7; cursor: pointer; }
.privacy-consent input { flex: 0 0 auto; width: 15px; height: 15px; margin: 2px 0 0; accent-color: var(--vermilion); }
.privacy-consent strong { display: block; margin-bottom: 2px; color: var(--ink); font-size: 11px; }
.chart-save-status { margin: 9px 0 0; color: var(--ink-soft); text-align: center; font-size: 10px; line-height: 1.6; }
.loading-mark { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: spin .7s linear infinite; }
.scroll-cue { position: absolute; bottom: 27px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 8px; color: rgba(255,255,255,.42); font-size: 9px; letter-spacing: .2em; writing-mode: vertical-rl; }
.scroll-cue svg { animation: bob 1.8s ease-in-out infinite; }
.value-strip { display: grid; grid-template-columns: repeat(4, 1fr); padding: 0 clamp(24px, 8vw, 128px); background: #ded4c1; border-bottom: 1px solid var(--line); }
.value-strip div { min-height: 108px; display: flex; align-items: center; justify-content: center; gap: 13px; border-right: 1px solid var(--line); }
.value-strip div:first-child { border-left: 1px solid var(--line); }
.value-strip b { font-family: var(--serif); font-size: 28px; font-weight: 500; color: var(--vermilion); }
.value-strip span { font-size: 11px; color: #666960; }
.report-section { padding-top: 112px; scroll-margin-top: 20px; }
.section-heading > span { color: var(--vermilion); font-size: 10px; letter-spacing: .3em; font-weight: 600; }
.section-heading h2 { margin: 13px 0 12px; font-family: var(--serif); font-size: clamp(34px, 4.3vw, 58px); line-height: 1.28; font-weight: 600; letter-spacing: -.035em; }
.section-heading p { margin: 0; color: var(--ink-soft); font-size: 13px; line-height: 1.8; }
.section-heading.centered { text-align: center; }
.chart-paper {
width: min(920px, calc(100% - 40px));
margin: 52px auto 28px;
background-color: #efe7d7;
background-image: radial-gradient(rgba(79,55,32,.12) .6px, transparent .6px);
background-size: 5px 5px;
border: 1px solid #cfc1aa;
box-shadow: 0 18px 50px rgba(59,43,24,.11);
padding: 32px 45px;
position: relative;
}
.chart-paper::before, .chart-paper::after { content: ''; position: absolute; width: 18px; height: 18px; border-color: var(--vermilion); opacity: .55; }
.chart-paper::before { top: 10px; left: 10px; border-top: 1px solid; border-left: 1px solid; }
.chart-paper::after { right: 10px; bottom: 10px; border-right: 1px solid; border-bottom: 1px solid; }
.paper-topline { display: flex; justify-content: space-between; align-items: center; padding-bottom: 20px; border-bottom: 1px solid rgba(45,37,26,.18); font-family: var(--serif); font-size: 11px; letter-spacing: .12em; color: #797267; }
.paper-topline strong { color: var(--vermilion); font-size: 14px; }
.pillars-grid { display: grid; grid-template-columns: repeat(4, 1fr); max-width: 650px; margin: 28px auto; }
.pillar { text-align: center; border-right: 1px solid rgba(45,37,26,.13); padding: 3px 18px 15px; }
.pillar:last-child { border: 0; }
.pillar > span { display: block; font-family: var(--serif); color: #6c695f; font-size: 12px; letter-spacing: .15em; }
.pillar small { display: block; margin: 10px 0 3px; color: #9a9387; font-size: 10px; }
.pillar b { display: block; font-family: var(--serif); font-size: clamp(38px, 4vw, 52px); font-weight: 600; line-height: 1.15; }
.pillar i { display: block; margin-top: 10px; font-family: var(--serif); font-size: 10px; font-style: normal; color: #82796c; }
.day-pillar { background: rgba(181,55,44,.045); box-shadow: inset 0 -2px var(--vermilion); }
.element-木 { color: #3d6f4c; }
.element-火 { color: #b5372c; }
.element-土 { color: #8b693a; }
.element-金 { color: #77736c; }
.element-水 { color: #365f71; }
.chart-meta { display: grid; grid-template-columns: repeat(4, 1fr); padding-top: 18px; border-top: 1px solid rgba(45,37,26,.18); text-align: center; font-size: 10px; color: #888075; }
.chart-meta b { display: block; margin-top: 5px; font-family: var(--serif); color: #383a35; font-size: 13px; }
.preview-grid { width: min(920px, calc(100% - 40px)); margin: 0 auto 100px; display: grid; grid-template-columns: .9fr 1.1fr; gap: 18px; }
.elements-card, .insight-card { padding: 28px 30px; border: 1px solid var(--line); }
.elements-card { background: rgba(255,255,255,.35); }
.insight-card { background: var(--vermilion-dark); color: white; }
.mini-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 26px; }
.mini-heading span { color: var(--vermilion); font-size: 9px; }
.mini-heading h3 { margin: 0; font-family: var(--serif); font-weight: 600; font-size: 18px; }
.mini-heading i { margin-left: auto; font-style: normal; color: #7c7d75; font-size: 9px; border: 1px solid var(--line); padding: 4px 7px; }
.mini-heading.light span { color: #e2c790; }
.mini-heading.light i { color: rgba(255,255,255,.65); border-color: rgba(255,255,255,.22); }
.element-list { display: grid; gap: 12px; }
.element-row { display: grid; grid-template-columns: 27px 1fr 28px; gap: 10px; align-items: center; }
.element-dot { width: 25px; height: 25px; display: grid; place-items: center; color: white; font-family: var(--serif); font-size: 11px; border-radius: 50%; }
.element-dot.wood { background: #477854; }.element-dot.fire { background: #b94a3d; }.element-dot.earth { background: #967247; }.element-dot.metal { background: #7d7e7a; }.element-dot.water { background: #426b7d; }
.element-row > div { height: 5px; background: #ddd5c6; }
.element-row > div i { display: block; height: 100%; background: var(--ink); }
.element-row b { font-size: 10px; text-align: right; color: #77786f; }
.elements-card > p { margin: 22px 0 0; padding-top: 15px; border-top: 1px solid var(--line); color: #7c7d75; font-size: 10px; line-height: 1.7; }
.insight-list { display: grid; gap: 16px; }
.insight-list > div { display: grid; grid-template-columns: 28px 1fr; gap: 12px; align-items: start; }
.insight-list b { color: #dec48f; font-size: 9px; padding-top: 5px; }
.insight-list p { margin: 0; font-family: var(--serif); color: rgba(255,255,255,.78); font-size: 12px; line-height: 1.8; }
.technical-chart { padding: 10px clamp(20px, 8vw, 128px) 110px; background: var(--paper); }
.luck-header { max-width: 1120px; margin: 46px auto 14px; display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); background: rgba(255,255,255,.32); }
.luck-header > div { min-height: 86px; display: flex; flex-direction: column; align-items: center; justify-content: center; border-right: 1px solid var(--line); }
.luck-header > div:last-child { border: 0; }
.luck-header small { color: var(--ink-soft); font-size: 9px; letter-spacing: .08em; }
.luck-header b { margin-top: 6px; font-family: var(--serif); font-size: 15px; }
.luck-header i { margin-top: 4px; color: #929086; font-size: 8px; font-style: normal; }
.luck-panel, .relation-panel, .shensha-panel { max-width: 1120px; margin: 14px auto 0; padding: 27px; border: 1px solid var(--line); background: #ece4d5; }
.technical-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 23px; }
.technical-title > div { display: flex; align-items: center; gap: 10px; }
.technical-title > div > span { color: var(--vermilion); font-size: 9px; }
.technical-title h3 { margin: 0; font-family: var(--serif); font-size: 18px; }
.technical-title > small { color: var(--ink-soft); font-size: 9px; }
.decade-track { display: flex; gap: 9px; overflow-x: auto; padding: 2px 1px 9px; scrollbar-color: rgba(181,55,44,.45) transparent; }
.decade-card { flex: 0 0 132px; min-height: 155px; padding: 17px 13px; display: flex; flex-direction: column; align-items: center; justify-content: center; border: 1px solid var(--line); background: rgba(255,255,255,.45); position: relative; cursor: pointer; font-family: inherit; color: inherit; }
.decade-card > small { color: var(--ink-soft); font-size: 9px; }
.decade-card > b { margin: 9px 0 7px; font-family: var(--serif); color: var(--vermilion-dark); font-size: 25px; letter-spacing: .1em; }
.decade-card > span { font-size: 9px; color: #565950; }
.decade-card > i { margin-top: 8px; color: #929086; font-size: 8px; font-style: normal; }
.decade-card.current em { position: absolute; right: 6px; top: 6px; padding: 2px 5px; background: var(--vermilion); color: white; font-size: 8px; font-style: normal; }
.decade-card.selected { border-color: var(--vermilion); box-shadow: inset 0 -3px var(--vermilion); }
.annual-track { display: grid; grid-template-columns: repeat(10, minmax(70px, 1fr)); overflow-x: auto; }
.annual-track > div { min-width: 70px; padding: 15px 5px; display: flex; flex-direction: column; align-items: center; border-right: 1px solid var(--line); background: rgba(255,255,255,.28); }
.annual-track > div:last-child { border: 0; }
.annual-track small, .annual-track span { color: var(--ink-soft); font-size: 8px; }
.annual-track b { margin: 6px 0; font-family: var(--serif); font-size: 17px; }
.annual-track > div.active { color: white; background: var(--vermilion-dark); }
.annual-track > div.active small, .annual-track > div.active span { color: rgba(255,255,255,.62); }
.technical-grid { max-width: 1120px; margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; position: relative; }
.technical-grid > .relation-panel,
.technical-grid > .shensha-panel { width: 100%; min-width: 0; margin: 14px 0 0; }
.relation-list { display: grid; gap: 8px; }
.relation-list > div { min-height: 47px; padding: 10px 12px; display: grid; grid-template-columns: 88px 1fr; align-items: center; gap: 10px; background: rgba(255,255,255,.45); border-left: 3px solid #8b693a; }
.relation-list > div.good { border-color: #477854; }.relation-list > div.alert { border-color: #b5372c; }.relation-list > div.warn { border-color: #80658d; }
.relation-list b { font-family: var(--serif); font-size: 11px; }
.relation-list span { color: var(--ink-soft); font-size: 9px; line-height: 1.5; }
.empty-technical { color: var(--ink-soft); font-size: 11px; line-height: 1.8; }
.shensha-list { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; max-height: 330px; overflow-y: auto; padding-right: 3px; }
.shensha-list > div { display: grid; grid-template-columns: 26px 1fr; gap: 9px; align-items: start; padding: 11px; background: rgba(255,255,255,.45); }
.shensha-list > div > span { width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid; border-radius: 50%; font-family: var(--serif); font-size: 9px; }
.shensha-list .star-吉 > span { color: #477854; }.shensha-list .star-凶 > span { color: #b5372c; }.shensha-list .star-中性 > span { color: #8b693a; }
.shensha-list b { display: block; font-family: var(--serif); font-size: 11px; }
.shensha-list small { display: block; margin-top: 4px; color: var(--ink-soft); font-size: 8px; line-height: 1.55; }
.technical-note { max-width: 1120px; margin: 12px auto 0; color: #858278; font-size: 9px; text-align: right; }
.premium-report { background: var(--ink); color: #f7f1e4; padding: 100px clamp(20px, 8vw, 128px) 115px; position: relative; }
.premium-report::before { content: '命'; position: absolute; left: -30px; top: -60px; color: rgba(255,255,255,.025); font-family: var(--serif); font-size: 340px; font-weight: 900; pointer-events: none; }
.light-heading > span { color: #c8aa76; }
.light-heading p { color: rgba(255,255,255,.53); }
.chapter-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; max-width: 1120px; margin: 48px auto 0; }
.chapter { min-height: 270px; position: relative; overflow: hidden; padding: 28px; border: 1px solid rgba(255,255,255,.12); background: #1d1f1a; }
.chapter-title { display: flex; align-items: center; gap: 14px; position: relative; z-index: 1; }
.chapter-title > span { width: 35px; height: 35px; border: 1px solid rgba(200,170,118,.5); display: grid; place-items: center; color: #c8aa76; font-family: var(--serif); }
.chapter-title > div { flex: 1; }
.chapter-title small { color: rgba(255,255,255,.38); font-size: 8px; letter-spacing: .12em; }
.chapter-title h3 { margin: 4px 0 0; font-family: var(--serif); font-size: 17px; }
.chapter-title svg { color: rgba(255,255,255,.25); }
.chapter-summary { position: relative; z-index: 1; margin: 23px 0 13px; color: rgba(255,255,255,.72); font-family: var(--serif); font-size: 12px; line-height: 1.8; }
.chapter ul { position: relative; z-index: 1; margin: 0; padding: 0; list-style: none; display: grid; gap: 9px; }
.chapter li { display: flex; gap: 7px; align-items: flex-start; color: rgba(255,255,255,.5); font-size: 10px; line-height: 1.55; }
.chapter li svg { flex: 0 0 auto; color: #c8aa76; margin-top: 1px; }
.chapter.locked .chapter-summary, .chapter.locked ul { filter: blur(5px); user-select: none; }
.chapter .veil { position: absolute; inset: 65px 0 0; background: #1d1f1a; z-index: 2; }
.chapter.unlocked { animation: chapterReveal .5s ease both; }
.unlock-panel { max-width: 800px; margin: 42px auto 0; padding: 30px; display: grid; grid-template-columns: auto 1fr auto; gap: 20px; align-items: center; border: 1px solid rgba(200,170,118,.38); background: #24251f; box-shadow: 0 30px 70px rgba(0,0,0,.28); }
.unlock-icon { width: 47px; height: 47px; display: grid; place-items: center; color: #c8aa76; border: 1px solid rgba(200,170,118,.38); border-radius: 50%; }
.unlock-panel > div:nth-child(2) > span { color: #c8aa76; font-size: 9px; letter-spacing: .18em; }
.unlock-panel h3 { margin: 6px 0 5px; font-family: var(--serif); font-size: 18px; }
.unlock-panel p { margin: 0; color: rgba(255,255,255,.43); font-size: 10px; }
.price { text-align: right; min-width: 100px; }
.price small { display: block; color: rgba(255,255,255,.4); font-size: 9px; }
.price b { display: block; font-size: 29px; color: #ead6ad; }
.price sup { font-size: 13px; }
.price del { color: rgba(255,255,255,.32); font-size: 10px; }
.unlock-panel button { grid-column: 2 / 4; }
.unlock-panel .safe-pay { grid-column: 2 / 4; display: flex; align-items: center; justify-content: center; gap: 5px; }
.how-section { padding: 120px clamp(24px, 8vw, 128px); background: #e8dfcf; }
.how-section > .section-heading { max-width: 720px; }
.method-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 60px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.method-grid article { min-height: 280px; padding: 38px 34px; border-right: 1px solid var(--line); position: relative; }
.method-grid article:first-child { border-left: 1px solid var(--line); }
.method-grid article > span { position: absolute; right: 22px; top: 20px; font-family: var(--serif); color: rgba(181,55,44,.25); font-size: 38px; }
.method-grid svg { color: var(--vermilion); width: 25px; height: 25px; }
.method-grid h3 { margin: 48px 0 13px; font-family: var(--serif); font-size: 20px; }
.method-grid p { margin: 0; color: var(--ink-soft); font-size: 12px; line-height: 1.9; }
.offer-section { display: grid; grid-template-columns: 1.2fr .8fr; background: var(--vermilion-dark); color: #fff; padding: 100px clamp(24px, 10vw, 150px); gap: clamp(60px, 10vw, 150px); align-items: center; position: relative; }
.offer-copy { display: grid; grid-template-columns: auto 1fr; align-items: start; gap: 30px; }
.vertical-mark { writing-mode: vertical-rl; border: 1px solid rgba(255,255,255,.25); padding: 13px 10px; color: rgba(255,255,255,.62); font-family: var(--serif); letter-spacing: .32em; font-size: 11px; }
.offer-card { background: var(--paper); color: var(--ink); padding: 35px; box-shadow: 18px 18px 0 rgba(36,9,7,.18); }
.offer-tag { color: var(--vermilion); font-size: 10px; letter-spacing: .15em; }
.offer-price { display: flex; align-items: end; gap: 18px; margin: 18px 0 24px; padding-bottom: 22px; border-bottom: 1px solid var(--line); }
.offer-price > b { font-size: 44px; font-weight: 600; }
.offer-price sup { font-size: 18px; }
.offer-price > span { display: flex; flex-direction: column; margin-bottom: 7px; }
.offer-price del { color: #99968e; font-size: 11px; }
.offer-price small { color: #666960; font-size: 10px; }
.offer-card ul { list-style: none; margin: 0 0 28px; padding: 0; display: grid; gap: 14px; }
.offer-card li { display: flex; align-items: center; gap: 9px; font-family: var(--serif); font-size: 12px; }
.offer-card li svg { color: var(--vermilion); width: 17px; }
.offer-card > small { display: block; margin-top: 12px; text-align: center; color: #908d85; font-size: 9px; }
.faq-section { padding: 115px clamp(24px, 14vw, 220px); background: var(--paper); display: grid; grid-template-columns: .6fr 1.4fr; gap: 90px; }
.faq-list { border-top: 1px solid var(--line); }
details { border-bottom: 1px solid var(--line); }
summary { list-style: none; min-height: 74px; display: flex; align-items: center; justify-content: space-between; cursor: pointer; font-family: var(--serif); font-size: 14px; }
summary::-webkit-details-marker { display: none; }
summary span { color: var(--vermilion); font-family: sans-serif; font-size: 20px; transition: transform .2s ease; }
details[open] summary span { transform: rotate(45deg); }
details p { color: var(--ink-soft); font-size: 12px; line-height: 1.9; margin: -8px 35px 24px 0; }
footer { background: #10120f; color: rgba(255,255,255,.45); min-height: 180px; padding: 42px clamp(24px, 8vw, 128px); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.small-seal { color: var(--vermilion); width: 36px; height: 36px; }
footer p { margin: 14px 0 8px; font-family: var(--serif); color: rgba(255,255,255,.72); font-size: 13px; letter-spacing: .18em; }
footer > span { font-size: 9px; line-height: 1.7; }
.modal-backdrop { position: fixed; inset: 0; z-index: 100; background: rgba(8,9,7,.82); backdrop-filter: blur(7px); display: grid; place-items: center; padding: 20px; animation: fadeIn .2s ease; }
.checkout-modal { width: min(460px, 100%); background: var(--paper); padding: 38px; position: relative; text-align: center; box-shadow: 0 40px 100px rgba(0,0,0,.42); }
.modal-close { position: absolute; right: 15px; top: 15px; border: 0; background: transparent; color: #77786f; padding: 5px; }
.modal-close svg { width: 20px; }
.checkout-seal { color: var(--vermilion); margin: 0 auto 15px; }
.modal-kicker { color: var(--vermilion); font-size: 9px; letter-spacing: .2em; }
.checkout-modal h2 { margin: 8px 0 23px; font-family: var(--serif); font-size: 25px; }
.checkout-price { display: flex; justify-content: space-between; align-items: center; padding: 17px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.checkout-price span { color: var(--ink-soft); font-size: 11px; }
.checkout-price b { font-size: 30px; }
.checkout-price sup { font-size: 13px; }
.payment-options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 22px 0 15px; }
.payment-options button { min-height: 60px; background: transparent; border: 1px solid var(--line); color: var(--ink-soft); display: flex; align-items: center; justify-content: center; gap: 5px; position: relative; }
.payment-options button span { font-weight: 600; color: var(--ink); }
.payment-options button svg { position: absolute; right: 6px; top: 6px; width: 15px; opacity: 0; }
.payment-options button.selected { border-color: var(--ink); background: rgba(255,255,255,.5); }
.payment-options button.selected svg { opacity: 1; }
.payment-options .stripe.selected svg { color: #635bff; }
.payment-options .binance.selected svg { color: #c99400; }
.email-login { display: grid; gap: 11px; text-align: left; }
.email-login p { margin: 0 0 4px; color: var(--ink-soft); font-size: 11px; line-height: 1.7; }
.email-login input { width: 100%; height: 48px; border: 1px solid #d7cebd; background: white; padding: 0 13px; outline: 0; }
.email-login input:focus { border-color: var(--vermilion); }
.purchased-report-list { display: grid; gap: 7px; margin-bottom: 5px; }
.history-report { display: flex; justify-content: space-between; gap: 10px; padding: 10px 11px; border: 1px solid var(--line); background: rgba(255,255,255,.55); text-align: left; cursor: pointer; }
.history-report:hover { border-color: var(--vermilion); }
.history-report b { color: var(--vermilion-dark); font-size: 10px; }
.history-report span { color: var(--ink-soft); font-size: 9px; }
.demo-notice { margin: 0 0 15px; padding: 10px; background: #fff4db; color: #806525; text-align: left; font-size: 10px; line-height: 1.6; }
.payment-error { color: var(--vermilion); font-size: 10px; }
.checkout-note { display: flex; justify-content: center; align-items: center; gap: 5px; color: #8d8b83; font-size: 9px; margin: 13px 0 0; }
.checkout-note svg { width: 13px; }
.mobile-pay-bar { display: none; }
.reveal-one { animation: revealUp .7s .08s ease both; }
.reveal-two { animation: revealUp .7s .22s ease both; }
@keyframes revealUp { from { opacity: 0; transform: translateY(22px); } to { opacity: 1; transform: none; } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes bob { 50% { transform: translateY(5px); } }
@keyframes chapterReveal { from { opacity: .5; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@media (max-width: 980px) {
header nav { gap: 20px; margin-right: 24px; }
.hero { grid-template-columns: 1fr; padding-top: 145px; }
.hero-copy { text-align: center; margin: 0 auto; }
.eyebrow { justify-content: center; }
.trust-row { justify-content: center; }
.birth-card { justify-self: center; }
.scroll-cue { display: none; }
.chapter-grid { grid-template-columns: repeat(2, 1fr); }
.offer-section { grid-template-columns: 1fr 1fr; gap: 50px; }
.faq-section { grid-template-columns: 1fr; gap: 35px; }
}
@media (max-width: 720px) {
header { height: 66px; padding: 0 18px; }
.brand b { font-size: 15px; }
.brand small { display: none; }
header .seal { width: 35px; height: 35px; }
header nav { display: none; position: absolute; left: 0; right: 0; top: 66px; margin: 0; padding: 18px; background: #20221d; flex-direction: column; gap: 0; border-top: 1px solid rgba(255,255,255,.1); }
header nav.open { display: flex; }
header nav a { padding: 13px 8px; border-bottom: 1px solid rgba(255,255,255,.08); }
.recovery-link { margin-left: auto; margin-right: 8px; font-size: 9px; }
.header-cta { display: none; }
.menu-button { display: grid; place-items: center; }
.hero { min-height: 0; padding: 110px 18px 75px; gap: 42px; }
.hero-copy { text-align: left; }
.eyebrow { justify-content: flex-start; font-size: 10px; }
.hero h1 { font-size: clamp(48px, 15vw, 68px); margin: 22px 0; }
.hero-lead { font-size: 14px; line-height: 1.85; }
.trust-row { justify-content: flex-start; gap: 12px 18px; margin-top: 26px; padding-top: 22px; }
.trust-row span { width: calc(50% - 9px); font-size: 10px; }
.birth-card { padding: 27px 22px; box-shadow: 10px 13px 0 rgba(0,0,0,.2); }
.field-grid { gap: 8px; }
.card-heading p { font-size: 16px; }
.value-strip { grid-template-columns: 1fr 1fr; padding: 0; }
.value-strip div { min-height: 82px; justify-content: flex-start; padding-left: 23px; border-bottom: 1px solid var(--line); }
.value-strip b { font-size: 23px; }
.value-strip span { font-size: 9px; }
.report-section { padding-top: 78px; }
.section-heading h2 { font-size: 34px; }
.chart-paper { width: calc(100% - 24px); padding: 25px 14px; }
.pillars-grid { margin: 23px auto; }
.pillar { padding: 3px 4px 12px; }
.pillar b { font-size: 36px; }
.pillar i { font-size: 8px; }
.chart-meta { gap: 4px; }
.preview-grid { width: calc(100% - 24px); grid-template-columns: 1fr; margin-bottom: 70px; }
.elements-card, .insight-card { padding: 25px 22px; }
.technical-chart { padding: 0 12px 75px; }
.luck-header { grid-template-columns: 1fr 1fr; }
.luck-header > div { min-height: 70px; border-bottom: 1px solid var(--line); }
.luck-header > div:nth-child(2) { border-right: 0; }
.luck-panel, .relation-panel, .shensha-panel { padding: 21px 16px; }
.technical-title { align-items: flex-start; }
.technical-title > small { max-width: 105px; text-align: right; line-height: 1.5; }
.annual-track { grid-template-columns: repeat(10, 70px); }
.technical-grid { grid-template-columns: 1fr; gap: 0; }
.shensha-list { grid-template-columns: 1fr; max-height: 430px; }
.technical-note { text-align: left; line-height: 1.6; }
.premium-report { padding: 76px 12px 100px; }
.chapter-grid { grid-template-columns: 1fr; gap: 9px; }
.chapter { min-height: 245px; }
.unlock-panel { margin: 25px 2px 0; grid-template-columns: auto 1fr; padding: 23px 18px; }
.unlock-icon { width: 42px; height: 42px; }
.price { grid-column: 1 / 3; text-align: center; }
.price small, .price b, .price del { display: inline-block; margin: 0 3px; }
.unlock-panel button, .unlock-panel .safe-pay { grid-column: 1 / 3; }
.how-section { padding: 80px 18px; }
.method-grid { grid-template-columns: 1fr; margin-top: 40px; }
.method-grid article { min-height: 220px; border-left: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.method-grid article:last-child { border-bottom: 0; }
.offer-section { grid-template-columns: 1fr; padding: 74px 18px 88px; gap: 42px; }
.offer-copy { gap: 18px; }
.vertical-mark { display: none; }
.offer-card { padding: 28px 22px; box-shadow: 9px 10px 0 rgba(36,9,7,.18); }
.faq-section { padding: 78px 18px 110px; }
.checkout-modal { padding: 32px 22px; }
.mobile-pay-bar { position: fixed; left: 9px; right: 9px; bottom: 9px; z-index: 50; min-height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 8px 9px 8px 18px; background: #171915; color: white; box-shadow: 0 14px 45px rgba(0,0,0,.35); }
.mobile-pay-bar > div { display: flex; align-items: baseline; gap: 5px; }
.mobile-pay-bar span { display: block; position: absolute; top: 8px; color: rgba(255,255,255,.48); font-size: 8px; }
.mobile-pay-bar b { font-size: 21px; color: #ead6ad; margin-top: 10px; }
.mobile-pay-bar del { font-size: 9px; color: rgba(255,255,255,.35); }
.mobile-pay-bar button { border: 0; background: var(--vermilion); color: white; height: 46px; min-width: 130px; font-family: var(--serif); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": { "@lunar/core": ["../web/packages/core/dist/index"] }
},
"include": ["src"],
"references": []
}
+23
View File
@@ -0,0 +1,23 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@lunar/core': fileURLToPath(new URL('../web/packages/core/dist/index.js', import.meta.url)),
},
},
server: {
port: 4260,
strictPort: true,
},
preview: {
port: 4260,
strictPort: true,
},
build: {
target: 'es2022',
},
});
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
.vite
*.tsbuildinfo
*.local
.DS_Store
*.log
coverage
.swc
+72
View File
@@ -0,0 +1,72 @@
# Lunar Calendar App (万年历)
Chinese almanac/perpetual calendar app with Bazi calculation, daily fortune, divination tools, and beautiful UI.
## Documentation
Detailed docs live in `docs/` — read them before starting any task:
- **docs/ARCHITECTURE.md** — tech stack, layered design, API surface, routes, stores
- **docs/REQUIREMENTS.md** — full feature inventory with completion status
- **docs/PROGRESS.md** — milestones, current state, prioritized backlog
- **docs/BUGS.md** — known bugs, algorithmic approximations, dead code
- **docs/CHANGELOG.md** — version history
## Tech Stack
- **Monorepo**: pnpm workspaces
- **Core**: TypeScript + tyme4ts (calendar engine)
- **Web**: React 19 + Vite + TailwindCSS v4 + Framer Motion + Zustand + React Router v7
- **PWA**: vite-plugin-pwa (configured and built — autoUpdate + Workbox)
## Project Structure
```
lunar/
├── docs/ # Architecture / Requirements / Progress / Bugs / Changelog
├── packages/
│ ├── core/ # @lunar/core — Pure TS, no UI deps
│ │ └── src/
│ │ ├── types/ # calendar.ts, bazi.ts, almanac.ts, fortune.ts
│ │ ├── transformers/ # tyme4ts → plain objects (day.ts, bazi.ts, almanac.ts)
│ │ └── calculators/ # dailyMatch.ts, relationship.ts, elementStrength.ts, plumBlossom.ts, boneWeight.ts
│ └── web/ # @lunar/web — React app
│ └── src/
│ ├── stores/ # Zustand: calendar, user, settings, ui, bookmarks
│ ├── hooks/ # useCalendar, useDayDetail, useBazi, useDailyFortune
│ ├── components/ # calendar/, layout/, ui/
│ └── pages/ # Home, Calendar, DayDetail, Bazi, DailyFortune, Settings, Divination, SolarTerms
```
## Key Architecture Principles
1. **tyme4ts objects never enter React** — all transformed to plain JS objects in @lunar/core
2. **@lunar/core has zero UI deps** — works in any JS runtime (web, Node, RN, mini-program)
3. **Mobile-first responsive** — phone first, desktop sidebar layout
4. **Dark mode via CSS custom properties** — theme toggle with system preference detection
## Commands
- `pnpm dev` — Start Vite dev server (port 4258)
- `pnpm build` — Build core (ESM+CJS) + web
- `pnpm --filter @lunar/core build` — Build core only (tsup)
- `pnpm --filter @lunar/web dev` — Start web dev server
- `pnpm preview` — Preview production build
- `pnpm test` — Run core unit tests (vitest)
- `pnpm lint` — Run ESLint on all packages
## Core API (from @lunar/core)
- `getDayInfo(year, month, day)` → DayInfo
- `getTodayInfo()` → DayInfo
- `getMonthCalendar(year, month, weekStart?)` → DayInfo[][]
- `getAlmanacInfo(year, month, day)` → AlmanacInfo
- `birthInfoToBazi(params)` → BaziFullResult
- `calculateDailyFortune(userBazi, date)` → DailyFortuneResult
- `analyzeElementBalance(bazi)` → ElementProfile
- `calculatePlumBlossom(y, m, d, hour?)` → PlumBlossomResult
- `calculateBoneWeight(...)` → BoneWeightResult
- `getBranchRelationship(a, b)` / `getTenStarRelationship(s, o)` / `checkStemCombine` / `checkStemOpposite`
- `getBuddhistFestival(lunarMonth, lunarDay)` → string | null
- `analyzeShensha(bazi)` → ShenshaInfo[](22 个常见神煞)
- `analyzeFortuneGanzhi(ganzhi, dayStem, dayBranch)` → FortuneLuck(大运/流年 vs 日主生克冲合)
- `getYearMonths(year)` → YearMonthInfo[](按节气月)
## Known Issues
See **docs/BUGS.md** for the current list. Resolved: core CJS export, week-start setting, PWA manifest metadata, tests (50), lint, 称骨 data tables, 八字流派 switching, shensha, 大运流年 explorer. Remaining: simplified 互卦 algorithm, dead code cleanup.
+72
View File
@@ -0,0 +1,72 @@
# Lunar Calendar App (万年历)
Chinese almanac/perpetual calendar app with Bazi calculation, daily fortune, divination tools, and beautiful UI.
## Documentation
Detailed docs live in `docs/` — read them before starting any task:
- **docs/ARCHITECTURE.md** — tech stack, layered design, API surface, routes, stores
- **docs/REQUIREMENTS.md** — full feature inventory with completion status
- **docs/PROGRESS.md** — milestones, current state, prioritized backlog
- **docs/BUGS.md** — known bugs, algorithmic approximations, dead code
- **docs/CHANGELOG.md** — version history
## Tech Stack
- **Monorepo**: pnpm workspaces
- **Core**: TypeScript + tyme4ts (calendar engine)
- **Web**: React 19 + Vite + TailwindCSS v4 + Framer Motion + Zustand + React Router v7
- **PWA**: vite-plugin-pwa (configured and built — autoUpdate + Workbox)
## Project Structure
```
lunar/
├── docs/ # Architecture / Requirements / Progress / Bugs / Changelog
├── packages/
│ ├── core/ # @lunar/core — Pure TS, no UI deps
│ │ └── src/
│ │ ├── types/ # calendar.ts, bazi.ts, almanac.ts, fortune.ts
│ │ ├── transformers/ # tyme4ts → plain objects (day.ts, bazi.ts, almanac.ts)
│ │ └── calculators/ # dailyMatch.ts, relationship.ts, elementStrength.ts, plumBlossom.ts, boneWeight.ts
│ └── web/ # @lunar/web — React app
│ └── src/
│ ├── stores/ # Zustand: calendar, user, settings, ui, bookmarks
│ ├── hooks/ # useCalendar, useDayDetail, useBazi, useDailyFortune
│ ├── components/ # calendar/, layout/, ui/
│ └── pages/ # Home, Calendar, DayDetail, Bazi, DailyFortune, Settings, Divination, SolarTerms
```
## Key Architecture Principles
1. **tyme4ts objects never enter React** — all transformed to plain JS objects in @lunar/core
2. **@lunar/core has zero UI deps** — works in any JS runtime (web, Node, RN, mini-program)
3. **Mobile-first responsive** — phone first, desktop sidebar layout
4. **Dark mode via CSS custom properties** — theme toggle with system preference detection
## Commands
- `pnpm dev` — Start Vite dev server (port 4258)
- `pnpm build` — Build core (ESM+CJS) + web
- `pnpm --filter @lunar/core build` — Build core only (tsup)
- `pnpm --filter @lunar/web dev` — Start web dev server
- `pnpm preview` — Preview production build
- `pnpm test` — Run core unit tests (vitest)
- `pnpm lint` — Run ESLint on all packages
## Core API (from @lunar/core)
- `getDayInfo(year, month, day)` → DayInfo
- `getTodayInfo()` → DayInfo
- `getMonthCalendar(year, month, weekStart?)` → DayInfo[][]
- `getAlmanacInfo(year, month, day)` → AlmanacInfo
- `birthInfoToBazi(params)` → BaziFullResult
- `calculateDailyFortune(userBazi, date)` → DailyFortuneResult
- `analyzeElementBalance(bazi)` → ElementProfile
- `calculatePlumBlossom(y, m, d, hour?)` → PlumBlossomResult
- `calculateBoneWeight(...)` → BoneWeightResult
- `getBranchRelationship(a, b)` / `getTenStarRelationship(s, o)` / `checkStemCombine` / `checkStemOpposite`
- `getBuddhistFestival(lunarMonth, lunarDay)` → string | null
- `analyzeShensha(bazi)` → ShenshaInfo[](22 个常见神煞)
- `analyzeFortuneGanzhi(ganzhi, dayStem, dayBranch)` → FortuneLuck(大运/流年 vs 日主生克冲合)
- `getYearMonths(year)` → YearMonthInfo[](按节气月)
## Known Issues
See **docs/BUGS.md** for the current list. Resolved: core CJS export, week-start setting, PWA manifest metadata, tests (50), lint, 称骨 data tables, 八字流派 switching, shensha, 大运流年 explorer. Remaining: simplified 互卦 algorithm, dead code cleanup.
+56
View File
@@ -0,0 +1,56 @@
# 万年历(Lunar Calendar App)
中国农历 / 黄历应用:农历转换、黄历宜忌、八字排盘(大运流年流月流日)、每日运势、神煞、梅花易数、称骨算命、节气、佛教节日。移动端优先,支持 PWA 离线安装。
## 技术栈
- **Monorepo**:pnpm workspaces
- **@lunar/core**:TypeScript + [tyme4ts](https://github.com/6tail/tyme4ts)(历法引擎),零 UI 依赖,ESM+CJS 双产物
- **@lunar/web**:React 19 + Vite 6 + TailwindCSS v4 + Zustand 5 + React Router v7 + Framer Motion + vite-plugin-pwa
## 快速开始
```bash
pnpm install
pnpm dev # http://localhost:4258
pnpm test # core 单元测试(vitest,50 例)
pnpm lint # ESLint
pnpm build # 构建 core + web(含 PWA)
pnpm preview # 预览构建产物
```
> 需要 Node ≥ 18、pnpm ≥ 9。
## 文档
详细文档见 `docs/`:
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) — 架构、API、路由、状态管理
- [REQUIREMENTS.md](docs/REQUIREMENTS.md) — 功能点清单与完成状态
- [PROGRESS.md](docs/PROGRESS.md) — 里程碑与待办
- [BUGS.md](docs/BUGS.md) — 已知 BUG、技术债、死代码
- [CHANGELOG.md](docs/CHANGELOG.md) — 变更日志
- [RETROSPECT.md](docs/RETROSPECT.md) — 会话回顾(做了什么/没做什么/疑问)
## 目录结构
```
lunar/
├── docs/ # 项目文档
├── packages/
│ ├── core/ # @lunar/core —— 纯 TS 计算引擎
│ └── web/ # @lunar/web —— React 应用
├── AGENTS.md / CLAUDE.md # 开发代理指引
└── eslint.config.js # ESLint flat config
```
## 核心能力
| 模块 | 说明 |
|---|---|
| 历法 | 公历/农历/干支/节气/季节/儒略日/佛历/伊斯兰历/月相 |
| 黄历 | 宜忌、值神(日/时)、二十八宿、彭祖百忌、胎神、冲煞 |
| 八字 | 四柱、十神、藏干、十二长生、大运、流年、神煞、流派切换 |
| 运势 | 每日运势、流年/流月/流日 vs 日主生克冲合 |
| 占卜 | 梅花易数(64 卦)、袁天罡称骨 |
| 其他 | 佛教节日、节气列表、收藏、PWA |
+162
View File
@@ -0,0 +1,162 @@
# 架构文档(Architecture)
> 最后更新:2026-08-03
## 1. 项目概述
万年历(Lunar Calendar App):中国农历/黄历应用,提供农历转换、黄历宜忌、八字排盘、每日运势、梅花易数、称骨算命、节气等命理功能,移动端优先,支持 PWA 离线安装。
## 2. 技术栈
| 层 | 技术 |
|---|---|
| Monorepo | pnpm workspaces(`packages/*`) |
| 核心引擎 | TypeScript + [tyme4ts](https://github.com/6tail/tyme4ts) ^1.5.1(历法/干支计算) |
| 构建 | tsup(core)、Vite 6 + tsc(web) |
| UI | React 19、React Router v7、Zustand 5、TailwindCSS v4(CSS-first)、Framer Motion、lucide-react |
| PWA | vite-plugin-pwa 0.21(Workbox,已启用) |
## 3. 目录结构
```
lunar/
├── docs/ # 项目文档(本目录,含 RETROSPECT 会话回顾)
├── AGENTS.md # 开发代理指引
├── package.json # workspace 聚合脚本
├── pnpm-workspace.yaml # 工作区定义
├── tsconfig.base.json # 共享 TS 配置
└── packages/
├── core/ # @lunar/core —— 纯 TS 计算引擎,零 UI 依赖
│ ├── tsup.config.ts
│ └── src/
│ ├── index.ts # 公共 API 出口(barrel)
│ ├── types/ # calendar.ts / bazi.ts / almanac.ts / fortune.ts
│ ├── transformers/ # day.ts / bazi.ts / almanac.ts / yearMonths.ts(tyme4ts → 纯对象)
│ └── calculators/ # dailyMatch / relationship / elementStrength / plumBlossom / boneWeight / buddhistDates / shensha / fortuneLuck
└── web/ # @lunar/web —— React 应用
├── vite.config.ts # Vite + Tailwind v4 + PWA
├── index.html
└── src/
├── App.tsx # 路由 + 懒加载 + 页面过渡 + ErrorBoundary
├── main.tsx
├── styles/globals.css # 设计令牌(亮/暗主题)
├── lib/utils.ts # cn、日期格式化等工具
├── stores/ # calendar / user / settings / ui / bookmarks(Zustand)
├── hooks/ # useCalendar / useDayDetail / useBazi / useDailyFortune
├── components/
│ ├── calendar/ # CalendarGrid / CalendarCell / MonthYearPicker / WeekDayBar
│ ├── layout/ # AppShell / Header / BottomNav
│ ├── ui/ # Badge / Button / Card / AnimatedPanel / ErrorBoundary / Skeleton
│ ├── day-detail/ bazi/ daily-fortune/(页面内联实现,目录内暂无独立组件)
└── pages/ # 8 个页面(见路由表)
```
## 4. 分层设计
```
┌───────────────────────────────┐
│ @lunar/web (React 页面层) │
│ pages → hooks → stores │
├───────────────────────────────┤
│ @lunar/core (纯计算引擎) │
│ index.ts │
│ ├─ transformers tyme4ts → │
│ │ 纯 JS 对象 │
│ ├─ calculators 业务算法 │
│ └─ types 类型定义 │
├───────────────────────────────┤
│ tyme4ts (历法计算源) │
└───────────────────────────────┘
```
### 4.1 核心原则
1. **tyme4ts 对象永不进入 React**:所有历法对象在 `@lunar/core` 内转换为普通 JS 对象(`DayInfo`、`AlmanacInfo`、`BaziFullResult` 等),React 层只消费纯数据。
2. **@lunar/core 零 UI 依赖**:仅依赖 tyme4ts,可在任意 JS 运行时运行(web / Node / RN / 小程序)。
3. **移动端优先**:手机优先布局,桌面端自适应。
4. **暗色模式**:CSS 自定义属性 + `.dark` class,跟随系统偏好 + 手动切换。
### 4.2 数据流示例
```
SolarDay (tyme4ts)
→ solarDayToDayInfo() # DayInfo
→ solarDayToAlmanacInfo() # AlmanacInfo(含 12 时辰)
→ birthInfoToBazi() # BaziFullResult(四柱/十神/大运…)
→ calculateDailyFortune() # DailyFortuneResult(每日运势)
```
## 5. @lunar/core API 一览
### 类型
`DayInfo`、`AlmanacInfo`、`HourAlmanac`、`PillarInfo`、`HideStemInfo`、`EightCharInfo`、`DecadeFortuneInfo`、`FortuneInfo`、`ChildLimitInfo`、`BaziFullResult`、`PillarRelationship`、`DailyFortuneResult`、`BirthParams`、`BranchRelationship`、`ElementProfile`、`TrigramInfo`、`HexagramInfo`、`PlumBlossomResult`、`BoneWeightResult`
### 函数
| 函数 | 说明 |
|---|---|
| `solarDayToDayInfo(solarDay)` | SolarDay → DayInfo(含季节/节气进度/儒略日/佛历/伊斯兰历/佛教节日) |
| `getDayInfo(y, m, d)` / `getTodayInfo()` | 获取某日/今日信息 |
| `getMonthCalendar(y, m, weekStart?)` | 月历二维数组(周 × 天),可指定周起始 |
| `solarDayToAlmanacInfo(solarDay)` | SolarDay → AlmanacInfo |
| `getAlmanacInfo(y, m, d)` | 获取黄历信息(宜忌/值神/冲煞/时辰) |
| `birthInfoToBazi(params)` | 出生信息 → 完整八字排盘(`ziSect` 流派参数:晚子时换日) |
| `getYearMonths(year)` | 按节气月返回某年 12 个流月 |
| `getBranchRelationship(a, b)` | 地支六合/三合/六冲/六害/相刑 |
| `getTenStarRelationship(s, o)` / `checkStemCombine` / `checkStemOpposite` | 干支关系判断 |
| `calculateDailyFortune(userBazi, date)` | 用户八字 × 日期 → 每日运势评分 |
| `analyzeElementBalance(bazi)` | 五行力量分析 |
| `calculatePlumBlossom(y, m, d, h?)` | 梅花易数起卦 |
| `calculateBoneWeight(...)` | 袁天罡称骨算命 |
| `getBuddhistFestival(lunarMonth, lunarDay)` | 农历佛教节日 |
| `analyzeShensha(bazi)` | 22 个常见神煞 |
| `analyzeFortuneGanzhi(ganzhi, dayStem, dayBranch)` | 大运/流年/流月/流日 vs 日主生克冲合 |
## 6. 前端状态管理(Zustand)
| Store | 持久化 Key | 职责 | 备注 |
|---|---|---|---|
| `calendar` | — | 视图日期 / 选中日期 / 周起始 | `weekStart`、`clearSelection` 当前未被使用 |
| `user` | `lunar-user-profiles` | 出生档案(最多 3 个)、active 档案、八字结果 | |
| `settings` | `lunar-settings` | 主题、周起始、显示开关、八字来源 | `showLunar` 等 4 个字段未被消费 |
| `ui` | — | 侧栏 / 移动端 / 底部面板 | 多数 action 未被消费 |
| `bookmarks` | `lunar-bookmarks` | 日期收藏(标记在日历格上) | `getByDate`/`getByLunarDate` 未使用 |
## 7. 路由(React Router v7,全部懒加载)
| 路径 | 页面 | 底部导航 |
|---|---|---|
| `/` | HomePage(今日概览) | ✔ |
| `/calendar` | CalendarPage(月/周视图) | ✔ |
| `/calendar/:date` | DayDetailPage(日详情) | — |
| `/bazi` | BaziPage(八字排盘) | ✔ |
| `/daily-fortune` | DailyFortunePage(每日运势) | ✔ |
| `/settings` | SettingsPage(设置) | ✔ |
| `/divination` | DivinationPage(梅花易数 + 称骨) | 首页快捷入口 |
| `/solar-terms` | SolarTermsPage(节气) | 首页快捷入口 |
| `*` | 重定向 `/` | — |
## 8. PWA(已启用)
- `vite-plugin-pwa`:autoUpdate 模式 + Workbox 预缓存 + google-fonts 运行时缓存
- 构建产物:`manifest.webmanifest`、`sw.js`、`registerSW.js`
- manifest 已与 index.html 对齐(`lang: zh-CN`、`theme_color: #FFFBF5`)
## 9. 构建与命令
| 命令 | 说明 |
|---|---|
| `pnpm dev` | 启动 web dev(端口 4258) |
| `pnpm build` | 构建 core → web |
| `pnpm --filter @lunar/core build` | 仅构建 core(tsup,ESM+CJS) |
| `pnpm --filter @lunar/web build` | 仅构建 web(tsc -b && vite build) |
| `pnpm preview` | 预览构建产物 |
| `pnpm test` | core 单元测试(vitest,50 例) |
| `pnpm lint` | ESLint(flat config + typescript-eslint) |
| `pnpm clean` | 清理 dist |
## 10. 已知架构问题(详见 BUGS.md)
- 梅花易数互卦为简化实现(上下卦互换,非真·互卦 2-4/3-5 爻法)
- 每日运势当日八字固定取午时;称骨极端总重仍取"最近值"
- 若干死代码(未使用的 hooks / 组件 / store action,见 BUGS.md 清单)
+64
View File
@@ -0,0 +1,64 @@
# BUG 记录(Known Issues)
> 最后更新:2026-08-02
> 严重度:🔴 高(影响功能/构建) · 🟠 中 · 🟡 低
> 状态:⬜ 待修复 · 🔧 修复中 · ✅ 已修复
## 0. 已修复汇总(2026-08-02)
- BUG-01 core CJS 导出 · BUG-02 周起始设置 · BUG-05 lint · BUG-07 版本号 · BUG-08 死分支
- BUG-03 PWA manifest 与 index.html 对齐(lang=zh-CN、theme_color 统一)
- BUG-09 日历页"回到今天"按钮永不显示(条件恒为 false)
- BUG-10 日详情页时辰列表重复 React key(早子/晚子均为"子",改用 ganzhi 作 key)
- TECH-03 更正:梅花卦库实测 **64/64 完整**(原 review 报告"缺 8 卦"不属实),已添加测试锁定
- TECH-06 以 `solarAdjusted` 类型安全字段替代 `(as any)._solarAdjusted`
- TECH-04 称骨数据表整体修正(年表主流版本 + 日表初五 + 歌诀 21~72 补全)
- TECH-05 占卜页称骨年索引改用出生年干支
- 新增单元测试 50 个(vitest)与 ESLint 配置
- 功能深化:八字流派/出生地全国化/海外时区/太阳时开关、神煞、大运流年流月流日交互、历法信息、佛教节日(见 CHANGELOG)
## 1. 已知 BUG
| ID | 严重度 | 状态 | 位置 | 描述 |
|---|---|---|---|---|
| BUG-01 | 🟠 | ✅ | `packages/core` | ~~exports.require 指向不存在的 dist/index.cjs~~ → tsup 改为 `format: ['esm','cjs']`,`dist/index.cjs` 已生成并通过 `require()` 验证 |
| BUG-02 | 🟠 | ✅ | `useCalendar` / `CalendarGrid` / `WeekDayBar` / `CalendarPage` | ~~周一开始设置无效~~ → `settings.weekStartDay` 已贯通至 `getMonthCalendar(weekStart)` 与表头渲染 |
| BUG-03 | 🟡 | ✅ | `vite.config.ts` / `index.html` | ~~PWA manifest lang=en、theme_color 不一致~~ → manifest 增加 `lang: zh-CN`,`theme_color` 统一为 `#FFFBF5`,与 index.html 一致 |
| BUG-04 | 🟡 | ⬜ | `HomePage`(InfoCard) | `highlight` prop 传为布尔值,但渲染时当作 `border-primary/30` 样式类处理,语义不符(需确认意图) |
| BUG-05 | 🟡 | ✅ | 根 `package.json` | ~~pnpm lint 失败~~ → 已配置 ESLint 9 flat config + typescript-eslint,两个包均通过 |
| BUG-06 | 🟡 | ⬜ | `pnpm-workspace.yaml` | `allowBuilds` 不是 pnpm 标准字段(应为 `onlyBuiltDependencies`),疑似无效配置 |
| BUG-07 | 🟡 | ✅ | `SettingsPage` 关于区 | ~~版本号 v0.2~~ → 与 package.json 统一为 v0.1.0 |
| BUG-08 | 🟡 | ✅ | `transformers/day.ts` | ~~getMonthCalendar 死分支~~ → 已移除 if/else 相同调用 |
| BUG-09 | 🟡 | ✅ | `CalendarPage.tsx` | ~~"回到今天"按钮永不显示~~ → 条件误用 `todaySummary.di.isToday`(恒为 true),改为基于 `viewDate` 判断是否正在查看今天;已在浏览器实测 |
| BUG-10 | 🟡 | ✅ | `DayDetailPage.tsx` | ~~时辰列表重复 React key~~ → `getHours()` 返回 13 个时辰(早子 00:00 / 晚子 23:00 均为地支"子"),原用 `h.branch` 作 key 冲突,改为唯一的 `h.ganzhi`;已在浏览器实测无警告 |
## 2. 算法近似 / 技术债
> 非紧急,但应在后续迭代中改善。
| ID | 位置 | 说明 |
|---|---|---|
| TECH-01 | `calculators/dailyMatch.ts` | 当日八字固定取午时(`getHours()[6]`),无法体现时辰差异 |
| TECH-02 | `calculators/plumBlossom.ts` | 互卦为简化实现(上下卦互换),非真·互卦(2-4/3-5 爻法) |
| TECH-03 | `calculators/plumBlossom.ts` | ✅ 已核实:数据集 64/64 完整,`plumBlossom.test.ts` 含完整性校验用例 |
| TECH-04 | `calculators/boneWeight.ts` | ✅ 已修复:歌诀补全 2两1钱~7两2钱(原缺 5两8钱/5两9钱/6两1钱/6两3钱/6两5钱/6两7钱/6两9钱/7两1钱,此前这些总重会错误地取"最近值");年表已按主流版本整体修正(原混用两套网络变体错约 40 处),日表修正初五重量 |
| TECH-05 | `DivinationPage` | ✅ 已修复:称骨年索引改用出生年干支(`ec.yearPillar.ganzhi`),不再用日干支近似 |
| TECH-06 | `types/bazi.ts` | ✅ 已修复:`BaziFullResult` 新增 `solarAdjusted?: boolean`,替代 `(as any)` 补丁 |
| TECH-07 | `calculators/elementStrength.ts` | 五行推断按天干名字映射而非复用 tyme4ts 元素定义(可接受但存在重复逻辑) |
| TECH-08 | `lib/utils.ts` | `getChineseDayName` / `getChineseMonthName` 未使用 |
## 3. 死代码清单
| 位置 | 内容 | 建议 |
|---|---|---|
| `hooks/useDayDetail.ts` | 从未被 import | 删除或接入 DayDetailPage |
| `hooks/useBazi.ts` | 从未被 import(本轮仅修复了其 lint 错误) | 删除或接入 BaziPage |
| `components/ui/AnimatedPanel.tsx` | 从未使用 | 删除或接入日详情弹层 |
| `components/ui/Card.tsx` `CardHeader` | 未使用 | 删除导出 |
| `components/ui/Skeleton.tsx` `CalendarSkeleton`/`BaziSkeleton` | 未使用 | 删除 |
| `stores/ui.ts` | `toggleSideNav`/`closeSideNav`/`setActiveTab`/`openDayDetail`/`closeDayDetail` 及 `activeTab`/`showDayDetail` 未被消费 | 精简或接入 |
| `stores/settings.ts` | `showLunar`/`showSolarTerm`/`showHoliday`/`eightCharProvider` 及对应 toggle 未被读取 | 接入设置页或删除 |
| `stores/calendar.ts` | `weekStart`/`setWeekStart`/`clearSelection` 未使用(周起始现直接读 settings store) | 删除 |
| `stores/bookmarks.ts` | `getByDate`/`getByLunarDate` 未使用 | 配合农历收藏规划 |
| `lib/utils.ts` | `getChineseDayName`/`getChineseMonthName` | 删除 |
| web 依赖 | `tailwind-variants` 从未 import | 移除依赖 |
+80
View File
@@ -0,0 +1,80 @@
# 变更日志(Changelog)
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。版本号遵循 SemVer。
## [Unreleased]
### 文档
- 新增 `docs/` 文档体系:架构(ARCHITECTURE)、需求(REQUIREMENTS)、进度(PROGRESS)、BUG(BUGS)、变更日志(CHANGELOG)
- 更新 `AGENTS.md` / `CLAUDE.md`:补全新页面/Store/API、修正 PWA 状态、指向文档目录
### 功能
- **八字流派切换**:`birthInfoToBazi` 新增 `ziSect` 参数,设置页新增"八字流派"选项(晚子时算次日 23点换日 / 晚子时算当日 0点换日),BaziPage 排盘实时生效;浏览器实测两种流派排出不同日柱/时柱
- **出生时间选择器**:BaziPage 出生时间改为 时/分 下拉选择器(替代不对称的 −/+ 步进),时辰格可点击快速设置时辰
- **出生地全国化 + 海外时区**:出生地列出全国 34 个省级城市(含省会经度真太阳时校正),新增"海外"模式按时区(UTC-12~+14,含半小时时区)将当地出生时间换算为北京时间(UTC+8)排盘;换算与真太阳时校正均按日期运算处理,支持跨日回退(浏览器实测:UTC-5 23:30 → 次日北京 12:30;乌鲁木齐 00:30 真太阳 → 前日 22:20)
- **真太阳时校正开关**:设置页新增"真太阳时校正"开关(默认开启);关闭后中国出生按北京时间直接排盘,海外时区换算不受影响(浏览器实测:关闭时乌鲁木齐 00:30 排日柱己酉,开启时排戊申)
- **宜忌完整显示**:引擎本就返回完整黄历宜忌(如 8/3 共 22 条宜),此前首页 `slice(0,6)` 截断导致显得比别的黄历站少;已放开并标注总数
- **历法信息增强**:DayInfo 新增季节、所处节气/第几天/距下节气天数、儒略日、佛历年(公历+543)、伊斯兰历日期(tyme4ts 原生支持 Hijri),日详情页新增"历法信息"卡片
- **佛教节日**:tyme4ts 无佛教日期支持,新增 `getBuddhistFestival`(农历 21 个重大佛教节日表),日详情页显示 🪷 徽章
- **八字神煞**:新增 `analyzeShensha`(22 个常见神煞:天乙/天厨/文昌贵人、禄神、羊刃、金舆、天德/月德贵人、桃花、驿马、华盖、劫煞、亡神、将星、红鸾、天喜、孤辰、寡宿、天罗、地网、魁罡、阴差阳错),八字页新增"神煞"卡片;查法采用主流排盘工具版本(神煞版本差异已在文档注明)
- **大运/流年生克冲合**:新增 `analyzeFortuneGanzhi`(任意干支 vs 日主:十神、五行生克 生我/我生/克我/我克/比和、天干合冲、地支六合三合冲害刑、吉凶分级);八字页"起运·大运"改为全量 10 个大运并附生克冲合,新增"流年(小运)"10 条卡片
- **今日流年·流月·流日**:八字页新增卡片,用今日年/月/日干支 vs 日主展示生克冲合
- **大运·流年·流月·流日交互下钻**:core 新增 `getYearMonths(year)`(按节气月返回 12 个流月);八字页改为交互浏览器——点选大运 → 流年(按年度切换)→ 流月 12 chips → 该月每日流日列表(带吉凶点与十神),点击日期跳转日详情页;默认定位到当前年份/月份
### 修复
- **称骨算命数据表整体修正**:年份表改为主流通行版本(原混用两套网络变体错约 40 处);日表修正初五(1两6钱);歌诀补全 2两1钱~7两2钱全部 52 条(原缺 5两8钱~7两1钱共 8 条,此前这些总重错误取"最近值")
- **占卜页称骨年索引**:改用出生年干支(`ec.yearPillar.ganzhi`),不再用日干支近似
- **BUG-01**:core 包改为同时输出 ESM + CJS,`exports.require` 指向的 `dist/index.cjs` 现在真实存在并通过 `require()` 验证
- **BUG-02**:周起始设置生效 —— `settings.weekStartDay` 贯通 `useCalendar` → `getMonthCalendar(weekStart)` → `WeekDayBar` 表头渲染
- **BUG-03**:PWA manifest 对齐 —— `lang: zh-CN`,`theme_color` 统一为 `#FFFBF5`(与 index.html 一致)
- **BUG-05**:配置 ESLint 9 flat config + typescript-eslint,`pnpm lint` 通过(修复 24 处代码问题)
- **BUG-07**:设置页版本号统一为 0.1.0
- **BUG-08**:移除 `getMonthCalendar` 中的死分支
- **BUG-09**:日历页"回到今天"浮动按钮恢复正常 —— 原条件 `todaySummary.di.isToday` 恒为 true 导致按钮永不显示,改为基于 `viewDate` 判断(浏览器实测:非本月可见、点击返回本月)
- **BUG-10**:日详情页时辰列表重复 key 修复 —— 早子/晚子地支均为"子",key 改用唯一的干支(`h.ganzhi`)
- **TECH-06**:`BaziFullResult` 新增 `solarAdjusted?: boolean` 字段,替代 `(as any)._solarAdjusted` 非类型安全补丁
### 工程
- 为 @lunar/core 引入 vitest 单元测试(7 个测试文件 / 29 个用例),覆盖历法转换、八字排盘、每日运势、梅花易数(含 64 卦完整性校验)、称骨、干支关系、五行分析
- 根与子包新增 `test` / `lint` 脚本;根 package.json 标记 `"type": "module"`
- 核实梅花卦库为 64/64 完整(此前 review 报告"缺 8 卦"不属实),并以测试固化
### 已知问题(见 docs/BUGS.md)
- 梅花易数互卦仍为简化实现(上下卦互换)
- 每日运势当日八字固定取午时;称骨年索引用日干支近似
- PWA manifest `lang`/`theme_color` 不一致;死代码待清理
## [0.1.0] - 2026-06-07
首个可用版本(monorepo 初始化于 2026-06-06)。
### 新增 — @lunar/core
- 历法转换:`getDayInfo` / `getTodayInfo` / `getMonthCalendar` / `solarDayToDayInfo`
- 黄历:`getAlmanacInfo` / `solarDayToAlmanacInfo`(含 12 时辰逐时黄历)
- 八字:`birthInfoToBazi`(四柱、十神、藏干、十二长生、胎元胎息、命宫身宫、空亡、起运、10 大运、10 流年)
- 干支关系:`getBranchRelationship` / `getTenStarRelationship` / `checkStemCombine` / `checkStemOpposite`
- 五行分析:`analyzeElementBalance`
- 每日运势:`calculateDailyFortune`(四柱打分、宜忌、幸运色/数/方位、分项得分)
- 占卜:`calculatePlumBlossom`(梅花易数,56/64 卦)、`calculateBoneWeight`(袁天罡称骨)
### 新增 — @lunar/web
- 路由与骨架:8 页面懒加载、页面过渡动画、ErrorBoundary、Suspense 骨架屏
- 首页:今日概览、宜忌、运势预览、快捷入口
- 日历:月/周视图、年/月切换、收藏星标、运势点、滑动切月、日详情页(四柱/黄历/12 时辰/收藏)
- 八字排盘页:档案管理(最多 3 个,localStorage 持久化)、排盘全览、称骨
- 每日运势页:日期导航、评分与建议
- 占卜页:梅花易数今日卦象、称骨
- 节气页:全年 24 节气
- 设置页:主题切换(亮/暗/跟随系统)、周起始、阴阳历转换、今日干支
- 状态管理:Zustand 5 个 store(calendar / user / settings / ui / bookmarks)
- PWA:vite-plugin-pwa 集成(autoUpdate + Workbox 预缓存)
- 设计系统:Tailwind v4 设计令牌(亮/暗主题)、Badge / Button / Card / Skeleton
### 已知问题(见 docs/BUGS.md)
- core 包 require 导出指向不存在的 `dist/index.cjs`
- 周起始设置未生效
- 梅花易数互卦简化、卦库缺 8 卦;称骨年索引用日干支近似
- 无自动化测试、无 lint
+49
View File
@@ -0,0 +1,49 @@
# 开发进度(Progress)
> 最后更新:2026-08-03
## 1. 里程碑
| 里程碑 | 时间 | 内容 | 状态 |
|---|---|---|---|
| M0 项目初始化 | 2026-06-06 | monorepo 脚手架、tsconfig、workspace、核心类型定义 | ✅ |
| M1 核心引擎 | 2026-06-06 ~ 06-07 | transformers(day/almanac/bazi)、calculators(relationship/elementStrength) | ✅ |
| M2 高级算法 | 2026-06-07 | dailyMatch(每日运势)、plumBlossom(梅花)、boneWeight(称骨) | ✅ |
| M3 前端页面 | 2026-06-07 | 8 个页面、5 个 store、路由、布局、组件库 | ✅ |
| M4 构建与 PWA | 2026-06-07 | Vite 构建、tsup、PWA 集成(Workbox 缓存) | ✅ |
| M5 文档化 | 2026-08-02 | 架构/需求/进度/BUG/CHANGELOG 文档建立 | ✅ |
| M6 质量加固 | 2026-08-02 | 单元测试(vitest)、ESLint、CJS 导出修复、周起始修复、版本号 | ✅ |
| M7 功能深化 | 2026-08-02 ~ 08-03 | 称骨数据修正、八字流派/出生地/太阳时开关、神煞、大运流年流月流日交互、历法信息、佛教节日 | ✅ |
| M8 收尾 | 2026-08-03 | README、文档整理、源码打包(node_modules 清理) | ✅ |
> 注:主体功能于 2026-06-06 ~ 06-07 完成;2026-08-02 ~ 08-03 进行质量加固与功能深化。
## 2. 当前状态
- **可运行**:`pnpm dev` 可启动,`pnpm build` 可产出 core(ESM+CJS)+ web 产物(含 PWA)。
- **质量**:50 个单元测试通过(vitest)、`pnpm lint` 通过(ESLint flat config + typescript-eslint)。
- 剩余 BUG 与技术债见 BUGS.md。
## 3. 待办清单(Backlog)
按优先级排序:
| 优先级 | 事项 | 类型 | 关联 | 状态 |
|---|---|---|---|---|
| P0 | 修复 core 包 `require` 导出指向不存在的 `dist/index.cjs` | BUG | BUG-01 | ✅ |
| P1 | 周起始设置生效 | 功能 | BUG-02 | ✅ |
| P1 | 为 core 算法补充单元测试 | 工程 | N5 | ✅ 50 例 |
| P2 | 六十四卦数据补全 | 算法 | C10 | ✅ 已核实 64/64 完整,测试锁定 |
| P2 | 互卦算法实现真·互卦(2-4/3-5 爻) | 算法 | C10 | ⬜ |
| P2 | 清理死代码(useDayDetail/useBazi、AnimatedPanel、CardHeader、未用 store action 等) | 工程 | — | ⬜ |
| P3 | PWA manifest 修复 | BUG | BUG-03 | ✅ |
| P3 | 农历周期收藏 UI | 功能 | M2 | ⬜ |
| P3 | 收藏列表页 | 功能 | M3 | ⬜ |
| P4 | 配置 lint 并修复 `pnpm lint` | 工程 | N6 | ✅ |
| P4 | 显示开关字段真正消费(showLunar 等) | 功能 | T6 | ⬜ |
| P4 | 版本号统一(0.1.0) | BUG | BUG-07 | ✅ |
| P4 | 称骨数据表整体修正(年表/日表/歌诀) | 算法 | C11 | ✅ |
| P4 | 占卜页称骨年索引用出生年干支 | 算法 | TECH-05 | ✅ |
| P5 | 每日运势当日八字取午时 → 改为可选时辰 | 算法 | C9 | ⬜ |
| P5 | 称骨极端总重仍取"最近值" → 提示超出范围 | 算法 | TECH-04 | ⬜ |
| P5 | README 编写 | 工程 | M8 | ✅ |
+142
View File
@@ -0,0 +1,142 @@
# 需求文档(Requirements & 功能清单)
> 最后更新:2026-08-02
> 状态说明:✅ 已完成 · 🟡 部分完成 / 有已知问题 · ⬜ 规划中
## 1. 需求总览
| 模块 | 状态 | 说明 |
|---|---|---|
| 历法核心引擎(@lunar/core) | ✅ | 农历/干支/黄历/八字/运势算法 |
| 万年历(首页 + 日历 + 日详情) | ✅ | 功能齐全(周起始设置已修复) |
| 八字排盘 | 🟡 | 功能齐全,含一个非类型安全补丁 |
| 每日运势 | ✅ | 依赖用户档案 |
| 占卜工具(梅花易数 / 称骨) | 🟡 | 算法为简化实现(见备注) |
| 节气 | ✅ | 24 节气列表 |
| 用户档案 + 设置 | 🟡 | 部分设置字段未生效 |
| 收藏 | 🟡 | 仅支持公历单日收藏 |
| PWA | ✅ | 已构建,manifest 有瑕疵 |
## 2. 功能点清单
### 2.1 历法核心(@lunar/core)
| # | 功能点 | 描述 | 状态 |
|---|---|---|---|
| C1 | 日期转换 | tyme4ts → DayInfo(公历/农历/干支/星座/生肖/节日/假日/月相) | ✅ |
| C2 | 月历生成 | `getMonthCalendar` 周×天网格,含相邻月补位 | ✅ |
| C3 | 黄历信息 | 宜忌、值神(日/时)、十二/廿八/九/六曜星、彭祖百忌、胎神、冲煞、纳音、三合/六合 | ✅ |
| C4 | 时辰黄历 | 12 时辰逐时黄历(buildHourlyAlmanac) | ✅ |
| C5 | 八字排盘 | 四柱、十神、藏干、十二长生、胎元/胎息、命宫/身宫、空亡、起运 | ✅ |
| C6 | 大运/流年 | 10 大运 + 10 流年 | ✅ |
| C7 | 干支关系 | 六合/三合/六冲/六害/相刑/三合局/天干合冲 | ✅ |
| C8 | 五行分析 | 五行力量统计(藏干 0.5 权重)、旺衰、平衡度 | ✅ |
| C9 | 每日运势 | 四柱逐柱 vs 当日干支打分(日 40%/月 25%/年 20%/时 15%),输出评分、宜忌、幸运色/数/方位、分项(感情/事业/财运/健康) | 🟡 当日八字固定取午时 |
| C10 | 梅花易数 | 时间起卦、变卦、互卦(简化)、体用关系、64 卦辞 | 🟡 互卦简化为上下互换(卦库 64/64 完整,已有测试) |
| C11 | 称骨算命 | 年/月/日/时称骨表 + 52 首解诗(2两1钱~7两2钱完整) | ✅ 数据表已按主流版本修正,歌诀补全(极端值仍取最近) |
| C12 | 历法信息 | 季节、所处节气/第几天/距下节气、儒略日、佛历年、伊斯兰历(Hijri) | ✅ |
| C13 | 佛教节日 | 农历 21 个重大佛教节日(`getBuddhistFestival`) | ✅ 日详情页 🪷 徽章展示 |
| C14 | 八字神煞 | 22 个常见神煞(`analyzeShensha`) | ✅ 八字页"神煞"卡片 |
| C15 | 大运/流年 vs 日主生克冲合 | 十神、五行生克、天干合冲、地支关系、吉凶(`analyzeFortuneGanzhi`) | ✅ |
| C16 | 流年·流月·流日分析 | 任意日期年/月/日干支 vs 日主(`getYearMonths` 按节气月) | ✅ 八字页交互浏览器 |
### 2.2 首页 HomePage(`/`)
| # | 功能点 | 状态 |
|---|---|---|
| H1 | 今日概览:日期、农历、干支、宜忌摘要 | ✅ |
| H2 | 运势预览(需档案) | ✅ |
| H3 | 快捷导航(占卜、节气) | ✅ |
| H4 | 值神/彭祖/胎神展示 | ✅ |
### 2.3 日历 CalendarPage(`/calendar`)
| # | 功能点 | 状态 |
|---|---|---|
| K1 | 月视图/周视图切换 | ✅ |
| K2 | 年/月切换(MonthYearPicker) | ✅ |
| K3 | 格子显示:节日/节气缩写、农历、周末/今日高亮、收藏星标、运势点 | ✅ |
| K4 | 点击日期 → 日详情页 | ✅ |
| K5 | 手势滑动切换月份 | ✅ |
| K6 | 周起始(周一/周日)设置生效 | ✅ 已修复(BUG-02) |
| K7 | 非本月时显示"回到今天"浮动按钮,点击返回当月 | ✅ 已修复(BUG-09) |
### 2.4 日详情 DayDetailPage(`/calendar/:date`)
| # | 功能点 | 状态 |
|---|---|---|
| D1 | 四柱、纳音、值星展示 | ✅ |
| D2 | 冲合害煞、宜忌列表 | ✅ |
| D3 | 值神、彭祖百忌、胎神 | ✅ |
| D4 | 12 时辰逐时黄历 | ✅ |
| D5 | 收藏按钮 | ✅ 仅公历收藏 |
### 2.5 八字 BaziPage(`/bazi`)
| # | 功能点 | 状态 |
|---|---|---|
| B1 | 出生档案管理(最多 3 个,持久化) | ✅ |
| B2 | 排盘:四柱 + 十神 + 藏干 | ✅ |
| B3 | 五行力量 / 十二长生展示 | ✅ |
| B4 | 起运 / 大运 / 流年 | ✅ |
| B5 | 称骨集成 | ✅ 使用出生年干支(占卜页已同步修正) |
| B6 | 日柱类型安全 | ✅ `solarAdjusted` 字段替代 `(as any)` 补丁 |
| B7 | 八字流派切换(晚子时换日 23点 / 0点换日) | ✅ 设置页可切换,排盘实时生效 |
| B8 | 出生地:全国 34 省级城市真太阳时校正 | ✅ 含跨日处理,受"真太阳时校正"开关控制 |
| B9 | 出生地:海外时区 → 北京时间(UTC+8)换算排盘 | ✅ 含跨日处理 |
| B10 | 神煞展示(22 个常见神煞,吉/凶/中性分组) | ✅ |
| B11 | 大运全量 10 条 + 流年(小运)10 条,均附生克冲合 | ✅ |
| B12 | 大运→流年→流月→流日交互下钻(按年度切换) | ✅ 点选日期跳转日详情 |
### 2.6 每日运势 DailyFortunePage(`/daily-fortune`)
| # | 功能点 | 状态 |
|---|---|---|
| F1 | 日期导航查看任意日运势 | ✅ |
| F2 | 无档案时引导创建 | ✅ |
| F3 | 评分等级、宜忌、幸运信息、分项得分 | ✅ |
### 2.7 占卜 DivinationPage(`/divination`)
| # | 功能点 | 状态 |
|---|---|---|
| P1 | 今日梅花易数卦象 + 体用分析 | 🟡 算法简化 |
| P2 | 称骨算命输入与结果 | 🟡 年索引用日干支近似 |
### 2.8 节气 SolarTermsPage(`/solar-terms`)
| # | 功能点 | 状态 |
|---|---|---|
| S1 | 全年 24 节气列表 | ✅ |
### 2.9 设置 SettingsPage(`/settings`)
| # | 功能点 | 状态 |
|---|---|---|
| T1 | 档案管理入口 | ✅ |
| T2 | 农历日期查询(阴阳历转换) | ✅ |
| T3 | 今日天干地支/纳音 | ✅ |
| T4 | 主题切换(亮/暗/跟随系统) | ✅ |
| T5 | 周起始设置 | ✅ 已修复(BUG-02) |
| T6 | 显示开关(农历/节气/假日)、八字来源 | 🟡 字段已持久化但未消费 |
| T7 | 关于:版本号 | ✅ 已统一为 0.1.0(BUG-07) |
| T8 | 真太阳时校正开关(默认开启) | ✅ 关闭后按北京时间排盘 |
### 2.10 收藏 Bookmarks
| # | 功能点 | 状态 |
|---|---|---|
| M1 | 收藏/取消收藏日期,日历格星标 | ✅ |
| M2 | 农历周期收藏(每年/月循环) | ⬜ 字段已预留,UI 未提供 |
| M3 | 收藏列表页 | ⬜ 规划中 |
### 2.11 非功能需求
| # | 需求 | 状态 |
|---|---|---|
| N1 | PWA 可安装、离线可用 | ✅ 已构建 |
| N2 | 移动端优先响应式 | ✅ |
| N3 | 暗色模式(跟随系统) | ✅ |
| N4 | 页面懒加载 + 骨架屏 + 错误边界 | ✅ |
| N5 | 自动化测试 | ✅ 2026-08-02 引入 vitest,29 个用例(历法/八字/运势/梅花/称骨/干支) |
| N6 | Lint / 代码规范 | ✅ 2026-08-02 配置 ESLint 9 flat config + typescript-eslint,`pnpm lint` 通过 |
+74
View File
@@ -0,0 +1,74 @@
# 开发回顾记录(Retrospect)
> 会话日期:2026-08-02 ~ 2026-08-03
> 用途:记录本次会话"做了什么 / 没做什么 / 有疑问的事项",便于事后回顾与决策。
## 一、本次完成的工作
### 1. 文档体系(docs/)
- 新建 `docs/`:ARCHITECTURE / REQUIREMENTS / PROGRESS / BUGS / CHANGELOG,另加根 README.md
- 修正 AGENTS.md / CLAUDE.md 的过时信息(PWA 状态、页面/Store/API 补全),并持续同步
### 2. BUG 修复(BUGS.md 均已标记 ✅)
| ID | 内容 |
|---|---|
| BUG-01 | core 包 CJS 导出:tsup 增加 cjs 产物,`require()` 实测可用 |
| BUG-02 | 周起始设置生效:settings → useCalendar → getMonthCalendar(weekStart) → WeekDayBar |
| BUG-03 | PWA manifest 对齐(lang=zh-CN、theme_color=#FFFBF5) |
| BUG-05 | 配置 ESLint 9 flat config + typescript-eslint,`pnpm lint` 通过(修复 24 处) |
| BUG-07 | 版本号统一 v0.1.0 |
| BUG-08 | getMonthCalendar 死分支移除 |
| BUG-09 | "回到今天"按钮条件恒 false,改为基于 viewDate 判断 |
| BUG-10 | 日详情时辰列表重复 React key(早子/晚子均"子"),改用 ganzhi 作 key |
| TECH-06 | `(as any)._solarAdjusted` → `BaziFullResult.solarAdjusted` 类型化 |
### 3. 功能深化
- **八字流派切换**:`birthInfoToBazi` 新增 `ziSect`(晚子时算次日/当日),设置页开关
- **出生时间选择器**:时/分下拉 + 时辰格可点
- **出生地全国化 + 海外时区**:34 省级城市(真太阳时校正)+ 海外 UTC-12~+14 换算北京时间,均支持跨日
- **真太阳时校正开关**(设置页,默认开启)
- **宜忌完整显示**:根因是首页 `slice(0,6)`,引擎本有 22 条宜
- **历法信息增强**:DayInfo 新增 季节/所处节气/第几天/距下节气/儒略日/佛历年/伊斯兰历(tyme4ts 原生 Hijri)
- **佛教节日**:新增 `getBuddhistFestival`(农历 21 个节日),日详情 🪷 徽章
- **八字神煞**:新增 `analyzeShensha`(22 个常见神煞),八字页"神煞"卡
- **大运/流年生克冲合**:新增 `analyzeFortuneGanzhi`(十神/五行生克/合冲害刑/吉凶)
- **大运→流年→流月→流日交互下钻**:新增 `getYearMonths`(节气月);八字页交互浏览器,流日可跳日详情
### 4. 质量
- 单元测试 29 → **50 个**(vitest),覆盖历法/八字/运势/梅花/称骨/干支/五行/神煞/流月/流年
- 浏览器 E2E 验证(headless Chrome + CDP 脚本,无新增依赖):周起始、回到今天、流派、出生地、太阳时开关、宜忌、历法信息、神煞、大运流年交互 全部实测通过
### 5. 工程清理
- 整理全部文档;删除 node_modules / dist / tsbuildinfo;源码 241M → 764K;打包 164K(/tmp/lunar-source-20260803.tar.gz)
## 二、未完成 / 待办(详见 PROGRESS.md Backlog)
| 事项 | 说明 |
|---|---|
| 互卦真算法 | 梅花易数互卦仍为上下卦互换简化(2-4/3-5 爻法未实现) |
| 死代码清理 | useDayDetail/useBazi hooks、AnimatedPanel、CardHeader、CalendarSkeleton/BaziSkeleton、ui store 未用 action、settings 显示开关字段、calendar store weekStart、bookmarks getByDate、utils 未用函数、tailwind-variants 依赖 |
| 显示开关字段 | settings.showLunar/showSolarTerm/showHoliday/eightCharProvider 已持久化但未消费(其中 eightCharProvider 已被 ziHourSect 替换) |
| 农历周期收藏 | bookmarks 的 isLunar 字段已预留,UI 未提供;收藏列表页未做 |
| 午时假设 | calculateDailyFortune 当日八字固定取午时(TECH-01) |
| 称骨极端值 | 超出 2两1钱~7两2钱范围的总重仍取"最近值"(TECH-04 残余) |
| 紫微斗数 | 未做(大工程,用户同意先做神煞) |
| web 端自动化测试 | 仅 core 有单测;web 无测试框架 |
| git | 项目未初始化 git 仓库,无版本管理与回滚能力 |
## 三、有疑问 / 待确认事项
1. **称骨年表存在两套网络变体**:本实现采用主流版本(算准网/网易/sunfinelife 三家一致:丙子16钱、戊子15钱等),但另一套变体(丙子19钱、戊子12钱等)也在流通。若用户希望换另一套,改 `boneWeight.ts` 的 `YEAR_WEIGHTS` 即可。
2. **天厨贵人查法有版本差异**:本实现采用主流版(甲巳、乙午、丙巳、丁午、戊申、己酉、庚亥、辛子、壬寅、癸卯);传统版(丙寅、丁酉、戊申、己未、庚亥、辛戌、壬卯、癸子)也有出处。代码注释已注明。
3. **tyme4ts 十神方向语义**:实测 `A.getTenStar(B)` 返回"B 以 A 为日主"的十神。`dailyMatch.ts` 中的用法方向(userPillar vs dayPillar 同位置比较)语义上存疑,但**未改动**(避免影响每日运势结果);新写的 `fortuneLuck` 已按正确方向实现。需确认 dailyMatch 是否也要调整。
4. **佛历年**:按公历 + 543(泰国惯例);bmcx 示例 2570 对应的是其他年份。
5. **佛教节日表**:21 个为通行版本,个别日期(如腊月廿九华严菩萨圣诞)在不同资料有出入。
6. **默认出生地变化**:默认从"120°E 不校正"改为"北京 116.4°E(-14 分钟真太阳校正)",临界时辰的结果会变。太阳时校正开关默认开启——用户接受度未知,如需要可默认关闭。
7. **BUG-04(HomePage InfoCard highlight)**:`highlight` 传布尔但被当作样式类 `border-primary/30` 处理,语义不清,需确认意图(是要"高亮边框"还是别的)。
8. **起运前年份 UX**:大运流年浏览器中,出生当年(起运前)不在任何大运区间,自动落到第一柱大运(如 2026 年出生 → 从 2028 起显示)。是否要显示"起运前"年份待定。
9. **pnpm-workspace.yaml `allowBuilds`**:非 pnpm 标准字段(应为 onlyBuiltDependencies),疑似无效但无害,未改。
10. **打包清理**:已删除 node_modules/dist,开发前需 `pnpm install`。
## 四、验证方式备忘
- E2E 通过 headless Chrome(Playwright 缓存目录的 chromium headless shell)+ CDP 协议 Node 脚本驱动,未引入 Playwright npm 依赖;脚本存于 `/tmp/lunar-*.mjs`(会话临时文件,未入库)。
- 所有功能均以"单测 + 浏览器实测"双重验证;仅 UI 纯样式类改动(如时间选择器)以 tsc 构建验证为主。
+11
View File
@@ -0,0 +1,11 @@
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['**/dist/**', '**/node_modules/**', '**/*.tsbuildinfo'] },
tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
},
},
);
+24
View File
@@ -0,0 +1,24 @@
{
"name": "lunar-calendar",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "万年历 - Chinese Almanac Calendar App",
"scripts": {
"dev": "pnpm --filter @lunar/web dev",
"build": "pnpm --filter @lunar/core build && pnpm --filter @lunar/web build",
"preview": "pnpm --filter @lunar/web preview",
"test": "pnpm -r test",
"lint": "pnpm -r lint",
"clean": "pnpm -r clean"
},
"engines": {
"node": ">=18",
"pnpm": ">=9"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.8.0",
"typescript-eslint": "^8.65.0"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@lunar/core",
"version": "0.1.0",
"private": true,
"description": "Core calculation engine for lunar calendar app",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"test": "vitest run",
"lint": "eslint .",
"clean": "rm -rf dist"
},
"dependencies": {
"tyme4ts": "^1.5.1"
},
"devDependencies": {
"tsup": "^8.4.0",
"typescript": "^5.7.0",
"vitest": "^4.1.10"
}
}
@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { birthInfoToBazi } from '../index';
const GANZHI = /^[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]$/;
describe('bazi transformer', () => {
const bazi = birthInfoToBazi({ year: 1990, month: 5, day: 15, hour: 12, minute: 0, gender: 'male' });
it('produces four pillars with valid ganzhi', () => {
expect(bazi.eightChar.yearPillar.ganzhi).toMatch(GANZHI);
expect(bazi.eightChar.monthPillar.ganzhi).toMatch(GANZHI);
expect(bazi.eightChar.dayPillar.ganzhi).toMatch(GANZHI);
expect(bazi.eightChar.hourPillar.ganzhi).toMatch(GANZHI);
});
it('exposes day master info', () => {
expect(bazi.eightChar.dayMasterStem).toMatch(/^[甲乙丙丁戊己庚辛壬癸]$/);
expect(['木', '火', '土', '金', '水']).toContain(bazi.eightChar.dayMasterElement);
});
it('returns 10 decade fortunes and 10 annual fortunes', () => {
expect(bazi.decadeFortunes).toHaveLength(10);
expect(bazi.annualFortunes).toHaveLength(10);
expect(bazi.decadeFortunes[0].ganzhi).toMatch(GANZHI);
});
it('attaches each decade its own 10 annual fortunes', () => {
const chart = birthInfoToBazi({ year: 1989, month: 12, day: 22, hour: 15, minute: 20, gender: 'male' });
expect(chart.decadeFortunes).toHaveLength(10);
for (const decade of chart.decadeFortunes) {
expect(decade.annualFortunes).toHaveLength(10);
}
expect(chart.decadeFortunes[0].annualFortunes.map(f => f.year))
.toEqual([1995, 1996, 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004]);
expect(chart.decadeFortunes[3].annualFortunes.map(f => f.year))
.toEqual([2025, 2026, 2027, 2028, 2029, 2030, 2031, 2032, 2033, 2034]);
// top-level annualFortunes mirrors the first decade
expect(chart.annualFortunes).toEqual(chart.decadeFortunes[0].annualFortunes);
});
it('returns child limit with non-negative start age', () => {
expect(bazi.childLimit.startAge).toBeGreaterThanOrEqual(0);
expect(bazi.childLimit.forward).toBeTypeOf('boolean');
expect(bazi.childLimit.birthTime).toContain('1990年5月15日');
});
it('matches paipan sect-2 start for 1989-12-22 15:20 male', () => {
const chart = birthInfoToBazi({ year: 1989, month: 12, day: 22, hour: 15, minute: 20, gender: 'male' });
expect(chart.childLimit.forward).toBe(false);
expect(chart.childLimit.yearCount).toBe(5);
expect(chart.childLimit.monthCount).toBe(0);
expect(chart.childLimit.dayCount).toBe(20);
expect(chart.childLimit.hourCount).toBe(0);
expect(chart.childLimit.startTime).toBe('1995年1月11日 15:20:00');
});
it('uses the first-luck transition as the displayed start time', () => {
const sameDay = birthInfoToBazi({ year: 1990, month: 8, day: 8, hour: 10, minute: 30, gender: 'male' });
expect(sameDay.childLimit.birthTime).toContain('1990年8月8日');
expect(sameDay.childLimit.startTime).toContain('2000年11月14日');
expect(sameDay.childLimit.startTime).not.toBe(sameDay.childLimit.birthTime);
});
it('includes hidden stems with ten stars', () => {
const yearHs = bazi.eightChar.yearPillar.hideStems;
expect(yearHs.length).toBeGreaterThan(0);
expect(yearHs[0].stem).toMatch(/^[甲乙丙丁戊己庚辛壬癸]$/);
});
it('default sect: late zi (23:30) rolls to next day', () => {
const late = birthInfoToBazi({ year: 2026, month: 8, day: 3, hour: 23, minute: 30, gender: 'male' });
expect(late.eightChar.dayPillar.ganzhi).toBe('庚戌');
expect(late.eightChar.hourPillar.ganzhi).toBe('丙子');
});
it('earlyZiSameDay sect: late zi (23:30) keeps the current day', () => {
const early = birthInfoToBazi({
year: 2026, month: 8, day: 3, hour: 23, minute: 30, gender: 'male', ziSect: 'earlyZiSameDay',
});
expect(early.eightChar.dayPillar.ganzhi).toBe('己酉');
expect(early.eightChar.hourPillar.ganzhi).toBe('甲子');
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { calculateBoneWeight } from '../index';
describe('bone weight', () => {
it('sums weights correctly for 甲子年正月初一子时', () => {
const r = calculateBoneWeight(0, 1, 1, 0);
expect(r.yearWeight).toBe(12);
expect(r.monthWeight).toBe(6);
expect(r.dayWeight).toBe(5);
expect(r.hourWeight).toBe(16);
expect(r.totalWeight).toBe(39);
expect(r.totalLiang).toBe(3);
expect(r.totalQian).toBe(9);
});
it('returns a non-empty interpretation with a valid fortune grade', () => {
const r = calculateBoneWeight(12, 6, 15, 6);
expect(r.interpretation.length).toBeGreaterThan(0);
expect(['上上', '上', '中上', '中', '中下', '下']).toContain(r.fortune);
});
it('falls back to the nearest interpretation for unknown totals', () => {
// 4 + 9 + 9 + 9 = 31 → exact match exists; use an out-of-range input
const r = calculateBoneWeight(0, 1, 1, 11); // 12 + 6 + 5 + 6 = 29
expect(r.totalWeight).toBe(29);
expect(r.interpretation.length).toBeGreaterThan(0);
});
it('year table matches the mainstream 称骨年表', () => {
const cases: [number, number][] = [
[0, 12], // 甲子
[7, 8], // 辛未
[16, 12], // 庚辰
[17, 6], // 辛巳
[18, 8], // 壬午
[21, 15], // 乙酉
[51, 8], // 乙卯
[54, 19], // 戊午
];
for (const [idx, wt] of cases) {
const r = calculateBoneWeight(idx, 1, 1, 0);
expect(r.yearWeight, `year index ${idx}`).toBe(wt);
}
});
it('day table: 初五 is 1两6钱', () => {
const r = calculateBoneWeight(0, 1, 5, 0);
expect(r.dayWeight).toBe(16);
});
it('previously missing poems (5两8钱~7两1钱) resolve exactly, not by nearest match', () => {
const cases: [number, number, number, number, number, string][] = [
[18, 6, 26, 5, 58, '雁塔题名'],
[15, 6, 8, 7, 59, '甲第之中'],
[24, 3, 18, 6, 61, '金榜客'],
[42, 6, 26, 5, 63, '定中高科'],
[54, 9, 18, 6, 65, '安邦'],
[54, 6, 8, 5, 67, '田园家业'],
[54, 6, 18, 5, 69, '前禄星'],
[54, 3, 26, 5, 71, '公侯卿相'],
];
for (const [y, m, d, h, total, phrase] of cases) {
const r = calculateBoneWeight(y, m, d, h);
expect(r.totalWeight, `expected total ${total}`).toBe(total);
expect(r.interpretation, `poem for ${total}`).toContain(phrase);
}
});
});
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { getDayInfo, getMonthCalendar, getTodayInfo } from '../index';
describe('day transformers', () => {
it('returns correct lunar info for 2024-02-10 (春节正月初一)', () => {
const di = getDayInfo(2024, 2, 10);
expect(di.solarDate).toBe('2024-02-10');
expect(di.lunarMonth).toBe(1);
expect(di.lunarDay).toBe(1);
expect(di.lunarDayName).toBe('初一');
expect(di.lunarYearGanzhi).toMatch(/^[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]$/);
expect(di.weekDayIndex).toBe(6);
expect(di.isWeekend).toBe(true);
});
it('getTodayInfo returns today', () => {
const now = new Date();
const di = getTodayInfo();
expect(di.solarYear).toBe(now.getFullYear());
expect(di.solarMonth).toBe(now.getMonth() + 1);
expect(di.solarDay).toBe(now.getDate());
expect(di.isToday).toBe(true);
});
it('getMonthCalendar respects weekStart', () => {
// 2024-02-01 is a Thursday
const sunFirst = getMonthCalendar(2024, 2, 0);
const monFirst = getMonthCalendar(2024, 2, 1);
for (const row of sunFirst) expect(row).toHaveLength(7);
for (const row of monFirst) expect(row).toHaveLength(7);
expect(sunFirst[0][0].weekDayIndex).toBe(0); // Sunday first
expect(monFirst[0][0].weekDayIndex).toBe(1); // Monday first
// Both grids must contain the 1st of the month
const flatSun = sunFirst.flat();
const flatMon = monFirst.flat();
expect(flatSun.some(d => d.solarDate === '2024-02-01')).toBe(true);
expect(flatMon.some(d => d.solarDate === '2024-02-01')).toBe(true);
});
it('provides season, term progress, julian, buddhist era and hijri date', () => {
const di = getDayInfo(2026, 8, 3); // 大暑期间,农历六月廿一
expect(di.season).toBe('夏季');
expect(di.termDayIndex).toBeGreaterThan(0);
expect(di.nextSolarTerm).toBe('立秋');
expect(di.daysToNextTerm).toBe(4);
expect(di.julianDay).toBeGreaterThan(2450000);
expect(di.buddhistYear).toBe(2026 + 543);
expect(di.hijriDate).toMatch(/^\d{4}年\d{2}月\d{2}日$/);
});
it('marks Buddhist festivals by lunar date', () => {
// 2024-05-15 is 佛诞(浴佛节)农历四月初八
const di = getDayInfo(2024, 5, 15);
expect(di.lunarMonth).toBe(4);
expect(di.lunarDay).toBe(8);
expect(di.buddhistFestival).toBe('释迦牟尼佛圣诞(浴佛节)');
});
});
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { analyzeElementBalance, birthInfoToBazi } from '../index';
describe('element balance', () => {
const eightChar = birthInfoToBazi({
year: 1990, month: 5, day: 15, hour: 12, minute: 0, gender: 'male',
}).eightChar;
const profile = analyzeElementBalance(eightChar);
it('has positive total that matches sum of parts', () => {
expect(profile.total).toBeGreaterThan(0);
expect(profile.wood + profile.fire + profile.earth + profile.metal + profile.water)
.toBeCloseTo(profile.total);
});
it('identifies dominant and weakest elements', () => {
expect(['木', '火', '土', '金', '水']).toContain(profile.dominant);
expect(['木', '火', '土', '金', '水']).toContain(profile.weakest);
expect(profile.isBalanced).toBeTypeOf('boolean');
});
});
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { calculateDailyFortune, birthInfoToBazi } from '../index';
const SCORE_LEVELS = ['great', 'good', 'fair', 'poor', 'bad'] as const;
describe('daily fortune', () => {
const eightChar = birthInfoToBazi({
year: 1990, month: 5, day: 15, hour: 12, minute: 0, gender: 'female',
}).eightChar;
const fortune = calculateDailyFortune(eightChar, new Date(2026, 7, 2));
it('returns bounded score and valid level', () => {
expect(fortune.overallScore).toBeGreaterThanOrEqual(-100);
expect(fortune.overallScore).toBeLessThanOrEqual(100);
expect(SCORE_LEVELS).toContain(fortune.scoreLevel);
});
it('evaluates all four pillars', () => {
expect(fortune.pillarRelationships).toHaveLength(4);
expect(fortune.pillarRelationships.map(r => r.pillar)).toEqual(['year', 'month', 'day', 'hour']);
});
it('returns the requested date', () => {
expect(fortune.date).toBe('2026-08-02');
});
it('produces suggestions and category scores', () => {
expect(fortune.suggestions.length).toBeGreaterThan(0);
expect(fortune.luckyAspects.length).toBeGreaterThan(0);
const { love, career, wealth, health } = fortune.categoryScores;
expect([love, career, wealth, health].every(v => v >= -100 && v <= 100)).toBe(true);
});
});
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { analyzeFortuneGanzhi, birthInfoToBazi } from '../index';
describe('fortune luck (大运/流年 vs 日主)', () => {
// 2026-08-03 12:00: 日干己、日支酉
const bazi = birthInfoToBazi({ year: 2026, month: 8, day: 3, hour: 12, minute: 0, gender: 'male' });
const dayStem = bazi.eightChar.dayPillar.heavenStem;
const dayBranch = bazi.eightChar.dayPillar.earthBranch;
it('甲子 vs 己酉: 正官 + 天干合 + 克我 → 吉', () => {
const r = analyzeFortuneGanzhi('甲子', dayStem, dayBranch);
expect(r.tenStar).toBe('正官');
expect(r.stemCombine).toBe(true);
expect(r.elementRelation).toBe('克我');
expect(r.level).toBe('吉');
});
it('乙酉 vs 己酉: 七杀 + 自刑 → 凶', () => {
const r = analyzeFortuneGanzhi('乙酉', dayStem, dayBranch);
expect(r.tenStar).toBe('七杀');
expect(r.branchPunish).toBe(true); // 酉酉自刑
expect(r.level).toBe('凶');
});
it('丙子 vs 己未: 正印 + 子未六害', () => {
const r = analyzeFortuneGanzhi('丙子', '己', '未');
expect(r.tenStar).toBe('正印');
expect(r.branchHarm).toBe(true);
});
it('all decade fortunes produce valid luck info', () => {
for (const df of bazi.decadeFortunes) {
const r = analyzeFortuneGanzhi(df.ganzhi, dayStem, dayBranch);
expect(['比和', '生我', '我生', '克我', '我克']).toContain(r.elementRelation);
expect(['吉', '凶', '平']).toContain(r.level);
expect(r.score).toBeGreaterThanOrEqual(-10);
expect(r.score).toBeLessThanOrEqual(10);
}
});
});
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { calculatePlumBlossom } from '../index';
describe('plum blossom', () => {
it('produces expected hexagram for known input (2026-08-02 12:00)', () => {
const r = calculatePlumBlossom(2026, 8, 2, 12);
expect(r.upperTrigram.name).toBe('巽');
expect(r.lowerTrigram.name).toBe('兑');
expect(r.originalHexagram.name).toBe('风泽中孚');
expect(r.originalHexagram.changingLine).toBe(4);
expect(r.relationship).toBeTruthy();
});
it('is deterministic', () => {
const a = calculatePlumBlossom(2024, 3, 5, 8);
const b = calculatePlumBlossom(2024, 3, 5, 8);
expect(a).toEqual(b);
});
it('dataset covers all 64 hexagram combinations', () => {
const srcFile = fileURLToPath(new URL('../calculators/plumBlossom.ts', import.meta.url));
const src = fs.readFileSync(srcFile, 'utf8');
const keys = [...src.matchAll(/'(\d,\d)':/g)].map(m => m[1]);
expect(keys).toHaveLength(64);
expect(new Set(keys).size).toBe(64);
for (let upper = 1; upper <= 8; upper++) {
for (let lower = 1; lower <= 8; lower++) {
expect(keys).toContain(`${upper},${lower}`);
}
}
});
it('never falls back to a placeholder across a broad date grid', () => {
for (let y = 2000; y <= 2030; y++) {
for (let m = 1; m <= 12; m++) {
for (let d = 1; d <= 28; d += 7) {
const r = calculatePlumBlossom(y, m, d, 12);
// Real hexagram names are 3+ chars; fallback names are 2-char concatenations
expect(r.originalHexagram.name.length).toBeGreaterThan(2);
}
}
}
});
});
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import {
getBranchRelationship,
getTenStarRelationship,
checkStemCombine,
checkStemOpposite,
} from '../index';
describe('relationship calculator', () => {
it('detects 六合 (子丑合)', () => {
expect(getBranchRelationship('子', '丑').combine).toBe(true);
expect(getBranchRelationship('子', '午').combine).toBe(false);
});
it('detects 六冲 (子午冲)', () => {
expect(getBranchRelationship('子', '午').opposite).toBe(true);
});
it('detects 三合 (申子辰水局)', () => {
const rel = getBranchRelationship('申', '辰');
expect(rel.threeCombine).toBe(true);
expect(rel.formation).toBe('水局');
});
it('detects 六害 (子未害)', () => {
expect(getBranchRelationship('子', '未').harm).toBe(true);
});
it('detects 相刑 (寅巳无恩之刑)', () => {
expect(getBranchRelationship('寅', '巳').punish).toBe(true);
});
it('detects 天干合 (甲己合)', () => {
expect(checkStemCombine('甲', '己')).toBe(true);
expect(checkStemCombine('甲', '乙')).toBe(false);
});
it('detects 天干冲 (甲庚冲)', () => {
expect(checkStemOpposite('甲', '庚')).toBe(true);
});
it('computes ten star for a day master (甲日主见戊土 → 偏财)', () => {
expect(getTenStarRelationship('甲', '戊')).toContain('财');
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { analyzeShensha, birthInfoToBazi } from '../index';
describe('shensha', () => {
// 2026-08-03 12:00: 年柱丙午、月柱乙未、日柱己酉、时柱庚午
const bazi = birthInfoToBazi({ year: 2026, month: 8, day: 3, hour: 12, minute: 0, gender: 'male' });
it('does not claim 天乙贵人 from year stem alone (day stem 己 → 子申 not in chart)', () => {
const s = analyzeShensha(bazi.eightChar);
const t = s.find(x => x.name === '天乙贵人');
expect(t).toBeUndefined();
});
it('1989-12-22 15:20 male has no 天乙贵人 (day stem 丙 → 亥酉 not in chart)', () => {
const chart = birthInfoToBazi({ year: 1989, month: 12, day: 22, hour: 15, minute: 20, gender: 'male' });
const s = analyzeShensha(chart.eightChar);
expect(s.find(x => x.name === '天乙贵人')).toBeUndefined();
});
it('2020-01-01 12:00 male has 天乙贵人 at 日支 via day stem 癸', () => {
const chart = birthInfoToBazi({ year: 2020, month: 1, day: 1, hour: 12, minute: 0, gender: 'male' });
expect(chart.eightChar.dayPillar.heavenStem).toBe('癸');
const s = analyzeShensha(chart.eightChar);
const t = s.find(x => x.name === '天乙贵人');
expect(t).toBeDefined();
expect(t!.foundIn).toContain('日支');
expect(t!.anchors.some(a => a.includes('日干'))).toBe(true);
});
it('finds 文昌贵人 by day stem 己 → 酉, hit 日支酉', () => {
const s = analyzeShensha(bazi.eightChar);
const t = s.find(x => x.name === '文昌贵人');
expect(t).toBeDefined();
expect(t!.anchors.some(a => a.includes('日干'))).toBe(true);
});
it('finds 禄神 and 羊刃 by day/year stem 己/丙 → 午', () => {
const s = analyzeShensha(bazi.eightChar);
const lu = s.find(x => x.name === '禄神');
const yang = s.find(x => x.name === '羊刃');
expect(lu).toBeDefined();
expect(lu!.foundIn).toEqual(expect.arrayContaining(['年支', '时支']));
expect(yang).toBeDefined();
});
it('finds 桃花 by day branch 酉 (巳酉丑) → 午', () => {
const s = analyzeShensha(bazi.eightChar);
const t = s.find(x => x.name.startsWith('桃花'));
expect(t).toBeDefined();
expect(t!.foundIn).toContain('年支');
});
it('finds 将星 and 红鸾', () => {
const s = analyzeShensha(bazi.eightChar);
expect(s.find(x => x.name === '将星')).toBeDefined(); // 年支午(寅午戌) → 午
expect(s.find(x => x.name === '红鸾')).toBeDefined(); // 年支午 → 酉,日支酉
});
it('does not flag 魁罡/阴差阳错 for day 己酉', () => {
const s = analyzeShensha(bazi.eightChar);
expect(s.find(x => x.name === '魁罡')).toBeUndefined();
expect(s.find(x => x.name === '阴差阳错')).toBeUndefined();
});
it('all stem rules cover all 10 stems and branch rules cover 12 branches', () => {
const s = analyzeShensha(bazi.eightChar);
expect(s.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { getYearMonths } from '../index';
describe('getYearMonths (流月)', () => {
it('returns 12 months starting from 立春', () => {
const months = getYearMonths(2026);
expect(months).toHaveLength(12);
expect(months[0].name).toBe('正月');
expect(months[0].startDate).toBe('2026-02-04'); // 立春 2026
expect(months[0].ganzhi).toBe('庚寅'); // 丙午年 五虎遁 → 寅月庚寅
expect(months[11].name).toBe('腊月');
expect(months[11].startDate).toMatch(/^2027-01-/); // 小寒 2027
});
it('endDate is the day before the next 节', () => {
const months = getYearMonths(2026);
expect(months[0].endDate).toBe('2026-03-04'); // 惊蛰 2026-03-05 前一天
expect(months[1].ganzhi).toBe('辛卯'); // 惊蛰起卯月
});
it('all month ganzhi match the 五虎遁 sequence', () => {
const months = getYearMonths(2026);
const seq = ['庚寅', '辛卯', '壬辰', '癸巳', '甲午', '乙未', '丙申', '丁酉', '戊戌', '己亥', '庚子', '辛丑'];
expect(months.map(m => m.ganzhi)).toEqual(seq);
});
});
@@ -0,0 +1,161 @@
/**
* 袁天罡称骨算命法 (Bone Weight Fortune Telling)
* Based on year/month/day/hour pillars to calculate total "bone weight"
* Each unit = 钱 (1两 = 10钱)
*/
export interface BoneWeightResult {
yearWeight: number; // in 钱
monthWeight: number;
dayWeight: number;
hourWeight: number;
totalWeight: number; // in 钱
totalLiang: number; // 两
totalQian: number; // 钱
interpretation: string; // The fate poem/interpretation
fortune: '上上' | '上' | '中上' | '中' | '中下' | '下';
}
// Year bone weight table (by 干支 year stem-branch)
// 主流称骨年表(百度百科/网易/算准网等通行版本):year % 60 → weight in 钱
const YEAR_WEIGHTS: Record<number, number> = {
0: 12, 1: 9, 2: 6, 3: 7, 4: 12, 5: 5, 6: 9, 7: 8, 8: 7, 9: 8,
10: 15, 11: 9, 12: 16, 13: 8, 14: 8, 15: 19, 16: 12, 17: 6, 18: 8, 19: 7,
20: 5, 21: 15, 22: 6, 23: 16, 24: 15, 25: 7, 26: 9, 27: 12, 28: 10, 29: 7,
30: 15, 31: 6, 32: 5, 33: 14, 34: 14, 35: 9, 36: 7, 37: 7, 38: 9, 39: 12,
40: 8, 41: 7, 42: 13, 43: 5, 44: 14, 45: 5, 46: 9, 47: 17, 48: 5, 49: 7,
50: 12, 51: 8, 52: 8, 53: 6, 54: 19, 55: 6, 56: 8, 57: 16, 58: 10, 59: 6,
};
// Month bone weight (lunar month 1-12)
const MONTH_WEIGHTS: Record<number, number> = {
1: 6, 2: 7, 3: 18, 4: 9, 5: 5, 6: 16,
7: 9, 8: 15, 9: 18, 10: 8, 11: 9, 12: 5,
};
// Day bone weight (lunar day 1-30)
const DAY_WEIGHTS: Record<number, number> = {
1: 5, 2: 10, 3: 8, 4: 15, 5: 16, 6: 15, 7: 8, 8: 16, 9: 8, 10: 16,
11: 9, 12: 17, 13: 8, 14: 17, 15: 10, 16: 8, 17: 9, 18: 18, 19: 5, 20: 15,
21: 10, 22: 9, 23: 8, 24: 9, 25: 15, 26: 18, 27: 7, 28: 8, 29: 16, 30: 6,
};
// Hour bone weight (时辰, 地支 index 0-11)
const HOUR_WEIGHTS: Record<number, number> = {
0: 16, // 子时 23-01
1: 6, // 丑时 01-03
2: 7, // 寅时 03-05
3: 10, // 卯时 05-07
4: 9, // 辰时 07-09
5: 16, // 巳时 09-11
6: 10, // 午时 11-13
7: 8, // 未时 13-15
8: 8, // 申时 15-17
9: 9, // 酉时 17-19
10: 6, // 戌时 19-21
11: 6, // 亥时 21-23
};
// Interpretation for each total weight (in 钱)
const INTERPRETATIONS: Record<number, { text: string; fortune: string }> = {
21: { text: '短命非业谓大凶,平生灾难事重重,凶祸频临陷逆境,终世困苦事不成。', fortune: '下' },
22: { text: '身寒骨冷苦伶仃,此命推来行乞人,劳劳碌碌无度日,终年打拱过平生。', fortune: '下' },
23: { text: '此命推来骨格轻,求谋作事事难成,妻儿兄弟应难许,别处他乡作散人。', fortune: '下' },
24: { text: '此命推来福禄无,门庭困苦总难荣,六亲骨肉皆无靠,流浪他乡作老翁。', fortune: '下' },
25: { text: '此命推来祖业微,门庭营度似稀奇,六亲骨肉如冰炭,一世勤劳自把持。', fortune: '中下' },
26: { text: '平生衣禄苦中求,独自营谋事不休,离祖出门宜早计,晚来衣禄自无休。', fortune: '中下' },
27: { text: '一生作事少商量,难靠祖宗怎主张,独马单枪空做去,早年晚岁总无长。', fortune: '中下' },
28: { text: '一生行事似飘蓬,祖宗产业在梦中,若不过房改名姓,也当移徒二三通。', fortune: '中下' },
29: { text: '初年运限未曾亨,纵有功名在后成,须过四旬才可立,移居改姓始为良。', fortune: '中' },
30: { text: '劳劳碌碌苦中求,东奔西走何日休,若使终身勤与俭,老来稍可免忧愁。', fortune: '中' },
31: { text: '忙忙碌碌苦中求,何日云开见日头,难得祖基家可立,中年衣食渐无忧。', fortune: '中' },
32: { text: '初年运蹇事难谋,渐有财源如水流,到得中年衣食旺,那时名利一齐收。', fortune: '中上' },
33: { text: '早年做事事难成,百年勤劳枉费心,半世自如流水去,后来运到始得金。', fortune: '中上' },
34: { text: '此命福气果如何,僧道门中衣禄多,离祖出家方为妙,朝晚拜佛念弥陀。', fortune: '中上' },
35: { text: '生平福量不周全,祖业根基觉少传,营事生涯宜守旧,时来衣食胜从前。', fortune: '中' },
36: { text: '不须劳碌过平生,独自成家福不轻,早有福星常照命,任君行去百般成。', fortune: '上' },
37: { text: '此命般般事不成,弟兄少力自孤行,虽然祖业须微有,来得明时去不明。', fortune: '中下' },
38: { text: '一身骨肉最清高,早入簧门姓氏标,待到年将三十六,蓝衫脱去换红袍。', fortune: '上' },
39: { text: '此命终身运不通,劳劳作事尽皆空,苦心竭力成家计,到得那时在梦中。', fortune: '中下' },
40: { text: '平生衣禄是绵长,件件心中自主张,前面风霜多受过,后来必定享安康。', fortune: '上' },
41: { text: '此命推来自不同,为人能干异凡庸,中年还有逍遥福,不比前时运未通。', fortune: '上' },
42: { text: '得宽怀处且宽怀,何用双眉皱不开,若使中年命运济,那时名利一齐来。', fortune: '上' },
43: { text: '为人心性最聪明,作事轩昂近贵人,衣禄一生天注定,不须劳碌是丰亨。', fortune: '上上' },
44: { text: '万事由天莫苦求,须知福碌赖人修,当年财帛难如意,晚景欣然便不忧。', fortune: '中上' },
45: { text: '名利推求竟若何,前番辛苦后奔波,命中难养男和女,骨肉扶持也不多。', fortune: '中' },
46: { text: '东西南北尽皆通,出姓移居更觉隆,衣禄无穷无数定,中年晚景一般同。', fortune: '上' },
47: { text: '此命推求旺末年,妻荣子贵自怡然,平生原有滔滔福,可卜财源若水泉。', fortune: '上' },
48: { text: '初年运道未曾通,几许蹉跎命亦穷,兄弟六亲无依靠,一生事业晚来整。', fortune: '中' },
49: { text: '此命推来福不轻,自成自立显门庭,从来富贵人钦敬,使婢差奴过一生。', fortune: '上' },
50: { text: '为利为名终日劳,中年福禄也多遭,老来自有财星照,不比前番目下高。', fortune: '上' },
51: { text: '一世荣华事事通,不须劳碌自亨通,兄弟叔侄皆如意,家业成时福禄宏。', fortune: '上' },
52: { text: '一世亨通事事能,不须劳苦自然宁,宗族有光欣喜甚,家产丰盈自称心。', fortune: '上' },
53: { text: '此格推来福泽宏,兴家立业在其中,一生衣食安排定,却是人间一福翁。', fortune: '上' },
54: { text: '此命推来厚且清,诗书满腹看功成,丰衣足食自然稳,正是人间有福人。', fortune: '上上' },
55: { text: '走马扬鞭争利名,少年作事费筹论,一朝福禄源源至,富贵荣华显六亲。', fortune: '上' },
56: { text: '此格推来礼义通,一身福禄用无穷,甜酸苦辣皆尝过,滚滚财源稳且丰。', fortune: '上' },
57: { text: '福禄丰盈万事全,一身荣耀乐天年,名扬威震人争羡,此世逍遥宛似仙。', fortune: '上上' },
58: { text: '平生福禄自然来,名利双全福禄偕,雁塔题名为贵客,紫袍玉带走金阶。', fortune: '上' },
59: { text: '细推此格妙且清,必定财高礼义通,甲第之中应有分,扬鞭走马显威荣。', fortune: '上' },
60: { text: '一朝金榜快题名,显祖荣宗大器成,衣禄定然无欠缺,田园财帛更丰盈。', fortune: '上上' },
61: { text: '不作朝中金榜客,定为世上一财翁,聪明天赋经书熟,名显高科自是荣。', fortune: '上上' },
62: { text: '此命生来福不穷,读书必定显亲宗,紫衣玉带为卿相,富贵荣华孰与同。', fortune: '上上' },
63: { text: '命主为官福禄长,得来富贵定非常,名题雁塔传金榜,定中高科天下扬。', fortune: '上上' },
64: { text: '此命生成福不轻,读书必定有功名,果然富贵前生定,一世荣华事事成。', fortune: '上上' },
65: { text: '细推此命福不轻,安国安邦极品人,文纷雕梁徽富贵,威声照耀四方闻。', fortune: '上上' },
66: { text: '此命推来福且宏,荣华富贵自然通,命中注定衣禄足,一世亨通稳且丰。', fortune: '上上' },
67: { text: '此命生来福自宏,田园家业最高隆,平生衣禄丰盈足,一世荣华万事通。', fortune: '上上' },
68: { text: '富贵荣华莫强求,强求不出反成羞,有福之人还自至,无福之人反成忧。', fortune: '中' },
69: { text: '君是人间前禄星,一生富贵众人钦,纵然福禄由天定,安享荣华过一生。', fortune: '上上' },
70: { text: '此命推来福禄宏,不须劳碌过平生,妻儿和顺皆如意,家道兴隆福自成。', fortune: '上' },
71: { text: '此命生来大不同,公侯卿相在其中,一生自有逍遥福,富贵荣华极品隆。', fortune: '上上' },
72: { text: '此命生来福泽长,兴家立业有祯祥,一生自有逍遥福,富贵荣华极品良。', fortune: '上上' },
};
/** Calculate Bone Weight Fortune from lunar calendar data */
export function calculateBoneWeight(
lunarYearGanzhiIndex: number, // 0-59 六十甲子 index
lunarMonth: number, // 1-12
lunarDay: number, // 1-30
earthBranchHourIndex: number, // 0-11 地支时辰 index
): BoneWeightResult {
const yearWt = YEAR_WEIGHTS[lunarYearGanzhiIndex] || 9;
const monthWt = MONTH_WEIGHTS[lunarMonth] || 9;
const dayWt = DAY_WEIGHTS[lunarDay] || 9;
const hourWt = HOUR_WEIGHTS[earthBranchHourIndex] || 9;
const totalWeight = yearWt + monthWt + dayWt + hourWt;
const totalLiang = Math.floor(totalWeight / 10);
const totalQian = totalWeight % 10;
// Find closest interpretation
const interpretation = findInterpretation(totalWeight);
return {
yearWeight: yearWt,
monthWeight: monthWt,
dayWeight: dayWt,
hourWeight: hourWt,
totalWeight,
totalLiang,
totalQian,
interpretation: interpretation.text,
fortune: interpretation.fortune as BoneWeightResult['fortune'],
};
}
function findInterpretation(totalQian: number): { text: string; fortune: string } {
// Direct match
if (INTERPRETATIONS[totalQian]) return INTERPRETATIONS[totalQian];
// Find closest
const keys = Object.keys(INTERPRETATIONS).map(Number).sort((a, b) => a - b);
let closest = keys[0];
let minDiff = Math.abs(totalQian - closest);
for (const k of keys) {
const diff = Math.abs(totalQian - k);
if (diff < minDiff) { minDiff = diff; closest = k; }
}
return INTERPRETATIONS[closest] || { text: '命格推来,自有天定', fortune: '中' };
}
@@ -0,0 +1,32 @@
/**
* 佛教节日(农历)— tyme4ts 不提供,这里维护常用重大佛教日期表
*/
const BUDDHIST_FESTIVALS: Record<string, string> = {
'1-1': '弥勒菩萨圣诞',
'2-8': '释迦牟尼佛出家日',
'2-15': '释迦牟尼佛涅槃日',
'2-19': '观音菩萨圣诞',
'2-21': '普贤菩萨圣诞',
'3-16': '准提菩萨圣诞',
'4-4': '文殊菩萨圣诞',
'4-8': '释迦牟尼佛圣诞(浴佛节)',
'5-3': '伽蓝菩萨圣诞',
'6-3': '韦驮菩萨圣诞',
'6-19': '观音菩萨成道日',
'7-13': '大势至菩萨圣诞',
'7-15': '盂兰盆节(佛欢喜日)',
'7-30': '地藏菩萨圣诞',
'8-22': '燃灯佛圣诞',
'9-19': '观音菩萨出家日',
'9-30': '药师佛圣诞',
'10-5': '达摩祖师诞辰',
'11-17': '阿弥陀佛圣诞',
'12-8': '释迦牟尼佛成道日(腊八)',
'12-29': '华严菩萨圣诞',
};
/** Get Buddhist festival name by lunar month/day (闰月不重复过节) */
export function getBuddhistFestival(lunarMonth: number, lunarDay: number): string | null {
return BUDDHIST_FESTIVALS[`${lunarMonth}-${lunarDay}`] || null;
}
@@ -0,0 +1,275 @@
import { SolarDay } from 'tyme4ts';
import type { EightCharInfo, PillarInfo } from '../types/bazi';
import type { DailyFortuneResult, PillarRelationship } from '../types/fortune';
import {
getBranchRelationship,
getTenStarRelationship,
checkStemCombine,
checkStemOpposite,
} from './relationship';
const PILLAR_LABELS: Record<string, string> = {
year: '年柱', month: '月柱', day: '日柱', hour: '时柱',
};
const PILLAR_KEYS = ['year', 'month', 'day', 'hour'] as const;
export function calculateDailyFortune(userBazi: EightCharInfo, date: Date): DailyFortuneResult {
const solarDay = SolarDay.fromYmd(date.getFullYear(), date.getMonth() + 1, date.getDate());
const lunarDay = solarDay.getLunarDay();
const lunarHour = lunarDay.getHours()[6];
const dayEightChar = lunarHour.getEightChar();
const dayPillars: Record<string, { ganzhi: string; stem: string; branch: string }> = {
year: { ganzhi: dayEightChar.getYear().getName(), stem: dayEightChar.getYear().getHeavenStem().getName(), branch: dayEightChar.getYear().getEarthBranch().getName() },
month: { ganzhi: dayEightChar.getMonth().getName(), stem: dayEightChar.getMonth().getHeavenStem().getName(), branch: dayEightChar.getMonth().getEarthBranch().getName() },
day: { ganzhi: dayEightChar.getDay().getName(), stem: dayEightChar.getDay().getHeavenStem().getName(), branch: dayEightChar.getDay().getEarthBranch().getName() },
hour: { ganzhi: dayEightChar.getHour().getName(), stem: dayEightChar.getHour().getHeavenStem().getName(), branch: dayEightChar.getHour().getEarthBranch().getName() },
};
const userPillars: Record<string, PillarInfo> = {
year: userBazi.yearPillar, month: userBazi.monthPillar, day: userBazi.dayPillar, hour: userBazi.hourPillar,
};
const relationships: PillarRelationship[] = [];
for (const key of PILLAR_KEYS) {
const userPillar = userPillars[key]; const dayP = dayPillars[key];
const stemTenStar = getTenStarRelationship(userPillar.heavenStem, dayP.stem);
const stemCombine = checkStemCombine(userPillar.heavenStem, dayP.stem);
const stemOpposite = checkStemOpposite(userPillar.heavenStem, dayP.stem);
const branchRel = getBranchRelationship(userPillar.earthBranch, dayP.branch);
let score = 0;
if (stemTenStar) {
const good = ['正印','偏印','食神','正财','偏财','正官'];
const bad = ['七杀','劫财','伤官'];
if (good.includes(stemTenStar)) score += 4;
else if (bad.includes(stemTenStar)) score -= 3;
else score += 1;
}
if (stemCombine) score += 5;
if (stemOpposite) score -= 6;
if (branchRel.combine) score += 4;
if (branchRel.threeCombine) score += 3;
if (branchRel.opposite) score -= 5;
if (branchRel.harm) score -= 4;
if (branchRel.punish) score -= 3;
score = Math.max(-10, Math.min(10, score));
relationships.push({
pillar: key, pillarLabel: PILLAR_LABELS[key],
userGanzhi: userPillar.ganzhi, dayGanzhi: dayP.ganzhi,
stemTenStar, stemCombine, stemOpposite,
branchCombine: branchRel.combine, branchThreeCombine: branchRel.threeCombine,
branchOpposite: branchRel.opposite, branchHarm: branchRel.harm,
branchPunish: branchRel.punish, branchFormation: branchRel.formation,
score,
});
}
const weightedScore =
relationships[0].score * 0.20 + relationships[1].score * 0.25 +
relationships[2].score * 0.40 + relationships[3].score * 0.15;
const overallScore = Math.max(-100, Math.min(100, Math.round(weightedScore * 10)));
const scoreLevel: DailyFortuneResult['scoreLevel'] =
overallScore >= 50 ? 'great' : overallScore >= 20 ? 'good' :
overallScore >= -20 ? 'fair' : overallScore >= -50 ? 'poor' : 'bad';
const luckyAspects = generateLuckyAspects(relationships);
const unluckyAspects = generateUnluckyAspects(relationships);
const suggestions = generateSuggestions(relationships, userBazi, overallScore);
const affectedAreas = determineAffectedAreas(relationships);
const luckyMeta = computeLuckyMeta(userBazi, dayPillars.day.ganzhi, overallScore);
const categoryScores = computeCategoryScores(relationships);
const lunarDateStr = `${lunarDay.getLunarMonth().getLunarYear().getYear()}年${lunarDay.getLunarMonth().getName()}${lunarDay.getName()}`;
return {
date: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`,
lunarDate: lunarDateStr, dayGanzhi: dayPillars.day.ganzhi,
overallScore, scoreLevel, pillarRelationships: relationships,
luckyAspects, unluckyAspects, suggestions, affectedAreas, luckyMeta, categoryScores,
};
}
function generateLuckyAspects(relationships: PillarRelationship[]): string[] {
const seen = new Set<string>(); const aspects: string[] = [];
const tenStarPlain: Record<string, string> = {
'正官':'事业运不错,工作上容易得到认可和赏识',
'七杀':'进取心强,适合挑战难题和竞争性事务',
'正印':'学习运佳,适合读书充电或向长辈请教',
'偏印':'灵感丰富,适合研究和创意工作',
'食神':'心情舒畅,适合休闲放松和享受生活',
'伤官':'表达欲强,适合沟通交流和展示自我',
'正财':'财运平稳,适合处理日常财务和长期投资',
'偏财':'偏财运佳,可能有意外之财或投资收益',
'比肩':'人际关系和睦,容易得到朋友和同事的帮助',
'劫财':'社交活跃,适合参加聚会和团队活动',
};
const hasStemCombine = relationships.some(r => r.stemCombine);
const hasBranchCombine = relationships.some(r => r.branchCombine || r.branchThreeCombine);
for (const rel of relationships) {
if (rel.stemTenStar && tenStarPlain[rel.stemTenStar] && !seen.has(rel.stemTenStar)) {
aspects.push(tenStarPlain[rel.stemTenStar]); seen.add(rel.stemTenStar);
}
}
if (hasStemCombine && !seen.has('combine')) { aspects.push('天时地利人和,容易遇到贵人相助和好机会'); seen.add('combine'); }
if (hasBranchCombine && !seen.has('branch')) { aspects.push('身边关系和谐,适合与人合作或开展团队项目'); seen.add('branch'); }
if (aspects.length === 0) aspects.push('今天整体运势平稳,适合按部就班处理日常事务');
return aspects.slice(0, 4);
}
function generateUnluckyAspects(relationships: PillarRelationship[]): string[] {
const aspects: string[] = [];
const hasOpposite = relationships.some(r => r.branchOpposite);
const hasHarm = relationships.some(r => r.branchHarm);
const hasPunish = relationships.some(r => r.branchPunish);
const hasStemOpposite = relationships.some(r => r.stemOpposite);
const hasQiSha = relationships.some(r => r.stemTenStar === '七杀');
const hasJieCai = relationships.some(r => r.stemTenStar === '劫财');
const hasShangGuan = relationships.some(r => r.stemTenStar === '伤官');
if (hasStemOpposite) aspects.push('今天可能遇到突发变化或计划被打乱,保持冷静和弹性很重要');
if (hasOpposite) aspects.push('与他人意见不合的可能性较高,尽量避免争执和正面冲突');
if (hasHarm) aspects.push('留意身边的小人和是非,不要轻易透露自己的计划和秘密');
if (hasPunish) aspects.push('容易说错话或做出不恰当的举动,多听少说更安全');
if (hasQiSha) aspects.push('压力较大的一天,注意调节情绪,重要决策可暂缓');
if (hasJieCai) aspects.push('财务方面要谨慎,不宜大额消费或借钱给别人');
if (hasShangGuan) aspects.push('表达时注意方式方法,容易词不达意引起误会');
if (aspects.length === 0) aspects.push('今天没有什么特别需要注意的,保持平常心即可');
return aspects.slice(0, 3);
}
function generateSuggestions(
relationships: PillarRelationship[],
userBazi: EightCharInfo,
overallScore: number,
): string[] {
const s: string[] = [];
const de = userBazi.dayMasterElement;
const colors: Record<string,string> = { '木':'绿色/青色','火':'红色/紫色','土':'黄色/棕色','金':'白色/浅色','水':'蓝色/黑色' };
const dirs: Record<string,string> = { '木':'东方','火':'南方','土':'中央','金':'西方','水':'北方' };
const hasConflict = relationships.some(r => r.branchOpposite || r.stemOpposite);
const hasCombine = relationships.some(r => r.branchCombine || r.stemCombine);
const hasHarm = relationships.some(r => r.branchHarm);
const hasPunish = relationships.some(r => r.branchPunish);
const goodPool = [
de ? `👔 幸运色${colors[de]||'浅色'},穿对颜色运气更好` : `👔 今天穿浅色衣服心情更好`,
`✅ 适合推进重要事项,果断决策会有好结果`,
`📝 把计划写下来按部就班执行,效率翻倍`,
`🎯 专注一件事比多线作战更有效果`,
`🤝 适合约重要的人见面,贵人运在线`,
`🌅 早起一点早晨的效率最高`,
`📊 适合总结和复盘能发现新机会`,
`🏃 运动或户外活动对运势有加成`,
];
const fairPool = [
`💡 运势平稳该做什么就做什么`,
`☕ 适合处理日常事务不宜做重大改变`,
`📖 适合学习充电多读多听少说`,
`🧹 整理收纳会让心情变好`,
`🍵 节奏放慢一点稳扎稳打`,
`📞 适合和老朋友聊聊天`,
];
const poorPool = [
`🧘 适合保守行事多观察多思考`,
`☕ 适合独处和复盘少社交`,
`🛡️ 以守成为主避免冲动`,
`📿 冥想或听音乐能化解负面情绪`,
`🙏 遇到不顺心的事深呼吸三秒`,
`📝 把想法写下来明天再行动`,
];
const badPool = [
`🛡️ 宜静不宜动重要决策能缓则缓`,
`🚫 避免签合同或做出重大承诺`,
`🧘 适合冥想休息养精蓄锐`,
`📿 去清净处走走转换气场`,
`💤 早点休息好睡眠是最好的转运`,
`🙏 好事多磨心态会好很多`,
];
let pool = fairPool;
if (overallScore >= 30) pool = goodPool;
else if (overallScore >= 0) pool = fairPool;
else if (overallScore >= -30) pool = poorPool;
else pool = badPool;
const seed = new Date().getDate() + new Date().getMonth() * 31;
s.push(pool[seed % pool.length]);
s.push(pool[(seed + 5) % pool.length]);
if (overallScore >= 10 && de && dirs[de]) s.push(`🧭 向${dirs[de]}行事有意外好运`);
if (hasCombine && !hasConflict) s.push('🤝 人际关系活跃适合合作洽谈');
else if (hasConflict && hasHarm) s.push('⚠️ 人际关系有暗礁保持微笑少说话');
else if (hasConflict) s.push('🙏 意见不合时先认同再引导');
else if (hasPunish) s.push('💬 说话前过遍脑子今天容易说错话');
const seen = new Set<string>();
const unique = s.filter(t => { if (seen.has(t)) return false; seen.add(t); return true; });
return unique.slice(0, 4);
}
function computeLuckyMeta(userBazi: EightCharInfo, dayGanzhi: string, score: number) {
const de = userBazi.dayMasterElement;
const colorMap: Record<string, string[]> = { '木': ['绿色','青色','翠绿'], '火': ['红色','紫色','粉色'], '土': ['黄色','棕色','米色'], '金': ['白色','银色','浅灰'], '水': ['蓝色','黑色','深灰'] };
const dirMap: Record<string, string> = { '木': '东方', '火': '南方', '土': '中央', '金': '西方', '水': '北方' };
const actMap: Record<string, string[]> = {
'木': ['户外散步','园艺','阅读','写作'], '火': ['社交','演讲','创意','运动'], '土': ['整理收纳','理财','烹饪','冥想'],
'金': ['商务洽谈','签约','购物','美容'], '水': ['学习进修','旅行','游泳','听音乐'],
};
const colors = colorMap[de] || ['红色','白色'];
const direction = dirMap[de] || '东方';
const seed = parseInt(dayGanzhi.charCodeAt(0).toString()) + new Date().getDate();
const n1 = (seed % 9) + 1;
const n2 = ((seed * 3 + 7) % 9) + 1;
const activities = actMap[de] || ['运动','阅读'];
const activity = activities[seed % activities.length];
return {
colors: [colors[0], colors[1]],
numbers: [n1, n2],
direction,
element: de,
activity: score >= 0 ? activity : activities[(seed + 3) % activities.length],
};
}
function computeCategoryScores(relationships: PillarRelationship[]) {
const tenStarLove: Record<string,number> = { '正官':8,'七杀':-2,'正印':3,'偏印':2,'食神':6,'伤官':5,'正财':4,'偏财':3 };
const tenStarCareer: Record<string,number> = { '正官':10,'七杀':8,'正印':5,'偏印':4,'食神':3,'伤官':2,'正财':2 };
const tenStarWealth: Record<string,number> = { '正财':10,'偏财':8,'食神':6,'伤官':4,'正官':2,'七杀':-2 };
const tenStarHealth: Record<string,number> = { '正印':8,'偏印':6,'比肩':5,'食神':4,'七杀':-4,'伤官':-2 };
let love=0, career=0, wealth=0, health=0;
for (const rel of relationships) {
const ts = rel.stemTenStar;
if (!ts) continue;
const w = rel.pillar === 'day' ? 3 : rel.pillar === 'month' ? 2 : 1;
love += (tenStarLove[ts]||0) * w + (rel.branchHarm?-5:0) * w + (rel.branchCombine?4:0) * w;
career += (tenStarCareer[ts]||0) * w + (rel.branchOpposite?-4:0) * w + (rel.branchThreeCombine?5:0) * w;
wealth += (tenStarWealth[ts]||0) * w + (rel.stemCombine?5:0) * w;
health += (tenStarHealth[ts]||0) * w + (rel.branchOpposite?-3:0) * w;
}
// Normalize to -100..100
const clamp = (v:number) => Math.max(-100, Math.min(100, Math.round(v * 2.5)));
return { love: clamp(love), career: clamp(career), wealth: clamp(wealth), health: clamp(health) };
}
function determineAffectedAreas(relationships: PillarRelationship[]): string[] {
const areas = new Set<string>();
const areaMapping: Record<string, string> = {
'正财':'财运','偏财':'偏财/投资','正官':'事业','七杀':'事业/压力',
'正印':'学业','偏印':'学业/智慧','食神':'创作/享乐','伤官':'口才/表达',
'比肩':'人际关系','劫财':'竞争/人际',
};
for (const rel of relationships) {
if (rel.stemTenStar && areaMapping[rel.stemTenStar]) areas.add(areaMapping[rel.stemTenStar]);
if (rel.branchOpposite && rel.pillar === 'year') areas.add('长辈/根基');
if (rel.branchOpposite && rel.pillar === 'month') areas.add('事业/家庭');
if (rel.branchOpposite && rel.pillar === 'day') areas.add('婚姻/健康');
if (rel.branchOpposite && rel.pillar === 'hour') areas.add('子女/晚年');
}
return areas.size > 0 ? [...areas] : ['综合运势'];
}
@@ -0,0 +1,99 @@
import type { EightCharInfo } from '../types/bazi';
/** Five Element profile */
export interface ElementProfile {
wood: number;
fire: number;
earth: number;
metal: number;
water: number;
total: number;
dominant: string;
weakest: string;
isBalanced: boolean;
}
/** Analyze the five element balance in a Bazi chart */
export function analyzeElementBalance(bazi: EightCharInfo): ElementProfile {
const pillars = [bazi.yearPillar, bazi.monthPillar, bazi.dayPillar, bazi.hourPillar];
const counts: Record<string, number> = {
'木': 0,
'火': 0,
'土': 0,
'金': 0,
'水': 0,
};
for (const pillar of pillars) {
// Count stem element
if (pillar.elementStem && counts[pillar.elementStem] !== undefined) {
counts[pillar.elementStem] += 1;
}
// Count branch element
if (pillar.elementBranch && counts[pillar.elementBranch] !== undefined) {
counts[pillar.elementBranch] += 1;
}
// Count hidden stem elements (half weight)
for (const hs of pillar.hideStems) {
// Infer element from stem name
const element = inferElementFromStem(hs.stem);
if (element && counts[element] !== undefined) {
counts[element] += 0.5;
}
}
}
const total = counts['木'] + counts['火'] + counts['土'] + counts['金'] + counts['水'];
// Find dominant and weakest
let dominant = '木';
let weakest = '木';
let maxCount = 0;
let minCount = Infinity;
for (const [element, count] of Object.entries(counts)) {
if (count > maxCount) {
maxCount = count;
dominant = element;
}
if (count < minCount) {
minCount = count;
weakest = element;
}
}
// Balance check: each element should be within 30% of ideal (20% each)
const ideal = total / 5;
let isBalanced = true;
for (const count of Object.values(counts)) {
if (Math.abs(count - ideal) > ideal * 0.4) {
isBalanced = false;
break;
}
}
return {
wood: counts['木'] || 0,
fire: counts['火'] || 0,
earth: counts['土'] || 0,
metal: counts['金'] || 0,
water: counts['水'] || 0,
total,
dominant,
weakest,
isBalanced,
};
}
/** Infer five element from heavenly stem name */
function inferElementFromStem(stem: string): string | null {
const stemElements: Record<string, string> = {
'甲': '木', '乙': '木',
'丙': '火', '丁': '火',
'戊': '土', '己': '土',
'庚': '金', '辛': '金',
'壬': '水', '癸': '水',
};
return stemElements[stem] || null;
}
@@ -0,0 +1,88 @@
/**
* 大运/流年/流月/流日 与日主的生克冲合分析
* 将任意干支与日主(日干/日支)比较,输出十神、五行生克、天干合冲、地支关系与简化吉凶。
*/
import { getTenStarRelationship, checkStemCombine, checkStemOpposite, getBranchRelationship } from './relationship';
export interface FortuneLuck {
ganzhi: string;
tenStar: string | null;
/** 该柱五行 vs 日主五行:比和/生我/我生/克我/我克 */
elementRelation: string;
stemCombine: boolean;
stemOpposite: boolean;
branchCombine: boolean;
branchThreeCombine: boolean;
branchOpposite: boolean;
branchHarm: boolean;
branchPunish: boolean;
score: number;
level: '吉' | '凶' | '平';
}
const STEM_ELEMENT: Record<string, string> = {
'甲': '木', '乙': '木', '丙': '火', '丁': '火', '戊': '土',
'己': '土', '庚': '金', '辛': '金', '壬': '水', '癸': '水',
};
const GENERATES: Record<string, string> = { '木': '火', '火': '土', '土': '金', '金': '水', '水': '木' };
const KILLS: Record<string, string> = { '木': '土', '土': '水', '水': '火', '火': '金', '金': '木' };
const GOOD_TEN_STARS = ['正印', '偏印', '食神', '正财', '偏财', '正官'];
const BAD_TEN_STARS = ['七杀', '劫财', '伤官'];
/** Analyze an external ganzhi (大运/流年/流月/流日) against the day master pillar */
export function analyzeFortuneGanzhi(
ganzhi: string,
dayStem: string,
dayBranch: string,
): FortuneLuck {
const stem = ganzhi[0];
const branch = ganzhi[1];
const tenStar = getTenStarRelationship(dayStem, stem);
const el = STEM_ELEMENT[stem];
const de = STEM_ELEMENT[dayStem];
let elementRelation: string;
if (!el || !de || el === de) elementRelation = '比和';
else if (GENERATES[el] === de) elementRelation = '生我';
else if (GENERATES[de] === el) elementRelation = '我生';
else if (KILLS[el] === de) elementRelation = '克我';
else elementRelation = '我克';
const stemCombine = checkStemCombine(stem, dayStem);
const stemOpposite = checkStemOpposite(stem, dayStem);
const br = getBranchRelationship(branch, dayBranch);
let score = 0;
if (tenStar) {
if (GOOD_TEN_STARS.includes(tenStar)) score += 4;
else if (BAD_TEN_STARS.includes(tenStar)) score -= 3;
else score += 1;
}
if (stemCombine) score += 5;
if (stemOpposite) score -= 6;
if (br.combine) score += 4;
if (br.threeCombine) score += 3;
if (br.opposite) score -= 5;
if (br.harm) score -= 4;
if (br.punish) score -= 3;
score = Math.max(-10, Math.min(10, score));
const level: FortuneLuck['level'] = score >= 3 ? '吉' : score <= -3 ? '凶' : '平';
return {
ganzhi,
tenStar,
elementRelation,
stemCombine,
stemOpposite,
branchCombine: br.combine,
branchThreeCombine: br.threeCombine,
branchOpposite: br.opposite,
branchHarm: br.harm,
branchPunish: br.punish,
score,
level,
};
}
@@ -0,0 +1,258 @@
/**
* 梅花易数 (Plum Blossom I-Ching Divination)
* Based on time (year, month, day, hour) to derive hexagrams
*/
export interface TrigramInfo {
index: number; // 1-8 (乾兑离震巽坎艮坤)
name: string; // Chinese name
symbol: string; // Unicode trigram symbol
element: string; // Five element
direction: string; // Direction
nature: string; // Natural phenomenon
trait: string; // Personality trait
body: string; // Body part
}
export interface HexagramInfo {
number: number; // 1-64
name: string; // Chinese name e.g. "乾为天"
upperTrigram: TrigramInfo;
lowerTrigram: TrigramInfo;
changingLine: number; // 1-6, which line changes
interpretation: string; // Overall interpretation
judgment: string; // 彖辞
image: string; // 象辞
lines: string[]; // 6 line interpretations
}
export interface PlumBlossomResult {
originalHexagram: HexagramInfo;
transformedHexagram: HexagramInfo | null;
mutualHexagram: HexagramInfo | null;
upperTrigram: TrigramInfo;
lowerTrigram: TrigramInfo;
changingLine: number;
constitution: string; // 体卦
function: string; // 用卦
relationship: string; // 体用关系
}
// 8 Trigrams (八卦)
const TRIGRAMS: Record<number, TrigramInfo> = {
1: { index: 1, name: '乾', symbol: '☰', element: '金', direction: '西北', nature: '天', trait: '健', body: '首' },
2: { index: 2, name: '兑', symbol: '☱', element: '金', direction: '西', nature: '泽', trait: '悦', body: '口' },
3: { index: 3, name: '离', symbol: '☲', element: '火', direction: '南', nature: '火', trait: '丽', body: '目' },
4: { index: 4, name: '震', symbol: '☳', element: '木', direction: '东', nature: '雷', trait: '动', body: '足' },
5: { index: 5, name: '巽', symbol: '☴', element: '木', direction: '东南', nature: '风', trait: '入', body: '股' },
6: { index: 6, name: '坎', symbol: '☵', element: '水', direction: '北', nature: '水', trait: '陷', body: '耳' },
7: { index: 7, name: '艮', symbol: '☶', element: '土', direction: '东北', nature: '山', trait: '止', body: '手' },
8: { index: 8, name: '坤', symbol: '☷', element: '土', direction: '西南', nature: '地', trait: '顺', body: '腹' },
};
// All 64 Hexagrams (complete I-Ching)
const H: Record<string, { name: string; judgment: string; image: string; lines: string[] }> = {
// 乾宫八卦 (1-8)
'1,1':{name:'乾为天',judgment:'大哉乾元,万物资始,乃统天。云行雨施,品物流形',image:'天行健,君子以自强不息',lines:['潜龙勿用','见龙在田,利见大人','君子终日乾乾,夕惕若厉','或跃在渊,无咎','飞龙在天,利见大人','亢龙有悔']},
'1,5':{name:'天风姤',judgment:'姤,遇也,柔遇刚也。天地相遇,品物咸章',image:'天下有风,姤。后以施命诰四方',lines:['系于金柅,贞吉','包有鱼,无咎','臀无肤,其行次且','包无鱼,起凶','以杞包瓜,含章','姤其角,吝']},
'1,7':{name:'天山遁',judgment:'遁亨,遁而亨也。刚当位而应,与时行也',image:'天下有山,遁。君子以远小人,不恶而严',lines:['遁尾厉,勿用有攸往','执之用黄牛之革','系遁,有疾厉','好遁,君子吉','嘉遁,贞吉','肥遁,无不利']},
'1,8':{name:'天地否',judgment:'否之匪人,不利君子贞。大往小来',image:'天地不交,否。君子以俭德辟难',lines:['拔茅茹,以其汇,贞吉','包承,小人吉,大人否','包羞','有命无咎,畴离祉','休否,大人吉','倾否,先否后喜']},
'5,8':{name:'风地观',judgment:'观,盥而不荐,有孚颙若。观天之神道而四时不忒',image:'风行地上,观。先王以省方观民设教',lines:['童观,小人无咎','窥观,利女贞','观我生进退','观国之光,利用宾于王','观我生,君子无咎','观其生,君子无咎']},
'7,8':{name:'山地剥',judgment:'剥,剥也,柔变刚也。不利有攸往',image:'山附于地,剥。上以厚下安宅',lines:['剥床以足,蔑贞凶','剥床以辨,蔑贞凶','剥之无咎','剥床以肤,凶','贯鱼以宫人宠,无不利','硕果不食,君子得舆']},
'3,8':{name:'火地晋',judgment:'晋,进也。明出地上,顺而丽乎大明',image:'明出地上,晋。君子以自昭明德',lines:['晋如摧如,贞吉','晋如愁如,贞吉','众允,悔亡','晋如鼫鼠,贞厉','悔亡,失得勿恤','晋其角,维用伐邑']},
'3,1':{name:'火天大有',judgment:'大有,柔得尊位,大中而上下应之',image:'火在天上,大有。君子以遏恶扬善,顺天休命',lines:['无交害,匪咎','大车以载,有攸往','公用亨于天子','匪其彭,无咎','厥孚交如,威如','自天佑之,吉无不利']},
// 坎宫八卦 (9-16)
'6,6':{name:'坎为水',judgment:'习坎,重险也。水流而不盈,行险而不失其信',image:'水洊至,习坎。君子以常德行习教事',lines:['习坎,入于坎窞','坎有险,求小得','来之坎坎,险且枕','樽酒簋贰,用缶','坎不盈,祗既平','系用徽纆,寘于丛棘']},
'6,2':{name:'水泽节',judgment:'节亨,苦节不可贞',image:'泽上有水,节。君子以制数度议德行',lines:['不出户庭,无咎','不出门庭,凶','不节若,则嗟若','安节,亨','甘节,吉','苦节,贞凶']},
'6,4':{name:'水雷屯',judgment:'屯,刚柔始交而难生。动乎险中,大亨贞',image:'云雷屯,君子以经纶',lines:['磐桓,利居贞','屯如邅如,乘马班如','即鹿无虞,惟入于林中','乘马班如,求婚媾','屯其膏,小贞吉','乘马班如,泣血涟如']},
'6,3':{name:'水火既济',judgment:'既济亨,小者亨也。利贞,初吉终乱',image:'水在火上,既济。君子以思患而豫防之',lines:['曳其轮,濡其尾','妇丧其茀,勿逐','高宗伐鬼方,三年克之','繻有衣袽,终日戒','东邻杀牛,不如西邻','濡其首,厉']},
'2,3':{name:'泽火革',judgment:'革,水火相息。天地革而四时成',image:'泽中有火,革。君子以治历明时',lines:['巩用黄牛之革','巳日乃革之,征吉','征凶,贞厉','悔亡,有孚改命','大人虎变,未占有孚','君子豹变,小人革面']},
'4,3':{name:'雷火丰',judgment:'丰,大也。明以动,故丰',image:'雷电皆至,丰。君子以折狱致刑',lines:['遇其配主,虽旬无咎','丰其蔀,日中见斗','丰其沛,日中见沬','丰其蔀,日中见斗','来章,有庆誉','丰其屋,蔀其家']},
'8,3':{name:'地火明夷',judgment:'明夷,利艰贞。明入地中,明夷',image:'明入地中,明夷。君子以莅众用晦而明',lines:['明夷于飞,垂其翼','明夷,夷于左股','明夷于南狩,得其大首','入于左腹,获明夷之心','箕子之明夷,利贞','不明晦,初登于天']},
'8,6':{name:'地水师',judgment:'师,众也。贞,丈人吉,无咎',image:'地中有水,师。君子以容民畜众',lines:['师出以律,否臧凶','在师中,吉无咎','师或舆尸,凶','师左次,无咎','田有禽,利执言','大君有命,开国承家']},
// 艮宫八卦 (17-24)
'7,7':{name:'艮为山',judgment:'艮,止也。时止则止,时行则行',image:'兼山,艮。君子以思不出其位',lines:['艮其趾,无咎','艮其腓,不拯其随','艮其限,列其夤','艮其身,无咎','艮其辅,言有序','敦艮,吉']},
'7,3':{name:'山火贲',judgment:'贲亨,柔来而文刚,故亨',image:'山下有火,贲。君子以明庶政无敢折狱',lines:['贲其趾,舍车而徒','贲其须','贲如濡如,永贞吉','贲如皤如,白马翰如','贲于丘园,束帛戋戋','白贲,无咎']},
'7,1':{name:'山天大畜',judgment:'大畜,刚健笃实辉光,日新其德',image:'天在山中,大畜。君子以多识前言往行',lines:['有厉,利已','舆说輹','良马逐,利艰贞','童牛之牿,元吉','豮豕之牙,吉','何天之衢,亨']},
'7,2':{name:'山泽损',judgment:'损,损下益上,其道上行',image:'山下有泽,损。君子以惩忿窒欲',lines:['已事遄往,无咎','利贞,征凶,弗损益之','三人行则损一人','损其疾,使遄有喜','或益之十朋之龟','弗损益之,无咎']},
'3,2':{name:'火泽睽',judgment:'睽,火动而上,泽动而下',image:'上火下泽,睽。君子以同而异',lines:['悔亡,丧马勿逐','遇主于巷,无咎','见舆曳,其牛掣','睽孤,遇元夫','悔亡,厥宗噬肤','睽孤,见豕负涂']},
'1,2':{name:'天泽履',judgment:'履,柔履刚也。说而应乎乾',image:'上天下泽,履。君子以辩上下定民志',lines:['素履往,无咎','履道坦坦,幽人贞吉','眇能视,跛能履','履虎尾,愬愬终吉','夬履,贞厉','视履考祥,其旋元吉']},
'5,2':{name:'风泽中孚',judgment:'中孚,柔在内而刚得中',image:'泽上有风,中孚。君子以议狱缓死',lines:['虞吉,有它不燕','鸣鹤在阴,其子和之','得敌,或鼓或罢','月几望,马匹亡','有孚挛如,无咎','翰音登于天,贞凶']},
'5,7':{name:'风山渐',judgment:'渐,女归吉也。进得位,往有功也',image:'山上有木,渐。君子以居贤德善俗',lines:['鸿渐于干,小子厉','鸿渐于磐,饮食衎衎','鸿渐于陆,夫征不复','鸿渐于木,或得其桷','鸿渐于陵,妇三岁不孕','鸿渐于逵,其羽可用为仪']},
// 震宫八卦 (25-32)
'4,4':{name:'震为雷',judgment:'震亨。震来虩虩,笑言哑哑,震惊百里',image:'洊雷,震。君子以恐惧修省',lines:['震来虩虩,后笑言哑哑','震来厉,亿丧贝','震苏苏,震行无眚','震遂泥','震往来厉,亿无丧','震索索,视矍矍']},
'4,8':{name:'雷地豫',judgment:'豫,刚应而志行,顺以动',image:'雷出地奋,豫。先王以作乐崇德',lines:['鸣豫,凶','介于石,不终日','盱豫悔,迟有悔','由豫,大有得','贞疾,恒不死','冥豫,成有渝']},
'4,6':{name:'雷水解',judgment:'解,险以动,动而免乎险',image:'雷雨作,解。君子以赦过宥罪',lines:['无咎','田获三狐,得黄矢','负且乘,致寇至','解而拇,朋至斯孚','君子维有解,吉','公用射隼于高墉之上']},
'4,5':{name:'雷风恒',judgment:'恒,久也。刚上而柔下',image:'雷风,恒。君子以立不易方',lines:['浚恒,贞凶','悔亡','不恒其德,或承之羞','田无禽','恒其德,贞妇人吉','振恒,凶']},
'8,5':{name:'地风升',judgment:'柔以时升,巽而顺,刚中而应',image:'地中生木,升。君子以顺德积小以高大',lines:['允升,大吉','孚乃利用禴,无咎','升虚邑','王用亨于岐山','贞吉,升阶','冥升,利于不息之贞']},
'6,5':{name:'水风井',judgment:'井,改邑不改井,无丧无得',image:'木上有水,井。君子以劳民劝相',lines:['井泥不食,旧井无禽','井谷射鲋,瓮敝漏','井渫不食,为我心恻','井甃,无咎','井洌,寒泉食','井收勿幕,有孚元吉']},
'2,5':{name:'泽风大过',judgment:'大过,大者过也。栋桡,本末弱也',image:'泽灭木,大过。君子以独立不惧遁世无闷',lines:['藉用白茅,无咎','枯杨生稊,老夫得其女妻','栋桡,凶','栋隆,吉','枯杨生华,老妇得士夫','过涉灭顶,凶']},
'2,4':{name:'泽雷随',judgment:'随,刚来而下柔,动而说',image:'泽中有雷,随。君子以向晦入宴息',lines:['官有渝,贞吉','系小子,失丈夫','系丈夫,失小子','随有获,贞凶','孚于嘉,吉','拘系之,乃从维之']},
// 巽宫八卦 (33-40)
'5,5':{name:'巽为风',judgment:'重巽以申命,刚巽乎中正而志行',image:'随风,巽。君子以申命行事',lines:['进退,利武人之贞','巽在床下,用史巫纷若','频巽,吝','悔亡,田获三品','贞吉,悔亡无不利','巽在床下,丧其资斧']},
'5,1':{name:'风天小畜',judgment:'小畜,柔得位而上下应之',image:'风行天上,小畜。君子以懿文德',lines:['复自道,何其咎','牵复,吉','舆说辐,夫妻反目','有孚,血去惕出','有孚挛如,富以其邻','既雨既处,尚德载']},
'5,3':{name:'风火家人',judgment:'家人,女正位乎内,男正位乎外',image:'风自火出,家人。君子以言有物而行有恒',lines:['闲有家,悔亡','无攸遂,在中馈','家人嗃嗃,悔厉吉','富家,大吉','王假有家,勿恤','有孚威如,终吉']},
'5,4':{name:'风雷益',judgment:'益,损上益下,民说无疆',image:'风雷,益。君子以见善则迁有过则改',lines:['利用为大作,元吉','或益之十朋之龟','益之用凶事,无咎','中行告公从,利用为依迁国','有孚惠心,勿问元吉','莫益之,或击之']},
'1,4':{name:'天雷无妄',judgment:'无妄,刚自外来而为主于内',image:'天下雷行,物与无妄。先王以茂对时育万物',lines:['无妄,往吉','不耕获,不菑畬','无妄之灾,或系之牛','可贞,无咎','无妄之疾,勿药有喜','无妄,行有眚']},
'3,4':{name:'火雷噬嗑',judgment:'噬嗑亨,利用狱。刚柔分动而明',image:'雷电噬嗑,先王以明罚敕法',lines:['屦校灭趾,无咎','噬肤灭鼻,无咎','噬腊肉,遇毒','噬干胏,得金矢','噬干肉,得黄金','何校灭耳,凶']},
'7,4':{name:'山雷颐',judgment:'颐,贞吉。观颐,自求口实',image:'山下有雷,颐。君子以慎言语节饮食',lines:['舍尔灵龟,观我朵颐','颠颐,拂经于丘颐','拂颐,贞凶','颠颐,吉','拂经,居贞吉','由颐,厉吉,利涉大川']},
'7,5':{name:'山风蛊',judgment:'蛊,元亨。利涉大川,先甲三日后甲三日',image:'山下有风,蛊。君子以振民育德',lines:['干父之蛊,有子考无咎','干母之蛊,不可贞','干父之蛊,小有悔','裕父之蛊,往见吝','干父之蛊,用誉','不事王侯,高尚其事']},
// 离宫八卦 (41-48)
'3,3':{name:'离为火',judgment:'离,丽也。日月丽乎天,百谷草木丽乎土',image:'明两作,离。大人以继明照于四方',lines:['履错然,敬之无咎','黄离,元吉','日昃之离,不鼓缶而歌','突如其来如,焚如死如弃如','出涕沱若,戚嗟若','王用出征,有嘉折首']},
'3,7':{name:'火山旅',judgment:'旅,小亨。旅贞吉',image:'山上有火,旅。君子以明慎用刑而不留狱',lines:['旅琐琐,斯其所取灾','旅即次,怀其资','旅焚其次,丧其童仆','旅于处,得其资斧','射雉,一矢亡','鸟焚其巢,旅人先笑后号咷']},
'3,5':{name:'火风鼎',judgment:'鼎,象也。以木巽火,亨饪也',image:'木上有火,鼎。君子以正位凝命',lines:['鼎颠趾,利出否','鼎有实,我仇有疾','鼎耳革,其行塞','鼎折足,覆公餗','鼎黄耳金铉,利贞','鼎玉铉,大吉']},
'3,6':{name:'火水未济',judgment:'未济亨,小狐汔济,濡其尾',image:'火在水上,未济。君子以慎辨物居方',lines:['濡其尾,吝','曳其轮,贞吉','未济,征凶','贞吉,悔亡','贞吉,无悔','有孚于饮酒,无咎']},
'7,6':{name:'山水蒙',judgment:'蒙亨。匪我求童蒙,童蒙求我',image:'山下出泉,蒙。君子以果行育德',lines:['发蒙,利用刑人','包蒙,吉','勿用取女,见金夫','困蒙,吝','童蒙,吉','击蒙,不利为寇']},
'5,6':{name:'风水涣',judgment:'涣亨。王假有庙,利涉大川',image:'风行水上,涣。先王以享于帝立庙',lines:['用拯马壮,吉','涣奔其机,悔亡','涣其躬,无悔','涣其群,元吉','涣汗其大号','涣其血,去逖出']},
'1,6':{name:'天水讼',judgment:'讼,上刚下险,险而健,讼',image:'天与水违行,讼。君子以作事谋始',lines:['不永所事,小有言','不克讼,归而逋','食旧德,贞厉终吉','不克讼,复即命渝','讼,元吉','或锡之鞶带,终朝三褫']},
'1,3':{name:'天火同人',judgment:'同人,柔得位得中而应乎乾',image:'天与火,同人。君子以类族辨物',lines:['同人于门,无咎','同人于宗,吝','伏戎于莽,升其高陵','乘其墉,弗克攻','同人先号咷而后笑','同人于郊,无悔']},
// 坤宫八卦 (49-56)
'8,8':{name:'坤为地',judgment:'至哉坤元,万物资生,乃顺承天',image:'地势坤,君子以厚德载物',lines:['履霜,坚冰至','直方大,不习无不利','含章可贞,或从王事','括囊,无咎无誉','黄裳,元吉','龙战于野,其血玄黄']},
'8,4':{name:'地雷复',judgment:'复亨。出入无疾,朋来无咎',image:'雷在地中,复。先王以至日闭关',lines:['不远复,无祗悔','休复,吉','频复,厉','中行独复','敦复,无悔','迷复,凶有灾眚']},
'8,2':{name:'地泽临',judgment:'临,刚浸而长,说而顺',image:'泽上有地,临。君子以教思无穷容保民无疆',lines:['咸临,贞吉','咸临,吉无不利','甘临,无攸利','至临,无咎','知临,大君之宜','敦临,吉无咎']},
'8,1':{name:'地天泰',judgment:'泰,小往大来,吉亨。天地交而万物通',image:'天地交,泰。后以财成天地之道',lines:['拔茅茹,以其汇','包荒,用冯河','无平不陂,无往不复','翩翩,不富以其邻','帝乙归妹,以祉元吉','城复于隍,勿用师']},
'4,1':{name:'雷天大壮',judgment:'大壮,大者壮也。刚以动,故壮',image:'雷在天上,大壮。君子以非礼勿履',lines:['壮于趾,征凶','贞吉','小人用壮,君子用罔','贞吉,悔亡','丧羊于易,无悔','羝羊触藩,不能退']},
'2,1':{name:'泽天夬',judgment:'夬,决也,刚决柔也。健而说,决而和',image:'泽上于天,夬。君子以施禄及下',lines:['壮于前趾,往不胜为咎','惕号,莫夜有戎','壮于頄,有凶','臀无肤,其行次且','苋陆夬夬,中行无咎','无号,终有凶']},
'6,1':{name:'水天需',judgment:'需,须也。险在前也,刚健而不陷',image:'云上于天,需。君子以饮食宴乐',lines:['需于郊,利用恒','需于沙,小有言','需于泥,致寇至','需于血,出自穴','需于酒食,贞吉','入于穴,有不速之客三人来']},
'6,8':{name:'水地比',judgment:'比,吉也。比,辅也,下顺从也',image:'地上有水,比。先王以建万国亲诸侯',lines:['有孚比之,无咎','比之自内,贞吉','比之匪人','外比之,贞吉','显比,王用三驱','比之无首,凶']},
// 兑宫八卦 (57-64)
'2,2':{name:'兑为泽',judgment:'兑,说也。刚中而柔外,说以利贞',image:'丽泽,兑。君子以朋友讲习',lines:['和兑,吉','孚兑,吉','来兑,凶','商兑未宁,介疾有喜','孚于剥,有厉','引兑']},
'2,6':{name:'泽水困',judgment:'困,刚掩也。险以说,困而不失其所',image:'泽无水,困。君子以致命遂志',lines:['臀困于株木,入于幽谷','困于酒食,朱绂方来','困于石,据于蒺藜','来徐徐,困于金车','劓刖,困于赤绂','困于葛藟,于臲兀']},
'2,8':{name:'泽地萃',judgment:'萃,聚也。顺以说,刚中而应',image:'泽上于地,萃。君子以除戎器戒不虞',lines:['有孚不终,乃乱乃萃','引吉,无咎','萃如嗟如,无攸利','大吉,无咎','萃有位,无咎','赍咨涕洟,无咎']},
'2,7':{name:'泽山咸',judgment:'咸,感也。柔上而刚下,二气感应以相与',image:'山上有泽,咸。君子以虚受人',lines:['咸其拇','咸其腓,凶','咸其股,执其随','贞吉,悔亡','咸其脢,无悔','咸其辅颊舌']},
'6,7':{name:'水山蹇',judgment:'蹇,难也,险在前也。见险而能止',image:'山上有水,蹇。君子以反身修德',lines:['往蹇,来誉','王臣蹇蹇,匪躬之故','往蹇,来反','往蹇,来连','大蹇,朋来','往蹇,来硕']},
'8,7':{name:'地山谦',judgment:'谦亨。天道下济而光明,地道卑而上行',image:'地中有山,谦。君子以裒多益寡称物平施',lines:['谦谦君子,用涉大川','鸣谦,贞吉','劳谦君子,有终吉','无不利,撝谦','不富以其邻,利用侵伐','鸣谦,利用行师']},
'4,7':{name:'雷山小过',judgment:'小过,小者过而亨也。过以利贞',image:'山上有雷,小过。君子以行过乎恭',lines:['飞鸟以凶','过其祖,遇其妣','弗过防之,从或戕之','无咎,弗过遇之','密云不雨,自我西郊','弗遇过之,飞鸟离之']},
'4,2':{name:'雷泽归妹',judgment:'归妹,天地之大义也。天地不交而万物不兴',image:'泽上有雷,归妹。君子以永终知敝',lines:['归妹以娣,跛能履','眇能视,利幽人之贞','归妹以须,反归以娣','归妹愆期,迟归有时','帝乙归妹,其君之袂','女承筐无实,士刲羊无血']},
};
// Generate all 64 hexagrams
function buildHexagramMap() {
const map: Record<string, HexagramInfo> = {};
for (let upper = 1; upper <= 8; upper++) {
for (let lower = 1; lower <= 8; lower++) {
const key = `${upper},${lower}`;
const hName = H[key];
if (hName) {
map[key] = {
number: (upper - 1) * 8 + lower,
name: hName.name,
upperTrigram: TRIGRAMS[upper],
lowerTrigram: TRIGRAMS[lower],
changingLine: 0,
interpretation: hName.judgment,
judgment: hName.judgment,
image: hName.image,
lines: hName.lines,
};
}
}
}
return map;
}
const HEXAGRAM_MAP = buildHexagramMap();
/** Calculate Plum Blossom I-Ching from date and time */
export function calculatePlumBlossom(
year: number,
month: number,
day: number,
hour: number = 12,
): PlumBlossomResult {
// Use full numbers for more entropy
const yearNum = year;
const monthNum = month;
const dayNum = day;
const hourIndex = Math.floor(((hour + 1) % 24) / 2); // 0-11 地支时辰
// Standard Plum Blossom formula:
// Upper trigram: (year + month + day) % 8 → 0-7 → +1 → 1-8
const upperIdx = ((yearNum + monthNum + dayNum) % 8) + 1;
// Lower trigram: (month + day + hourIndex + 1) % 8 + 1
const lowerIdx = ((monthNum + dayNum + hourIndex + 1) % 8) + 1;
// Changing line: (year + month + day + hourIndex + 1) % 6 + 1
const changingLine = ((yearNum + monthNum + dayNum + hourIndex + 1) % 6) + 1;
const upperTrigram = TRIGRAMS[upperIdx];
const lowerTrigram = TRIGRAMS[lowerIdx];
// Original hexagram
const originalKey = `${upperIdx},${lowerIdx}`;
const originalHexagram = HEXAGRAM_MAP[originalKey] || createFallbackHexagram(upperIdx, lowerIdx, changingLine);
// Transformed hexagram (after changing line)
let transUpperIdx = upperIdx;
let transLowerIdx = lowerIdx;
// Changing line 1-3 affects lower trigram, 4-6 affects upper
if (changingLine >= 4) {
transUpperIdx = flipTrigramLine(upperIdx, changingLine - 3);
} else {
transLowerIdx = flipTrigramLine(lowerIdx, changingLine);
}
const transKey = `${transUpperIdx},${transLowerIdx}`;
const transformedHexagram = HEXAGRAM_MAP[transKey] || createFallbackHexagram(transUpperIdx, transLowerIdx, 0);
// Mutual hexagram (互卦): 2-4 lines → lower, 3-5 lines → upper
// Simplified: use middle two trigrams
const mutualUpperIdx = lowerIdx; // simplified
const mutualLowerIdx = upperIdx; // simplified
const mutualKey = `${mutualUpperIdx},${mutualLowerIdx}`;
const mutualHexagram = HEXAGRAM_MAP[mutualKey] || null;
// Constitution (体卦) and Function (用卦)
// 体卦 = lower trigram, 用卦 = upper trigram
const constitution = lowerTrigram.element;
const function_ = upperTrigram.element;
const relationship = getElementRelationship(constitution, function_);
return {
originalHexagram: { ...originalHexagram, changingLine },
transformedHexagram,
mutualHexagram,
upperTrigram,
lowerTrigram,
changingLine,
constitution,
function: function_,
relationship,
};
}
function flipTrigramLine(trigramIdx: number, line: number): number {
// Flip a single line of the trigram
// Trigrams encoded as bits: 乾111=7, 兑110=6, 离101=5, 震100=4, 巽011=3, 坎010=2, 艮001=1, 坤000=0
const encoding = [0, 7, 6, 5, 4, 3, 2, 1, 0]; // index → bit pattern
let bits = encoding[trigramIdx] || 0;
bits ^= (1 << (line - 1)); // flip the line
// Map back
const decoding = [8, 7, 6, 2, 5, 3, 4, 1]; // bit pattern → index
return decoding[bits] || trigramIdx;
}
function getElementRelationship(body: string, func: string): string {
const cycle: Record<string, string> = { '木': '火', '火': '土', '土': '金', '金': '水', '水': '木' };
const reverse: Record<string, string> = { '木': '水', '水': '金', '金': '土', '土': '火', '火': '木' };
if (body === func) return '比和(体用相同,诸事顺利)';
if (cycle[body] === func) return '体生用(泄气,宜守不宜攻)';
if (reverse[body] === func) return '用生体(得力,有贵人相助)';
if (cycle[func] === body) return '用克体(受制,诸事不顺)';
if (reverse[func] === body) return '体克用(主动,需付出努力)';
return '体用相生';
}
function createFallbackHexagram(upper: number, lower: number, changingLine: number): HexagramInfo {
const ut = TRIGRAMS[upper];
const lt = TRIGRAMS[lower];
return {
number: (upper - 1) * 8 + lower,
name: `${ut.name}${lt.name}`,
upperTrigram: ut,
lowerTrigram: lt,
changingLine,
interpretation: '此卦象需结合具体事理参详',
judgment: '',
image: '',
lines: ['','','','','',''],
};
}
@@ -0,0 +1,116 @@
import {
HeavenStem,
EarthBranch,
} from 'tyme4ts';
/** Relationship classification for branch interactions */
export interface BranchRelationship {
combine: boolean; // 六合
threeCombine: boolean; // 三合
opposite: boolean; // 六冲
harm: boolean; // 六害
punish: boolean; // 相刑
formation: string | null; // 三合局名称
}
/** Three-combine formations (三合局) */
const THREE_COMBINES: Record<string, { branches: string[]; name: string; element: string }> = {
'水局': { branches: ['申', '子', '辰'], name: '水局', element: '水' },
'木局': { branches: ['亥', '卯', '未'], name: '木局', element: '木' },
'火局': { branches: ['寅', '午', '戌'], name: '火局', element: '火' },
'金局': { branches: ['巳', '酉', '丑'], name: '金局', element: '金' },
};
/** Check branch relationships between two earth branches */
export function getBranchRelationship(branchA: string, branchB: string): BranchRelationship {
const a = EarthBranch.fromName(branchA);
const b = EarthBranch.fromName(branchB);
// Six combine (六合): each branch's combine partner
const combinePartner = a.getCombine();
const hasCombine = combinePartner ? combinePartner.getName() === b.getName() : false;
// Opposite (六冲): each branch's opposite
const oppositePartner = a.getOpposite();
const hasOpposite = oppositePartner ? oppositePartner.getName() === b.getName() : false;
// Harm (六害): each branch's harm partner
let hasHarm = false;
try {
const harmPartner = a.getHarm();
hasHarm = harmPartner ? harmPartner.getName() === b.getName() : false;
} catch { /* harm may not be available */ }
// Three combine (三合): check if both branches are in the same formation
let hasThreeCombine = false;
let formation: string | null = null;
for (const [name, info] of Object.entries(THREE_COMBINES)) {
if (info.branches.includes(branchA) && info.branches.includes(branchB)) {
hasThreeCombine = true;
formation = name;
break;
}
}
// Punish (相刑)
const hasPunish = checkPunish(branchA, branchB);
return {
combine: hasCombine,
threeCombine: hasThreeCombine,
opposite: hasOpposite,
harm: hasHarm,
punish: hasPunish,
formation,
};
}
/** Check for punishment relationship between two branches */
function checkPunish(a: string, b: string): boolean {
// Self punishment (自刑)
const selfPunish = ['辰', '午', '酉', '亥'];
if (a === b && selfPunish.includes(a)) return true;
// Classic punishment pairs
const punishPairs: [string, string][] = [
['寅', '巳'], ['巳', '申'], ['申', '寅'], // 无恩之刑
['丑', '戌'], ['戌', '未'], ['未', '丑'], // 恃势之刑
['子', '卯'], ['卯', '子'], // 无礼之刑
];
return punishPairs.some(([x, y]) => x === a && y === b);
}
/** Get Ten Star relationship between two heavenly stems */
export function getTenStarRelationship(subjectStem: string, objectStem: string): string {
try {
const s = HeavenStem.fromName(subjectStem);
const o = HeavenStem.fromName(objectStem);
return s.getTenStar(o).getName();
} catch {
return '';
}
}
/** Check if two heavenly stems combine (天干合) */
export function checkStemCombine(stemA: string, stemB: string): boolean {
const combinePairs: Record<string, string> = {
'甲': '己', '己': '甲',
'乙': '庚', '庚': '乙',
'丙': '辛', '辛': '丙',
'丁': '壬', '壬': '丁',
'戊': '癸', '癸': '戊',
};
return combinePairs[stemA] === stemB;
}
/** Check if two heavenly stems oppose (天干冲) */
export function checkStemOpposite(stemA: string, stemB: string): boolean {
const oppositePairs: Record<string, string> = {
'甲': '庚', '庚': '甲',
'乙': '辛', '辛': '乙',
'丙': '壬', '壬': '丙',
'丁': '癸', '癸': '丁',
};
return oppositePairs[stemA] === stemB;
}
@@ -0,0 +1,361 @@
/**
* 八字神煞 (Shen Sha)
* 神煞是固定规则查表 + 四柱匹配的标记,规则来源《三命通会》《渊海子平》。
* 注意:部分神煞网上存在版本差异,本模块采用主流排盘工具通行版本。
*/
import type { EightCharInfo, PillarInfo } from '../types/bazi';
export interface ShenshaInfo {
name: string;
/** 吉/凶/中性 */
type: '吉' | '凶' | '中性';
/** 分类:贵人/文星/财禄/感情/变动/威权/灾煞/孤独/格局 */
category: string;
/** 命中查法说明,如 "日干己查四支" */
anchors: string[];
/** 命中位置,如 ["年支","时支"] */
foundIn: string[];
/** 吉凶含义简述 */
description: string;
}
interface ShenshaRule {
name: string;
type: ShenshaInfo['type'];
category: string;
description: string;
/** 按天干(年干/日干)查四支 */
byStem?: Record<string, string[]>;
stemAnchors?: Array<'yearStem' | 'dayStem'>;
/** 按地支(年支/日支)查四支 */
byBranch?: Record<string, string[]>;
/** 按月支查四干 */
byMonthStem?: Record<string, string[]>;
/** 日柱特殊格局 */
byDayPillar?: string[];
}
const PILLAR_KEYS = ['year', 'month', 'day', 'hour'] as const;
const RULES: ShenshaRule[] = [
{
name: '天乙贵人',
type: '吉',
category: '贵人',
description: '最吉之神煞,主逢凶化吉、贵人相助',
stemAnchors: ['dayStem'],
byStem: {
'甲': ['丑', '未'], '戊': ['丑', '未'], '庚': ['丑', '未'],
'乙': ['子', '申'], '己': ['子', '申'],
'丙': ['亥', '酉'], '丁': ['亥', '酉'],
'壬': ['卯', '巳'], '癸': ['卯', '巳'],
'辛': ['寅', '午'],
},
},
{
name: '天厨贵人',
type: '吉',
category: '贵人',
description: '主口福与衣食之禄,衣食无忧',
byStem: {
'甲': ['巳'], '乙': ['午'], '丙': ['巳'], '丁': ['午'],
'戊': ['申'], '己': ['酉'], '庚': ['亥'], '辛': ['子'],
'壬': ['寅'], '癸': ['卯'],
},
},
{
name: '文昌贵人',
type: '吉',
category: '文星',
description: '主聪明好学、利科名学业',
byStem: {
'甲': ['巳'], '乙': ['午'], '丙': ['申'], '丁': ['酉'],
'戊': ['申'], '己': ['酉'], '庚': ['亥'], '辛': ['子'],
'壬': ['寅'], '癸': ['卯'],
},
},
{
name: '禄神',
type: '吉',
category: '财禄',
description: '日干之禄,主衣禄与财运根基',
byStem: {
'甲': ['寅'], '乙': ['卯'], '丙': ['巳'], '丁': ['午'],
'戊': ['巳'], '己': ['午'], '庚': ['申'], '辛': ['酉'],
'壬': ['亥'], '癸': ['子'],
},
},
{
name: '羊刃',
type: '凶',
category: '灾煞',
description: '刚烈冲动之星,主脾气刚猛,喜用则有权柄',
byStem: {
'甲': ['卯'], '乙': ['寅'], '丙': ['午'], '丁': ['巳'],
'戊': ['午'], '己': ['巳'], '庚': ['酉'], '辛': ['申'],
'壬': ['子'], '癸': ['亥'],
},
},
{
name: '金舆',
type: '吉',
category: '财禄',
description: '禄前二位,主婚恋顺遂、富贵安逸',
byStem: {
'甲': ['辰'], '乙': ['巳'], '丙': ['未'], '丁': ['申'],
'戊': ['未'], '己': ['申'], '庚': ['戌'], '辛': ['亥'],
'壬': ['丑'], '癸': ['寅'],
},
},
{
name: '天德贵人',
type: '吉',
category: '贵人',
description: '主心地仁慈、逢凶化吉,利化解灾厄',
byMonthStem: {
'寅': ['丁'], '卯': ['申'], '辰': ['壬'], '巳': ['辛'],
'午': ['亥'], '未': ['甲'], '申': ['癸'], '酉': ['寅'],
'戌': ['丙'], '亥': ['乙'], '子': ['巳'], '丑': ['庚'],
},
},
{
name: '月德贵人',
type: '吉',
category: '贵人',
description: '主福荫深厚、遇难呈祥',
byMonthStem: {
'寅': ['丙'], '午': ['丙'], '戌': ['丙'],
'申': ['壬'], '子': ['壬'], '辰': ['壬'],
'亥': ['甲'], '卯': ['甲'], '未': ['甲'],
'巳': ['庚'], '酉': ['庚'], '丑': ['庚'],
},
},
{
name: '桃花(咸池)',
type: '中性',
category: '感情',
description: '主异性缘、魅力与风流,利艺术才华',
byBranch: {
'申': ['酉'], '子': ['酉'], '辰': ['酉'],
'寅': ['卯'], '午': ['卯'], '戌': ['卯'],
'巳': ['午'], '酉': ['午'], '丑': ['午'],
'亥': ['子'], '卯': ['子'], '未': ['子'],
},
},
{
name: '驿马',
type: '中性',
category: '变动',
description: '主奔波变动、外出发展,动中得财',
byBranch: {
'申': ['寅'], '子': ['寅'], '辰': ['寅'],
'寅': ['申'], '午': ['申'], '戌': ['申'],
'巳': ['亥'], '酉': ['亥'], '丑': ['亥'],
'亥': ['巳'], '卯': ['巳'], '未': ['巳'],
},
},
{
name: '华盖',
type: '中性',
category: '孤独',
description: '主艺术天赋、悟性高,也主清高孤傲',
byBranch: {
'申': ['辰'], '子': ['辰'], '辰': ['辰'],
'寅': ['戌'], '午': ['戌'], '戌': ['戌'],
'巳': ['丑'], '酉': ['丑'], '丑': ['丑'],
'亥': ['未'], '卯': ['未'], '未': ['未'],
},
},
{
name: '劫煞',
type: '凶',
category: '灾煞',
description: '主突发变故、是非破财,宜谨慎',
byBranch: {
'申': ['巳'], '子': ['巳'], '辰': ['巳'],
'寅': ['亥'], '午': ['亥'], '戌': ['亥'],
'巳': ['寅'], '酉': ['寅'], '丑': ['寅'],
'亥': ['申'], '卯': ['申'], '未': ['申'],
},
},
{
name: '亡神',
type: '凶',
category: '灾煞',
description: '主心机深、谋略强,亦主口舌是非',
byBranch: {
'申': ['亥'], '子': ['亥'], '辰': ['亥'],
'寅': ['巳'], '午': ['巳'], '戌': ['巳'],
'巳': ['申'], '酉': ['申'], '丑': ['申'],
'亥': ['寅'], '卯': ['寅'], '未': ['寅'],
},
},
{
name: '将星',
type: '吉',
category: '威权',
description: '主领导才能与威严,掌权柄之星',
byBranch: {
'申': ['子'], '子': ['子'], '辰': ['子'],
'寅': ['午'], '午': ['午'], '戌': ['午'],
'巳': ['酉'], '酉': ['酉'], '丑': ['酉'],
'亥': ['卯'], '卯': ['卯'], '未': ['卯'],
},
},
{
name: '红鸾',
type: '吉',
category: '感情',
description: '主婚恋喜事、姻缘早成',
byBranch: {
'子': ['卯'], '丑': ['寅'], '寅': ['丑'], '卯': ['子'],
'辰': ['亥'], '巳': ['戌'], '午': ['酉'], '未': ['申'],
'申': ['未'], '酉': ['午'], '戌': ['巳'], '亥': ['辰'],
},
},
{
name: '天喜',
type: '吉',
category: '感情',
description: '红鸾对宫,主喜事临门、人缘佳',
byBranch: {
'子': ['酉'], '丑': ['申'], '寅': ['未'], '卯': ['午'],
'辰': ['巳'], '巳': ['辰'], '午': ['卯'], '未': ['寅'],
'申': ['丑'], '酉': ['子'], '戌': ['亥'], '亥': ['戌'],
},
},
{
name: '孤辰',
type: '凶',
category: '孤独',
description: '主孤独离群、六亲缘薄',
byBranch: {
'亥': ['寅'], '子': ['寅'], '丑': ['寅'],
'寅': ['巳'], '卯': ['巳'], '辰': ['巳'],
'巳': ['申'], '午': ['申'], '未': ['申'],
'申': ['亥'], '酉': ['亥'], '戌': ['亥'],
},
},
{
name: '寡宿',
type: '凶',
category: '孤独',
description: '主孤寡之象,婚恋宜晚',
byBranch: {
'亥': ['戌'], '子': ['戌'], '丑': ['戌'],
'寅': ['丑'], '卯': ['丑'], '辰': ['丑'],
'巳': ['辰'], '午': ['辰'], '未': ['辰'],
'申': ['未'], '酉': ['未'], '戌': ['未'],
},
},
{
name: '天罗',
type: '凶',
category: '灾煞',
description: '戌亥为天罗,主困顿束缚,男命尤忌',
byBranch: { '子': ['戌', '亥'], '丑': ['戌', '亥'], '寅': ['戌', '亥'], '卯': ['戌', '亥'], '辰': ['戌', '亥'], '巳': ['戌', '亥'], '午': ['戌', '亥'], '未': ['戌', '亥'], '申': ['戌', '亥'], '酉': ['戌', '亥'], '戌': ['戌', '亥'], '亥': ['戌', '亥'] },
},
{
name: '地网',
type: '凶',
category: '灾煞',
description: '辰巳为地网,主束缚波折,女命尤忌',
byBranch: { '子': ['辰', '巳'], '丑': ['辰', '巳'], '寅': ['辰', '巳'], '卯': ['辰', '巳'], '辰': ['辰', '巳'], '巳': ['辰', '巳'], '午': ['辰', '巳'], '未': ['辰', '巳'], '申': ['辰', '巳'], '酉': ['辰', '巳'], '戌': ['辰', '巳'], '亥': ['辰', '巳'] },
},
{
name: '魁罡',
type: '中性',
category: '格局',
description: '日柱魁罡,主刚毅果断、聪明果敢,忌刑冲',
byDayPillar: ['庚辰', '庚戌', '壬辰', '戊戌'],
},
{
name: '阴差阳错',
type: '凶',
category: '格局',
description: '日柱阴差阳错,主婚姻不顺、易生波折',
byDayPillar: ['丙子', '丁丑', '戊寅', '辛卯', '壬辰', '癸巳', '丙午', '丁未', '戊申', '辛酉', '壬戌', '癸亥'],
},
];
const PILLAR_LABELS: Record<string, string> = {
yearStem: '年干', monthStem: '月干', dayStem: '日干', hourStem: '时干',
yearBranch: '年支', monthBranch: '月支', dayBranch: '日支', hourBranch: '时支',
};
/** Analyze Shen Sha presence in a Bazi chart */
export function analyzeShensha(bazi: EightCharInfo): ShenshaInfo[] {
const pillars: Record<string, PillarInfo> = {
year: bazi.yearPillar, month: bazi.monthPillar, day: bazi.dayPillar, hour: bazi.hourPillar,
};
const stems: Record<string, string> = {};
const branches: Record<string, string> = {};
for (const k of PILLAR_KEYS) {
stems[`${k}Stem`] = pillars[k].heavenStem;
branches[`${k}Branch`] = pillars[k].earthBranch;
}
const dayGanzhi = pillars.day.ganzhi;
const result: ShenshaInfo[] = [];
for (const rule of RULES) {
const anchors: string[] = [];
const foundIn = new Set<string>();
if (rule.byStem) {
// 按规则指定的天干锚点(默认年干、日干)查四支
for (const anchorKey of rule.stemAnchors ?? ['yearStem', 'dayStem'] as const) {
const targets = rule.byStem[stems[anchorKey]];
if (!targets) continue;
const hits = PILLAR_KEYS.filter(k => targets.includes(branches[`${k}Branch`]));
if (hits.length > 0) {
anchors.push(PILLAR_LABELS[anchorKey]);
hits.forEach(k => foundIn.add(PILLAR_LABELS[`${k}Branch`]));
}
}
}
if (rule.byBranch) {
// 年支、日支查四支
for (const anchorKey of ['yearBranch', 'dayBranch'] as const) {
const targets = rule.byBranch[branches[anchorKey]];
if (!targets) continue;
const hits = PILLAR_KEYS.filter(k => targets.includes(branches[`${k}Branch`]));
if (hits.length > 0) {
anchors.push(PILLAR_LABELS[anchorKey]);
hits.forEach(k => foundIn.add(PILLAR_LABELS[`${k}Branch`]));
}
}
}
if (rule.byMonthStem) {
// 月支查四干
const targets = rule.byMonthStem[branches.monthBranch];
if (targets) {
const hits = PILLAR_KEYS.filter(k => targets.includes(stems[`${k}Stem`]));
if (hits.length > 0) {
anchors.push('月支');
hits.forEach(k => foundIn.add(PILLAR_LABELS[`${k}Stem`]));
}
}
}
if (rule.byDayPillar && rule.byDayPillar.includes(dayGanzhi)) {
anchors.push('日柱');
foundIn.add('日柱');
}
if (foundIn.size > 0) {
result.push({
name: rule.name,
type: rule.type,
category: rule.category,
anchors,
foundIn: [...foundIn],
description: rule.description,
});
}
}
return result;
}
+84
View File
@@ -0,0 +1,84 @@
// Types
export type {
DayInfo,
AlmanacInfo,
HourAlmanac,
PillarInfo,
HideStemInfo,
EightCharInfo,
DecadeFortuneInfo,
FortuneInfo,
ChildLimitInfo,
BaziFullResult,
PillarRelationship,
DailyFortuneResult,
} from './types';
// Transformers
export {
solarDayToDayInfo,
getMonthCalendar,
getDayInfo,
getTodayInfo,
} from './transformers/day';
export {
solarDayToAlmanacInfo,
getAlmanacInfo,
} from './transformers/almanac';
export {
birthInfoToBazi,
} from './transformers/bazi';
export type { BirthParams } from './transformers/bazi';
export {
getYearMonths,
} from './transformers/yearMonths';
export type { YearMonthInfo } from './transformers/yearMonths';
// Calculators
export {
getBranchRelationship,
getTenStarRelationship,
checkStemCombine,
checkStemOpposite,
} from './calculators/relationship';
export type { BranchRelationship } from './calculators/relationship';
export {
calculateDailyFortune,
} from './calculators/dailyMatch';
export {
analyzeElementBalance,
} from './calculators/elementStrength';
export type { ElementProfile } from './calculators/elementStrength';
export {
calculatePlumBlossom,
} from './calculators/plumBlossom';
export type {
TrigramInfo,
HexagramInfo,
PlumBlossomResult,
} from './calculators/plumBlossom';
export {
calculateBoneWeight,
} from './calculators/boneWeight';
export type { BoneWeightResult } from './calculators/boneWeight';
export {
getBuddhistFestival,
} from './calculators/buddhistDates';
export {
analyzeShensha,
} from './calculators/shensha';
export type { ShenshaInfo } from './calculators/shensha';
export {
analyzeFortuneGanzhi,
} from './calculators/fortuneLuck';
export type { FortuneLuck } from './calculators/fortuneLuck';
@@ -0,0 +1,186 @@
import {
SolarDay,
type LunarHour,
} from 'tyme4ts';
import type { AlmanacInfo, HourAlmanac } from '../types/almanac';
/** Transform a SolarDay into a full AlmanacInfo object */
export function solarDayToAlmanacInfo(solarDay: SolarDay): AlmanacInfo {
const lunarDay = solarDay.getLunarDay();
const sixtyCycle = lunarDay.getSixtyCycle();
const stem = sixtyCycle.getHeavenStem();
const branch = sixtyCycle.getEarthBranch();
// Duty officer
const duty = lunarDay.getDuty();
// Twelve star
const twelveStar = lunarDay.getTwelveStar();
const ecliptic = twelveStar.getEcliptic();
// Twenty-eight star
const twentyEightStar = lunarDay.getTwentyEightStar();
// Nine star
const nineStar = lunarDay.getNineStar();
// Six star
const sixStar = lunarDay.getSixStar ? lunarDay.getSixStar().getName() : '';
// Minor Ren
const minorRen = lunarDay.getMinorRen ? lunarDay.getMinorRen() : null;
// Fetus
const fetusDay = lunarDay.getFetusDay();
// Recommendations and avoidances
let recommends: string[] = [];
let avoids: string[] = [];
try {
recommends = lunarDay.getRecommends().map(r => r.getName());
avoids = lunarDay.getAvoids().map(a => a.getName());
} catch { /* may throw for some dates */ }
// Gods
const goodGods: string[] = [];
const badGods: string[] = [];
try {
const gods = lunarDay.getGods();
for (const god of gods) {
const luck = god.getLuck();
if (luck) {
if (luck.getName() === '吉') {
goodGods.push(god.getName());
} else {
badGods.push(god.getName());
}
}
}
} catch { /* may throw */ }
// Peng Zu taboos
const pengZu = sixtyCycle.getPengZu();
// Branch relationships
const opposite = branch.getOpposite();
const harm = branch.getHarm();
const combine = branch.getCombine();
// Evil direction
const ominous = branch.getOminous ? branch.getOminous() : null;
// Na Yin
const nayin = sixtyCycle.getSound().getName();
// Moon phase
let phase = '';
try {
const p = lunarDay.getPhase();
if (p) phase = p.getName();
} catch { /* no phase info */ }
// Hourly almanac
const hourDetails = buildHourlyAlmanac(lunarDay.getHours(), recommends, avoids);
// Determine duty luck
const dutyName = duty.getName();
const luckyDuties = ['除', '执', '危', '成', '开'];
const unluckyDuties = ['建', '满', '平', '破', '收', '闭'];
let dutyLuck: 'good' | 'bad' | 'neutral' = 'neutral';
if (luckyDuties.includes(dutyName)) dutyLuck = 'good';
else if (unluckyDuties.includes(dutyName)) dutyLuck = 'bad';
// Note: duty luck also depends on day branch; simplified here
return {
duty: dutyName,
dutyLuck,
twelveStar: {
name: twelveStar.getName(),
ecliptic: ecliptic ? ecliptic.getName() : '',
luck: ecliptic && ecliptic.getName() === '黄道' ? 'good' : 'bad',
},
twentyEightStar: {
name: twentyEightStar.getName(),
luck: twentyEightStar.getLuck()?.getName() === '吉' ? 'good' : 'bad',
animal: twentyEightStar.getAnimal()?.getName() || '',
},
nineStar: {
name: nineStar.getName(),
color: nineStar.getColor ? nineStar.getColor() : '',
element: nineStar.getElement ? nineStar.getElement().getName() : '',
},
sixStar,
minorRen: minorRen ? {
name: minorRen.getName(),
luck: minorRen.getLuck()?.getName() === '吉' ? 'good' : 'bad',
element: minorRen.getElement()?.getName() || '',
} : { name: '', luck: 'bad' as const, element: '' },
phase,
fetus: {
direction: fetusDay.getDirection()?.getName() || '',
side: fetusDay.getSide() !== undefined ? (fetusDay.getSide() as unknown as number === 0 ? '房内' : '房外') : '',
position: fetusDay.getName(),
},
recommends,
avoids,
goodGods,
badGods,
dayStem: stem.getName(),
dayBranch: branch.getName(),
dayGanzhi: sixtyCycle.getName(),
pengZu: pengZu.getName(),
pengZuStem: pengZu.getPengZuHeavenStem()?.getName() || '',
pengZuBranch: pengZu.getPengZuEarthBranch()?.getName() || '',
clash: `${opposite.getZodiac().getName()}(${opposite.getName()})`,
harm: harm ? `${harm.getZodiac().getName()}(${harm.getName()})` : '',
combine: combine ? `${combine.getZodiac().getName()}(${combine.getName()})` : '',
evilDirection: ominous ? ominous.getName() : '',
nayin,
hourDetails,
};
}
function buildHourlyAlmanac(hours: LunarHour[], _dayRecommends: string[], _dayAvoids: string[]): HourAlmanac[] {
return hours.map(hour => {
const hourSixtyCycle = hour.getSixtyCycle();
const twelveStar = hour.getTwelveStar ? hour.getTwelveStar() : null;
const nineStar = hour.getNineStar ? hour.getNineStar() : null;
let hourRecommends: string[] = [];
let hourAvoids: string[] = [];
try {
hourRecommends = hour.getRecommends().map(r => r.getName());
hourAvoids = hour.getAvoids().map(a => a.getName());
} catch { /* may throw */ }
const branchName = hourSixtyCycle.getEarthBranch().getName();
const hourNames: Record<string, string> = {
'子': '子时', '丑': '丑时', '寅': '寅时', '卯': '卯时',
'辰': '辰时', '巳': '巳时', '午': '午时', '未': '未时',
'申': '申时', '酉': '酉时', '戌': '戌时', '亥': '亥时',
};
const hourRanges: Record<string, string> = {
'子': '23:00-01:00', '丑': '01:00-03:00', '寅': '03:00-05:00',
'卯': '05:00-07:00', '辰': '07:00-09:00', '巳': '09:00-11:00',
'午': '11:00-13:00', '未': '13:00-15:00', '申': '15:00-17:00',
'酉': '17:00-19:00', '戌': '19:00-21:00', '亥': '21:00-23:00',
};
return {
branch: branchName,
name: hourNames[branchName] || branchName,
range: hourRanges[branchName] || '',
ganzhi: hourSixtyCycle.getName(),
recommends: hourRecommends,
avoids: hourAvoids,
twelveStar: twelveStar?.getName() || '',
nineStar: nineStar?.getName() || '',
};
});
}
/** Get AlmanacInfo for a specific date */
export function getAlmanacInfo(year: number, month: number, day: number): AlmanacInfo {
const solarDay = SolarDay.fromYmd(year, month, day);
return solarDayToAlmanacInfo(solarDay);
}
+241
View File
@@ -0,0 +1,241 @@
import {
SolarTime,
Gender,
AbstractChildLimitProvider,
ChildLimit,
HideHeavenStemType,
type SolarTerm,
YinYang,
type SixtyCycle,
type HeavenStem,
} from 'tyme4ts';
import type {
PillarInfo,
HideStemInfo,
DecadeFortuneInfo,
FortuneInfo,
BaziFullResult,
} from '../types/bazi';
export interface BirthParams {
year: number;
month: number;
day: number;
hour: number;
minute: number;
gender: 'male' | 'female';
/** 八字流派:lateZiNextDay=晚子时算次日(23点换日,默认);earlyZiSameDay=晚子时算当日(0点换日) */
ziSect?: 'lateZiNextDay' | 'earlyZiSameDay';
}
class PaipanChildLimitProvider extends AbstractChildLimitProvider {
getInfo(birthTime: SolarTime, term: SolarTerm) {
const termTime = term.getJulianDay().getSolarTime();
const endTime = birthTime.isAfter(termTime) ? birthTime : termTime;
const startTime = birthTime.isAfter(termTime) ? termTime : birthTime;
const startDate = Date.UTC(startTime.getYear(), startTime.getMonth() - 1, startTime.getDay());
const endDate = Date.UTC(endTime.getYear(), endTime.getMonth() - 1, endTime.getDay());
let dayDiff = Math.round((endDate - startDate) / 86_400_000);
let minuteDiff = (endTime.getHour() * 60 + endTime.getMinute()) - (startTime.getHour() * 60 + startTime.getMinute());
if (minuteDiff < 0) {
minuteDiff += 1_440;
dayDiff -= 1;
}
let minutes = dayDiff * 1_440 + minuteDiff;
const year = Math.floor(minutes / 4_320);
minutes -= year * 4_320;
const month = Math.floor(minutes / 360);
minutes -= month * 360;
const day = Math.floor(minutes / 12);
minutes -= day * 12;
const hour = minutes * 2;
return this.next(birthTime, year, month, day, hour, 0, 0);
}
}
/** Transform birth parameters into full Bazi result */
export function birthInfoToBazi(params: BirthParams): BaziFullResult {
const { year, month, day, hour, minute, gender, ziSect = 'lateZiNextDay' } = params;
const solarTime = SolarTime.fromYmdHms(year, month, day, hour, minute, 0);
// 早子时流派:23:00 后出生按当日早子时排四柱(日柱不换日),起运仍按真实出生时间
const ziHour =
ziSect === 'earlyZiSameDay' && hour >= 23
? SolarTime.fromYmdHms(year, month, day, 0, minute, 0)
: solarTime;
const lunarHour = ziHour.getLunarHour();
const eightChar = lunarHour.getEightChar();
const yearPillar = eightChar.getYear();
const monthPillar = eightChar.getMonth();
const dayPillar = eightChar.getDay();
const hourPillar = eightChar.getHour();
// Day master
const dayStem = dayPillar.getHeavenStem();
const dayMasterStem = dayStem.getName();
const dayMasterElement = dayStem.getElement().getName();
// Extract pillars with Ten Star relative to day master
const yearPillarInfo = extractPillarInfo(yearPillar, dayStem);
const monthPillarInfo = extractPillarInfo(monthPillar, dayStem);
const dayPillarInfo = extractPillarInfo(dayPillar, dayStem);
const hourPillarInfo = extractPillarInfo(hourPillar, dayStem);
// Fetal origin, fetal breath, own sign, body sign
const fetalOrigin = eightChar.getFetalOrigin();
const fetalBreath = eightChar.getFetalBreath();
const ownSign = eightChar.getOwnSign();
const bodySign = eightChar.getBodySign();
// Empty branches
const emptyTen = dayPillar.getTen();
const emptyBranches = emptyTen ? dayPillar.getExtraEarthBranches().map(b => b.getName()) : [];
// Child limit
ChildLimit.provider = new PaipanChildLimitProvider();
const genderEnum = gender === 'male' ? Gender.MAN : Gender.WOMAN;
const childLimit = ChildLimit.fromSolarTime(solarTime, genderEnum);
const startDecade = childLimit.getStartDecadeFortune();
// Decade fortunes (大运) — 10 decades
const decadeFortunes: DecadeFortuneInfo[] = [];
let currentDecade = startDecade;
for (let i = 0; i < 10 && currentDecade; i++) {
const sc = currentDecade.getSixtyCycle();
const decadeAnnualFortunes: FortuneInfo[] = [];
let currentFortune = currentDecade.getStartFortune();
for (let j = 0; j < 10 && currentFortune; j++) {
const fsc = currentFortune.getSixtyCycle();
decadeAnnualFortunes.push({
age: currentFortune.getAge(),
year: currentFortune.getLunarYear().getYear(),
ganzhi: fsc.getName(),
nayin: fsc.getSound().getName(),
});
currentFortune = currentFortune.next(1) as typeof currentFortune;
}
decadeFortunes.push({
index: i,
ganzhi: sc.getName(),
startAge: currentDecade.getStartAge(),
endAge: currentDecade.getEndAge(),
startYear: currentDecade.getStartLunarYear().getYear(),
endYear: currentDecade.getEndLunarYear().getYear(),
heavenStem: sc.getHeavenStem().getName(),
earthBranch: sc.getEarthBranch().getName(),
nayin: sc.getSound().getName(),
annualFortunes: decadeAnnualFortunes,
});
currentDecade = currentDecade.next(1) as typeof currentDecade;
}
const annualFortunes: FortuneInfo[] = decadeFortunes[0]?.annualFortunes ?? [];
return {
eightChar: {
birthDate: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
birthTime: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
gender,
yearPillar: yearPillarInfo,
monthPillar: monthPillarInfo,
dayPillar: dayPillarInfo,
hourPillar: hourPillarInfo,
dayMaster: `${dayMasterStem}${dayMasterElement}`,
dayMasterStem,
dayMasterElement,
fetalOrigin: fetalOrigin.getName(),
fetalBreath: fetalBreath.getName(),
ownSign: ownSign.getName(),
bodySign: bodySign.getName(),
emptyBranches,
emptyTen: emptyTen ? emptyTen.getName() : '',
},
childLimit: {
birthTime: childLimit.getStartTime().toString(),
startTime: childLimit.getEndTime().toString(),
endTime: childLimit.getEndTime().toString(),
yearCount: childLimit.getYearCount(),
monthCount: childLimit.getMonthCount(),
dayCount: childLimit.getDayCount(),
hourCount: childLimit.getHourCount(),
minuteCount: childLimit.getMinuteCount(),
forward: childLimit.isForward(),
startAge: childLimit.getStartAge(),
endAge: childLimit.getEndAge(),
},
decadeFortunes,
annualFortunes,
};
}
/** Extract pillar info from a SixtyCycle, with Ten Star relative to day master */
function extractPillarInfo(sixtyCycle: SixtyCycle, dayMasterStem: HeavenStem): PillarInfo {
const stem = sixtyCycle.getHeavenStem();
const branch = sixtyCycle.getEarthBranch();
const sound = sixtyCycle.getSound();
// Hidden stems
const hideStems: HideStemInfo[] = [];
try {
const allHideStems = branch.getHideHeavenStems();
if (allHideStems) {
for (const hs of allHideStems) {
let typeName = '';
const type = hs.getType();
if (type === HideHeavenStemType.MAIN) typeName = '本气';
else if (type === HideHeavenStemType.MIDDLE) typeName = '中气';
else if (type === HideHeavenStemType.RESIDUAL) typeName = '余气';
let tenStarName: string | null = null;
try {
tenStarName = hs.getHeavenStem().getTenStar(dayMasterStem).getName();
} catch { /* ten star may not be available */ }
hideStems.push({
stem: hs.getHeavenStem().getName(),
type: typeName,
tenStar: tenStarName,
});
}
}
} catch { /* hide stems may not be available */ }
// Terrain (十二长生)
let terrainName = '';
let terrainFortune: 'good' | 'bad' | 'neutral' = 'neutral';
try {
const terrain = stem.getTerrain(branch);
terrainName = terrain.getName();
const goodTerrain = ['长生', '冠带', '临官', '帝旺', '胎', '养'];
const badTerrain = ['死', '墓', '绝'];
if (goodTerrain.includes(terrainName)) terrainFortune = 'good';
else if (badTerrain.includes(terrainName)) terrainFortune = 'bad';
} catch { /* terrain might fail */ }
// Ten Star
let tenStarName: string | null = null;
try {
tenStarName = stem.getTenStar(dayMasterStem).getName();
} catch { /* ten star may not be available */ }
const yinYang = stem.getYinYang();
const branchYinYang = branch.getYinYang();
return {
ganzhi: sixtyCycle.getName(),
heavenStem: stem.getName(),
earthBranch: branch.getName(),
elementStem: stem.getElement().getName(),
elementBranch: branch.getElement().getName(),
yinYangStem: yinYang === YinYang.YANG ? 'yang' : 'yin',
yinYangBranch: branchYinYang === YinYang.YANG ? 'yang' : 'yin',
hideStems,
nayin: sound.getName(),
terrain: {
name: terrainName,
fortune: terrainFortune,
},
tenStar: tenStarName,
};
}
+201
View File
@@ -0,0 +1,201 @@
import {
SolarDay,
SolarMonth,
} from 'tyme4ts';
import type { DayInfo } from '../types/calendar';
import { getBuddhistFestival } from '../calculators/buddhistDates';
const SEASON_BY_MONTH: Record<number, string> = {
3: '春季', 4: '春季', 5: '春季',
6: '夏季', 7: '夏季', 8: '夏季',
9: '秋季', 10: '秋季', 11: '秋季',
12: '冬季', 1: '冬季', 2: '冬季',
};
/** Transform a SolarDay into a plain DayInfo object */
export function solarDayToDayInfo(solarDay: SolarDay): DayInfo {
const lunarDay = solarDay.getLunarDay();
const lunarMonth = lunarDay.getLunarMonth();
const week = solarDay.getWeek();
const constellation = solarDay.getConstellation();
const term = solarDay.getTerm();
const termDay = solarDay.getTermDay();
const month = solarDay.getSolarMonth().getMonth();
const solarYear = solarDay.getSolarMonth().getSolarYear().getYear();
// Solar term
const isTermDay = termDay !== null;
const solarTerm = isTermDay ? term.getName() : null;
let solarTermTime: string | null = null;
if (isTermDay) {
try {
const jd = term.getJulianDay();
if (jd) {
const st = jd.getSolarTime();
solarTermTime = `${String(st.getHour()).padStart(2,'0')}:${String(st.getMinute()).padStart(2,'0')}`;
}
} catch { /* time not available */ }
}
// Season + term progress (every day belongs to a solar term)
const season = SEASON_BY_MONTH[month] || '';
let currentSolarTerm: string | null = null;
let termDayIndex: number | null = null;
let nextSolarTerm: string | null = null;
let daysToNextTerm: number | null = null;
try {
const currentTerm = solarDay.getTerm();
const nextTerm = currentTerm.next(1);
currentSolarTerm = currentTerm.getName();
termDayIndex = solarDay.subtract(currentTerm.getSolarDay()) + 1;
nextSolarTerm = nextTerm.getName();
daysToNextTerm = nextTerm.getSolarDay().subtract(solarDay);
} catch { /* term may fail */ }
// Julian day, Buddhist era, Islamic/Hijri date
let julianDay: number | null = null;
try { julianDay = solarDay.getJulianDay().getDay(); } catch { /* */ }
const buddhistYear = solarYear + 543;
let hijriDate: string | null = null;
try {
const hd = solarDay.getHijriDay();
const hm = hd.getHijriMonth();
hijriDate = `${hm.getHijriYear().getYear()}年${String(hm.getIndexInYear()).padStart(2, '0')}月${String(hd.getDay()).padStart(2, '0')}日`;
} catch { /* */ }
const buddhistFestival = getBuddhistFestival(lunarMonth.getMonth(), lunarDay.getDay());
// Phenology
let phenology: string | null = null;
try {
const pd = solarDay.getPhenologyDay();
if (pd) phenology = pd.getPhenology().getName();
} catch { /* not available for all dates */ }
// Dog days
let dogDay: string | null = null;
try {
const dd = solarDay.getDogDay();
if (dd) dogDay = dd.getDog().getName();
} catch { /* not in dog days */ }
// Nine-day cold
let nineDay: string | null = null;
try {
const nd = solarDay.getNineDay();
if (nd) nineDay = nd.getNine().getName();
} catch { /* not in nine days */ }
// Moon phase
let moonPhase: string | null = null;
try {
const phase = solarDay.getPhase();
if (phase) moonPhase = phase.getName();
} catch { /* not available */ }
// Festivals
let lunarFestival: string | null = null;
try {
const lf = lunarDay.getFestival();
if (lf) lunarFestival = lf.getName();
} catch { /* no festival */ }
let solarFestival: string | null = null;
try {
const sf = solarDay.getFestival();
if (sf) solarFestival = sf.getName();
} catch { /* no festival */ }
let legalHoliday: { name: string; isWork: boolean } | null = null;
try {
const lh = solarDay.getLegalHoliday();
if (lh) legalHoliday = { name: lh.getName(), isWork: lh.isWork() };
} catch { /* not a legal holiday */ }
// Gan-Zhi
const daySixtyCycle = lunarDay.getSixtyCycle();
const yearSixtyCycle = lunarDay.getYearSixtyCycle();
const monthSixtyCycle = lunarDay.getMonthSixtyCycle();
// Today check
const now = new Date();
const isToday =
solarDay.getSolarMonth().getSolarYear().getYear() === now.getFullYear() &&
solarDay.getSolarMonth().getMonth() === now.getMonth() + 1 &&
solarDay.getDay() === now.getDate();
const weekDayIndex = week.getIndex();
const isWeekend = weekDayIndex === 0 || weekDayIndex === 6;
return {
solarDate: `${solarDay.getSolarMonth().getSolarYear().getYear()}-${String(solarDay.getSolarMonth().getMonth()).padStart(2, '0')}-${String(solarDay.getDay()).padStart(2, '0')}`,
solarDay: solarDay.getDay(),
solarMonth: solarDay.getSolarMonth().getMonth(),
solarYear: solarDay.getSolarMonth().getSolarYear().getYear(),
weekDay: week.getName(),
weekDayIndex,
constellation: constellation.getName(),
solarTerm,
solarTermTime,
isTermDay,
currentSolarTerm,
season,
termDayIndex,
nextSolarTerm,
daysToNextTerm,
julianDay,
buddhistYear,
hijriDate,
buddhistFestival,
phenology,
dogDay,
nineDay,
lunarYear: lunarMonth.getLunarYear().getYear(),
lunarMonth: lunarMonth.getMonth(),
lunarMonthName: lunarMonth.getName(),
lunarDay: lunarDay.getDay(),
lunarDayName: lunarDay.getName(),
isLeapMonth: lunarMonth.isLeap(),
lunarYearGanzhi: yearSixtyCycle.getName(),
lunarMonthGanzhi: monthSixtyCycle.getName(),
lunarDayGanzhi: daySixtyCycle.getName(),
zodiac: daySixtyCycle.getEarthBranch().getZodiac().getName(),
lunarFestival,
solarFestival,
legalHoliday,
moonPhase,
isToday,
isWeekend,
dayOfWeek: weekDayIndex,
};
}
/** Get calendar days for a month as a 2D array (weeks × days) */
export function getMonthCalendar(year: number, month: number, weekStart: 0 | 1 = 0): DayInfo[][] {
const solarMonth = SolarMonth.fromYm(year, month);
const weekCount = solarMonth.getWeekCount(weekStart);
const weeks = solarMonth.getWeeks(weekStart);
const result: DayInfo[][] = [];
for (let w = 0; w < weekCount; w++) {
const week = weeks[w];
const days = week.getDays();
const row: DayInfo[] = [];
for (const day of days) {
row.push(solarDayToDayInfo(day));
}
result.push(row);
}
return result;
}
/** Get DayInfo for a specific date */
export function getDayInfo(year: number, month: number, day: number): DayInfo {
const solarDay = SolarDay.fromYmd(year, month, day);
return solarDayToDayInfo(solarDay);
}
/** Get today's DayInfo */
export function getTodayInfo(): DayInfo {
const now = new Date();
return getDayInfo(now.getFullYear(), now.getMonth() + 1, now.getDate());
}
@@ -0,0 +1,59 @@
/**
* 流月计算:按节气月(立春起 12 节)划分某公历年的 12 个流月
*/
import { SolarTerm } from 'tyme4ts';
import { getDayInfo } from './day';
export interface YearMonthInfo {
/** 0-11,从寅月(正月/立春)起 */
index: number;
/** 正月..腊月 */
name: string;
/** 起始公历月 */
solarMonth: number;
/** 节日期 YYYY-MM-DD */
startDate: string;
/** 月末 YYYY-MM-DD(下一节前一天) */
endDate: string;
/** 月柱干支 */
ganzhi: string;
}
// SolarTerm 索引:冬至0 小寒1 大寒2 立春3 雨水4 惊蛰5 春分6 清明7 谷雨8 立夏9 小满10 芒种11 夏至12 小暑13 大暑14 立秋15 处暑16 白露17 秋分18 寒露19 霜降20 立冬21 小雪22 大雪23
const JIE_INDICES = [3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 1]; // 立春..小寒(次年)
const MONTH_NAME_BY_BRANCH: Record<string, string> = {
'寅': '正月', '卯': '二月', '辰': '三月', '巳': '四月', '午': '五月', '未': '六月',
'申': '七月', '酉': '八月', '戌': '九月', '亥': '十月', '子': '冬月', '丑': '腊月',
};
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function toDateStr(day: { getYear(): number; getMonth(): number; getDay(): number }): string {
return `${day.getYear()}-${pad(day.getMonth())}-${pad(day.getDay())}`;
}
/** Get the 12 节气月(流月)of a solar year, starting from 立春 */
export function getYearMonths(year: number): YearMonthInfo[] {
const months: YearMonthInfo[] = [];
for (let k = 0; k < 12; k++) {
const termYear = k === 11 ? year + 1 : year;
const start = SolarTerm.fromIndex(termYear, JIE_INDICES[k]).getSolarDay();
const end = k === 11
? start
: SolarTerm.fromIndex(year, JIE_INDICES[k + 1]).getSolarDay().next(-1);
const di = getDayInfo(start.getYear(), start.getMonth(), start.getDay());
const branch = di.lunarMonthGanzhi[1];
months.push({
index: k,
name: MONTH_NAME_BY_BRANCH[branch] || di.lunarMonthName || `${k + 1}月`,
solarMonth: start.getMonth(),
startDate: toDateStr(start),
endDate: toDateStr(end),
ganzhi: di.lunarMonthGanzhi,
});
}
return months;
}
+89
View File
@@ -0,0 +1,89 @@
/** Full almanac (黄历) information for a single day */
export interface AlmanacInfo {
// Duty officer (建除十二值神)
duty: string;
dutyLuck: 'good' | 'bad' | 'neutral';
// Twelve star (黄道黑道十二神)
twelveStar: {
name: string;
ecliptic: string; // 黄道 or 黑道
luck: 'good' | 'bad';
};
// Twenty-eight lunar mansion (二十八星宿)
twentyEightStar: {
name: string;
luck: 'good' | 'bad';
animal: string;
};
// Nine star (九星)
nineStar: {
name: string;
color: string;
element: string;
};
// Six star (六曜)
sixStar: string;
// Minor Ren (小六壬)
minorRen: {
name: string;
luck: 'good' | 'bad';
element: string;
};
// Moon phase
phase: string;
// Fetus god (胎神)
fetus: {
direction: string;
side: string; // 房内/房外
position: string; // Full position description
};
// Recommendations and avoidances (宜忌)
recommends: string[];
avoids: string[];
// Gods (神煞)
goodGods: string[];
badGods: string[];
// Day stem-branch info
dayStem: string; // 天干 e.g. "甲"
dayBranch: string; // 地支 e.g. "子"
dayGanzhi: string; // 干支 e.g. "甲子"
// Peng Zu taboo (彭祖百忌)
pengZu: string;
pengZuStem: string; // 天干禁忌
pengZuBranch: string; // 地支禁忌
// Chong/Sha/Harm/Combine (冲煞害合)
clash: string; // 冲 e.g. "马(午)"
harm: string; // 害 e.g. "羊(未)"
combine: string; // 合 e.g. "牛(丑)"
evilDirection: string;// 煞 e.g. "北"
// Na Yin sound (纳音)
nayin: string;
// Hourly almanac
hourDetails: HourAlmanac[];
}
/** Hourly almanac for each of the 12 two-hour periods (时辰) */
export interface HourAlmanac {
branch: string; // 地支 e.g. "子"
name: string; // 时辰名 e.g. "子时"
range: string; // Time range e.g. "23:00-01:00"
ganzhi: string; // Hour pillar e.g. "甲子"
recommends: string[];
avoids: string[];
twelveStar: string;
nineStar: string;
}
+98
View File
@@ -0,0 +1,98 @@
/** A single pillar (柱) in the Bazi — year, month, day, or hour */
export interface PillarInfo {
ganzhi: string; // "甲子"
heavenStem: string; // "甲"
earthBranch: string; // "子"
elementStem: string; // Stem's five element "木"
elementBranch: string; // Branch's five element "水"
yinYangStem: 'yin' | 'yang';
yinYangBranch: 'yin' | 'yang';
hideStems: HideStemInfo[];
nayin: string; // Na Yin sound "海中金"
terrain: {
name: string; // 十二长生 stage
fortune: 'good' | 'bad' | 'neutral';
};
tenStar: string | null; // Ten Star relative to day master
}
/** Hidden stem within an earth branch (藏干) */
export interface HideStemInfo {
stem: string; // "甲"
type: string; // "本气" | "中气" | "余气"
tenStar: string | null; // Ten Star relative to day master
}
/** Complete Bazi (八字) result for a birth date/time */
export interface EightCharInfo {
birthDate: string; // ISO date
birthTime: string; // HH:mm
gender: 'male' | 'female';
yearPillar: PillarInfo;
monthPillar: PillarInfo;
dayPillar: PillarInfo; // Day master pillar
hourPillar: PillarInfo;
// Derived
dayMaster: string; // "甲木" — day stem + element
dayMasterStem: string; // "甲"
dayMasterElement: string; // "木"
fetalOrigin: string; // 胎元
fetalBreath: string; // 胎息
ownSign: string; // 命宫
bodySign: string; // 身宫
emptyBranches: string[]; // 空亡 branches
emptyTen: string; // 旬
}
/** Decade fortune (大运) */
export interface DecadeFortuneInfo {
index: number;
ganzhi: string;
startAge: number;
endAge: number;
startYear: number;
endYear: number;
heavenStem: string;
earthBranch: string;
nayin: string;
/** 该大运内的 10 个流年 */
annualFortunes: FortuneInfo[];
}
/** Annual fortune (流年/小运) */
export interface FortuneInfo {
age: number;
year: number;
ganzhi: string;
nayin: string;
}
/** Child limit (起运) information */
export interface ChildLimitInfo {
/** Actual birth time used as the start of the child-limit interval. */
birthTime: string;
/** Actual first-luck transition time (交运时间). */
startTime: string;
endTime: string;
yearCount: number;
monthCount: number;
dayCount: number;
hourCount: number;
minuteCount: number;
forward: boolean; // 顺排/逆排
startAge: number;
endAge: number;
}
/** Full Bazi analysis result */
export interface BaziFullResult {
eightChar: EightCharInfo;
childLimit: ChildLimitInfo;
decadeFortunes: DecadeFortuneInfo[];
annualFortunes: FortuneInfo[];
/** True when the caller applied solar-time longitude correction to the birth time */
solarAdjusted?: boolean;
}
+76
View File
@@ -0,0 +1,76 @@
/** Core calendar day information — framework-agnostic plain object */
export interface DayInfo {
/** ISO date string YYYY-MM-DD */
solarDate: string;
solarDay: number;
solarMonth: number;
solarYear: number;
/** Chinese weekday name: 日,一,二,三,四,五,六 */
weekDay: string;
/** 0=Sunday ... 6=Saturday */
weekDayIndex: number;
/** Western zodiac constellation name */
constellation: string;
/** Solar term name if this day is a term day */
solarTerm: string | null;
/** Exact solar term time (HH:mm) */
solarTermTime: string | null;
/** Whether this day is the exact solar term transition day */
isTermDay: boolean;
/** The solar term this day belongs to (e.g. 大暑) */
currentSolarTerm: string | null;
/** Season name: 春季/夏季/秋季/冬季 */
season: string;
/** 1-based day index within the current solar term (节气第几天) */
termDayIndex: number | null;
/** Next solar term name */
nextSolarTerm: string | null;
/** Days until the next solar term */
daysToNextTerm: number | null;
/** Julian Day number (儒略日) */
julianDay: number | null;
/** Buddhist Era year (佛历年 = 公历年 + 543) */
buddhistYear: number | null;
/** Islamic/Hijri date as "1448年02月18日" */
hijriDate: string | null;
/** Buddhist festival name (农历) if applicable */
buddhistFestival: string | null;
/** 72 phenology name if applicable */
phenology: string | null;
/** Three periods (三伏) if applicable */
dogDay: string | null;
/** Nine-day cold period (数九) if applicable */
nineDay: string | null;
// Lunar calendar
lunarYear: number;
lunarMonth: number;
/** Chinese lunar month name e.g. "五月" */
lunarMonthName: string;
lunarDay: number;
/** Chinese lunar day name e.g. "初十", "廿一" */
lunarDayName: string;
isLeapMonth: boolean;
/** Gan-Zhi of the lunar year e.g. "丙午" */
lunarYearGanzhi: string;
/** Gan-Zhi of the lunar month */
lunarMonthGanzhi: string;
/** Gan-Zhi of the lunar day */
lunarDayGanzhi: string;
/** Chinese zodiac animal */
zodiac: string;
// Festivals & holidays
lunarFestival: string | null;
solarFestival: string | null;
legalHoliday: { name: string; isWork: boolean } | null;
// Moon phase
moonPhase: string | null;
// Metadata
isToday: boolean;
isWeekend: boolean;
/** Day-of-week position within the month grid (0-based column) */
dayOfWeek: number;
}
+66
View File
@@ -0,0 +1,66 @@
/** Relationship between a single pillar in user's Bazi and day's Bazi */
export interface PillarRelationship {
pillar: 'year' | 'month' | 'day' | 'hour';
pillarLabel: string; // "年柱", "月柱", "日柱", "时柱"
userGanzhi: string;
dayGanzhi: string;
// Heaven stem relationships
stemTenStar: string; // Ten Star: day stem vs user stem
stemCombine: boolean; // 天干合
stemOpposite: boolean; // 天干冲
// Earth branch relationships
branchCombine: boolean; // 六合
branchThreeCombine: boolean; // 三合
branchOpposite: boolean; // 六冲
branchHarm: boolean; // 六害
branchPunish: boolean; // 相刑
branchFormation: string | null;// 三合局名
// Score contribution
score: number; // -10 to +10
}
/** Complete daily fortune result for a user on a specific day */
export interface DailyFortuneResult {
date: string; // ISO date
lunarDate: string; // Lunar date description
dayGanzhi: string; // Day's Gan-Zhi
/** Overall score from -100 to +100 */
overallScore: number;
/** Score level classification */
scoreLevel: 'great' | 'good' | 'fair' | 'poor' | 'bad';
/** Pillar-by-pillar relationship analysis */
pillarRelationships: PillarRelationship[];
/** Positive aspects of the day */
luckyAspects: string[];
/** Negative aspects / warnings */
unluckyAspects: string[];
/** Actionable suggestions */
suggestions: string[];
/** Primary affected life area */
affectedAreas: string[];
/** Lucky meta info for the day (personalized) */
luckyMeta: {
colors: string[];
numbers: number[];
direction: string;
element: string;
activity: string;
};
/** Category scores (-100 to +100) */
categoryScores: {
love: number;
career: number;
wealth: number;
health: number;
};
}
+15
View File
@@ -0,0 +1,15 @@
export type { DayInfo } from './calendar';
export type { AlmanacInfo, HourAlmanac } from './almanac';
export type {
PillarInfo,
HideStemInfo,
EightCharInfo,
DecadeFortuneInfo,
FortuneInfo,
ChildLimitInfo,
BaziFullResult,
} from './bazi';
export type {
PillarRelationship,
DailyFortuneResult,
} from './fortune';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022"]
},
"include": ["src"]
}

Some files were not shown because too many files have changed in this diff Show More