Author SHA1 Message Date
gouki 3b67d893b6 fix(ci): npm install 加 --legacy-peer-deps,规避 arborist 在 vitest peer 上的 edgesOut 崩溃
Build and Publish Commercial Web / build (push) Successful in 5m3s
2026-10-21 09:20:46 +00:00
gouki df63a06aea fix(ci): 前端构建改用 npm——core 为独立包无需 pnpm 工作区,绕开 runner 安装 pnpm 失败
Build and Publish Commercial Web / build (push) Failing after 3m10s
2026-10-21 09:04:51 +00:00
gouki a024d5dcf6 fix(ci): pnpm 改用 linuxstatic 单文件二进制,绕开 npm registry 安装
Build and Publish Commercial Web / build (push) Failing after 20s
2026-10-21 08:55:01 +00:00
gouki 98d6a67a5a fix(ci): pnpm 版本对齐 11——pnpm 9 不识别 allowBuilds,esbuild 构建脚本被跳过
Build and Publish Commercial Web / build (push) Failing after 58s
2026-10-21 08:43:17 +00:00
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7c7880bbd8 fix(ci): GOPROXY 与 npm 镜像源修复两条流水线的依赖下载失败
Build and Publish Commercial Web / build (push) Failing after 3m18s
Build and Publish Server / build (push) Successful in 9m33s
server-deploy 在 Install dependencies 失败、web-deploy 在 Install pnpm
失败——亚洲 runner 直连 proxy.golang.org / registry.npmjs.org 不稳定。
Go 侧加 goproxy.cn 优先回落官方源;pnpm 安装与前端依赖安装走
registry.npmmirror.com。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 08:34:04 +00:00
gouki 69b8907711 docs: 新增根 CHANGELOG 记录部署拆分与演示版改造 2026-10-18 21:28:22 +00:00
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 51d1a1999f feat: 小程序演示版改造 + 服务端安全加固
Build and Publish Commercial Web / build (push) Failing after 25s
Publish Mini Program Dev Version / publish (push) Successful in 1m9s
Build and Publish Server / build (push) Failing after 6m42s
- 许愿树移除付费许愿/商品选择/微信支付调用,全部走免费许愿
- 许愿发布前接入微信 msgSecCheck v2 内容安全检测(access_token 内存缓存)
- 许愿字数限制改为按字符计算(中文按 1 字计)
- 占卜页改名「民俗趣算」并弱化「算命」措辞,占卜/每日运势页加文化声明
- 生产环境 JWT_SECRET 缺失或为默认值/不足 32 位时拒绝启动

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-18 21:25:24 +00:00
goukiandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 12fa2b0d19 refactor(server): 拆分 cmd/mini 与 cmd/web 两个部署入口
Build and Publish Commercial Web / build (push) Failing after 1m25s
Build and Publish Server / build (push) Successful in 3m25s
小程序接口(lunar.neatcn.com)与商业版 Web(kepo.net)此前共用一个
二进制,导致小程序域名暴露商业后台与 Stripe Webhook,商业站点也
暴露了许愿树等小程序接口。现拆为两个入口:

- cmd/mini:微信登录、生辰档案、许愿树、百科、版本号
- cmd/web:邮箱登录、commerce、报告/订单、/admin 后台、商业前端
  静态托管与 SPA 回退
- internal/app 承载共用启动与路由注册;图片资源移至 internal/assets

CI:server-deploy.yml 改构建 cmd/mini 并随包移除后台静态资源;
新增 web-deploy.yml 构建 cmd/web + web-commercial 前端,发布
web-v* Release;deploy-watch.sh 支持 TAG_PREFIX/ASSET_NAME 变量;
新增 deploy-web.sh(systemd 版)与 kepo 一次性配置文档
.gitea/docs/PULL_DEPLOY_WEB.md。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-18 11:31:51 +00:00
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
gouki 241a04496b fix(term): 清理百度百科摘要的截断结尾
Build and Publish Server / build (push) Successful in 2m6s
Publish Mini Program Dev Version / publish (push) Successful in 3m3s
百度百科 BaikeLemmaCardApi 摘要以 ... 硬截断,出现半截话。
新增 trimTruncatedTail:去掉结尾省略号后,若末句不完整则
回退到最后一个完整句标点(。!?),避免「唐宋时期,冬至...」
这类戛然而止的结尾。仅依赖百度百科,不引入维基,线上可用。
2026-08-18 00:09:12 +00:00
gouki bff792a974 fix(home): 首页排版细节优化
- 宜忌弹窗字体 44→32rpx、收紧间距,20+ 条可完整显示
- 历史上的今天内页年份加粗、文字稍小,修正层级
- 节日速查外页3条/内页10条,佛历开启时并入排序
- 统一「更多」箭头为 >>
2026-08-18 00:08:54 +00:00
gouki 5a5388234a feat(mini): 节气详情排版优化 + 本地开发连线上数据
Publish Mini Program Dev Version / publish (push) Successful in 1m2s
- wiki 页节气详情排版:配图圆角阴影、涵义引言(米黄渐变+红竖条)、
  简介两端对齐、结构化字段列表(红点标签)分块展示
- wiki.js 提取 meaning 单独展示,extraList 排除避免重复
- request.js develop 环境指向线上(本地 8080 被占用且 localhost 受域名校验限制)
- project.config.json 关闭 urlCheck 便于本地开发连线上调试
2026-08-13 23:37:12 +00:00
gouki fec383f257 feat(wiki): 二十四节气详情抓取百度百科并图文展示
Publish Mini Program Dev Version / publish (push) Successful in 1m12s
Build and Publish Server / build (push) Successful in 8m24s
数据源:百度百科开放接口 BaikeLemmaCardApi(无反爬,返回结构化 JSON)
与「历史上的今天」(维基)完全解耦,维基代码原样保留可随时恢复:

服务端:
- WikiEntry 增加 image(配图)和 extra(结构化字段 JSON)字段
- 新建 BaikeTermSyncLog 独立日志表(不污染维基 WikiSyncLog)
- 新建 BaikeTermSyncService 独立同步服务(24 节气,间隔 1.2s)
- 启动时缺详情自动补齐,之后按配置间隔定时增量刷新
- 管理 API:GET/POST /admin/api/wiki/term-sync*

管理后台:
- 百科条目 Tab 顶部加节气同步卡片(X/24 + 状态 + 同步按钮)
- 条目编辑器加配图 URL(含预览)和节气扩展字段编辑

小程序:
- 百科页展开详情图文展示:配图 + 简介 + 物候/习俗/养生等分块

本地验证:24/24 节气抓取成功,image/extra 全部填充,维基数据不受影响
2026-08-13 00:31:43 +00:00
180 changed files with 21970 additions and 709 deletions
+137
View File
@@ -0,0 +1,137 @@
# kepo.net 商业版 Web 拉取式部署配置(一次性)
> 安全原则同 [PULL_DEPLOY.md](./PULL_DEPLOY.md):CI 只构建、不部署;任何 AI 与流水线都不允许 SSH 到服务器执行命令。
> 部署由 kepo 服务器本地定时任务轮询 Gitea Release 自动完成。
> 本文档所有操作均由管理员本人在服务器终端执行,不依赖 AI 远程执行。
## 架构
```
push main → Gitea Actions 构建 cmd/web + web-commercial 前端 → web-deploy.tar.gz 发布为 web-v* Release
↓
kepo 服务器 cron(每分钟)运行 deploy-watch.sh(TAG_PREFIX=web-v)→ 发现新 release → HTTPS 拉取
↓
deploy-web.sh 原子切换 + systemctl restart lunar-web + 健康检查(失败自动回滚)
↓
Cloudflare (HTTPS 终结) → kepo 源站 :8080 lunar-web systemd 服务
```
## 与小程序接口部署的差异
| | lunar.neatcn.com | kepo.net |
|---|---|---|
| 二进制 | `cmd/mini` | `cmd/web` |
| Release 前缀 | `server-v*` | `web-v*` |
| 部署包 | `deploy.tar.gz` | `web-deploy.tar.gz` |
| 运行方式 | Docker 容器 | systemd 服务 `lunar-web` |
| 部署目录 | `/opt/lunar/production` | `/opt/lunar-web/production` |
## 一次性安装步骤(按序执行)
### 1. 准备目录与脚本
```bash
mkdir -p /opt/lunar-web/{production,scripts,backups}
# 从仓库复制脚本(任选其一):
# git clone 后 cp .gitea/scripts/{deploy-watch.sh,deploy-web.sh} /opt/lunar-web/scripts/
# 或直接下载原始文件写入
chmod +x /opt/lunar-web/scripts/*.sh
```
### 2. 生产环境变量
写入 `/opt/lunar-web/production/.env`,权限 `600`:
```ini
SERVER_ENV=production
SERVER_PORT=8080
PUBLIC_BASE_URL=https://kepo.net
WEB_APP_URL=https://kepo.net
CORS_ORIGINS=https://kepo.net,https://www.kepo.net
DB_TYPE=mysql
DB_HOST=<数据库地址>
DB_PORT=3306
DB_USER=<用户名>
DB_PASSWORD=<密码>
DB_NAME=lunar_web # 建议与小程序库分开;若暂时共用,填 lunar
JWT_SECRET=<强随机值,至少32位>
ADMIN_PASSWORD=<后台登录密码>
SMTP_HOST=smtp.qq.com
SMTP_PORT=465
SMTP_USERNAME=<QQ邮箱>
SMTP_PASSWORD=<SMTP授权码>
SMTP_FROM_EMAIL=z@kepo.net
SMTP_TLS_MODE=implicit
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
BINANCE_PAY_API_KEY=
BINANCE_PAY_SECRET_KEY=
BINANCE_PAY_WEBHOOK_PUBLIC_KEY=
```
> kepo 侧不需要 `MINI_APP_ID` / `MINI_APP_SECRET` / `WECHAT_PAY_*`;
> lunar 侧则不需要 SMTP / Stripe / Binance 变量。
### 3. systemd 服务
`/etc/systemd/system/lunar-web.service`:
```ini
[Unit]
Description=Lunar Commercial Web (kepo.net)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/lunar-web/production/current
EnvironmentFile=/opt/lunar-web/production/.env
ExecStart=/opt/lunar-web/production/current/bin/server
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
```bash
systemctl daemon-reload
systemctl enable lunar-web
# 首次启动前需要先手工放一版部署包,或等待第一次 Release 拉取
```
### 4. 定时拉取任务
crontab(`crontab -e`)添加:
```cron
* * * * * TAG_PREFIX=web-v ASSET_NAME=web-deploy.tar.gz \
STATE_FILE=/opt/lunar-web/production/.last-deployed-release \
DEPLOY_SCRIPT=/opt/lunar-web/scripts/deploy-web.sh \
LOG_FILE=/opt/lunar-web/deploy-watch.log \
/opt/lunar-web/scripts/deploy-watch.sh
```
首次运行后会自动部署最新 `web-v*` Release。日志:`/opt/lunar-web/deploy-watch.log`。
### 5. Cloudflare 回源
kepo.net 已走 Cloudflare。确认回源指向本机 8080(或 Nginx 反代到 8080)。
服务只监听 `127.0.0.1` 或由防火墙限制 8080 仅本机访问。
## 验证
```bash
curl -s http://127.0.0.1:8080/api/version # 应返回 version/buildTime/commitSha
curl -s -o /dev/null -w '%{http_code}' https://kepo.net/
curl -s -o /dev/null -w '%{http_code}' https://kepo.net/admin/
```
## 注意
- **数据库**:短期内两边共用 `lunar` 库(后台 `/admin` 在 kepo 侧统一查看订单与小程序数据)。后续如需分离,新建 `lunar_web` 库并改 `DB_NAME`。
- **回滚**:`deploy-web.sh` 健康检查失败会自动恢复 `current.old`;也可手动 `systemctl restart lunar-web`。
- **小程序端**:部署包结构不变(`deploy/bin/server`),doc79 上的 watcher 无需改动。
+12 -5
View File
@@ -13,6 +13,11 @@ set -u
GITEA_URL="${GITEA_URL:-https://gitea.neatcn.com}" GITEA_URL="${GITEA_URL:-https://gitea.neatcn.com}"
REPO="${REPO:-gouki/lunar-mini}" REPO="${REPO:-gouki/lunar-mini}"
# 同一脚本服务两种部署形态:
# 小程序接口(doc79):默认 server-v* + deploy.tar.gz
# 商业版 Web(kepo):TAG_PREFIX=web-v ASSET_NAME=web-deploy.tar.gz
TAG_PREFIX="${TAG_PREFIX:-server-v}"
ASSET_NAME="${ASSET_NAME:-deploy.tar.gz}"
STATE_FILE="${STATE_FILE:-/opt/lunar/production/.last-deployed-release}" STATE_FILE="${STATE_FILE:-/opt/lunar/production/.last-deployed-release}"
DEPLOY_SCRIPT="${DEPLOY_SCRIPT:-/opt/lunar/scripts/deploy.sh}" DEPLOY_SCRIPT="${DEPLOY_SCRIPT:-/opt/lunar/scripts/deploy.sh}"
LOG_FILE="${LOG_FILE:-/opt/lunar/deploy-watch.log}" LOG_FILE="${LOG_FILE:-/opt/lunar/deploy-watch.log}"
@@ -28,7 +33,7 @@ if [ -n "${GITEA_DEPLOY_TOKEN:-}" ]; then
AUTH_ARGS=(-H "Authorization: token ${GITEA_DEPLOY_TOKEN}") AUTH_ARGS=(-H "Authorization: token ${GITEA_DEPLOY_TOKEN}")
fi fi
# 查询最新 20 个 release,取 tag 前缀为 server-v 且带 deploy.tar.gz 附件的最新一个 # 查询最新 20 个 release,取 tag 前缀匹配且带部署包附件的最新一个
RELEASE_JSON=$(curl -fsS --connect-timeout 10 --max-time 30 \ RELEASE_JSON=$(curl -fsS --connect-timeout 10 --max-time 30 \
"${AUTH_ARGS[@]}" \ "${AUTH_ARGS[@]}" \
"$GITEA_URL/api/v1/repos/$REPO/releases?limit=20" \ "$GITEA_URL/api/v1/repos/$REPO/releases?limit=20" \
@@ -37,16 +42,18 @@ RELEASE_JSON=$(curl -fsS --connect-timeout 10 --max-time 30 \
exit 1 exit 1
} }
PARSED=$(python3 - "$WORK_DIR/releases.json" <<'PY' PARSED=$(TAG_PREFIX="$TAG_PREFIX" ASSET_NAME="$ASSET_NAME" python3 - "$WORK_DIR/releases.json" <<'PY'
import json, sys import json, os, sys
tag_prefix = os.environ["TAG_PREFIX"]
asset_name = os.environ["ASSET_NAME"]
releases = json.load(open(sys.argv[1])) releases = json.load(open(sys.argv[1]))
for r in releases: for r in releases:
if not r.get("tag_name", "").startswith("server-v"): if not r.get("tag_name", "").startswith(tag_prefix):
continue continue
if r.get("draft") or r.get("prerelease"): if r.get("draft") or r.get("prerelease"):
continue continue
for a in r.get("assets", []): for a in r.get("assets", []):
if a.get("name") == "deploy.tar.gz": if a.get("name") == asset_name:
print(r["id"]) print(r["id"])
print(r["tag_name"]) print(r["tag_name"])
print(a["id"]) print(a["id"])
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# 商业版 Web(kepo.net)自动部署脚本 - systemd 直接运行二进制
# 设计原则:拉取式部署,只在服务器本地执行,CI 不通过 SSH 触发
# 使用方法: ./deploy-web.sh <web-deploy.tar.gz>
#
# 目录约定:
# /opt/lunar-web/production/current 当前运行版本(systemd WorkingDirectory)
# /opt/lunar-web/production/.env 环境变量(systemd EnvironmentFile)
# /opt/lunar-web/backups 历史版本备份
#
# 可通过环境变量覆盖:SERVICE_NAME / DEPLOY_DIR / BACKUP_DIR / HEALTH_URL
set -e
DEPLOY_PACKAGE="$1"
SERVICE_NAME="${SERVICE_NAME:-lunar-web}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/lunar-web/production}"
BACKUP_DIR="${BACKUP_DIR:-/opt/lunar-web/backups}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8080/api/version}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# 回滚:恢复 current.old 并重启服务
rollback() {
if [ -d "$DEPLOY_DIR/current.old" ]; then
rm -rf "$DEPLOY_DIR/current"
mv "$DEPLOY_DIR/current.old" "$DEPLOY_DIR/current"
systemctl restart "$SERVICE_NAME" || log_error "回滚后服务重启失败,需人工介入"
log_warn "已回滚到上一版本"
else
log_error "无可回滚版本,需人工介入"
fi
}
if [ ! -f "$DEPLOY_PACKAGE" ]; then
log_error "部署包不存在: $DEPLOY_PACKAGE"
exit 1
fi
log_info "开始自动部署(商业版 Web)..."
log_info "部署包: $DEPLOY_PACKAGE"
log_info "systemd 服务: $SERVICE_NAME"
log_info "部署目录: $DEPLOY_DIR"
mkdir -p "$DEPLOY_DIR" "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/backup_${TIMESTAMP}.tar.gz"
if [ -d "$DEPLOY_DIR/current" ]; then
log_info "备份当前版本到: $BACKUP_FILE"
tar -czf "$BACKUP_FILE" -C "$DEPLOY_DIR" current/ || log_warn "备份失败,继续部署"
fi
# 解压到暂存目录(不碰 current,避免部署空窗)
log_info "解压部署包..."
TEMP_DIR=$(mktemp -d)
tar -xzf "$DEPLOY_PACKAGE" -C "$TEMP_DIR"
if [ ! -d "$TEMP_DIR/deploy" ]; then
log_error "部署包结构错误:缺少 deploy 目录"
rm -rf "$TEMP_DIR"
exit 1
fi
if [ ! -f "$TEMP_DIR/deploy/bin/server" ]; then
log_error "部署包缺少 bin/server 二进制"
rm -rf "$TEMP_DIR"
exit 1
fi
if [ ! -f "$TEMP_DIR/deploy/frontend/index.html" ]; then
log_error "部署包缺少 frontend/index.html(商业前端未构建?)"
rm -rf "$TEMP_DIR"
exit 1
fi
chmod +x "$TEMP_DIR/deploy/bin/server"
# 目录级交换,接近原子
log_info "部署新版本..."
mv "$TEMP_DIR/deploy" "$DEPLOY_DIR/current.new"
rm -rf "$DEPLOY_DIR/current.old"
if [ -d "$DEPLOY_DIR/current" ]; then
mv "$DEPLOY_DIR/current" "$DEPLOY_DIR/current.old"
fi
mv "$DEPLOY_DIR/current.new" "$DEPLOY_DIR/current"
rm -rf "$TEMP_DIR"
# 重启 systemd 服务
log_info "重启服务: $SERVICE_NAME"
if systemctl restart "$SERVICE_NAME"; then
log_info "服务重启成功"
else
log_error "服务重启失败,回滚到上一版本"
rollback
exit 1
fi
# 健康检查
log_info "执行健康检查: $HEALTH_URL"
HEALTHY=0
for i in $(seq 1 10); do
sleep 2
if curl -fsS --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
HEALTHY=1
break
fi
done
if [ "$HEALTHY" = "1" ]; then
log_info "健康检查通过"
curl -fsS --max-time 5 "$HEALTH_URL" && echo ""
rm -rf "$DEPLOY_DIR/current.old"
else
log_error "健康检查失败,回滚到上一版本"
journalctl -u "$SERVICE_NAME" --no-pager -n 50 || true
rollback
exit 1
fi
# 清理旧备份(保留最近 5 个)
ls -t "$BACKUP_DIR"/backup_*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -f
log_info "部署完成!"
log_info "备份文件: $BACKUP_FILE"
log_info "部署目录: $DEPLOY_DIR/current"
+7 -5
View File
@@ -60,6 +60,8 @@ jobs:
working-directory: server working-directory: server
run: | run: |
export HOME="$RUNNER_WORKSPACE" export HOME="$RUNNER_WORKSPACE"
# 国内 runner 优先 goproxy.cn,海外 runner 自动回落官方源
export GOPROXY="https://goproxy.cn,https://proxy.golang.org,direct"
"$LUNAR_GO" mod download "$LUNAR_GO" mod download
- name: Run tests - name: Run tests
@@ -85,11 +87,13 @@ jobs:
echo "Building server version: $VERSION" echo "Building server version: $VERSION"
# ldflags 注入版本信息;目标平台为 doc79(linux/amd64) # ldflags 注入版本信息;目标平台为 doc79(linux/amd64)
# 本流水线构建的是小程序接口二进制(cmd/mini);
# 商业版 Web(cmd/web)由 web-deploy.yml 单独构建发布
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$LUNAR_GO" build -ldflags "\ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$LUNAR_GO" build -ldflags "\
-X main.Version=$VERSION \ -X main.Version=$VERSION \
-X main.BuildTime=$BUILD_TIME \ -X main.BuildTime=$BUILD_TIME \
-X main.CommitSha=$COMMIT_SHORT" \ -X main.CommitSha=$COMMIT_SHORT" \
-o bin/server cmd/main.go -o bin/server ./cmd/mini
# 版本元数据随包分发,便于服务器侧追溯 # 版本元数据随包分发,便于服务器侧追溯
cat > bin/version.env <<EOF cat > bin/version.env <<EOF
@@ -107,10 +111,8 @@ jobs:
cp -r bin/ deploy/ cp -r bin/ deploy/
cp -r migrations/ deploy/ cp -r migrations/ deploy/
cp -r scripts/ deploy/ cp -r scripts/ deploy/
# 管理后台静态资源必须随包发布(服务以相对路径 ./web 提供) # 管理后台已迁移到商业版 Web 服务(cmd/web),
if [ -d "web" ]; then # 小程序接口二进制不再注册 /admin,无需随包发布 web/ 静态资源
cp -r web/ deploy/web/
fi
tar -czf deploy.tar.gz deploy/ tar -czf deploy.tar.gz deploy/
sha256sum deploy.tar.gz sha256sum deploy.tar.gz
+268
View File
@@ -0,0 +1,268 @@
name: Build and Publish Commercial Web
on:
push:
branches:
- main
paths:
- "server/**"
- "web/**"
- "web-commercial/**"
- ".gitea/workflows/web-deploy.yml"
jobs:
build:
# 标准标签:hk / bt-runner 均带 ubuntu-latest,任一空闲 runner 接单
runs-on: ubuntu-latest
steps:
# 精简 runner(Alpine)无 node,无法跑 action-checkout;直接用 git 浅克隆
- name: Checkout
env:
REPO_URL: ${{ gitea.server_url }}/${{ gitea.repository }}
REF_NAME: ${{ gitea.ref_name }}
run: |
git init -q .
git remote add origin "$REPO_URL"
git fetch -q --depth 20 origin "$REF_NAME"
git checkout -q FETCH_HEAD
git log --oneline -3
- name: Ensure curl available
run: |
if ! command -v curl >/dev/null 2>&1; then
if command -v apk >/dev/null 2>&1; then apk add --no-cache curl
elif command -v apt-get >/dev/null 2>&1; then apt-get update -qq && apt-get install -y -qq curl
elif command -v dnf >/dev/null 2>&1; then dnf install -y -q curl
else echo "无法安装 curl"; exit 1; fi
fi
curl --version | head -1
- name: Prepare Go toolchain
run: |
TOOLDIR="$RUNNER_WORKSPACE/.lunar-toolchain"
mkdir -p "$TOOLDIR"
if [ -x "$TOOLDIR/go/bin/go" ]; then
echo "复用缓存 Go 工具链"
else
wget -q --tries=2 --timeout=60 -O "$TOOLDIR/go.tgz" \
"https://go.dev/dl/go1.22.10.linux-amd64.tar.gz" \
|| wget -q --tries=2 --timeout=60 -O "$TOOLDIR/go.tgz" \
"https://mirrors.aliyun.com/golang/go1.22.10.linux-amd64.tar.gz"
rm -rf "$TOOLDIR/go"
tar -C "$TOOLDIR" -xzf "$TOOLDIR/go.tgz"
rm -f "$TOOLDIR/go.tgz"
fi
"$TOOLDIR/go/bin/go" version
echo "LUNAR_GO=$TOOLDIR/go/bin/go" >> "$GITEA_ENV"
- name: Prepare Node toolchain
run: |
TOOLDIR="$RUNNER_WORKSPACE/.lunar-toolchain"
mkdir -p "$TOOLDIR"
if [ -x "$TOOLDIR/node/bin/node" ] && "$TOOLDIR/node/bin/node" --version >/dev/null 2>&1; then
echo "复用缓存 Node 工具链"
else
rm -rf "$TOOLDIR/node" "$TOOLDIR/node.tgz"
mkdir -p "$TOOLDIR"
if ldd --version 2>&1 | grep -qi musl || [ ! -e /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 ]; then
NODE_DIST="node-v22.12.0-linux-x64-musl"
NODE_SHA="8d96de0293fc3978d09e2a3fee68c66bca4efcca90f52105e6f7463636e97308"
URLS="https://registry.npmmirror.com/-/binary/node-unofficial-builds/v22.12.0/$NODE_DIST.tar.gz
https://unofficial-builds.nodejs.org/download/release/v22.12.0/$NODE_DIST.tar.gz"
else
NODE_DIST="node-v22.12.0-linux-x64"
NODE_SHA="e05a4d65232ae2b27b3d77da2e368522fb46b923335b8e0d5f77624c32484044"
URLS="https://nodejs.org/dist/v22.12.0/$NODE_DIST.tar.gz
https://mirrors.aliyun.com/nodejs-release/v22.12.0/$NODE_DIST.tar.gz"
fi
DL_OK=""
for url in $URLS; do
echo "尝试下载: $url"
if wget -t 2 -T 120 -q -O "$TOOLDIR/node.tgz" "$url"; then
if [ "$(sha256sum "$TOOLDIR/node.tgz" | cut -d' ' -f1)" = "$NODE_SHA" ]; then
echo "下载成功且校验通过"
DL_OK=1
break
fi
echo "sha256 校验失败,尝试下一源"
else
echo "wget 下载失败,尝试下一源"
fi
rm -f "$TOOLDIR/node.tgz"
done
test -n "$DL_OK" || { echo "::error::所有下载源均失败或校验不通过"; exit 1; }
tar -C "$TOOLDIR" -xzf "$TOOLDIR/node.tgz"
mv "$TOOLDIR/$NODE_DIST" "$TOOLDIR/node"
rm -f "$TOOLDIR/node.tgz"
if [ "$NODE_DIST" = "node-v22.12.0-linux-x64-musl" ] && [ ! -e /usr/lib/libstdc++.so.6 ] && [ ! -e /lib/libstdc++.so.6 ]; then
if command -v apk >/dev/null 2>&1; then apk add --no-cache libstdc++ libgcc || echo "::warning::libstdc++/libgcc 安装失败"; fi
fi
fi
if ! "$TOOLDIR/node/bin/node" --version; then
echo "::error::Node 二进制无法执行"
rm -rf "$TOOLDIR/node"
exit 1
fi
echo "PATH=$TOOLDIR/node/bin:$PATH" >> "$GITEA_ENV"
- name: Run Go tests
working-directory: server
run: |
export HOME="$RUNNER_WORKSPACE"
# 国内 runner 优先 goproxy.cn,海外 runner 自动回落官方源
export GOPROXY="https://goproxy.cn,https://proxy.golang.org,direct"
"$LUNAR_GO" mod download
"$LUNAR_GO" test ./...
- name: Build commercial frontend
env:
CI: "true"
run: |
export HOME="$RUNNER_WORKSPACE"
# 依赖下载走 npmmirror;npm/pnpm 全局限定在部分 runner 不稳定,
# 故不使用 pnpm 工作区——core 是独立包,npm install 即可构建
npm config set registry https://registry.npmmirror.com || true
# @lunar/core:web-commercial 的 file: 依赖与 vite/tsconfig 别名目标
# --legacy-peer-deps:npm10 的 arborist 在 vitest 可选 peer 上会
# edgesOut 崩溃,跳过 peer 解析即可(构建不需要)
cd web/packages/core
npm install --legacy-peer-deps
npx tsup
test -f dist/index.js
cd ../../../web-commercial
npm install --legacy-peer-deps
npx tsc -b
npx vite build
test -f dist/index.html
- name: Build web server binary
working-directory: server
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
run: |
export HOME="$RUNNER_WORKSPACE"
mkdir -p bin
VERSION="1.1.${GITEA_RUN_NUMBER}"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
COMMIT_SHORT="${GITEA_SHA:0:7}"
echo "Building web server version: $VERSION"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$LUNAR_GO" build -ldflags "\
-X main.Version=$VERSION \
-X main.BuildTime=$BUILD_TIME \
-X main.CommitSha=$COMMIT_SHORT" \
-o bin/server ./cmd/web
cat > bin/version.env <<EOF
APP_VERSION=$VERSION
APP_BUILD_TIME=$BUILD_TIME
APP_COMMIT_SHA=$COMMIT_SHORT
EOF
cat bin/version.env
- name: Create deployment package
working-directory: server
run: |
mkdir -p deploy
cp -r bin/ deploy/
cp -r migrations/ deploy/
cp -r scripts/ deploy/
# 商业前端产物:web 二进制以相对路径 ./frontend 提供
cp -r ../web-commercial/dist deploy/frontend/
# 管理后台静态资源与模板(服务以相对路径 ./web 提供)
cp -r web/ deploy/web/
tar -czf web-deploy.tar.gz deploy/
sha256sum web-deploy.tar.gz
- name: Publish release with deployment package
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
LUNAR_PUBLISH_TOKEN: ${{ secrets.LUNAR_PUBLISH_TOKEN }}
run: |
set -e
VERSION="1.1.${GITEA_RUN_NUMBER}"
TAG="web-v${VERSION}"
API="https://gitea.neatcn.com/api/v1/repos/gouki/lunar-mini"
TOKEN="${LUNAR_PUBLISH_TOKEN:-$GITEA_TOKEN}"
test -n "$TOKEN"
AUTH="Authorization: token $TOKEN"
EXISTING=$(curl -sS -H "$AUTH" "$API/releases/tags/$TAG" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -n "$EXISTING" ]; then
curl -sS -X DELETE -H "$AUTH" "$API/releases/$EXISTING" -o /dev/null -w "删除旧 release: HTTP %{http_code}\n"
fi
CREATE_RESP=$(curl -sS -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"web $VERSION\",\"target_commitish\":\"main\",\"body\":\"CI 自动构建,commit ${GITEA_SHA:0:7}。kepo 服务器由 deploy-watch.sh(TAG_PREFIX=web-v)拉取本附件完成部署。\"}" \
"$API/releases")
RELEASE_ID=$(printf '%s' "$CREATE_RESP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$RELEASE_ID" ]; then
echo "创建 release 失败,API 响应: $CREATE_RESP"
exit 1
fi
echo "Release created: id=$RELEASE_ID tag=$TAG"
curl -sS -X POST -H "$AUTH" \
-F "attachment=@server/web-deploy.tar.gz" \
"$API/releases/$RELEASE_ID/assets" \
-o /dev/null -w "附件上传: HTTP %{http_code}\n"
echo "发布地址: https://gitea.neatcn.com/gouki/lunar-mini/releases/tag/$TAG"
- name: Cleanup old web releases (keep latest 10)
env:
LUNAR_PUBLISH_TOKEN: ${{ secrets.LUNAR_PUBLISH_TOKEN }}
run: |
API="https://gitea.neatcn.com/api/v1/repos/gouki/lunar-mini"
TOKEN="${LUNAR_PUBLISH_TOKEN:-$GITEA_TOKEN}"
AUTH="Authorization: token $TOKEN"
KEEP=10
PAIRS=$(curl -sS -H "$AUTH" "$API/releases?limit=50" \
| grep -oE '"id":[0-9]+,"tag_name":"web-v[^"]*"' || true)
TOTAL=$(printf '%s\n' "$PAIRS" | grep -c '"id"' || true)
if [ "$TOTAL" -le "$KEEP" ]; then
echo "当前 $TOTAL 个发布版本,未超过 $KEEP,无需清理"
exit 0
fi
printf '%s\n' "$PAIRS" | tail -n +$((KEEP + 1)) | while IFS= read -r line; do
[ -z "$line" ] && continue
RID=$(printf '%s' "$line" | grep -oE '"id":[0-9]+' | cut -d: -f2)
RTAG=$(printf '%s' "$line" | sed 's/.*"tag_name":"\([^"]*\)".*/\1/')
curl -sS -X DELETE -H "$AUTH" "$API/releases/$RID" -o /dev/null -w "删除旧发布 $RTAG: HTTP %{http_code}\n"
curl -sS -X DELETE -H "$AUTH" "$API/tags/$RTAG" -o /dev/null -w "删除 tag $RTAG: HTTP %{http_code}\n"
done
- name: Send success notification
if: success()
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
run: |
VERSION="1.1.${GITEA_RUN_NUMBER}"
bash .gitea/scripts/notify.sh success \
"商业版 Web 构建发布成功" \
"版本: v${VERSION}\n产物: releases/tag/web-v${VERSION}\nkepo 服务器将自动拉取部署" || echo "通知发送失败,不影响主流程"
- name: Send failure notification
if: failure()
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: |
bash .gitea/scripts/notify.sh failure \
"商业版 Web 构建失败" \
"请检查 Actions 日志了解失败原因" || echo "通知发送失败"
- name: Cleanup
if: always()
run: |
rm -rf server/deploy/
rm -f server/web-deploy.tar.gz
+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/
+84
View File
@@ -0,0 +1,84 @@
# AGENTS.md — lunar 项目代理指令
## 项目概述
lunar 是一个融合中国传统文化(农历、八字、许愿树)的现代化应用,包含:
- **mini/**: 微信小程序前端(农历日历、八字排盘、许愿树、百科)
- **server/**: Go 后端服务(Gin + GORM + MySQL,提供小程序 API 和商业版支付)
- **web/**: 前端工程化包(core + web)
- **web-commercial/**: 商业版前端(React + Vite)
- **wishing-tree-pages/**: 许愿树独立页面(Next.js)
## 核心路径导航
| 路径 | 说明 | 关键文件 |
|------|------|----------|
| `server/cmd/mini/` | 小程序接口入口(lunar.neatcn.com) | `main.go` |
| `server/cmd/web/` | 商业版 Web 入口(kepo.net,含后台与前端静态托管) | `main.go` |
| `server/internal/app/` | 两个入口的路由注册与共用启动逻辑 | `app.go`, `mini.go`, `web.go` |
| `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` — 开发提醒(商业化功能规范)
+33
View File
@@ -0,0 +1,33 @@
# 变更日志
## [1.1.88+] 2026-10-18 — 部署形态拆分与小程序演示版
### 后端拆分(双入口)
- 后端拆为两个部署入口:`cmd/mini`(小程序接口,lunar.neatcn.com)与 `cmd/web`(商业版,kepo.net)。
- `cmd/mini` 仅保留微信登录、生辰档案、许愿树、百科;不再暴露邮箱登录、commerce、`/admin` 后台与支付 Webhook。
- `cmd/web` 仅保留邮箱验证码登录、commerce、订单/报告权益、`/admin` 后台、商业前端静态托管与 SPA 回退;不再暴露微信登录与许愿树接口。
- 共用代码收敛到 `internal/app`(启动/路由注册)与 `internal/assets`(内嵌图片)。
### CI/CD
- `server-deploy.yml` 改构建 `cmd/mini`,部署包不再携带后台静态资源。
- 新增 `web-deploy.yml`:构建 `cmd/web` + web-commercial 前端,发布 `web-v*` Release。
- `deploy-watch.sh` 支持 `TAG_PREFIX`/`ASSET_NAME` 环境变量;新增 `deploy-web.sh`(systemd 版,含健康检查与自动回滚)。
- 新增 kepo 一次性配置文档:`.gitea/docs/PULL_DEPLOY_WEB.md`。
### 安全加固
- 生产环境 `JWT_SECRET` 未设置、为默认值或短于 32 位时拒绝启动。
- 许愿发布前接入微信 `msgSecCheck` v2 内容安全检测(access_token 内存缓存)。
- 修复许愿字数按字节计算的问题(中文原只能写约 6 字),改为按字符计算。
### 小程序(演示版)
- 许愿树移除付费许愿、商品选择与微信支付调用,全部走免费许愿。
- 「占卜」页更名「民俗趣算」,「称骨算命」改为「称骨测算」;占卜页与每日运势页新增文化声明,明确"传统文化知识、仅供娱乐参考"。
### 分支整理
- SMTP2GO 未提交改动备份至 `smtp2go-backup` 分支,`main` 保留已验证可用的 QQ SMTP。
- 合并 `privacy-chart-consent`:`web/` 排盘核心纳入仓库(修复 web-commercial 干净构建),排盘保存需用户显式同意并限流。
+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"
}, },
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"navigationBarTitleText": "占卜", "navigationBarTitleText": "民俗趣算",
"enablePullDownRefresh": false "enablePullDownRefresh": false
} }
+9 -3
View File
@@ -1,4 +1,10 @@
<view class="container"> <view class="container">
<!-- 文化声明 -->
<view class="culture-card">
<view class="culture-title">民俗趣算 · 传统文化</view>
<view class="culture-text">梅花易数与称骨歌诀源自古代典籍与民间传统文化,属于传统民俗知识与趣味体验,仅供学习参考与娱乐,不构成任何决策建议,请理性看待,切勿迷信。</view>
</view>
<!-- 梅花易数 --> <!-- 梅花易数 -->
<view class="card"> <view class="card">
<view class="section-title">梅花易数</view> <view class="section-title">梅花易数</view>
@@ -48,9 +54,9 @@
</view> </view>
</view> </view>
<!-- 称骨算命 --> <!-- 称骨测算 -->
<view class="card mt-2"> <view class="card mt-2">
<view class="section-title">称骨算命</view> <view class="section-title">称骨测算</view>
<view class="form-item"> <view class="form-item">
<text class="form-label">出生年份(干支)</text> <text class="form-label">出生年份(干支)</text>
<picker mode="selector" range="{{yearGanzhiList}}" value="{{boneYearIndex}}" bindchange="onBoneYearChange"> <picker mode="selector" range="{{yearGanzhiList}}" value="{{boneYearIndex}}" bindchange="onBoneYearChange">
@@ -75,7 +81,7 @@
<view class="picker-input">{{hourList[boneHourIndex]}}</view> <view class="picker-input">{{hourList[boneHourIndex]}}</view>
</picker> </picker>
</view> </view>
<view class="btn-primary" bindtap="calcBone">算命</view> <view class="btn-primary" bindtap="calcBone">测算</view>
</view> </view>
<!-- 称骨结果 --> <!-- 称骨结果 -->
+21
View File
@@ -50,3 +50,24 @@
.trigram-name { .trigram-name {
font-size: 28rpx; font-size: 28rpx;
} }
/* 文化声明 */
.culture-card {
background: linear-gradient(135deg, #C41E3A 0%, #8B1A2B 100%);
border-radius: 24rpx;
padding: 28rpx 24rpx;
margin-bottom: 16rpx;
}
.culture-title {
color: #FFFFFF;
font-size: 32rpx;
font-weight: 700;
margin-bottom: 12rpx;
}
.culture-text {
color: rgba(255, 255, 255, 0.92);
font-size: 24rpx;
line-height: 1.7;
}
+6
View File
@@ -1,4 +1,10 @@
<view class="container"> <view class="container">
<!-- 文化声明 -->
<view class="culture-card">
<view class="culture-title">每日运势 · 传统文化参考</view>
<view class="culture-text">本页内容基于传统干支历法与五行文化的趣味化呈现,仅供休闲娱乐与传统文化学习参考,不构成任何人生决策建议,请理性看待,切勿迷信。</view>
</view>
<!-- 无八字提示 --> <!-- 无八字提示 -->
<view class="card text-center" wx:if="{{!hasBazi}}"> <view class="card text-center" wx:if="{{!hasBazi}}">
<text class="text-lg text-muted">请先排八字以查看每日运势</text> <text class="text-lg text-muted">请先排八字以查看每日运势</text>
+21
View File
@@ -45,3 +45,24 @@
.suggestion-item { .suggestion-item {
padding: 12rpx 0; padding: 12rpx 0;
} }
/* 文化声明 */
.culture-card {
background: linear-gradient(135deg, #C41E3A 0%, #8B1A2B 100%);
border-radius: 24rpx;
padding: 28rpx 24rpx;
margin-bottom: 16rpx;
}
.culture-title {
color: #FFFFFF;
font-size: 32rpx;
font-weight: 700;
margin-bottom: 12rpx;
}
.culture-text {
color: rgba(255, 255, 255, 0.92);
font-size: 24rpx;
line-height: 1.7;
}
+5 -2
View File
@@ -47,6 +47,7 @@ Page({
zoomTitle: '', zoomTitle: '',
zoomType: '', zoomType: '',
zoomData: null, zoomData: null,
zoomScrollTop: 0,
// 设置 // 设置
showBuddhist: false, showBuddhist: false,
highlightLunar: true highlightLunar: true
@@ -97,7 +98,8 @@ Page({
const termTime = dayInfo.solarTerm ? (SOLAR_TERM_TIMES[dayInfo.solarTerm] || '') : ''; const termTime = dayInfo.solarTerm ? (SOLAR_TERM_TIMES[dayInfo.solarTerm] || '') : '';
const isFirstOrFifteen = this.data.highlightLunar && (dayInfo.lunarDay === 1 || dayInfo.lunarDay === 15); const isFirstOrFifteen = this.data.highlightLunar && (dayInfo.lunarDay === 1 || dayInfo.lunarDay === 15);
const nextBuddhist = this.data.showBuddhist ? getNextBuddhistFestival(y, m, d) : null; const nextBuddhist = this.data.showBuddhist ? getNextBuddhistFestival(y, m, d) : null;
const upcoming = getUpcomingFestivals(10, y, m, d); // 外页只显示最近3条(佛历开启时并入排序);内页弹窗见 openZoom 取10条
const upcoming = getUpcomingFestivals(3, y, m, d, this.data.showBuddhist);
return { return {
key: fmt(y, m, d), key: fmt(y, m, d),
dayInfo, almanac, legalHoliday, workday, termTime, dayInfo, almanac, legalHoliday, workday, termTime,
@@ -429,7 +431,8 @@ Page({
}; };
} else if (type === 'fest') { } else if (type === 'fest') {
zoomTitle = '近期节日'; zoomTitle = '近期节日';
zoomData = { upcoming: dayItem.upcoming }; // 内页显示最近10条(佛历开启时并入排序)
zoomData = { upcoming: getUpcomingFestivals(10, dayInfo.solarYear, dayInfo.solarMonth, dayInfo.solarDay, this.data.showBuddhist) };
} else if (type === 'fest-year') { } else if (type === 'fest-year') {
zoomTitle = `${dayInfo.solarYear}年节日`; zoomTitle = `${dayInfo.solarYear}年节日`;
zoomData = { yearFestivals: getYearFestivals(dayInfo.solarYear) }; zoomData = { yearFestivals: getYearFestivals(dayInfo.solarYear) };
+7 -2
View File
@@ -156,7 +156,7 @@
</view> </view>
<view class="otd-foot"> <view class="otd-foot">
<text class="otd-source">来源:中文维基百科</text> <text class="otd-source">来源:中文维基百科</text>
<text class="otd-more" bindtap="openZoom" data-type="otd" data-date="{{dayItem.key}}">查看完整 ›</text> <text class="otd-more" bindtap="openZoom" data-type="otd" data-date="{{dayItem.key}}">查看完整 >></text>
</view> </view>
</view> </view>
<view class="card placeholder-card" wx:elif="{{dayItem.otdLoading}}"> <view class="card placeholder-card" wx:elif="{{dayItem.otdLoading}}">
@@ -236,6 +236,7 @@
<text class="zoom-yiji-item ji" wx:for="{{zoomData.avoids}}" wx:key="*this">{{item}}</text> <text class="zoom-yiji-item ji" wx:for="{{zoomData.avoids}}" wx:key="*this">{{item}}</text>
</view> </view>
</view> </view>
<view class="zoom-end-pad" />
</scroll-view> </scroll-view>
<!-- 详情放大 --> <!-- 详情放大 -->
@@ -280,6 +281,7 @@
<text class="zoom-detail-label">彭祖百忌</text> <text class="zoom-detail-label">彭祖百忌</text>
<text class="zoom-detail-value">{{zoomData.pengZu}}</text> <text class="zoom-detail-value">{{zoomData.pengZu}}</text>
</view> </view>
<view class="zoom-end-pad" />
</scroll-view> </scroll-view>
<!-- 节日放大 --> <!-- 节日放大 -->
@@ -289,15 +291,17 @@
<text class="zoom-fest-date">{{item.solarDate}}</text> <text class="zoom-fest-date">{{item.solarDate}}</text>
<text class="zoom-fest-left {{item.daysLeft === 0 ? 'is-today' : ''}}">{{item.daysLeft === 0 ? '今天' : '还有' + item.daysLeft + '天'}}</text> <text class="zoom-fest-left {{item.daysLeft === 0 ? 'is-today' : ''}}">{{item.daysLeft === 0 ? '今天' : '还有' + item.daysLeft + '天'}}</text>
</view> </view>
<view class="zoom-end-pad" />
</scroll-view> </scroll-view>
<!-- 今年节日(字体稍小,避免溢出) --> <!-- 今年节日(字体稍小,避免溢出) -->
<scroll-view scroll-y class="zoom-body" wx:if="{{zoomType === 'fest-year' && zoomData}}"> <scroll-view scroll-y class="zoom-body" scroll-top="{{zoomScrollTop}}" wx:if="{{zoomType === 'fest-year' && zoomData}}">
<view class="zoom-fest-year-row" wx:for="{{zoomData.yearFestivals}}" wx:key="name" bindtap="goFestival" data-date="{{item.solarDate}}"> <view class="zoom-fest-year-row" wx:for="{{zoomData.yearFestivals}}" wx:key="name" bindtap="goFestival" data-date="{{item.solarDate}}">
<text class="zoom-fest-year-name">{{item.name}}</text> <text class="zoom-fest-year-name">{{item.name}}</text>
<text class="zoom-fest-year-date">{{item.solarMonth}}月{{item.solarDay}}日</text> <text class="zoom-fest-year-date">{{item.solarMonth}}月{{item.solarDay}}日</text>
<text class="zoom-fest-year-type {{item.type === 'lunar' ? 'lunar' : 'solar'}}">{{item.type === 'lunar' ? '农历' : '公历'}}</text> <text class="zoom-fest-year-type {{item.type === 'lunar' ? 'lunar' : 'solar'}}">{{item.type === 'lunar' ? '农历' : '公历'}}</text>
</view> </view>
<view class="zoom-end-pad" />
</scroll-view> </scroll-view>
<!-- 历史上的今天放大 --> <!-- 历史上的今天放大 -->
@@ -330,6 +334,7 @@
</view> </view>
</block> </block>
<view class="zoom-otd-source">数据来源:中文维基百科(CC BY-SA 4.0)</view> <view class="zoom-otd-source">数据来源:中文维基百科(CC BY-SA 4.0)</view>
<view class="zoom-end-pad" />
</scroll-view> </scroll-view>
</view> </view>
</view> </view>
+27 -11
View File
@@ -434,19 +434,23 @@
.zoom-fest-year-name { .zoom-fest-year-name {
font-size: 32rpx; font-size: 32rpx;
font-weight: 600; font-weight: 600;
min-width: 140rpx; width: 160rpx;
flex-shrink: 0;
} }
.zoom-fest-year-date { .zoom-fest-year-date {
font-size: 28rpx; font-size: 28rpx;
color: #9E8E7E; color: #9E8E7E;
flex: 1; flex: 1;
min-width: 0;
} }
.zoom-fest-year-type { .zoom-fest-year-type {
font-size: 22rpx; font-size: 22rpx;
padding: 4rpx 16rpx; padding: 4rpx 16rpx;
border-radius: 999rpx; border-radius: 999rpx;
flex-shrink: 0;
white-space: nowrap;
} }
.zoom-fest-year-type.lunar { .zoom-fest-year-type.lunar {
@@ -668,10 +672,18 @@
} }
.zoom-body { .zoom-body {
width: 100%;
box-sizing: border-box;
padding: 32rpx 28rpx; padding: 32rpx 28rpx;
max-height: 70vh; max-height: 70vh;
} }
/* scroll-view 底部占位:padding-bottom 不计入滚动高度,用真实元素保证最后一项完整显示 */
.zoom-end-pad {
height: 32rpx;
}
/* 宜忌放大 */ /* 宜忌放大 */
.zoom-yiji-block { .zoom-yiji-block {
margin-bottom: 40rpx; margin-bottom: 40rpx;
@@ -695,13 +707,13 @@
.zoom-yiji-items { .zoom-yiji-items {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 20rpx; gap: 14rpx;
} }
.zoom-yiji-item { .zoom-yiji-item {
font-size: 44rpx; font-size: 32rpx;
padding: 16rpx 32rpx; padding: 12rpx 24rpx;
border-radius: 16rpx; border-radius: 12rpx;
font-weight: 500; font-weight: 500;
} }
@@ -744,21 +756,24 @@
} }
.zoom-fest-name { .zoom-fest-name {
font-size: 44rpx; font-size: 40rpx;
font-weight: 600; font-weight: 600;
min-width: 200rpx; flex-shrink: 0;
} }
.zoom-fest-date { .zoom-fest-date {
font-size: 36rpx; font-size: 32rpx;
color: #9E8E7E; color: #9E8E7E;
flex: 1; flex: 1;
min-width: 0;
} }
.zoom-fest-left { .zoom-fest-left {
font-size: 36rpx; font-size: 32rpx;
color: #C41E3A; color: #C41E3A;
font-weight: 500; font-weight: 500;
flex-shrink: 0;
white-space: nowrap;
} }
.zoom-fest-left.is-today { .zoom-fest-left.is-today {
@@ -874,12 +889,13 @@
} }
.zoom-otd-item .otd-year { .zoom-otd-item .otd-year {
font-size: 30rpx; font-size: 28rpx;
font-weight: 600;
min-width: 120rpx; min-width: 120rpx;
} }
.zoom-otd-item .otd-text { .zoom-otd-item .otd-text {
font-size: 32rpx; font-size: 28rpx;
-webkit-line-clamp: unset; -webkit-line-clamp: unset;
overflow: visible; overflow: visible;
} }
+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'];
+10 -10
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>
+41
View File
@@ -41,6 +41,44 @@ const TERM_EXPLAINS = [
{ title: '胎神方位', brief: '孕妇需避开的方位', content: '胎神是传统民俗中保护胎儿的神灵,每日所在方位不同。孕妇不宜在胎神方位动土、钉钉或搬动重物,以免惊动胎神。' } { title: '胎神方位', brief: '孕妇需避开的方位', content: '胎神是传统民俗中保护胎儿的神灵,每日所在方位不同。孕妇不宜在胎神方位动土、钉钉或搬动重物,以免惊动胎神。' }
]; ];
// 节气扩展字段的展示顺序与中文标签
const EXTRA_LABELS = [
{ key: 'alias', name: '别名' },
{ key: 'english', name: '外文名' },
{ key: 'meaning', name: '涵义' },
{ key: 'solarDate', name: '公历时间' },
{ key: 'ecliptic', name: '黄道位置' },
{ key: 'climate', name: '气候特点' },
{ key: 'phenology', name: '物候现象' },
{ key: 'farming', name: '农事活动' },
{ key: 'customs', name: '传统习俗' },
{ key: 'health', name: '起居养生' },
{ key: 'flower', name: '花信' }
];
// buildExtraList 把服务端 extra JSON 字符串解析为 [{name, value}] 展示列表(仅保留非空项,按定义顺序;meaning 单独展示故排除)
function buildExtraList(extraStr) {
if (!extraStr) return [];
let obj = {};
try { obj = JSON.parse(extraStr); } catch (e) { return []; }
const list = [];
EXTRA_LABELS.forEach(f => {
if (f.key === 'meaning') return; // 涵义在引言区单独突出显示
const v = (obj[f.key] || '').trim();
if (v) list.push({ name: f.name, value: v });
});
return list;
}
// extractMeaning 从 extra JSON 中提取涵义(用于引言区突出显示)
function extractMeaning(extraStr) {
if (!extraStr) return '';
try {
const obj = JSON.parse(extraStr);
return (obj.meaning || '').trim();
} catch (e) { return ''; }
}
function buildEntries() { function buildEntries() {
const entries = []; const entries = [];
@@ -139,6 +177,9 @@ Page({
title: s.title, title: s.title,
brief: s.brief, brief: s.brief,
content: s.content, content: s.content,
image: s.image || '',
meaning: extractMeaning(s.extra),
extraList: buildExtraList(s.extra),
open: false open: false
}; };
if (titleIndex[s.title] !== undefined) { if (titleIndex[s.title] !== undefined) {
+22 -2
View File
@@ -25,8 +25,28 @@
<text class="badge badge-outline">{{item.catName}}</text> <text class="badge badge-outline">{{item.catName}}</text>
</view> </view>
<view class="text-sm text-muted mt-1" wx:if="{{!item.open}}">{{item.brief}}</view> <view class="text-sm text-muted mt-1" wx:if="{{!item.open}}">{{item.brief}}</view>
<view class="entry-detail mt-2" wx:if="{{item.open}}"> <view class="entry-detail" wx:if="{{item.open}}">
<text class="text-base">{{item.content}}</text> <!-- 配图(仅服务端条目有图时显示) -->
<image wx:if="{{item.image}}" class="entry-image" src="{{item.image}}" mode="widthFix" lazy-load/>
<!-- 涵义引言(节气有 meaning 时突出显示) -->
<view class="entry-meaning" wx:if="{{item.meaning}}">
<text class="meaning-text">{{item.meaning}}</text>
</view>
<!-- 详细简介 -->
<text class="text-base entry-content">{{item.content}}</text>
<!-- 结构化扩展字段(节气:物候/习俗/养生等) -->
<view class="extra-list" wx:if="{{item.extraList && item.extraList.length > 0}}">
<view class="extra-item" wx:for="{{item.extraList}}" wx:key="name" wx:for-item="field">
<view class="extra-name">
<text class="extra-dot"></text>
<text>{{field.name}}</text>
</view>
<text class="extra-value">{{field.value}}</text>
</view>
</view>
</view> </view>
</view> </view>
+86 -1
View File
@@ -47,7 +47,92 @@
.entry-detail { .entry-detail {
border-top: 1rpx solid #F0E6DA; border-top: 1rpx solid #F0E6DA;
padding-top: 16rpx; margin-top: 16rpx;
padding-top: 20rpx;
}
/* 节气配图 */
.entry-image {
width: 100%;
border-radius: 16rpx;
margin-bottom: 20rpx;
background: #F5F0EA;
box-shadow: 0 4rpx 16rpx rgba(74, 64, 54, 0.08);
}
/* 涵义引言(突出显示) */
.entry-meaning {
position: relative;
background: linear-gradient(135deg, #FBF3EC 0%, #F7ECE2 100%);
border-left: 6rpx solid #C41E3A;
border-radius: 8rpx;
padding: 20rpx 24rpx;
margin-bottom: 20rpx;
}
.meaning-text {
font-size: 28rpx;
color: #8C6D4F;
line-height: 1.6;
font-weight: 500;
letter-spacing: 1rpx;
}
/* 详细简介 */
.entry-content {
display: block;
color: #4A4036;
font-size: 28rpx;
line-height: 1.9;
text-align: justify;
letter-spacing: 0.5rpx;
}
/* 结构化扩展字段列表 */
.extra-list {
margin-top: 24rpx;
background: #FBF7F1;
border-radius: 16rpx;
padding: 8rpx 24rpx;
}
.extra-item {
display: flex;
align-items: flex-start;
padding: 16rpx 0;
border-bottom: 1rpx solid #F0E6DA;
}
.extra-item:last-child {
border-bottom: none;
}
.extra-name {
flex-shrink: 0;
width: 150rpx;
display: flex;
align-items: center;
font-size: 26rpx;
color: #A08B76;
font-weight: 500;
padding-top: 2rpx;
}
/* 标签前的小圆点装饰 */
.extra-dot {
display: inline-block;
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #C41E3A;
margin-right: 12rpx;
flex-shrink: 0;
}
.extra-value {
flex: 1;
font-size: 26rpx;
color: #4A4036;
line-height: 1.7; line-height: 1.7;
} }
+17 -100
View File
@@ -54,11 +54,7 @@ Page({
fireflies: [], fireflies: [],
showCreateModal: false, showCreateModal: false,
wishContent: '', wishContent: '',
wishType: 'free',
maxFreeLength: 20, maxFreeLength: 20,
maxPaidLength: 100,
products: [],
selectedProduct: null,
}, },
onLoad() { onLoad() {
@@ -72,7 +68,6 @@ Page({
this.initAmbiance(); this.initAmbiance();
this.loadTrees(); this.loadTrees();
this.loadProducts();
}, },
onUnload() { onUnload() {
@@ -407,7 +402,7 @@ Page({
id: i + 1, id: i + 1,
content: texts[i % texts.length], content: texts[i % texts.length],
author: authors[i % authors.length], author: authors[i % authors.length],
type: i % 5 === 0 ? 'paid' : 'free', type: 'free',
position: i === 0 ? 100 : 0, position: i === 0 ? 100 : 0,
isTop: i === 0, isTop: i === 0,
createdAt: new Date(Date.now() - i * 3600 * 1000).toISOString(), createdAt: new Date(Date.now() - i * 3600 * 1000).toISOString(),
@@ -427,26 +422,6 @@ Page({
this.setData({ detailWish: null }); this.setData({ detailWish: null });
}, },
// 加载许愿商品
async loadProducts() {
try {
const res = await request({ url: '/api/wish/products', method: 'GET' });
if (res.code === 0) {
this.setData({ products: res.data.list || [] });
return;
}
} catch (err) {
console.error('加载商品失败:', err);
}
this.setData({
products: [
{ id: 1, name: '普通许愿条', price: 100, duration: 7 },
{ id: 2, name: '精品许愿条', price: 500, duration: 30 },
{ id: 3, name: '至尊许愿条', price: 2000, duration: 90 },
],
});
},
// 打开/关闭许愿弹窗 // 打开/关闭许愿弹窗
openCreateModal() { openCreateModal() {
this.setData({ showCreateModal: true }); this.setData({ showCreateModal: true });
@@ -456,8 +431,6 @@ Page({
this.setData({ this.setData({
showCreateModal: false, showCreateModal: false,
wishContent: '', wishContent: '',
wishType: 'free',
selectedProduct: null,
}); });
}, },
@@ -465,16 +438,6 @@ Page({
this.setData({ wishContent: e.detail.value }); this.setData({ wishContent: e.detail.value });
}, },
selectType(e) {
this.setData({ wishType: e.currentTarget.dataset.type });
},
selectProduct(e) {
const id = e.currentTarget.dataset.id;
const product = this.data.products.find((p) => p.id === id);
this.setData({ selectedProduct: product });
},
// 当前树ID // 当前树ID
currentTreeId() { currentTreeId() {
const { trees, currentTreeIndex } = this.data; const { trees, currentTreeIndex } = this.data;
@@ -484,54 +447,32 @@ Page({
// 提交许愿 // 提交许愿
async submitWish() { async submitWish() {
const { wishContent, wishType, selectedProduct, maxFreeLength, maxPaidLength } = this.data; const { wishContent, maxFreeLength } = this.data;
if (!wishContent.trim()) { if (!wishContent.trim()) {
wx.showToast({ title: '请输入许愿内容', icon: 'none' }); wx.showToast({ title: '请输入许愿内容', icon: 'none' });
return; return;
} }
const maxLength = wishType === 'free' ? maxFreeLength : maxPaidLength; if (wishContent.length > maxFreeLength) {
if (wishContent.length > maxLength) { wx.showToast({ title: `最多输入${maxFreeLength}个字`, icon: 'none' });
wx.showToast({ title: `最多输入${maxLength}个字`, icon: 'none' });
return;
}
if (wishType === 'paid' && !selectedProduct) {
wx.showToast({ title: '请选择许愿商品', icon: 'none' });
return; return;
} }
try { try {
if (wishType === 'paid') { const res = await request({
const orderRes = await request({ url: '/api/wish/create',
url: '/api/pay/create', method: 'POST',
method: 'POST', data: {
data: { content: wishContent,
type: 'wish', type: 'free',
productId: selectedProduct.id, treeId: this.currentTreeId(),
content: wishContent, },
treeId: this.currentTreeId(), });
}, if (res.code === 0) {
}); wx.showToast({ title: '许愿成功', icon: 'success' });
if (orderRes.code === 0) { this.closeCreateModal();
await this.wxPay(orderRes.data); this.loadTreeWishes(this.data.currentTreeIndex, true);
}
} else {
const res = await request({
url: '/api/wish/create',
method: 'POST',
data: {
content: wishContent,
type: 'free',
treeId: this.currentTreeId(),
},
});
if (res.code === 0) {
wx.showToast({ title: '许愿成功', icon: 'success' });
this.closeCreateModal();
this.loadTreeWishes(this.data.currentTreeIndex, true);
}
} }
} catch (err) { } catch (err) {
console.error('许愿失败:', err); console.error('许愿失败:', err);
@@ -539,30 +480,6 @@ Page({
} }
}, },
// 微信支付
async wxPay(orderData) {
return new Promise((resolve, reject) => {
wx.requestPayment({
timeStamp: orderData.timeStamp,
nonceStr: orderData.nonceStr,
package: orderData.package,
signType: orderData.signType,
paySign: orderData.paySign,
success: () => {
wx.showToast({ title: '支付成功', icon: 'success' });
this.closeCreateModal();
this.loadTreeWishes(this.data.currentTreeIndex, true);
resolve();
},
fail: (err) => {
console.error('支付失败:', err);
wx.showToast({ title: '支付失败', icon: 'none' });
reject(err);
},
});
});
},
// 分享 // 分享
onShareAppMessage() { onShareAppMessage() {
return { return {
+3 -45
View File
@@ -122,64 +122,22 @@
</view> </view>
<view class="modal-body"> <view class="modal-body">
<!-- 许愿类型选择 -->
<view class="type-selector">
<view
class="type-item {{wishType === 'free' ? 'active' : ''}}"
bindtap="selectType"
data-type="free"
>
<text class="type-name">免费许愿</text>
<text class="type-desc">20字以内</text>
</view>
<view
class="type-item {{wishType === 'paid' ? 'active' : ''}}"
bindtap="selectType"
data-type="paid"
>
<text class="type-name">付费许愿</text>
<text class="type-desc">更多特权</text>
</view>
</view>
<!-- 许愿内容输入 --> <!-- 许愿内容输入 -->
<textarea <textarea
class="wish-input" class="wish-input"
placeholder="请输入你的愿望..." placeholder="请输入你的愿望..."
maxlength="{{wishType === 'free' ? maxFreeLength : maxPaidLength}}" maxlength="{{maxFreeLength}}"
bindinput="onInputContent" bindinput="onInputContent"
value="{{wishContent}}" value="{{wishContent}}"
></textarea> ></textarea>
<view class="input-count"> <view class="input-count">
{{wishContent.length}}/{{wishType === 'free' ? maxFreeLength : maxPaidLength}} {{wishContent.length}}/{{maxFreeLength}}
</view>
<!-- 付费商品选择 -->
<view class="product-section" wx:if="{{wishType === 'paid'}}">
<view class="product-title">选择许愿商品</view>
<view class="product-list">
<view
wx:for="{{products}}"
wx:key="id"
class="product-item {{selectedProduct && selectedProduct.id === item.id ? 'active' : ''}}"
bindtap="selectProduct"
data-id="{{item.id}}"
>
<view class="product-info">
<text class="product-name">{{item.name}}</text>
<text class="product-desc">展示{{item.duration}}天</text>
</view>
<view class="product-price">¥{{item.price / 100}}</view>
</view>
</view>
</view> </view>
</view> </view>
<view class="modal-footer"> <view class="modal-footer">
<button class="btn-outline" bindtap="closeCreateModal">取消</button> <button class="btn-outline" bindtap="closeCreateModal">取消</button>
<button class="btn-primary" bindtap="submitWish"> <button class="btn-primary" bindtap="submitWish">提交许愿</button>
{{wishType === 'paid' ? '支付并许愿' : '提交许愿'}}
</button>
</view> </view>
</view> </view>
</view> </view>
+1 -1
View File
@@ -7,7 +7,7 @@
"setting": { "setting": {
"bundle": false, "bundle": false,
"userConfirmedBundleSwitch": false, "userConfirmedBundleSwitch": false,
"urlCheck": true, "urlCheck": false,
"scopeDataCheck": false, "scopeDataCheck": false,
"coverView": true, "coverView": true,
"es6": true, "es6": true,
+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');
});
});
+27 -1
View File
@@ -1228,8 +1228,9 @@ function daysBetween(fromY, fromM, fromD, toY, toM, toD) {
/** 获取即将到来的 N 个节日(含基准日之后的) /** 获取即将到来的 N 个节日(含基准日之后的)
* baseYear/baseMonth/baseDay 可选,默认今天 * baseYear/baseMonth/baseDay 可选,默认今天
* includeBuddhist 为 true 时,把佛教节日也并入一起按倒计时排序
*/ */
function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) { function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay, includeBuddhist = false) {
const now = new Date(); const now = new Date();
const y = baseYear || now.getFullYear(); const y = baseYear || now.getFullYear();
const m = baseMonth || (now.getMonth() + 1); const m = baseMonth || (now.getMonth() + 1);
@@ -1245,6 +1246,7 @@ function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) {
if (diff >= 0) { if (diff >= 0) {
list.push({ list.push({
name: fest.name, name: fest.name,
type: fest.type,
solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`, solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`,
solarYear: solar.year, solarYear: solar.year,
solarMonth: solar.month, solarMonth: solar.month,
@@ -1256,6 +1258,30 @@ function getUpcomingFestivals(count = 6, baseYear, baseMonth, baseDay) {
} }
} }
// 佛历节日(开启后并入,统一按倒计时排序)
if (includeBuddhist) {
for (const [key, name] of Object.entries(BUDDHIST_FESTIVALS)) {
const [lm, ld] = key.split('-').map(Number);
for (const tryYear of [y, y + 1]) {
const solar = lunarFestivalToSolar(tryYear, lm, ld);
if (!solar) continue;
const diff = daysBetween(y, m, d, solar.year, solar.month, solar.day);
if (diff >= 0) {
list.push({
name,
type: 'buddhist',
solarDate: `${solar.year}-${String(solar.month).padStart(2, '0')}-${String(solar.day).padStart(2, '0')}`,
solarYear: solar.year,
solarMonth: solar.month,
solarDay: solar.day,
daysLeft: diff
});
break;
}
}
}
}
list.sort((a, b) => a.daysLeft - b.daysLeft); list.sort((a, b) => a.daysLeft - b.daysLeft);
return list.slice(0, count); return list.slice(0, count);
} }
+2 -2
View File
@@ -1,9 +1,9 @@
// 网络请求封装 // 网络请求封装
// 按小程序运行环境自动切换后端地址: // 按小程序运行环境自动切换后端地址:
// - develop(开发者工具):本地开发服务 // - develop(开发者工具):连线上数据(本地 8080 被其他服务占用,且 localhost 受域名校验限制)
// - trial/release(体验版/正式版):生产域名(需在微信公众平台配置为 request 合法域名) // - trial/release(体验版/正式版):生产域名(需在微信公众平台配置为 request 合法域名)
const API_HOSTS = { const API_HOSTS = {
develop: 'http://localhost:8080', develop: 'https://lunar.neatcn.com',
trial: 'https://lunar.neatcn.com', trial: 'https://lunar.neatcn.com',
release: 'https://lunar.neatcn.com', release: 'https://lunar.neatcn.com',
}; };
+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=
+95 -84
View File
@@ -1,114 +1,125 @@
# 祈福小助手后端服务 # 知命 / 祈福后端服务
基于 Go + Gin + Inertia.js 的后端服务。 基于 Go、Gin、GORM 的后端仓库,包含两个部署入口:
- `cmd/mini`:小程序接口(lunar.neatcn.com)——微信登录、生辰档案、许愿树、百科。
- `cmd/web`:商业版 Web(kepo.net)——邮箱验证码登录、Stripe / Binance Pay、订单权益、报告快照、管理后台与商业前端静态站点。
两侧共用 `internal/` 下的配置、数据库、模型与中间件,但路由互不暴露。
## 功能模块 ## 功能模块
- **用户模块** — 微信登录、资料修改、授权管理 - 微信小程序登录与生辰档案。
- **支付模块** — 微信支付集成、订单管理 - 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/mini # 小程序接口
# 启动开发服务器 go run ./cmd/web # 商业版 Web(本地需 ../web-commercial/dist 与 ./web 静态资源)
./scripts/dev.sh
``` ```
### 生产构建 默认地址:`http://localhost:8080`。
生产部署:
- **小程序接口**(doc79):CI 构建 `server-v*` Release,服务器 watcher 自动拉取,Docker 容器运行。见 `.gitea/docs/PULL_DEPLOY.md`。
- **商业版 Web**(kepo.net):CI 构建 `web-v*` Release(含 `cmd/web` 二进制 + `frontend/` 商业前端 + `web/` 后台静态资源),systemd 运行。一次性配置见 `.gitea/docs/PULL_DEPLOY_WEB.md`。
商业前端静态文件放在服务目录的 `frontend/`,根路径 `/` 为商业站点,`/admin/orders` 为管理后台,`/api/*` 为后端接口。
## 测试
项目包含 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。
-167
View File
@@ -1,167 +0,0 @@
package main
import (
"embed"
"io/fs"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware"
"github.com/gouki/lunar-server/internal/service"
"github.com/joho/godotenv"
)
// 版本信息由 CI 通过 ldflags 注入:
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
// 内嵌图片资源(许愿树背景图等),随二进制一起部署
//
//go:embed assets/images
var assetsFS embed.FS
func main() {
// 加载本地环境变量文件(生产环境由容器注入,忽略缺失;../.env.local 兼容从 server/ 目录启动)
_ = godotenv.Load(".env.local", "../.env.local", ".env")
// 将构建时注入的版本信息传递给 handler
handler.Version = Version
handler.BuildTime = BuildTime
handler.CommitSha = CommitSha
log.Printf("lunar-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
// 加载配置
cfg := config.Load()
// 初始化数据库
if err := config.InitDB(cfg); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// 启动机器人许愿定时任务
wishService := service.NewWishService()
wishService.StartRobotWishJob()
// 启动维基百科同步定时任务(历史上的今天;首次启动数据为空时自动全量抓取)
handler.WikiSync = service.NewWikiSyncService(config.GetDB())
handler.WikiSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 设置运行模式
if cfg.Server.Env == "production" {
gin.SetMode(gin.ReleaseMode)
}
// 创建路由(gin.Default 自带访问日志与 Recovery)
r := gin.Default()
// 中间件
r.Use(middleware.CORS())
// 静态文件与管理后台模板
r.Static("/static", "./web/static")
r.StaticFile("/", "./web/index.html")
r.LoadHTMLFiles("./web/index.html")
// 内嵌图片资源(许愿树背景图等)
if imagesFS, err := fs.Sub(assetsFS, "assets/images"); err == nil {
r.GET("/images/*path", gin.WrapH(http.StripPrefix("/images", http.FileServer(http.FS(imagesFS)))))
}
// API 路由
api := r.Group("/api")
{
// 版本信息(无需认证)
api.GET("/version", handler.GetVersion)
// 用户相关
user := api.Group("/user")
{
user.POST("/login", handler.UserLogin)
user.POST("/logout", handler.UserLogout)
user.GET("/profile", middleware.Auth(), handler.GetUserProfile)
user.PUT("/profile", middleware.Auth(), handler.UpdateUserProfile)
user.POST("/auth", handler.WechatAuth)
// 生辰档案(八字绑定,每用户免费 3 个)
user.GET("/birth-profiles", middleware.Auth(), handler.GetBirthProfiles)
user.POST("/birth-profiles", middleware.Auth(), handler.CreateBirthProfile)
user.DELETE("/birth-profiles/:id", middleware.Auth(), handler.DeleteBirthProfile)
}
// 支付相关
pay := api.Group("/pay")
{
pay.POST("/create", middleware.Auth(), handler.CreateOrder)
pay.POST("/notify", handler.PayNotify)
pay.GET("/status/:orderId", handler.GetPayStatus)
}
// 订单相关
order := api.Group("/order")
{
order.GET("/list", middleware.Auth(), handler.GetOrderList)
order.GET("/:id", middleware.Auth(), handler.GetOrderDetail)
order.POST("/cancel/:id", middleware.Auth(), handler.CancelOrder)
}
// 许愿相关
wish := api.Group("/wish")
{
wish.GET("/trees", handler.GetWishTrees)
wish.GET("/tree", handler.GetWishTree)
wish.GET("/tree/:id/wishes", handler.GetTreeWishes)
wish.POST("/create", middleware.Auth(), handler.CreateWish)
wish.GET("/list", handler.GetWishList)
wish.GET("/:id", handler.GetWishDetail)
wish.DELETE("/:id", middleware.Auth(), handler.DeleteWish)
wish.GET("/products", handler.GetWishProducts)
}
// 百科/历史上的今天(无需认证)
wiki := api.Group("/wiki")
{
wiki.GET("/entries", handler.GetWikiEntries)
wiki.GET("/on-this-day", handler.GetOnThisDay)
}
}
// 管理后台路由(Inertia.js)
admin := r.Group("/admin")
{
// 登录/登出接口无需认证;未配置 ADMIN_PASSWORD 时登录返回 503
admin.POST("/login", handler.AdminLogin)
admin.POST("/logout", handler.AdminLogout)
authed := admin.Group("")
authed.Use(middleware.AdminAuth())
{
authed.GET("/", handler.AdminDashboard)
authed.GET("/users", handler.AdminUsers)
authed.GET("/orders", handler.AdminOrders)
authed.GET("/wishes", handler.AdminWishes)
authed.GET("/wiki", handler.AdminWiki)
authed.GET("/settings", handler.AdminSettings)
// 百科管理 API
authed.GET("/api/wiki/entries", handler.AdminWikiEntries)
authed.POST("/api/wiki/entries", handler.AdminWikiEntryCreate)
authed.PUT("/api/wiki/entries/:id", handler.AdminWikiEntryUpdate)
authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete)
authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus)
authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger)
}
}
// 启动服务器
addr := ":" + cfg.Server.Port
log.Printf("Server starting on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
+30
View File
@@ -0,0 +1,30 @@
// 小程序接口服务(lunar.neatcn.com):微信登录、许愿树、百科等。
// 不包含商业版支付、邮箱验证码登录与管理后台。
package main
import (
"log"
"github.com/gouki/lunar-server/internal/app"
"github.com/gouki/lunar-server/internal/handler"
)
// 版本信息由 CI 通过 ldflags 注入:
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
func main() {
handler.Version = Version
handler.BuildTime = BuildTime
handler.CommitSha = CommitSha
log.Printf("lunar-mini-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
cfg := app.Bootstrap()
r := app.NewEngine()
app.SetupMini(r, cfg)
app.Run(r, cfg.Server.Port)
}
+31
View File
@@ -0,0 +1,31 @@
// 商业版 Web 服务(kepo.net):邮箱验证码登录、Stripe / Binance Pay
// 支付、订单权益、报告快照与恢复码、管理后台、商业前端静态站点。
// 不包含小程序接口(微信登录、许愿树、百科同步等)。
package main
import (
"log"
"github.com/gouki/lunar-server/internal/app"
"github.com/gouki/lunar-server/internal/handler"
)
// 版本信息由 CI 通过 ldflags 注入:
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
func main() {
handler.Version = Version
handler.BuildTime = BuildTime
handler.CommitSha = CommitSha
log.Printf("lunar-web-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
cfg := app.Bootstrap()
r := app.NewEngine()
app.SetupWeb(r, cfg)
app.Run(r, cfg.Server.Port)
}
+59
View File
@@ -0,0 +1,59 @@
// Package app 提供 lunar-server 两个部署形态(小程序接口 / 商业版 Web)
// 共用的启动逻辑与路由注册。
package app
import (
"io/fs"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/assets"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/middleware"
"github.com/joho/godotenv"
)
// Bootstrap 公共启动逻辑:加载 env、配置与数据库连接
func Bootstrap() *config.Config {
_ = godotenv.Load(".env.local", "../.env.local", ".env")
cfg := config.Load()
// 生产环境安全底线:JWT 密钥必须是强随机值
if cfg.Server.Env == "production" {
if cfg.JWT.Secret == "" || cfg.JWT.Secret == "your-secret-key" || len(cfg.JWT.Secret) < 32 {
log.Fatal("JWT_SECRET 未配置或过短:生产环境必须设置至少 32 位的强随机密钥")
}
}
if err := config.InitDB(cfg); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
if cfg.Server.Env == "production" {
gin.SetMode(gin.ReleaseMode)
}
return cfg
}
// NewEngine 创建带通用中间件的 HTTP 引擎
func NewEngine() *gin.Engine {
r := gin.Default()
r.Use(middleware.CORS())
return r
}
// MountImages 挂载内嵌图片资源(许愿树背景图等)
func MountImages(r *gin.Engine) {
if imagesFS, err := fs.Sub(assets.FS, "images"); err == nil {
r.GET("/images/*path", gin.WrapH(http.StripPrefix("/images", http.FileServer(http.FS(imagesFS)))))
}
}
// Run 启动 HTTP 服务
func Run(r *gin.Engine, port string) {
addr := ":" + port
log.Printf("Server starting on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
+79
View File
@@ -0,0 +1,79 @@
package app
import (
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware"
"github.com/gouki/lunar-server/internal/service"
)
// SetupMini 注册小程序接口路由与定时任务。
// 不包含商业版支付、邮箱登录与管理后台——这些只属于 cmd/web。
func SetupMini(r *gin.Engine, cfg *config.Config) {
// 启动机器人许愿定时任务
wishService := service.NewWishService()
wishService.StartRobotWishJob()
// 启动维基百科同步定时任务(历史上的今天;首次启动数据为空时自动全量抓取)
handler.WikiSync = service.NewWikiSyncService(config.GetDB())
handler.WikiSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 启动节气详情同步定时任务(百度百科;独立于维基同步,首次启动缺详情时自动补齐)
handler.BaikeTermSync = service.NewBaikeTermSyncService(config.GetDB())
handler.BaikeTermSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 内嵌图片资源(许愿树背景图等)
MountImages(r)
api := r.Group("/api")
{
api.GET("/version", handler.GetVersion)
user := api.Group("/user")
{
user.POST("/login", handler.UserLogin)
user.POST("/logout", handler.UserLogout)
user.GET("/profile", middleware.Auth(), handler.GetUserProfile)
user.PUT("/profile", middleware.Auth(), handler.UpdateUserProfile)
user.POST("/auth", handler.WechatAuth)
// 生辰档案(八字绑定,每用户免费 3 个)
user.GET("/birth-profiles", middleware.Auth(), handler.GetBirthProfiles)
user.POST("/birth-profiles", middleware.Auth(), handler.CreateBirthProfile)
user.DELETE("/birth-profiles/:id", middleware.Auth(), handler.DeleteBirthProfile)
}
// 支付相关(许愿付费;演示期保留接口占位)
pay := api.Group("/pay")
{
pay.POST("/create", middleware.Auth(), handler.CreateOrder)
pay.POST("/notify", handler.PayNotify)
pay.GET("/status/:orderId", middleware.Auth(), handler.GetPayStatus)
}
order := api.Group("/order")
{
order.GET("/list", middleware.Auth(), handler.GetOrderList)
order.GET("/:id", middleware.Auth(), handler.GetOrderDetail)
order.POST("/cancel/:id", middleware.Auth(), handler.CancelOrder)
}
wish := api.Group("/wish")
{
wish.GET("/trees", handler.GetWishTrees)
wish.GET("/tree", handler.GetWishTree)
wish.GET("/tree/:id/wishes", handler.GetTreeWishes)
wish.POST("/create", middleware.Auth(), handler.CreateWish)
wish.GET("/list", handler.GetWishList)
wish.GET("/:id", handler.GetWishDetail)
wish.DELETE("/:id", middleware.Auth(), handler.DeleteWish)
wish.GET("/products", handler.GetWishProducts)
}
wiki := api.Group("/wiki")
{
wiki.GET("/entries", handler.GetWikiEntries)
wiki.GET("/on-this-day", handler.GetOnThisDay)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
package app
import (
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware"
)
// SetupWeb 注册商业版 Web 路由:邮箱登录、支付/订单、报告权益、
// 管理后台与商业前端静态文件。不包含小程序接口(许愿树、微信登录等)。
//
// 静态资源约定(相对 WorkingDirectory):
//
// ./frontend/ 商业前端构建产物(web-commercial/dist)
// ./web/ 管理后台静态资源与模板(server/web)
func SetupWeb(r *gin.Engine, cfg *config.Config) {
// 商业版前端静态文件;后台仍通过 /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.LoadHTMLFiles("./web/index.html")
// 内嵌图片资源(管理后台可能引用许愿树图片)
MountImages(r)
api := r.Group("/api")
{
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", 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)
}
}
// 管理后台路由(Inertia.js)
admin := r.Group("/admin")
{
admin.POST("/login", handler.AdminLogin)
admin.POST("/logout", handler.AdminLogout)
authed := admin.Group("")
authed.Use(middleware.AdminAuth())
{
authed.GET("/", handler.AdminDashboard)
authed.GET("/users", handler.AdminUsers)
authed.GET("/orders", handler.AdminOrders)
authed.GET("/charts", handler.AdminCharts)
authed.GET("/wishes", handler.AdminWishes)
authed.GET("/wiki", handler.AdminWiki)
authed.GET("/settings", handler.AdminSettings)
authed.GET("/api/wiki/entries", handler.AdminWikiEntries)
authed.POST("/api/wiki/entries", handler.AdminWikiEntryCreate)
authed.PUT("/api/wiki/entries/:id", handler.AdminWikiEntryUpdate)
authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete)
authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus)
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.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger)
}
}
// SPA 回退:非 /api、/admin、/static、/images、/assets 的 GET 请求
// 一律返回商业前端 index.html,交给前端路由处理。
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
for _, prefix := range []string{"/api/", "/admin", "/static/", "/images/", "/assets/"} {
if strings.HasPrefix(path, prefix) {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "not found"})
return
}
}
if _, err := os.Stat("./frontend/index.html"); err == nil {
c.File("./frontend/index.html")
return
}
c.String(http.StatusNotFound, "frontend not deployed")
})
}
+8
View File
@@ -0,0 +1,8 @@
package assets
import "embed"
// 内嵌图片资源(许愿树背景图等),随二进制一起部署
//
//go:embed images
var FS embed.FS

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 332 KiB

+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", ""),
}, },
+16 -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,12 +56,19 @@ 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{},
&model.WikiEntry{}, &model.WikiEntry{},
&model.WikiOnThisDay{}, &model.WikiOnThisDay{},
&model.WikiSyncLog{}, &model.WikiSyncLog{},
&model.BaikeTermSyncLog{},
); err != nil { ); err != nil {
return err return err
} }
@@ -77,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": "订单不存在",
+40
View File
@@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"github.com/gin-gonic/gin" "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/model"
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
) )
@@ -12,6 +13,9 @@ import (
// WikiSync 维基百科同步服务实例(main 中注入) // WikiSync 维基百科同步服务实例(main 中注入)
var WikiSync *service.WikiSyncService var WikiSync *service.WikiSyncService
// BaikeTermSync 节气详情同步服务实例(main 中注入,独立于维基同步)
var BaikeTermSync *service.BaikeTermSyncService
// ===== 公开接口(小程序) ===== // ===== 公开接口(小程序) =====
// GetWikiEntries 百科条目列表(小程序百科页) // GetWikiEntries 百科条目列表(小程序百科页)
@@ -159,3 +163,39 @@ func AdminWikiSyncTrigger(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "同步任务已启动"}) c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "同步任务已启动"})
} }
// ===== 节气详情同步(百度百科,独立于维基) =====
// AdminTermSyncStatus 节气详情同步状态(X/24 已含详情 + 最近一次同步日志)
func AdminTermSyncStatus(c *gin.Context) {
db := config.GetDB()
var detailed int64
db.Model(&model.WikiEntry{}).Where("category = ? AND image != ''", "term").Count(&detailed)
var lastLog model.BaikeTermSyncLog
db.Order("started_at DESC").First(&lastLog)
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": gin.H{
"running": BaikeTermSync != nil && BaikeTermSync.IsRunning(),
"detailedTerms": detailed,
"totalTerms": 24,
"lastSync": lastLog,
},
})
}
// AdminTermSyncTrigger 手动触发节气详情同步(异步执行)
func AdminTermSyncTrigger(c *gin.Context) {
if BaikeTermSync == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "msg": "节气同步服务未初始化"})
return
}
if !BaikeTermSync.TriggerSync("admin", true) {
c.JSON(http.StatusConflict, gin.H{"code": 409, "msg": "已有节气同步任务正在执行,请稍后再试"})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "节气同步任务已启动"})
}
+17 -2
View File
@@ -9,6 +9,9 @@ import (
"github.com/gouki/lunar-server/internal/service" "github.com/gouki/lunar-server/internal/service"
) )
// 内容安全检测服务(微信 msgSecCheck),access_token 内部缓存
var securityService = service.NewContentSecurityService()
// GetWishTrees 获取许愿树列表 // GetWishTrees 获取许愿树列表
func GetWishTrees(c *gin.Context) { func GetWishTrees(c *gin.Context) {
wishService := service.NewWishService() wishService := service.NewWishService()
@@ -79,12 +82,12 @@ func CreateWish(c *gin.Context) {
return return
} }
// 检查内容长度 // 检查内容长度(按字符数,中文按 1 字计)
maxLength := 20 maxLength := 20
if req.Type == "paid" { if req.Type == "paid" {
maxLength = 100 maxLength = 100
} }
if len(req.Content) > maxLength { if len([]rune(req.Content)) > maxLength {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"code": 400, "code": 400,
"msg": "内容长度超过限制", "msg": "内容长度超过限制",
@@ -92,6 +95,18 @@ func CreateWish(c *gin.Context) {
return return
} }
// 内容安全检测(微信 msgSecCheck):UGC 内容发布前必须过检
userService := service.NewUserService()
if user, err := userService.GetUserByID(userID.(uint)); err == nil && user != nil {
if err := securityService.CheckText(user.OpenID, req.Content); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"msg": "内容未通过安全检测:" + err.Error(),
})
return
}
}
wishService := service.NewWishService() wishService := service.NewWishService()
wish := &model.Wish{ wish := &model.Wish{
UserID: userID.(uint), UserID: userID.(uint),
+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")
}
}
+18
View File
@@ -0,0 +1,18 @@
package model
import (
"time"
)
// BaikeTermSyncLog 节气详情同步日志(独立于维基 WikiSyncLog,互不影响)
type BaikeTermSyncLog struct {
ID uint `gorm:"primaryKey" json:"id"`
Trigger string `gorm:"size:20" json:"trigger"` // startup:启动自动 cron:定时任务 admin:手动
Status string `gorm:"size:20" json:"status"` // running:进行中 success:成功 partial:部分失败 failed:失败
Pages int `json:"pages"` // 总节气数(24)
Success int `json:"success"` // 成功数
Failed int `json:"failed"` // 失败数
Error string `gorm:"size:500" json:"error"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt"` // 同步完成时间,进行中为 NULL
}
+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"` // 数量
} }
+2
View File
@@ -11,6 +11,8 @@ type WikiEntry struct {
Title string `gorm:"size:50" json:"title"` Title string `gorm:"size:50" json:"title"`
Brief string `gorm:"size:255" json:"brief"` // 简介(列表折叠时显示) Brief string `gorm:"size:255" json:"brief"` // 简介(列表折叠时显示)
Content string `gorm:"type:text" json:"content"` // 详细内容(展开后显示) Content string `gorm:"type:text" json:"content"` // 详细内容(展开后显示)
Image string `gorm:"size:255" json:"image"` // 配图 URL(节气等条目的配图)
Extra string `gorm:"type:text" json:"extra"` // 扩展字段 JSON(节气:别名/外文名/物候/习俗/养生等)
Sort int `gorm:"default:0" json:"sort"` Sort int `gorm:"default:0" json:"sort"`
Status int `gorm:"default:1" json:"status"` // 1:显示 0:隐藏 Status int `gorm:"default:1" json:"status"` // 1:显示 0:隐藏
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
+398
View File
@@ -0,0 +1,398 @@
package service
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"sync/atomic"
"time"
"github.com/gouki/lunar-server/internal/model"
"gorm.io/gorm"
)
// BaikeTermSyncService 二十四节气详情同步服务
// 数据源:百度百科开放接口 BaikeLemmaCardApi(词条卡片,含简介/配图/信息卡)
// 与「历史上的今天」(WikiSyncService)完全独立,互不影响
type BaikeTermSyncService struct {
db *gorm.DB
running int32 // 原子标记:是否有同步正在执行
httpClient *http.Client
}
// NewBaikeTermSyncService 创建节气同步服务
func NewBaikeTermSyncService(db *gorm.DB) *BaikeTermSyncService {
return &BaikeTermSyncService{
db: db,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// solarTermNames 二十四节气(顺序即 Sort 排序)
var solarTermNames = []string{
"立春", "雨水", "惊蛰", "春分", "清明", "谷雨",
"立夏", "小满", "芒种", "夏至", "小暑", "大暑",
"立秋", "处暑", "白露", "秋分", "寒露", "霜降",
"立冬", "小雪", "大雪", "冬至", "小寒", "大寒",
}
var (
// reTermSup 匹配 <sup>...</sup> 引用标记(含内容一并去除,避免残留引用编号)
reTermSup = regexp.MustCompile(`(?s)<sup[^>]*>.*?</sup>`)
reTermHTMLTag = regexp.MustCompile(`</?[a-zA-Z][^>]*>`)
reTermSpace = regexp.MustCompile(`\s+`)
)
// IsRunning 是否有同步任务正在执行
func (s *BaikeTermSyncService) IsRunning() bool {
return atomic.LoadInt32(&s.running) == 1
}
// detailedTermCount 已含详情(image 非空)的节气条目数
func (s *BaikeTermSyncService) detailedTermCount() int64 {
var count int64
s.db.Model(&model.WikiEntry{}).
Where("category = ? AND image != ''", "term").
Count(&count)
return count
}
// TriggerSync 触发一次节气同步(异步执行);已有任务执行中时返回 false
// full=true 全量刷新 24 节气;full=false 仅补齐缺详情的节气
func (s *BaikeTermSyncService) TriggerSync(trigger string, full bool) bool {
names := solarTermNames
if !full {
names = s.missingTerms()
if len(names) == 0 {
return false // 无缺失,无需同步
}
}
if !atomic.CompareAndSwapInt32(&s.running, 0, 1) {
return false
}
go func() {
defer atomic.StoreInt32(&s.running, 0)
s.runSync(trigger, names)
}()
return true
}
// missingTerms 尚未抓取详情的节气(image 为空)
func (s *BaikeTermSyncService) missingTerms() []string {
var existing []string
s.db.Model(&model.WikiEntry{}).
Where("category = ? AND image != ''", "term").
Pluck("title", &existing)
set := make(map[string]bool, len(existing))
for _, t := range existing {
set[t] = true
}
missing := make([]string, 0, len(solarTermNames))
for _, name := range solarTermNames {
if !set[name] {
missing = append(missing, name)
}
}
return missing
}
// StartScheduler 启动定时同步任务:
// - 启动时若节气详情缺失,延迟 15 秒后自动补齐
// - 之后每天在 syncHour 小时点检查,距上次成功超过 intervalDays 天才执行
func (s *BaikeTermSyncService) StartScheduler(intervalDays, syncHour int) {
go func() {
if missing := s.missingTerms(); len(missing) > 0 {
log.Printf("term sync: 缺失 %d/24 节气详情,15秒后自动补齐", len(missing))
time.Sleep(15 * time.Second)
s.TriggerSync("startup", false)
}
if intervalDays <= 0 {
intervalDays = 7
}
if syncHour < 0 || syncHour > 23 {
syncHour = 4
}
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
if now.Hour() != syncHour {
continue
}
if last, ok := s.lastSuccessAt(); ok && time.Since(last) < time.Duration(intervalDays)*24*time.Hour {
continue // 未到刷新周期
}
log.Println("term sync: 定时任务触发全量同步")
s.TriggerSync("cron", true)
}
}()
}
// lastSuccessAt 最近一次成功(含部分成功)的同步完成时间
func (s *BaikeTermSyncService) lastSuccessAt() (time.Time, bool) {
var syncLog model.BaikeTermSyncLog
err := s.db.Where("status IN ?", []string{"success", "partial"}).
Order("finished_at DESC").First(&syncLog).Error
if err != nil || syncLog.FinishedAt == nil {
return time.Time{}, false
}
return *syncLog.FinishedAt, true
}
// runSync 同步指定节气集合(全量 24 或缺失补齐)
func (s *BaikeTermSyncService) runSync(trigger string, names []string) {
syncLog := model.BaikeTermSyncLog{
Trigger: trigger,
Status: "running",
Pages: len(names),
StartedAt: time.Now(),
}
s.db.Create(&syncLog)
var success, failed int32
for _, name := range names {
if err := s.syncOneTerm(name); err != nil {
atomic.AddInt32(&failed, 1)
log.Printf("term sync: %s 失败: %v", name, err)
} else {
atomic.AddInt32(&success, 1)
}
time.Sleep(1200 * time.Millisecond) // 控制请求频率,避免触发限流
}
status := "success"
if failed > 0 {
status = "partial"
}
if success == 0 {
status = "failed"
}
now := time.Now()
s.db.Model(&syncLog).Updates(map[string]interface{}{
"status": status,
"success": int(success),
"failed": int(failed),
"finished_at": &now,
})
log.Printf("term sync: 同步完成 trigger=%s terms=%d success=%d failed=%d", trigger, len(names), success, failed)
}
// ===== 百度百科节气抓取与解析 =====
// baikeCardItem 信息卡单项
type baikeCardItem struct {
Key string `json:"key"`
Name string `json:"name"`
Value []string `json:"value"`
}
// baikeLemmaCard 百度百科词条卡片返回结构(只取需要的字段)
type baikeLemmaCard struct {
Key string `json:"key"` // 词条名
Desc string `json:"desc"` // 一句话描述
Title string `json:"title"` // 标题
Abstract string `json:"abstract"`// 简介(含 HTML)
Image string `json:"image"` // 配图 URL
Card []baikeCardItem `json:"card"` // 信息卡
}
// syncOneTerm 抓取单个节气详情并写入/更新 WikiEntry
func (s *BaikeTermSyncService) syncOneTerm(name string) error {
lemma, err := s.fetchLemma(name)
if err != nil {
return err
}
// 解析信息卡为扩展字段
extra := map[string]string{}
for _, c := range lemma.Card {
text := cleanTermHTML(firstValue(c.Value))
if text == "" {
continue
}
switch c.Name {
case "别名":
extra["alias"] = text
case "外文名":
extra["english"] = text
case "涵义":
extra["meaning"] = text
case "公历时间":
extra["solarDate"] = text
case "黄道位置":
extra["ecliptic"] = text
case "气候特点":
extra["climate"] = text
case "物候现象":
extra["phenology"] = text
case "农事活动":
extra["farming"] = text
case "传统习俗":
extra["customs"] = text
case "起居养生":
extra["health"] = text
case "花信":
extra["flower"] = text
}
}
extraJSON, _ := json.Marshal(extra)
content := trimTruncatedTail(cleanTermHTML(lemma.Abstract))
brief := extra["meaning"]
if brief == "" {
brief = firstSentence(content)
}
if len([]rune(brief)) > 120 {
brief = string([]rune(brief)[:120])
}
// 按 category=term AND title=name 查找,存在则更新,不存在则新建
var entry model.WikiEntry
err = s.db.Where("category = ? AND title = ?", "term", name).First(&entry).Error
if err == gorm.ErrRecordNotFound {
entry = model.WikiEntry{
Category: "term",
Title: name,
Brief: brief,
Content: content,
Image: lemma.Image,
Extra: string(extraJSON),
Sort: termSort(name),
Status: 1,
}
return s.db.Create(&entry).Error
} else if err != nil {
return err
}
return s.db.Model(&entry).Updates(map[string]interface{}{
"brief": brief,
"content": content,
"image": lemma.Image,
"extra": string(extraJSON),
}).Error
}
// fetchLemma 调用百度百科 BaikeLemmaCardApi 抓取词条卡片(失败重试)
func (s *BaikeTermSyncService) fetchLemma(name string) (*baikeLemmaCard, error) {
apiURL := "https://baike.baidu.com/api/openapi/BaikeLemmaCardApi?scope=103&format=json&appid=379020&bk_length=600&bk_key=" + url.QueryEscape(name)
var lastErr error
for attempt := 0; attempt < 4; attempt++ {
if attempt > 0 {
time.Sleep(time.Duration(attempt*3) * time.Second)
}
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36")
req.Header.Set("Referer", "https://baike.baidu.com/")
req.Header.Set("Accept", "application/json, text/plain, */*")
resp, err := s.httpClient.Do(req)
if err != nil {
lastErr = err
continue
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) // 上限 2MB
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
continue
}
if err != nil {
lastErr = err
continue
}
var lemma baikeLemmaCard
if err := json.Unmarshal(body, &lemma); err != nil {
lastErr = fmt.Errorf("JSON 解析失败: %w", err)
continue
}
if lemma.Abstract == "" && len(lemma.Card) == 0 {
lastErr = fmt.Errorf("%s 返回内容为空", name)
continue
}
return &lemma, nil
}
return nil, lastErr
}
// termSort 节气在列表中的排序(按节气顺序)
func termSort(name string) int {
for i, n := range solarTermNames {
if n == name {
return i + 1
}
}
return 0
}
// firstValue 取信息卡 value 的第一个值
func firstValue(values []string) string {
if len(values) == 0 {
return ""
}
return values[0]
}
// firstSentence 取文本首句(按句号/感叹号/问号切分)
func firstSentence(s string) string {
for _, sep := range []string{"。", "!", "?"} {
if idx := strings.Index(s, sep); idx > 0 {
return s[:idx+len(sep)]
}
}
return s
}
// cleanTermHTML 去除 HTML 标签(含 sup 引用标记整体)与多余空白,输出纯文本(本地实现,与维基同步解耦)
func cleanTermHTML(s string) string {
s = reTermSup.ReplaceAllString(s, "") // 先去 <sup>引用编号</sup>(含内容)
s = reTermHTMLTag.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "&nbsp;", " ")
s = reTermSpace.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}
// trimTruncatedTail 清理百度百科摘要的截断结尾:
// 摘要以「...」「…」等结尾说明被接口截断,先去掉省略号;
// 若去掉后末句不是完整句(不以 。!? 结尾),回退到最后一个完整句标点,避免半截话
func trimTruncatedTail(s string) string {
// 去掉结尾的省略号(英文 ... / 中文 … / 多个点)
s = strings.TrimRight(s, ".… ")
if s == "" {
return s
}
// 末字符已是完整句标点则无需回退
last := []rune(s)
if len(last) == 0 {
return s
}
if strings.ContainsRune("。!?", last[len(last)-1]) {
return s
}
// 回退到最后一个完整句标点(LastIndex 未找到返回 -1,需先判断 j >= 0)
idx := -1
for _, sep := range []string{"。", "!", "?"} {
if j := strings.LastIndex(s, sep); j >= 0 && j+len(sep) > idx {
idx = j + len(sep)
}
}
if idx > 0 {
return s[:idx]
}
// 没有完整句标点则保留原文(避免清空)
return s
}
@@ -0,0 +1,151 @@
package service
import (
"encoding/json"
"strings"
"testing"
)
// 取自百度百科 BaikeLemmaCardApi 「立春」的真实返回片段
const sampleLemmaJSON = `{
"id":25702,"key":"立春","desc":"二十四节气之一","title":"立春",
"image":"https://bkimg.cdn.bcebos.com/pic/d62a6059252dd42a2834edd463674cb5c9ea15ce1205",
"abstract":"立春(Beginning of Spring),又名正月节、岁节、改岁、岁旦、打春等,为二十四节气之首。当太阳到达黄经315°时为立春,于每年公历2月3-5日交节。",
"card":[
{"key":"m25_nameC","name":"中文名","value":["立春"]},
{"key":"m25_nameE","name":"外文名","value":["Beginning of Spring<sup>3</sup><a name=\"ref_3\"></a>","Lichun"]},
{"key":"m25_alternateName","name":"别名","value":["岁首、岁节、改岁、立春节、正月节"]},
{"key":"m25_meaning","name":"涵义","value":["阳气上升,万物更生,新岁开启"]},
{"key":"m25_solarDate","name":"公历时间","value":["公历2月3日-2月5日交节"]},
{"key":"m25_ecliptic","name":"黄道位置","value":["太阳到达黄经315度"]},
{"key":"m25_phenology","name":"物候现象","value":["东风解冻、蛰虫始振、鱼陟负冰"]},
{"key":"m25_farming","name":"农事活动","value":["备耕春播"]},
{"key":"m25_customs","name":"传统习俗","value":["<a target=_blank href=\"/item/x\">演春</a>、迎春、打春"]},
{"key":"m25_health","name":"起居养生","value":["防躁动,避免过度劳累,保证睡眠情绪豁达。"]},
{"key":"m25_flower","name":"花信","value":["迎春、樱桃、望春<sup>35</sup><a name=\"ref_35\"></a>"]}
]
}`
func TestParseLemmaCard(t *testing.T) {
var lemma baikeLemmaCard
if err := json.Unmarshal([]byte(sampleLemmaJSON), &lemma); err != nil {
t.Fatalf("JSON 解析失败: %v", err)
}
if lemma.Key != "立春" {
t.Errorf("key = %q, want 立春", lemma.Key)
}
if lemma.Image == "" {
t.Error("image 不应为空")
}
if !strings.Contains(lemma.Abstract, "二十四节气之首") {
t.Errorf("abstract 应含「二十四节气之首」, got %q", lemma.Abstract)
}
if len(lemma.Card) == 0 {
t.Fatal("card 不应为空")
}
}
func TestLemmaCardMapping(t *testing.T) {
var lemma baikeLemmaCard
json.Unmarshal([]byte(sampleLemmaJSON), &lemma)
extra := map[string]string{}
for _, c := range lemma.Card {
text := cleanTermHTML(firstValue(c.Value))
if text == "" {
continue
}
switch c.Name {
case "别名":
extra["alias"] = text
case "外文名":
extra["english"] = text
case "涵义":
extra["meaning"] = text
case "物候现象":
extra["phenology"] = text
case "传统习俗":
extra["customs"] = text
case "起居养生":
extra["health"] = text
}
}
if extra["alias"] != "岁首、岁节、改岁、立春节、正月节" {
t.Errorf("alias = %q", extra["alias"])
}
if extra["meaning"] != "阳气上升,万物更生,新岁开启" {
t.Errorf("meaning = %q", extra["meaning"])
}
if extra["phenology"] != "东风解冻、蛰虫始振、鱼陟负冰" {
t.Errorf("phenology = %q", extra["phenology"])
}
// 传统习俗含 HTML 链接,应被清洗
if strings.Contains(extra["customs"], "<a") || strings.Contains(extra["customs"], "href") {
t.Errorf("customs 应清洗 HTML, got %q", extra["customs"])
}
if !strings.Contains(extra["customs"], "演春") {
t.Errorf("customs 应保留文字「演春」, got %q", extra["customs"])
}
// 外文名含 <sup> 应清洗
if strings.Contains(extra["english"], "<sup>") {
t.Errorf("english 应清洗 sup 标签, got %q", extra["english"])
}
}
func TestCleanTermHTML(t *testing.T) {
cases := map[string]string{
`<a target=_blank href="/item/x">演春</a>、迎春`: "演春、迎春",
`Beginning of Spring<sup>3</sup><a name="ref_3"></a>`: "Beginning of Spring",
` 多余&nbsp; 空白 `: "多余 空白",
}
for in, want := range cases {
if got := cleanTermHTML(in); got != want {
t.Errorf("cleanTermHTML(%q) = %q, want %q", in, got, want)
}
}
}
func TestFirstSentence(t *testing.T) {
s := "立春为二十四节气之首。当太阳到达黄经315°时为立春。"
if got := firstSentence(s); got != "立春为二十四节气之首。" {
t.Errorf("firstSentence = %q", got)
}
// 无句号时返回原文
if got := firstSentence("无句号文本"); got != "无句号文本" {
t.Errorf("firstSentence 无句号 = %q", got)
}
}
func TestTermSort(t *testing.T) {
if termSort("立春") != 1 {
t.Errorf("termSort(立春) = %d, want 1", termSort("立春"))
}
if termSort("大寒") != 24 {
t.Errorf("termSort(大寒) = %d, want 24", termSort("大寒"))
}
if termSort("不存在") != 0 {
t.Errorf("termSort(不存在) = %d, want 0", termSort("不存在"))
}
}
func TestTrimTruncatedTail(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"完整句号结尾不动", "夏至后人们普遍食用清补凉汤来避暑。", "夏至后人们普遍食用清补凉汤来避暑。"},
{"英文省略号回退", "表达感谢的心情。唐宋时期,冬至...", "表达感谢的心情。"},
{"中文省略号回退", "表达感谢的心情。唐宋时期,冬至…", "表达感谢的心情。"},
{"无句号半截话保留", "可明显感觉到早春的气...", "可明显感觉到早春的气"},
{"多点省略号", "农事谚语....", "农事谚语"},
{"结尾空白加省略号", "冬至大如年。 ...", "冬至大如年。"},
{"空字符串", "", ""},
}
for _, c := range cases {
if got := trimTruncatedTail(c.in); got != c.want {
t.Errorf("%s: trimTruncatedTail(%q) = %q, want %q", c.name, c.in, got, c.want)
}
}
}
+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
}
+134
View File
@@ -0,0 +1,134 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sync"
"time"
"github.com/gouki/lunar-server/internal/config"
)
// ContentSecurityService 微信内容安全检测(msgSecCheck v2)。
// 小程序 UGC 内容(许愿等)发布前必须过检测,否则有下架风险。
type ContentSecurityService struct {
mu sync.Mutex
accessToken string
tokenExpire time.Time
}
func NewContentSecurityService() *ContentSecurityService {
return &ContentSecurityService{}
}
// 获取小程序 access_token,内存缓存到过期前 5 分钟
func (s *ContentSecurityService) getAccessToken(cfg *config.Config) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.accessToken != "" && time.Now().Before(s.tokenExpire) {
return s.accessToken, nil
}
query := url.Values{
"grant_type": {"client_credential"},
"appid": {cfg.Wechat.AppID},
"secret": {cfg.Wechat.AppSecret},
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://api.weixin.qq.com/cgi-bin/token?" + query.Encode())
if err != nil {
return "", fmt.Errorf("get access_token failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", fmt.Errorf("read access_token response failed: %w", err)
}
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse access_token response failed: %w", err)
}
if result.ErrCode != 0 || result.AccessToken == "" {
return "", fmt.Errorf("get access_token failed: errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
s.accessToken = result.AccessToken
s.tokenExpire = time.Now().Add(time.Duration(result.ExpiresIn-300) * time.Second)
return s.accessToken, nil
}
// CheckText 校验文本是否安全。返回 nil 表示通过;
// 返回错误表示内容违规或检测失败(调用方应拒绝发布)。
//
// 未配置小程序凭证时(本地开发)放行并打日志;
// 生产环境若必须严格放行策略可再收紧。
func (s *ContentSecurityService) CheckText(openID, content string) error {
cfg := config.Load()
if cfg.Wechat.AppID == "" || cfg.Wechat.AppSecret == "" {
log.Printf("[content-security] 未配置小程序凭证,跳过检测")
return nil
}
token, err := s.getAccessToken(cfg)
if err != nil {
return err
}
reqBody, _ := json.Marshal(map[string]interface{}{
"version": 2,
"openid": openID,
"scene": 2, // 2=评论/留言类 UGC
"content": content,
})
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(
"https://api.weixin.qq.com/wxa/msg_sec_check?access_token="+token,
"application/json",
bytes.NewReader(reqBody),
)
if err != nil {
return fmt.Errorf("msg_sec_check request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return fmt.Errorf("read msg_sec_check response failed: %w", err)
}
var result struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Result struct {
Suggest string `json:"suggest"`
Label int `json:"label"`
} `json:"result"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("parse msg_sec_check response failed: %w", err)
}
if result.ErrCode != 0 {
return fmt.Errorf("msg_sec_check failed: errcode=%d errmsg=%s", result.ErrCode, result.ErrMsg)
}
switch result.Result.Suggest {
case "pass":
return nil
case "review":
// 人工复核档位:许愿类短文本直接拒绝,提示修改
return fmt.Errorf("内容需要审核,请修改后重试")
default:
return fmt.Errorf("内容不符合社区规范")
}
}
+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
} }
+2
View File
@@ -61,6 +61,8 @@ func (s *WikiService) UpdateEntry(id uint, entry *model.WikiEntry) error {
"title": entry.Title, "title": entry.Title,
"brief": entry.Brief, "brief": entry.Brief,
"content": entry.Content, "content": entry.Content,
"image": entry.Image,
"extra": entry.Extra,
"sort": entry.Sort, "sort": entry.Sort,
"status": entry.Status, "status": entry.Status,
}).Error }).Error
+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">
+107 -36
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">
<!-- 标签页切换 --> <!-- 标签页切换 -->
@@ -47,6 +19,27 @@ export default {
<!-- 百科条目管理 --> <!-- 百科条目管理 -->
<div class="px-4 py-2 sm:px-0" v-if="tab === 'entries'"> <div class="px-4 py-2 sm:px-0" v-if="tab === 'entries'">
<!-- 节气详情同步(百度百科) -->
<div class="bg-white shadow rounded-lg mb-4">
<div class="px-4 py-4 sm:px-6 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-4">
<div>
<div class="text-sm font-medium text-gray-900">节气详情(百度百科)</div>
<div class="text-xs text-gray-500 mt-0.5">
已含详情 {{ termSync.detailedTerms || 0 }} / {{ termSync.totalTerms || 24 }}
<span v-if="termSync.lastSync && termSync.lastSync.startedAt"> | 最近同步:{{ formatTime(termSync.lastSync.startedAt) }}</span>
<span v-if="termSync.lastSync && termSync.lastSync.status" :class="termSync.lastSync.status === 'success' ? 'text-green-600' : 'text-yellow-600'"> | {{ termSync.lastSync.status === 'success' ? '成功' : termSync.lastSync.status === 'partial' ? '部分失败' : '失败' }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="loadTermSyncStatus" class="px-3 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">刷新</button>
<button @click="triggerTermSync" :disabled="termSync.running || termSyncing" class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-sync mr-1" :class="termSync.running ? 'fa-spin' : ''"></i>{{ termSync.running ? '同步中...' : '同步节气详情' }}
</button>
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg"> <div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6"> <div class="px-4 py-5 sm:p-6">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4"> <div class="flex flex-wrap items-center justify-between gap-3 mb-4">
@@ -170,6 +163,21 @@ export default {
<label class="block text-sm font-medium text-gray-700 mb-1">详细内容</label> <label class="block text-sm font-medium text-gray-700 mb-1">详细内容</label>
<textarea v-model="editor.form.content" rows="6" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"></textarea> <textarea v-model="editor.form.content" rows="6" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"></textarea>
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">配图 URL</label>
<input v-model="editor.form.image" placeholder="https://..." class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
<div v-if="editor.form.image" class="mt-2"><img :src="editor.form.image" class="h-20 rounded border" alt="配图预览"/></div>
</div>
<!-- 节气扩展字段(仅节气分类显示) -->
<div v-if="editor.form.category === 'term'">
<label class="block text-sm font-medium text-gray-700 mb-2">节气扩展信息</label>
<div class="grid grid-cols-2 gap-3">
<div v-for="f in termExtraFields" :key="f.key">
<label class="block text-xs text-gray-500 mb-1">{{ f.name }}</label>
<input v-model="editor.extra[f.key]" class="block w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm"/>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">排序(越小越靠前)</label> <label class="block text-sm font-medium text-gray-700 mb-1">排序(越小越靠前)</label>
@@ -210,20 +218,39 @@ export default {
editor: { editor: {
visible: false, visible: false,
id: null, id: null,
form: { category: 'other', title: '', brief: '', content: '', sort: 0, status: 1 }, form: { category: 'other', title: '', brief: '', content: '', image: '', sort: 0, status: 1 },
extra: {},
}, },
termExtraFields: [
{ key: 'alias', name: '别名' },
{ key: 'english', name: '外文名' },
{ key: 'meaning', name: '涵义' },
{ key: 'solarDate', name: '公历时间' },
{ key: 'ecliptic', name: '黄道位置' },
{ key: 'climate', name: '气候特点' },
{ key: 'phenology', name: '物候现象' },
{ key: 'farming', name: '农事活动' },
{ key: 'customs', name: '传统习俗' },
{ key: 'health', name: '起居养生' },
{ key: 'flower', name: '花信' },
],
saving: false, saving: false,
syncing: false, syncing: false,
syncStatus: {}, syncStatus: {},
syncTimer: null, syncTimer: null,
termSync: {},
termSyncing: false,
termSyncTimer: null,
}; };
}, },
mounted() { mounted() {
this.loadEntries(); this.loadEntries();
this.loadSyncStatus(); this.loadSyncStatus();
this.loadTermSyncStatus();
}, },
beforeUnmount() { beforeUnmount() {
if (this.syncTimer) clearInterval(this.syncTimer); if (this.syncTimer) clearInterval(this.syncTimer);
if (this.termSyncTimer) clearInterval(this.termSyncTimer);
}, },
methods: { methods: {
catName(key) { catName(key) {
@@ -265,22 +292,40 @@ export default {
openEditor(entry) { openEditor(entry) {
this.editor.id = entry ? entry.id : null; this.editor.id = entry ? entry.id : null;
this.editor.form = entry this.editor.form = entry
? { category: entry.category, title: entry.title, brief: entry.brief, content: entry.content, sort: entry.sort, status: entry.status } ? { category: entry.category, title: entry.title, brief: entry.brief, content: entry.content, image: entry.image || '', sort: entry.sort, status: entry.status }
: { category: 'other', title: '', brief: '', content: '', sort: 0, status: 1 }; : { category: 'other', title: '', brief: '', content: '', image: '', sort: 0, status: 1 };
let extra = {};
if (entry && entry.extra) {
try { extra = JSON.parse(entry.extra); } catch (e) { extra = {}; }
}
this.editor.extra = extra;
this.editor.visible = true; this.editor.visible = true;
}, },
async saveEntry() { async saveEntry() {
const f = this.editor.form; const f = this.editor.form;
if (!f.title.trim()) { alert('标题不能为空'); return; } if (!f.title.trim()) { alert('标题不能为空'); return; }
// 节气分类:把 extra 对象序列化为 JSON 字符串
const payload = { ...f };
if (f.category === 'term') {
const extra = {};
this.termExtraFields.forEach(fd => {
const v = (this.editor.extra[fd.key] || '').trim();
if (v) extra[fd.key] = v;
});
payload.extra = JSON.stringify(extra);
} else {
payload.extra = '';
}
this.saving = true; this.saving = true;
try { try {
if (this.editor.id) { if (this.editor.id) {
await this.api(`/admin/api/wiki/entries/${this.editor.id}`, { method: 'PUT', body: JSON.stringify(f) }); await this.api(`/admin/api/wiki/entries/${this.editor.id}`, { method: 'PUT', body: JSON.stringify(payload) });
} else { } else {
await this.api('/admin/api/wiki/entries', { method: 'POST', body: JSON.stringify(f) }); await this.api('/admin/api/wiki/entries', { method: 'POST', body: JSON.stringify(payload) });
} }
this.editor.visible = false; this.editor.visible = false;
this.loadEntries(); this.loadEntries();
this.loadTermSyncStatus();
} catch (e) { } catch (e) {
alert(e.message); alert(e.message);
} finally { } finally {
@@ -322,5 +367,31 @@ export default {
this.syncing = false; this.syncing = false;
} }
}, },
async loadTermSyncStatus() {
try {
this.termSync = await this.api('/admin/api/wiki/term-sync-status');
if (this.termSync.running && !this.termSyncTimer) {
this.termSyncTimer = setInterval(() => this.loadTermSyncStatus(), 5000);
} else if (!this.termSync.running && this.termSyncTimer) {
clearInterval(this.termSyncTimer);
this.termSyncTimer = null;
}
} catch (e) {
console.error(e);
}
},
async triggerTermSync() {
if (!confirm('将从百度百科抓取 24 节气的详细图文(约 30 秒),确认执行?')) return;
this.termSyncing = true;
try {
await this.api('/admin/api/wiki/term-sync', { method: 'POST' });
this.loadTermSyncStatus();
setTimeout(() => this.loadEntries(), 3000);
} catch (e) {
alert(e.message);
} finally {
this.termSyncing = false;
}
},
}, },
} }
+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 清单)

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