Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d1a1999f | ||
|
|
12fa2b0d19 | ||
|
|
28309e0257 | ||
|
|
73374bf93e | ||
|
|
a07ddb46e2 | ||
|
|
0e21d8e63f | ||
|
|
59d1af73b9 | ||
|
|
b63aabaa79 | ||
|
|
9b0e290d5d | ||
|
|
c161d42b06 | ||
|
|
132fef051b | ||
|
|
ea6828b925 | ||
|
|
deb32f8fab |
@@ -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 无需改动。
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -85,11 +85,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 +109,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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
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: Install pnpm
|
||||||
|
run: |
|
||||||
|
# pnpm 随工具链缓存,避免每次重复下载
|
||||||
|
TOOLDIR="$RUNNER_WORKSPACE/.lunar-toolchain"
|
||||||
|
if [ ! -x "$TOOLDIR/pnpm/bin/pnpm" ]; then
|
||||||
|
npm install -g --prefix "$TOOLDIR/pnpm" pnpm@9
|
||||||
|
fi
|
||||||
|
echo "PATH=$TOOLDIR/pnpm/bin:$PATH" >> "$GITEA_ENV"
|
||||||
|
pnpm --version
|
||||||
|
|
||||||
|
- name: Run Go tests
|
||||||
|
working-directory: server
|
||||||
|
run: |
|
||||||
|
export HOME="$RUNNER_WORKSPACE"
|
||||||
|
"$LUNAR_GO" mod download
|
||||||
|
"$LUNAR_GO" test ./...
|
||||||
|
|
||||||
|
- name: Build commercial frontend
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
run: |
|
||||||
|
export HOME="$RUNNER_WORKSPACE"
|
||||||
|
# web/ 工作区:装依赖并构建 @lunar/core(web-commercial 的 file: 依赖)
|
||||||
|
cd web
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm --filter @lunar/core build
|
||||||
|
cd ../web-commercial
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
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
@@ -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/
|
||||||
@@ -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` — 开发提醒(商业化功能规范)
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# 开发提醒
|
||||||
|
|
||||||
|
## 2026-09-06:商业化功能不能只完成界面
|
||||||
|
|
||||||
|
- 任何标记为“支付已接入”的功能,必须同时具备真实服务商下单、Webhook 验签、订单状态机、幂等处理、用户权益、退款处理和后台查询。
|
||||||
|
- 演示支付必须明确标注,不能把模拟数据描述成真实支付能力。
|
||||||
|
- 付费数字内容必须支持跨设备恢复,并保存购买时的不可变快照,避免算法升级后用户看到不同结果。
|
||||||
|
- 完成页面设计后,应继续验证完整的“登录—下单—支付通知—发放权益—恢复报告—退款撤权”闭环。
|
||||||
|
- 没有商户凭证时只能声明代码与模拟测试完成,不能声明真实生产收款已经开通。
|
||||||
|
- 管理后台新增 PC 商业模块后,必须统一品牌名称、页面标题和导航,不能让新旧产品名称混在同一套后台界面里。
|
||||||
|
- 排盘产品必须保存服务端历史快照;不能只依赖浏览器状态,否则重复排盘、算法升级和跨设备恢复都会产生歧义。
|
||||||
|
- 基础排盘事实(四柱、起运、刑冲合害、神煞位置)应作为免费层展示,付费点应放在个性化解释而不是隐藏原始数据。
|
||||||
|
- 交运时间不能直接使用 ChildLimit 的出生区间起点;应显示真正的第一步大运交运时间。
|
||||||
|
- 商业前端的本地 `file:` 依赖可能缓存旧版 `@lunar/core`;生产构建必须先构建 core,并通过显式别名引用最新 `dist`。
|
||||||
|
- 神煞规则不能统一套用“年干与日干”两个锚点;必须按每个神煞的标准查法指定锚点,并使用 `pp.test` 中的真实命例做回归测试。
|
||||||
|
- 大运切换必须同时切换该步大运对应的十年流年,不能让当前大运标题与首步大运流年混用。
|
||||||
|
- 排盘数据会进入服务端时,必须明确说明采集字段、保存目的与范围;免费排盘默认不上传,主动同意后才保存,同意版本需留档,公开写入接口必须限流。付费下单的报告快照应在下单前另行说明。
|
||||||
|
|
||||||
|
## 2026-09-25:邮件服务方案必须先确认代码可用
|
||||||
|
|
||||||
|
- 推荐邮箱 SMTP 服务前,先核对后端是否已实现对应协议;不能把“服务商支持 SMTP”说成“当前应用已经可以发信”。必须先完成实现和端到端验证,再指导用户配置凭证。
|
||||||
@@ -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
@@ -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,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"navigationBarTitleText": "占卜",
|
"navigationBarTitleText": "民俗趣算",
|
||||||
"enablePullDownRefresh": false
|
"enablePullDownRefresh": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<!-- 称骨结果 -->
|
<!-- 称骨结果 -->
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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'];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,40 +447,19 @@ 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 orderRes = await request({
|
|
||||||
url: '/api/pay/create',
|
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
type: 'wish',
|
|
||||||
productId: selectedProduct.id,
|
|
||||||
content: wishContent,
|
|
||||||
treeId: this.currentTreeId(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (orderRes.code === 0) {
|
|
||||||
await this.wxPay(orderRes.data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const res = await request({
|
const res = await request({
|
||||||
url: '/api/wish/create',
|
url: '/api/wish/create',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -532,37 +474,12 @@ Page({
|
|||||||
this.closeCreateModal();
|
this.closeCreateModal();
|
||||||
this.loadTreeWishes(this.data.currentTreeIndex, true);
|
this.loadTreeWishes(this.data.currentTreeIndex, true);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('许愿失败:', err);
|
console.error('许愿失败:', err);
|
||||||
wx.showToast({ title: '许愿失败,请重试', icon: 'none' });
|
wx.showToast({ title: '许愿失败,请重试', icon: 'none' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 微信支付
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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。
|
||||||
|
|||||||
@@ -1,174 +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)
|
|
||||||
|
|
||||||
// 启动节气详情同步定时任务(百度百科;独立于维基同步,首次启动缺详情时自动补齐)
|
|
||||||
handler.BaikeTermSync = service.NewBaikeTermSyncService(config.GetDB())
|
|
||||||
handler.BaikeTermSync.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)
|
|
||||||
// 节气详情同步(百度百科,独立于维基)
|
|
||||||
authed.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus)
|
|
||||||
authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动服务器
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 |
@@ -11,6 +11,11 @@ type Config struct {
|
|||||||
Redis RedisConfig
|
Redis RedisConfig
|
||||||
JWT JWTConfig
|
JWT JWTConfig
|
||||||
Wechat WechatConfig
|
Wechat WechatConfig
|
||||||
|
Stripe StripeConfig
|
||||||
|
BinancePay BinancePayConfig
|
||||||
|
Postmark PostmarkConfig
|
||||||
|
SMTP SMTPConfig
|
||||||
|
Commerce CommerceConfig
|
||||||
Admin AdminConfig
|
Admin AdminConfig
|
||||||
Wiki WikiConfig
|
Wiki WikiConfig
|
||||||
}
|
}
|
||||||
@@ -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", ""),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/gouki/lunar-server/internal/model"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"github.com/gouki/lunar-server/internal/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var DB *gorm.DB
|
var DB *gorm.DB
|
||||||
@@ -56,6 +56,12 @@ func autoMigrate() error {
|
|||||||
&model.BirthProfile{},
|
&model.BirthProfile{},
|
||||||
&model.Order{},
|
&model.Order{},
|
||||||
&model.OrderItem{},
|
&model.OrderItem{},
|
||||||
|
&model.ReportProduct{},
|
||||||
|
&model.ReportEntitlement{},
|
||||||
|
&model.ReportSnapshot{},
|
||||||
|
&model.PaymentEvent{},
|
||||||
|
&model.EmailLoginCode{},
|
||||||
|
&model.BaziChartRecord{},
|
||||||
&model.WishTree{},
|
&model.WishTree{},
|
||||||
&model.Wish{},
|
&model.Wish{},
|
||||||
&model.WishProduct{},
|
&model.WishProduct{},
|
||||||
@@ -78,6 +84,14 @@ func autoMigrate() error {
|
|||||||
|
|
||||||
// seedDefaultData 初始化默认数据
|
// seedDefaultData 初始化默认数据
|
||||||
func seedDefaultData() error {
|
func seedDefaultData() error {
|
||||||
|
reportProduct := model.ReportProduct{
|
||||||
|
Code: "bazi_full_report", Name: "八字完整精批报告", Description: "六大主题与十步大运完整解读",
|
||||||
|
StripeAmount: 499, StripeCurrency: "USD", BinanceAmount: "5.00", BinanceCurrency: "USDT", Status: 1,
|
||||||
|
}
|
||||||
|
if err := DB.Where("code = ?", reportProduct.Code).FirstOrCreate(&reportProduct).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 补充已有许愿树的背景图(新增字段后老数据为空)
|
// 补充已有许愿树的背景图(新增字段后老数据为空)
|
||||||
if err := DB.Model(&model.WishTree{}).
|
if err := DB.Model(&model.WishTree{}).
|
||||||
Where("image = '' OR image IS NULL").
|
Where("image = '' OR image IS NULL").
|
||||||
|
|||||||
@@ -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 许愿管理
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "订单不存在",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:"-"`
|
||||||
|
}
|
||||||
@@ -13,6 +13,15 @@ type Order struct {
|
|||||||
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"` // 金额(分)
|
||||||
|
Currency string `gorm:"size:10;default:'CNY'" json:"currency"`
|
||||||
|
Provider string `gorm:"size:30;index" json:"provider"`
|
||||||
|
ProviderAmount string `gorm:"size:32" json:"providerAmount"`
|
||||||
|
ProviderOrderID string `gorm:"size:128;index" json:"providerOrderId"`
|
||||||
|
ProviderTransactionID string `gorm:"size:128;index" json:"providerTransactionId"`
|
||||||
|
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:已退款
|
Status string `gorm:"size:20;default:'pending'" json:"status"` // pending:待支付 paid:已支付 cancelled:已取消 refunded:已退款
|
||||||
PayTime *time.Time `json:"payTime"`
|
PayTime *time.Time `json:"payTime"`
|
||||||
ExpireTime *time.Time `json:"expireTime"`
|
ExpireTime *time.Time `json:"expireTime"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("内容不符合社区规范")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
`,
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 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="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>
|
||||||
</div>
|
</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-red-500 text-gray-900 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">
|
|
||||||
<div class="px-4 py-6 sm:px-0">
|
|
||||||
<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="p-5 border-b flex flex-col lg:flex-row gap-3 lg:items-center lg:justify-between">
|
||||||
<h2 class="text-lg font-medium text-gray-900 mb-4">订单列表</h2>
|
<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">
|
||||||
|
<input v-model="filters.search" @keyup.enter="load" class="border rounded px-3 py-2 text-sm" placeholder="订单号 / 邮箱 / 报告号" />
|
||||||
|
<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>
|
||||||
|
<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">
|
<div class="overflow-x-auto">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<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>
|
||||||
<tr>
|
<tbody class="divide-y divide-gray-100">
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">订单号</th>
|
<tr v-if="loading"><td colspan="7" class="p-10 text-center text-sm text-gray-500">正在加载订单...</td></tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">类型</th>
|
<tr v-else-if="orders.length === 0"><td colspan="7" class="p-10 text-center text-sm text-gray-500">暂无符合条件的订单</td></tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">商品</th>
|
<tr v-for="order in orders" :key="order.id" class="hover:bg-gray-50">
|
||||||
<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"><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>
|
||||||
<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-sm">{{ order.customerEmail || '-' }}</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-sm">{{ providerText(order.provider) }}</td>
|
||||||
</tr>
|
<td class="px-4 py-4 text-sm font-medium">{{ money(order) }}</td>
|
||||||
</thead>
|
<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>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<td class="px-4 py-4 text-xs text-gray-500 max-w-xs truncate" :title="order.providerTransactionId">{{ order.providerTransactionId || order.providerOrderId || '-' }}</td>
|
||||||
<tr>
|
<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>
|
||||||
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
`
|
`,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
<!-- 标签页切换 -->
|
<!-- 标签页切换 -->
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_PAYMENT_MODE=demo
|
||||||
|
VITE_API_BASE_URL=http://localhost:8080
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
@@ -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` 实现四柱、五行与神煞计算。
|
||||||
|
- 新增免费命盘、六章付费报告遮罩与响应式视觉系统。
|
||||||
@@ -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 地址。
|
||||||
|
- 补充隐私政策、用户协议、退款规则、公司与客服信息。
|
||||||
|
- 根据香港当地要求确认虚拟资产收款、税务和消费者保护义务。
|
||||||
@@ -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`。
|
||||||
|
|
||||||
|
本项目属于传统文化研究与娱乐内容,不构成医疗、法律、投资或人生决策建议。
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1172
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
@@ -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;
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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}。`, `完整报告保留十步大运时间轴,方便长期回看。`],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
coverage
|
||||||
|
.swc
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
@@ -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 清单)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# BUG 记录(Known Issues)
|
||||||
|
|
||||||
|
> 最后更新:2026-08-02
|
||||||
|
> 严重度:🔴 高(影响功能/构建) · 🟠 中 · 🟡 低
|
||||||
|
> 状态:⬜ 待修复 · 🔧 修复中 · ✅ 已修复
|
||||||
|
|
||||||
|
## 0. 已修复汇总(2026-08-02)
|
||||||
|
|
||||||
|
- BUG-01 core CJS 导出 · BUG-02 周起始设置 · BUG-05 lint · BUG-07 版本号 · BUG-08 死分支
|
||||||
|
- BUG-03 PWA manifest 与 index.html 对齐(lang=zh-CN、theme_color 统一)
|
||||||
|
- BUG-09 日历页"回到今天"按钮永不显示(条件恒为 false)
|
||||||
|
- BUG-10 日详情页时辰列表重复 React key(早子/晚子均为"子",改用 ganzhi 作 key)
|
||||||
|
- TECH-03 更正:梅花卦库实测 **64/64 完整**(原 review 报告"缺 8 卦"不属实),已添加测试锁定
|
||||||
|
- TECH-06 以 `solarAdjusted` 类型安全字段替代 `(as any)._solarAdjusted`
|
||||||
|
- TECH-04 称骨数据表整体修正(年表主流版本 + 日表初五 + 歌诀 21~72 补全)
|
||||||
|
- TECH-05 占卜页称骨年索引改用出生年干支
|
||||||
|
- 新增单元测试 50 个(vitest)与 ESLint 配置
|
||||||
|
- 功能深化:八字流派/出生地全国化/海外时区/太阳时开关、神煞、大运流年流月流日交互、历法信息、佛教节日(见 CHANGELOG)
|
||||||
|
|
||||||
|
## 1. 已知 BUG
|
||||||
|
|
||||||
|
| ID | 严重度 | 状态 | 位置 | 描述 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| BUG-01 | 🟠 | ✅ | `packages/core` | ~~exports.require 指向不存在的 dist/index.cjs~~ → tsup 改为 `format: ['esm','cjs']`,`dist/index.cjs` 已生成并通过 `require()` 验证 |
|
||||||
|
| BUG-02 | 🟠 | ✅ | `useCalendar` / `CalendarGrid` / `WeekDayBar` / `CalendarPage` | ~~周一开始设置无效~~ → `settings.weekStartDay` 已贯通至 `getMonthCalendar(weekStart)` 与表头渲染 |
|
||||||
|
| BUG-03 | 🟡 | ✅ | `vite.config.ts` / `index.html` | ~~PWA manifest lang=en、theme_color 不一致~~ → manifest 增加 `lang: zh-CN`,`theme_color` 统一为 `#FFFBF5`,与 index.html 一致 |
|
||||||
|
| BUG-04 | 🟡 | ⬜ | `HomePage`(InfoCard) | `highlight` prop 传为布尔值,但渲染时当作 `border-primary/30` 样式类处理,语义不符(需确认意图) |
|
||||||
|
| BUG-05 | 🟡 | ✅ | 根 `package.json` | ~~pnpm lint 失败~~ → 已配置 ESLint 9 flat config + typescript-eslint,两个包均通过 |
|
||||||
|
| BUG-06 | 🟡 | ⬜ | `pnpm-workspace.yaml` | `allowBuilds` 不是 pnpm 标准字段(应为 `onlyBuiltDependencies`),疑似无效配置 |
|
||||||
|
| BUG-07 | 🟡 | ✅ | `SettingsPage` 关于区 | ~~版本号 v0.2~~ → 与 package.json 统一为 v0.1.0 |
|
||||||
|
| BUG-08 | 🟡 | ✅ | `transformers/day.ts` | ~~getMonthCalendar 死分支~~ → 已移除 if/else 相同调用 |
|
||||||
|
| BUG-09 | 🟡 | ✅ | `CalendarPage.tsx` | ~~"回到今天"按钮永不显示~~ → 条件误用 `todaySummary.di.isToday`(恒为 true),改为基于 `viewDate` 判断是否正在查看今天;已在浏览器实测 |
|
||||||
|
| BUG-10 | 🟡 | ✅ | `DayDetailPage.tsx` | ~~时辰列表重复 React key~~ → `getHours()` 返回 13 个时辰(早子 00:00 / 晚子 23:00 均为地支"子"),原用 `h.branch` 作 key 冲突,改为唯一的 `h.ganzhi`;已在浏览器实测无警告 |
|
||||||
|
|
||||||
|
## 2. 算法近似 / 技术债
|
||||||
|
|
||||||
|
> 非紧急,但应在后续迭代中改善。
|
||||||
|
|
||||||
|
| ID | 位置 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| TECH-01 | `calculators/dailyMatch.ts` | 当日八字固定取午时(`getHours()[6]`),无法体现时辰差异 |
|
||||||
|
| TECH-02 | `calculators/plumBlossom.ts` | 互卦为简化实现(上下卦互换),非真·互卦(2-4/3-5 爻法) |
|
||||||
|
| TECH-03 | `calculators/plumBlossom.ts` | ✅ 已核实:数据集 64/64 完整,`plumBlossom.test.ts` 含完整性校验用例 |
|
||||||
|
| TECH-04 | `calculators/boneWeight.ts` | ✅ 已修复:歌诀补全 2两1钱~7两2钱(原缺 5两8钱/5两9钱/6两1钱/6两3钱/6两5钱/6两7钱/6两9钱/7两1钱,此前这些总重会错误地取"最近值");年表已按主流版本整体修正(原混用两套网络变体错约 40 处),日表修正初五重量 |
|
||||||
|
| TECH-05 | `DivinationPage` | ✅ 已修复:称骨年索引改用出生年干支(`ec.yearPillar.ganzhi`),不再用日干支近似 |
|
||||||
|
| TECH-06 | `types/bazi.ts` | ✅ 已修复:`BaziFullResult` 新增 `solarAdjusted?: boolean`,替代 `(as any)` 补丁 |
|
||||||
|
| TECH-07 | `calculators/elementStrength.ts` | 五行推断按天干名字映射而非复用 tyme4ts 元素定义(可接受但存在重复逻辑) |
|
||||||
|
| TECH-08 | `lib/utils.ts` | `getChineseDayName` / `getChineseMonthName` 未使用 |
|
||||||
|
|
||||||
|
## 3. 死代码清单
|
||||||
|
|
||||||
|
| 位置 | 内容 | 建议 |
|
||||||
|
|---|---|---|
|
||||||
|
| `hooks/useDayDetail.ts` | 从未被 import | 删除或接入 DayDetailPage |
|
||||||
|
| `hooks/useBazi.ts` | 从未被 import(本轮仅修复了其 lint 错误) | 删除或接入 BaziPage |
|
||||||
|
| `components/ui/AnimatedPanel.tsx` | 从未使用 | 删除或接入日详情弹层 |
|
||||||
|
| `components/ui/Card.tsx` `CardHeader` | 未使用 | 删除导出 |
|
||||||
|
| `components/ui/Skeleton.tsx` `CalendarSkeleton`/`BaziSkeleton` | 未使用 | 删除 |
|
||||||
|
| `stores/ui.ts` | `toggleSideNav`/`closeSideNav`/`setActiveTab`/`openDayDetail`/`closeDayDetail` 及 `activeTab`/`showDayDetail` 未被消费 | 精简或接入 |
|
||||||
|
| `stores/settings.ts` | `showLunar`/`showSolarTerm`/`showHoliday`/`eightCharProvider` 及对应 toggle 未被读取 | 接入设置页或删除 |
|
||||||
|
| `stores/calendar.ts` | `weekStart`/`setWeekStart`/`clearSelection` 未使用(周起始现直接读 settings store) | 删除 |
|
||||||
|
| `stores/bookmarks.ts` | `getByDate`/`getByLunarDate` 未使用 | 配合农历收藏规划 |
|
||||||
|
| `lib/utils.ts` | `getChineseDayName`/`getChineseMonthName` | 删除 |
|
||||||
|
| web 依赖 | `tailwind-variants` 从未 import | 移除依赖 |
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 变更日志(Changelog)
|
||||||
|
|
||||||
|
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。版本号遵循 SemVer。
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- 新增 `docs/` 文档体系:架构(ARCHITECTURE)、需求(REQUIREMENTS)、进度(PROGRESS)、BUG(BUGS)、变更日志(CHANGELOG)
|
||||||
|
- 更新 `AGENTS.md` / `CLAUDE.md`:补全新页面/Store/API、修正 PWA 状态、指向文档目录
|
||||||
|
|
||||||
|
### 功能
|
||||||
|
- **八字流派切换**:`birthInfoToBazi` 新增 `ziSect` 参数,设置页新增"八字流派"选项(晚子时算次日 23点换日 / 晚子时算当日 0点换日),BaziPage 排盘实时生效;浏览器实测两种流派排出不同日柱/时柱
|
||||||
|
- **出生时间选择器**:BaziPage 出生时间改为 时/分 下拉选择器(替代不对称的 −/+ 步进),时辰格可点击快速设置时辰
|
||||||
|
- **出生地全国化 + 海外时区**:出生地列出全国 34 个省级城市(含省会经度真太阳时校正),新增"海外"模式按时区(UTC-12~+14,含半小时时区)将当地出生时间换算为北京时间(UTC+8)排盘;换算与真太阳时校正均按日期运算处理,支持跨日回退(浏览器实测:UTC-5 23:30 → 次日北京 12:30;乌鲁木齐 00:30 真太阳 → 前日 22:20)
|
||||||
|
- **真太阳时校正开关**:设置页新增"真太阳时校正"开关(默认开启);关闭后中国出生按北京时间直接排盘,海外时区换算不受影响(浏览器实测:关闭时乌鲁木齐 00:30 排日柱己酉,开启时排戊申)
|
||||||
|
- **宜忌完整显示**:引擎本就返回完整黄历宜忌(如 8/3 共 22 条宜),此前首页 `slice(0,6)` 截断导致显得比别的黄历站少;已放开并标注总数
|
||||||
|
- **历法信息增强**:DayInfo 新增季节、所处节气/第几天/距下节气天数、儒略日、佛历年(公历+543)、伊斯兰历日期(tyme4ts 原生支持 Hijri),日详情页新增"历法信息"卡片
|
||||||
|
- **佛教节日**:tyme4ts 无佛教日期支持,新增 `getBuddhistFestival`(农历 21 个重大佛教节日表),日详情页显示 🪷 徽章
|
||||||
|
- **八字神煞**:新增 `analyzeShensha`(22 个常见神煞:天乙/天厨/文昌贵人、禄神、羊刃、金舆、天德/月德贵人、桃花、驿马、华盖、劫煞、亡神、将星、红鸾、天喜、孤辰、寡宿、天罗、地网、魁罡、阴差阳错),八字页新增"神煞"卡片;查法采用主流排盘工具版本(神煞版本差异已在文档注明)
|
||||||
|
- **大运/流年生克冲合**:新增 `analyzeFortuneGanzhi`(任意干支 vs 日主:十神、五行生克 生我/我生/克我/我克/比和、天干合冲、地支六合三合冲害刑、吉凶分级);八字页"起运·大运"改为全量 10 个大运并附生克冲合,新增"流年(小运)"10 条卡片
|
||||||
|
- **今日流年·流月·流日**:八字页新增卡片,用今日年/月/日干支 vs 日主展示生克冲合
|
||||||
|
- **大运·流年·流月·流日交互下钻**:core 新增 `getYearMonths(year)`(按节气月返回 12 个流月);八字页改为交互浏览器——点选大运 → 流年(按年度切换)→ 流月 12 chips → 该月每日流日列表(带吉凶点与十神),点击日期跳转日详情页;默认定位到当前年份/月份
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- **称骨算命数据表整体修正**:年份表改为主流通行版本(原混用两套网络变体错约 40 处);日表修正初五(1两6钱);歌诀补全 2两1钱~7两2钱全部 52 条(原缺 5两8钱~7两1钱共 8 条,此前这些总重错误取"最近值")
|
||||||
|
- **占卜页称骨年索引**:改用出生年干支(`ec.yearPillar.ganzhi`),不再用日干支近似
|
||||||
|
- **BUG-01**:core 包改为同时输出 ESM + CJS,`exports.require` 指向的 `dist/index.cjs` 现在真实存在并通过 `require()` 验证
|
||||||
|
- **BUG-02**:周起始设置生效 —— `settings.weekStartDay` 贯通 `useCalendar` → `getMonthCalendar(weekStart)` → `WeekDayBar` 表头渲染
|
||||||
|
- **BUG-03**:PWA manifest 对齐 —— `lang: zh-CN`,`theme_color` 统一为 `#FFFBF5`(与 index.html 一致)
|
||||||
|
- **BUG-05**:配置 ESLint 9 flat config + typescript-eslint,`pnpm lint` 通过(修复 24 处代码问题)
|
||||||
|
- **BUG-07**:设置页版本号统一为 0.1.0
|
||||||
|
- **BUG-08**:移除 `getMonthCalendar` 中的死分支
|
||||||
|
- **BUG-09**:日历页"回到今天"浮动按钮恢复正常 —— 原条件 `todaySummary.di.isToday` 恒为 true 导致按钮永不显示,改为基于 `viewDate` 判断(浏览器实测:非本月可见、点击返回本月)
|
||||||
|
- **BUG-10**:日详情页时辰列表重复 key 修复 —— 早子/晚子地支均为"子",key 改用唯一的干支(`h.ganzhi`)
|
||||||
|
- **TECH-06**:`BaziFullResult` 新增 `solarAdjusted?: boolean` 字段,替代 `(as any)._solarAdjusted` 非类型安全补丁
|
||||||
|
|
||||||
|
### 工程
|
||||||
|
- 为 @lunar/core 引入 vitest 单元测试(7 个测试文件 / 29 个用例),覆盖历法转换、八字排盘、每日运势、梅花易数(含 64 卦完整性校验)、称骨、干支关系、五行分析
|
||||||
|
- 根与子包新增 `test` / `lint` 脚本;根 package.json 标记 `"type": "module"`
|
||||||
|
- 核实梅花卦库为 64/64 完整(此前 review 报告"缺 8 卦"不属实),并以测试固化
|
||||||
|
|
||||||
|
### 已知问题(见 docs/BUGS.md)
|
||||||
|
- 梅花易数互卦仍为简化实现(上下卦互换)
|
||||||
|
- 每日运势当日八字固定取午时;称骨年索引用日干支近似
|
||||||
|
- PWA manifest `lang`/`theme_color` 不一致;死代码待清理
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-06-07
|
||||||
|
|
||||||
|
首个可用版本(monorepo 初始化于 2026-06-06)。
|
||||||
|
|
||||||
|
### 新增 — @lunar/core
|
||||||
|
|
||||||
|
- 历法转换:`getDayInfo` / `getTodayInfo` / `getMonthCalendar` / `solarDayToDayInfo`
|
||||||
|
- 黄历:`getAlmanacInfo` / `solarDayToAlmanacInfo`(含 12 时辰逐时黄历)
|
||||||
|
- 八字:`birthInfoToBazi`(四柱、十神、藏干、十二长生、胎元胎息、命宫身宫、空亡、起运、10 大运、10 流年)
|
||||||
|
- 干支关系:`getBranchRelationship` / `getTenStarRelationship` / `checkStemCombine` / `checkStemOpposite`
|
||||||
|
- 五行分析:`analyzeElementBalance`
|
||||||
|
- 每日运势:`calculateDailyFortune`(四柱打分、宜忌、幸运色/数/方位、分项得分)
|
||||||
|
- 占卜:`calculatePlumBlossom`(梅花易数,56/64 卦)、`calculateBoneWeight`(袁天罡称骨)
|
||||||
|
|
||||||
|
### 新增 — @lunar/web
|
||||||
|
|
||||||
|
- 路由与骨架:8 页面懒加载、页面过渡动画、ErrorBoundary、Suspense 骨架屏
|
||||||
|
- 首页:今日概览、宜忌、运势预览、快捷入口
|
||||||
|
- 日历:月/周视图、年/月切换、收藏星标、运势点、滑动切月、日详情页(四柱/黄历/12 时辰/收藏)
|
||||||
|
- 八字排盘页:档案管理(最多 3 个,localStorage 持久化)、排盘全览、称骨
|
||||||
|
- 每日运势页:日期导航、评分与建议
|
||||||
|
- 占卜页:梅花易数今日卦象、称骨
|
||||||
|
- 节气页:全年 24 节气
|
||||||
|
- 设置页:主题切换(亮/暗/跟随系统)、周起始、阴阳历转换、今日干支
|
||||||
|
- 状态管理:Zustand 5 个 store(calendar / user / settings / ui / bookmarks)
|
||||||
|
- PWA:vite-plugin-pwa 集成(autoUpdate + Workbox 预缓存)
|
||||||
|
- 设计系统:Tailwind v4 设计令牌(亮/暗主题)、Badge / Button / Card / Skeleton
|
||||||
|
|
||||||
|
### 已知问题(见 docs/BUGS.md)
|
||||||
|
|
||||||
|
- core 包 require 导出指向不存在的 `dist/index.cjs`
|
||||||
|
- 周起始设置未生效
|
||||||
|
- 梅花易数互卦简化、卦库缺 8 卦;称骨年索引用日干支近似
|
||||||
|
- 无自动化测试、无 lint
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 开发进度(Progress)
|
||||||
|
|
||||||
|
> 最后更新:2026-08-03
|
||||||
|
|
||||||
|
## 1. 里程碑
|
||||||
|
|
||||||
|
| 里程碑 | 时间 | 内容 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| M0 项目初始化 | 2026-06-06 | monorepo 脚手架、tsconfig、workspace、核心类型定义 | ✅ |
|
||||||
|
| M1 核心引擎 | 2026-06-06 ~ 06-07 | transformers(day/almanac/bazi)、calculators(relationship/elementStrength) | ✅ |
|
||||||
|
| M2 高级算法 | 2026-06-07 | dailyMatch(每日运势)、plumBlossom(梅花)、boneWeight(称骨) | ✅ |
|
||||||
|
| M3 前端页面 | 2026-06-07 | 8 个页面、5 个 store、路由、布局、组件库 | ✅ |
|
||||||
|
| M4 构建与 PWA | 2026-06-07 | Vite 构建、tsup、PWA 集成(Workbox 缓存) | ✅ |
|
||||||
|
| M5 文档化 | 2026-08-02 | 架构/需求/进度/BUG/CHANGELOG 文档建立 | ✅ |
|
||||||
|
| M6 质量加固 | 2026-08-02 | 单元测试(vitest)、ESLint、CJS 导出修复、周起始修复、版本号 | ✅ |
|
||||||
|
| M7 功能深化 | 2026-08-02 ~ 08-03 | 称骨数据修正、八字流派/出生地/太阳时开关、神煞、大运流年流月流日交互、历法信息、佛教节日 | ✅ |
|
||||||
|
| M8 收尾 | 2026-08-03 | README、文档整理、源码打包(node_modules 清理) | ✅ |
|
||||||
|
|
||||||
|
> 注:主体功能于 2026-06-06 ~ 06-07 完成;2026-08-02 ~ 08-03 进行质量加固与功能深化。
|
||||||
|
|
||||||
|
## 2. 当前状态
|
||||||
|
|
||||||
|
- **可运行**:`pnpm dev` 可启动,`pnpm build` 可产出 core(ESM+CJS)+ web 产物(含 PWA)。
|
||||||
|
- **质量**:50 个单元测试通过(vitest)、`pnpm lint` 通过(ESLint flat config + typescript-eslint)。
|
||||||
|
- 剩余 BUG 与技术债见 BUGS.md。
|
||||||
|
|
||||||
|
## 3. 待办清单(Backlog)
|
||||||
|
|
||||||
|
按优先级排序:
|
||||||
|
|
||||||
|
| 优先级 | 事项 | 类型 | 关联 | 状态 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| P0 | 修复 core 包 `require` 导出指向不存在的 `dist/index.cjs` | BUG | BUG-01 | ✅ |
|
||||||
|
| P1 | 周起始设置生效 | 功能 | BUG-02 | ✅ |
|
||||||
|
| P1 | 为 core 算法补充单元测试 | 工程 | N5 | ✅ 50 例 |
|
||||||
|
| P2 | 六十四卦数据补全 | 算法 | C10 | ✅ 已核实 64/64 完整,测试锁定 |
|
||||||
|
| P2 | 互卦算法实现真·互卦(2-4/3-5 爻) | 算法 | C10 | ⬜ |
|
||||||
|
| P2 | 清理死代码(useDayDetail/useBazi、AnimatedPanel、CardHeader、未用 store action 等) | 工程 | — | ⬜ |
|
||||||
|
| P3 | PWA manifest 修复 | BUG | BUG-03 | ✅ |
|
||||||
|
| P3 | 农历周期收藏 UI | 功能 | M2 | ⬜ |
|
||||||
|
| P3 | 收藏列表页 | 功能 | M3 | ⬜ |
|
||||||
|
| P4 | 配置 lint 并修复 `pnpm lint` | 工程 | N6 | ✅ |
|
||||||
|
| P4 | 显示开关字段真正消费(showLunar 等) | 功能 | T6 | ⬜ |
|
||||||
|
| P4 | 版本号统一(0.1.0) | BUG | BUG-07 | ✅ |
|
||||||
|
| P4 | 称骨数据表整体修正(年表/日表/歌诀) | 算法 | C11 | ✅ |
|
||||||
|
| P4 | 占卜页称骨年索引用出生年干支 | 算法 | TECH-05 | ✅ |
|
||||||
|
| P5 | 每日运势当日八字取午时 → 改为可选时辰 | 算法 | C9 | ⬜ |
|
||||||
|
| P5 | 称骨极端总重仍取"最近值" → 提示超出范围 | 算法 | TECH-04 | ⬜ |
|
||||||
|
| P5 | README 编写 | 工程 | M8 | ✅ |
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 需求文档(Requirements & 功能清单)
|
||||||
|
|
||||||
|
> 最后更新:2026-08-02
|
||||||
|
> 状态说明:✅ 已完成 · 🟡 部分完成 / 有已知问题 · ⬜ 规划中
|
||||||
|
|
||||||
|
## 1. 需求总览
|
||||||
|
|
||||||
|
| 模块 | 状态 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 历法核心引擎(@lunar/core) | ✅ | 农历/干支/黄历/八字/运势算法 |
|
||||||
|
| 万年历(首页 + 日历 + 日详情) | ✅ | 功能齐全(周起始设置已修复) |
|
||||||
|
| 八字排盘 | 🟡 | 功能齐全,含一个非类型安全补丁 |
|
||||||
|
| 每日运势 | ✅ | 依赖用户档案 |
|
||||||
|
| 占卜工具(梅花易数 / 称骨) | 🟡 | 算法为简化实现(见备注) |
|
||||||
|
| 节气 | ✅ | 24 节气列表 |
|
||||||
|
| 用户档案 + 设置 | 🟡 | 部分设置字段未生效 |
|
||||||
|
| 收藏 | 🟡 | 仅支持公历单日收藏 |
|
||||||
|
| PWA | ✅ | 已构建,manifest 有瑕疵 |
|
||||||
|
|
||||||
|
## 2. 功能点清单
|
||||||
|
|
||||||
|
### 2.1 历法核心(@lunar/core)
|
||||||
|
|
||||||
|
| # | 功能点 | 描述 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C1 | 日期转换 | tyme4ts → DayInfo(公历/农历/干支/星座/生肖/节日/假日/月相) | ✅ |
|
||||||
|
| C2 | 月历生成 | `getMonthCalendar` 周×天网格,含相邻月补位 | ✅ |
|
||||||
|
| C3 | 黄历信息 | 宜忌、值神(日/时)、十二/廿八/九/六曜星、彭祖百忌、胎神、冲煞、纳音、三合/六合 | ✅ |
|
||||||
|
| C4 | 时辰黄历 | 12 时辰逐时黄历(buildHourlyAlmanac) | ✅ |
|
||||||
|
| C5 | 八字排盘 | 四柱、十神、藏干、十二长生、胎元/胎息、命宫/身宫、空亡、起运 | ✅ |
|
||||||
|
| C6 | 大运/流年 | 10 大运 + 10 流年 | ✅ |
|
||||||
|
| C7 | 干支关系 | 六合/三合/六冲/六害/相刑/三合局/天干合冲 | ✅ |
|
||||||
|
| C8 | 五行分析 | 五行力量统计(藏干 0.5 权重)、旺衰、平衡度 | ✅ |
|
||||||
|
| C9 | 每日运势 | 四柱逐柱 vs 当日干支打分(日 40%/月 25%/年 20%/时 15%),输出评分、宜忌、幸运色/数/方位、分项(感情/事业/财运/健康) | 🟡 当日八字固定取午时 |
|
||||||
|
| C10 | 梅花易数 | 时间起卦、变卦、互卦(简化)、体用关系、64 卦辞 | 🟡 互卦简化为上下互换(卦库 64/64 完整,已有测试) |
|
||||||
|
| C11 | 称骨算命 | 年/月/日/时称骨表 + 52 首解诗(2两1钱~7两2钱完整) | ✅ 数据表已按主流版本修正,歌诀补全(极端值仍取最近) |
|
||||||
|
| C12 | 历法信息 | 季节、所处节气/第几天/距下节气、儒略日、佛历年、伊斯兰历(Hijri) | ✅ |
|
||||||
|
| C13 | 佛教节日 | 农历 21 个重大佛教节日(`getBuddhistFestival`) | ✅ 日详情页 🪷 徽章展示 |
|
||||||
|
| C14 | 八字神煞 | 22 个常见神煞(`analyzeShensha`) | ✅ 八字页"神煞"卡片 |
|
||||||
|
| C15 | 大运/流年 vs 日主生克冲合 | 十神、五行生克、天干合冲、地支关系、吉凶(`analyzeFortuneGanzhi`) | ✅ |
|
||||||
|
| C16 | 流年·流月·流日分析 | 任意日期年/月/日干支 vs 日主(`getYearMonths` 按节气月) | ✅ 八字页交互浏览器 |
|
||||||
|
|
||||||
|
### 2.2 首页 HomePage(`/`)
|
||||||
|
|
||||||
|
| # | 功能点 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| H1 | 今日概览:日期、农历、干支、宜忌摘要 | ✅ |
|
||||||
|
| H2 | 运势预览(需档案) | ✅ |
|
||||||
|
| H3 | 快捷导航(占卜、节气) | ✅ |
|
||||||
|
| H4 | 值神/彭祖/胎神展示 | ✅ |
|
||||||
|
|
||||||
|
### 2.3 日历 CalendarPage(`/calendar`)
|
||||||
|
|
||||||
|
| # | 功能点 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| K1 | 月视图/周视图切换 | ✅ |
|
||||||
|
| K2 | 年/月切换(MonthYearPicker) | ✅ |
|
||||||
|
| K3 | 格子显示:节日/节气缩写、农历、周末/今日高亮、收藏星标、运势点 | ✅ |
|
||||||
|
| K4 | 点击日期 → 日详情页 | ✅ |
|
||||||
|
| K5 | 手势滑动切换月份 | ✅ |
|
||||||
|
| K6 | 周起始(周一/周日)设置生效 | ✅ 已修复(BUG-02) |
|
||||||
|
| K7 | 非本月时显示"回到今天"浮动按钮,点击返回当月 | ✅ 已修复(BUG-09) |
|
||||||
|
|
||||||
|
### 2.4 日详情 DayDetailPage(`/calendar/:date`)
|
||||||
|
|
||||||
|
| # | 功能点 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 | 四柱、纳音、值星展示 | ✅ |
|
||||||
|
| D2 | 冲合害煞、宜忌列表 | ✅ |
|
||||||
|
| D3 | 值神、彭祖百忌、胎神 | ✅ |
|
||||||
|
| D4 | 12 时辰逐时黄历 | ✅ |
|
||||||
|
| D5 | 收藏按钮 | ✅ 仅公历收藏 |
|
||||||
|
|
||||||
|
### 2.5 八字 BaziPage(`/bazi`)
|
||||||
|
|
||||||
|
| # | 功能点 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| B1 | 出生档案管理(最多 3 个,持久化) | ✅ |
|
||||||
|
| B2 | 排盘:四柱 + 十神 + 藏干 | ✅ |
|
||||||
|
| B3 | 五行力量 / 十二长生展示 | ✅ |
|
||||||
|
| B4 | 起运 / 大运 / 流年 | ✅ |
|
||||||
|
| B5 | 称骨集成 | ✅ 使用出生年干支(占卜页已同步修正) |
|
||||||
|
| B6 | 日柱类型安全 | ✅ `solarAdjusted` 字段替代 `(as any)` 补丁 |
|
||||||
|
| B7 | 八字流派切换(晚子时换日 23点 / 0点换日) | ✅ 设置页可切换,排盘实时生效 |
|
||||||
|
| B8 | 出生地:全国 34 省级城市真太阳时校正 | ✅ 含跨日处理,受"真太阳时校正"开关控制 |
|
||||||
|
| B9 | 出生地:海外时区 → 北京时间(UTC+8)换算排盘 | ✅ 含跨日处理 |
|
||||||
|
| B10 | 神煞展示(22 个常见神煞,吉/凶/中性分组) | ✅ |
|
||||||
|
| B11 | 大运全量 10 条 + 流年(小运)10 条,均附生克冲合 | ✅ |
|
||||||
|
| B12 | 大运→流年→流月→流日交互下钻(按年度切换) | ✅ 点选日期跳转日详情 |
|
||||||
|
|
||||||
|
### 2.6 每日运势 DailyFortunePage(`/daily-fortune`)
|
||||||
|
|
||||||
|
| # | 功能点 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| F1 | 日期导航查看任意日运势 | ✅ |
|
||||||
|
| F2 | 无档案时引导创建 | ✅ |
|
||||||
|
| F3 | 评分等级、宜忌、幸运信息、分项得分 | ✅ |
|
||||||
|
|
||||||
|
### 2.7 占卜 DivinationPage(`/divination`)
|
||||||
|
|
||||||
|
| # | 功能点 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| P1 | 今日梅花易数卦象 + 体用分析 | 🟡 算法简化 |
|
||||||
|
| P2 | 称骨算命输入与结果 | 🟡 年索引用日干支近似 |
|
||||||
|
|
||||||
|
### 2.8 节气 SolarTermsPage(`/solar-terms`)
|
||||||
|
|
||||||
|
| # | 功能点 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| S1 | 全年 24 节气列表 | ✅ |
|
||||||
|
|
||||||
|
### 2.9 设置 SettingsPage(`/settings`)
|
||||||
|
|
||||||
|
| # | 功能点 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| T1 | 档案管理入口 | ✅ |
|
||||||
|
| T2 | 农历日期查询(阴阳历转换) | ✅ |
|
||||||
|
| T3 | 今日天干地支/纳音 | ✅ |
|
||||||
|
| T4 | 主题切换(亮/暗/跟随系统) | ✅ |
|
||||||
|
| T5 | 周起始设置 | ✅ 已修复(BUG-02) |
|
||||||
|
| T6 | 显示开关(农历/节气/假日)、八字来源 | 🟡 字段已持久化但未消费 |
|
||||||
|
| T7 | 关于:版本号 | ✅ 已统一为 0.1.0(BUG-07) |
|
||||||
|
| T8 | 真太阳时校正开关(默认开启) | ✅ 关闭后按北京时间排盘 |
|
||||||
|
|
||||||
|
### 2.10 收藏 Bookmarks
|
||||||
|
|
||||||
|
| # | 功能点 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| M1 | 收藏/取消收藏日期,日历格星标 | ✅ |
|
||||||
|
| M2 | 农历周期收藏(每年/月循环) | ⬜ 字段已预留,UI 未提供 |
|
||||||
|
| M3 | 收藏列表页 | ⬜ 规划中 |
|
||||||
|
|
||||||
|
### 2.11 非功能需求
|
||||||
|
|
||||||
|
| # | 需求 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| N1 | PWA 可安装、离线可用 | ✅ 已构建 |
|
||||||
|
| N2 | 移动端优先响应式 | ✅ |
|
||||||
|
| N3 | 暗色模式(跟随系统) | ✅ |
|
||||||
|
| N4 | 页面懒加载 + 骨架屏 + 错误边界 | ✅ |
|
||||||
|
| N5 | 自动化测试 | ✅ 2026-08-02 引入 vitest,29 个用例(历法/八字/运势/梅花/称骨/干支) |
|
||||||
|
| N6 | Lint / 代码规范 | ✅ 2026-08-02 配置 ESLint 9 flat config + typescript-eslint,`pnpm lint` 通过 |
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 开发回顾记录(Retrospect)
|
||||||
|
|
||||||
|
> 会话日期:2026-08-02 ~ 2026-08-03
|
||||||
|
> 用途:记录本次会话"做了什么 / 没做什么 / 有疑问的事项",便于事后回顾与决策。
|
||||||
|
|
||||||
|
## 一、本次完成的工作
|
||||||
|
|
||||||
|
### 1. 文档体系(docs/)
|
||||||
|
- 新建 `docs/`:ARCHITECTURE / REQUIREMENTS / PROGRESS / BUGS / CHANGELOG,另加根 README.md
|
||||||
|
- 修正 AGENTS.md / CLAUDE.md 的过时信息(PWA 状态、页面/Store/API 补全),并持续同步
|
||||||
|
|
||||||
|
### 2. BUG 修复(BUGS.md 均已标记 ✅)
|
||||||
|
| ID | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| BUG-01 | core 包 CJS 导出:tsup 增加 cjs 产物,`require()` 实测可用 |
|
||||||
|
| BUG-02 | 周起始设置生效:settings → useCalendar → getMonthCalendar(weekStart) → WeekDayBar |
|
||||||
|
| BUG-03 | PWA manifest 对齐(lang=zh-CN、theme_color=#FFFBF5) |
|
||||||
|
| BUG-05 | 配置 ESLint 9 flat config + typescript-eslint,`pnpm lint` 通过(修复 24 处) |
|
||||||
|
| BUG-07 | 版本号统一 v0.1.0 |
|
||||||
|
| BUG-08 | getMonthCalendar 死分支移除 |
|
||||||
|
| BUG-09 | "回到今天"按钮条件恒 false,改为基于 viewDate 判断 |
|
||||||
|
| BUG-10 | 日详情时辰列表重复 React key(早子/晚子均"子"),改用 ganzhi 作 key |
|
||||||
|
| TECH-06 | `(as any)._solarAdjusted` → `BaziFullResult.solarAdjusted` 类型化 |
|
||||||
|
|
||||||
|
### 3. 功能深化
|
||||||
|
- **八字流派切换**:`birthInfoToBazi` 新增 `ziSect`(晚子时算次日/当日),设置页开关
|
||||||
|
- **出生时间选择器**:时/分下拉 + 时辰格可点
|
||||||
|
- **出生地全国化 + 海外时区**:34 省级城市(真太阳时校正)+ 海外 UTC-12~+14 换算北京时间,均支持跨日
|
||||||
|
- **真太阳时校正开关**(设置页,默认开启)
|
||||||
|
- **宜忌完整显示**:根因是首页 `slice(0,6)`,引擎本有 22 条宜
|
||||||
|
- **历法信息增强**:DayInfo 新增 季节/所处节气/第几天/距下节气/儒略日/佛历年/伊斯兰历(tyme4ts 原生 Hijri)
|
||||||
|
- **佛教节日**:新增 `getBuddhistFestival`(农历 21 个节日),日详情 🪷 徽章
|
||||||
|
- **八字神煞**:新增 `analyzeShensha`(22 个常见神煞),八字页"神煞"卡
|
||||||
|
- **大运/流年生克冲合**:新增 `analyzeFortuneGanzhi`(十神/五行生克/合冲害刑/吉凶)
|
||||||
|
- **大运→流年→流月→流日交互下钻**:新增 `getYearMonths`(节气月);八字页交互浏览器,流日可跳日详情
|
||||||
|
|
||||||
|
### 4. 质量
|
||||||
|
- 单元测试 29 → **50 个**(vitest),覆盖历法/八字/运势/梅花/称骨/干支/五行/神煞/流月/流年
|
||||||
|
- 浏览器 E2E 验证(headless Chrome + CDP 脚本,无新增依赖):周起始、回到今天、流派、出生地、太阳时开关、宜忌、历法信息、神煞、大运流年交互 全部实测通过
|
||||||
|
|
||||||
|
### 5. 工程清理
|
||||||
|
- 整理全部文档;删除 node_modules / dist / tsbuildinfo;源码 241M → 764K;打包 164K(/tmp/lunar-source-20260803.tar.gz)
|
||||||
|
|
||||||
|
## 二、未完成 / 待办(详见 PROGRESS.md Backlog)
|
||||||
|
|
||||||
|
| 事项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 互卦真算法 | 梅花易数互卦仍为上下卦互换简化(2-4/3-5 爻法未实现) |
|
||||||
|
| 死代码清理 | useDayDetail/useBazi hooks、AnimatedPanel、CardHeader、CalendarSkeleton/BaziSkeleton、ui store 未用 action、settings 显示开关字段、calendar store weekStart、bookmarks getByDate、utils 未用函数、tailwind-variants 依赖 |
|
||||||
|
| 显示开关字段 | settings.showLunar/showSolarTerm/showHoliday/eightCharProvider 已持久化但未消费(其中 eightCharProvider 已被 ziHourSect 替换) |
|
||||||
|
| 农历周期收藏 | bookmarks 的 isLunar 字段已预留,UI 未提供;收藏列表页未做 |
|
||||||
|
| 午时假设 | calculateDailyFortune 当日八字固定取午时(TECH-01) |
|
||||||
|
| 称骨极端值 | 超出 2两1钱~7两2钱范围的总重仍取"最近值"(TECH-04 残余) |
|
||||||
|
| 紫微斗数 | 未做(大工程,用户同意先做神煞) |
|
||||||
|
| web 端自动化测试 | 仅 core 有单测;web 无测试框架 |
|
||||||
|
| git | 项目未初始化 git 仓库,无版本管理与回滚能力 |
|
||||||
|
|
||||||
|
## 三、有疑问 / 待确认事项
|
||||||
|
|
||||||
|
1. **称骨年表存在两套网络变体**:本实现采用主流版本(算准网/网易/sunfinelife 三家一致:丙子16钱、戊子15钱等),但另一套变体(丙子19钱、戊子12钱等)也在流通。若用户希望换另一套,改 `boneWeight.ts` 的 `YEAR_WEIGHTS` 即可。
|
||||||
|
2. **天厨贵人查法有版本差异**:本实现采用主流版(甲巳、乙午、丙巳、丁午、戊申、己酉、庚亥、辛子、壬寅、癸卯);传统版(丙寅、丁酉、戊申、己未、庚亥、辛戌、壬卯、癸子)也有出处。代码注释已注明。
|
||||||
|
3. **tyme4ts 十神方向语义**:实测 `A.getTenStar(B)` 返回"B 以 A 为日主"的十神。`dailyMatch.ts` 中的用法方向(userPillar vs dayPillar 同位置比较)语义上存疑,但**未改动**(避免影响每日运势结果);新写的 `fortuneLuck` 已按正确方向实现。需确认 dailyMatch 是否也要调整。
|
||||||
|
4. **佛历年**:按公历 + 543(泰国惯例);bmcx 示例 2570 对应的是其他年份。
|
||||||
|
5. **佛教节日表**:21 个为通行版本,个别日期(如腊月廿九华严菩萨圣诞)在不同资料有出入。
|
||||||
|
6. **默认出生地变化**:默认从"120°E 不校正"改为"北京 116.4°E(-14 分钟真太阳校正)",临界时辰的结果会变。太阳时校正开关默认开启——用户接受度未知,如需要可默认关闭。
|
||||||
|
7. **BUG-04(HomePage InfoCard highlight)**:`highlight` 传布尔但被当作样式类 `border-primary/30` 处理,语义不清,需确认意图(是要"高亮边框"还是别的)。
|
||||||
|
8. **起运前年份 UX**:大运流年浏览器中,出生当年(起运前)不在任何大运区间,自动落到第一柱大运(如 2026 年出生 → 从 2028 起显示)。是否要显示"起运前"年份待定。
|
||||||
|
9. **pnpm-workspace.yaml `allowBuilds`**:非 pnpm 标准字段(应为 onlyBuiltDependencies),疑似无效但无害,未改。
|
||||||
|
10. **打包清理**:已删除 node_modules/dist,开发前需 `pnpm install`。
|
||||||
|
|
||||||
|
## 四、验证方式备忘
|
||||||
|
|
||||||
|
- E2E 通过 headless Chrome(Playwright 缓存目录的 chromium headless shell)+ CDP 协议 Node 脚本驱动,未引入 Playwright npm 依赖;脚本存于 `/tmp/lunar-*.mjs`(会话临时文件,未入库)。
|
||||||
|
- 所有功能均以"单测 + 浏览器实测"双重验证;仅 UI 纯样式类改动(如时间选择器)以 tsc 构建验证为主。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['**/dist/**', '**/node_modules/**', '**/*.tsbuildinfo'] },
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "lunar-calendar",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "万年历 - Chinese Almanac Calendar App",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --filter @lunar/web dev",
|
||||||
|
"build": "pnpm --filter @lunar/core build && pnpm --filter @lunar/web build",
|
||||||
|
"preview": "pnpm --filter @lunar/web preview",
|
||||||
|
"test": "pnpm -r test",
|
||||||
|
"lint": "pnpm -r lint",
|
||||||
|
"clean": "pnpm -r clean"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18",
|
||||||
|
"pnpm": ">=9"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"eslint": "^10.8.0",
|
||||||
|
"typescript-eslint": "^8.65.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@lunar/core",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Core calculation engine for lunar calendar app",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"build": "tsup",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tyme4ts": "^1.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsup": "^8.4.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { birthInfoToBazi } from '../index';
|
||||||
|
|
||||||
|
const GANZHI = /^[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]$/;
|
||||||
|
|
||||||
|
describe('bazi transformer', () => {
|
||||||
|
const bazi = birthInfoToBazi({ year: 1990, month: 5, day: 15, hour: 12, minute: 0, gender: 'male' });
|
||||||
|
|
||||||
|
it('produces four pillars with valid ganzhi', () => {
|
||||||
|
expect(bazi.eightChar.yearPillar.ganzhi).toMatch(GANZHI);
|
||||||
|
expect(bazi.eightChar.monthPillar.ganzhi).toMatch(GANZHI);
|
||||||
|
expect(bazi.eightChar.dayPillar.ganzhi).toMatch(GANZHI);
|
||||||
|
expect(bazi.eightChar.hourPillar.ganzhi).toMatch(GANZHI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes day master info', () => {
|
||||||
|
expect(bazi.eightChar.dayMasterStem).toMatch(/^[甲乙丙丁戊己庚辛壬癸]$/);
|
||||||
|
expect(['木', '火', '土', '金', '水']).toContain(bazi.eightChar.dayMasterElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 10 decade fortunes and 10 annual fortunes', () => {
|
||||||
|
expect(bazi.decadeFortunes).toHaveLength(10);
|
||||||
|
expect(bazi.annualFortunes).toHaveLength(10);
|
||||||
|
expect(bazi.decadeFortunes[0].ganzhi).toMatch(GANZHI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches each decade its own 10 annual fortunes', () => {
|
||||||
|
const chart = birthInfoToBazi({ year: 1989, month: 12, day: 22, hour: 15, minute: 20, gender: 'male' });
|
||||||
|
expect(chart.decadeFortunes).toHaveLength(10);
|
||||||
|
for (const decade of chart.decadeFortunes) {
|
||||||
|
expect(decade.annualFortunes).toHaveLength(10);
|
||||||
|
}
|
||||||
|
expect(chart.decadeFortunes[0].annualFortunes.map(f => f.year))
|
||||||
|
.toEqual([1995, 1996, 1997, 1998, 1999, 2000, 2001, 2002, 2003, 2004]);
|
||||||
|
expect(chart.decadeFortunes[3].annualFortunes.map(f => f.year))
|
||||||
|
.toEqual([2025, 2026, 2027, 2028, 2029, 2030, 2031, 2032, 2033, 2034]);
|
||||||
|
// top-level annualFortunes mirrors the first decade
|
||||||
|
expect(chart.annualFortunes).toEqual(chart.decadeFortunes[0].annualFortunes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns child limit with non-negative start age', () => {
|
||||||
|
expect(bazi.childLimit.startAge).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(bazi.childLimit.forward).toBeTypeOf('boolean');
|
||||||
|
expect(bazi.childLimit.birthTime).toContain('1990年5月15日');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches paipan sect-2 start for 1989-12-22 15:20 male', () => {
|
||||||
|
const chart = birthInfoToBazi({ year: 1989, month: 12, day: 22, hour: 15, minute: 20, gender: 'male' });
|
||||||
|
expect(chart.childLimit.forward).toBe(false);
|
||||||
|
expect(chart.childLimit.yearCount).toBe(5);
|
||||||
|
expect(chart.childLimit.monthCount).toBe(0);
|
||||||
|
expect(chart.childLimit.dayCount).toBe(20);
|
||||||
|
expect(chart.childLimit.hourCount).toBe(0);
|
||||||
|
expect(chart.childLimit.startTime).toBe('1995年1月11日 15:20:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the first-luck transition as the displayed start time', () => {
|
||||||
|
const sameDay = birthInfoToBazi({ year: 1990, month: 8, day: 8, hour: 10, minute: 30, gender: 'male' });
|
||||||
|
expect(sameDay.childLimit.birthTime).toContain('1990年8月8日');
|
||||||
|
expect(sameDay.childLimit.startTime).toContain('2000年11月14日');
|
||||||
|
expect(sameDay.childLimit.startTime).not.toBe(sameDay.childLimit.birthTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes hidden stems with ten stars', () => {
|
||||||
|
const yearHs = bazi.eightChar.yearPillar.hideStems;
|
||||||
|
expect(yearHs.length).toBeGreaterThan(0);
|
||||||
|
expect(yearHs[0].stem).toMatch(/^[甲乙丙丁戊己庚辛壬癸]$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('default sect: late zi (23:30) rolls to next day', () => {
|
||||||
|
const late = birthInfoToBazi({ year: 2026, month: 8, day: 3, hour: 23, minute: 30, gender: 'male' });
|
||||||
|
expect(late.eightChar.dayPillar.ganzhi).toBe('庚戌');
|
||||||
|
expect(late.eightChar.hourPillar.ganzhi).toBe('丙子');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('earlyZiSameDay sect: late zi (23:30) keeps the current day', () => {
|
||||||
|
const early = birthInfoToBazi({
|
||||||
|
year: 2026, month: 8, day: 3, hour: 23, minute: 30, gender: 'male', ziSect: 'earlyZiSameDay',
|
||||||
|
});
|
||||||
|
expect(early.eightChar.dayPillar.ganzhi).toBe('己酉');
|
||||||
|
expect(early.eightChar.hourPillar.ganzhi).toBe('甲子');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { calculateBoneWeight } from '../index';
|
||||||
|
|
||||||
|
describe('bone weight', () => {
|
||||||
|
it('sums weights correctly for 甲子年正月初一子时', () => {
|
||||||
|
const r = calculateBoneWeight(0, 1, 1, 0);
|
||||||
|
expect(r.yearWeight).toBe(12);
|
||||||
|
expect(r.monthWeight).toBe(6);
|
||||||
|
expect(r.dayWeight).toBe(5);
|
||||||
|
expect(r.hourWeight).toBe(16);
|
||||||
|
expect(r.totalWeight).toBe(39);
|
||||||
|
expect(r.totalLiang).toBe(3);
|
||||||
|
expect(r.totalQian).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a non-empty interpretation with a valid fortune grade', () => {
|
||||||
|
const r = calculateBoneWeight(12, 6, 15, 6);
|
||||||
|
expect(r.interpretation.length).toBeGreaterThan(0);
|
||||||
|
expect(['上上', '上', '中上', '中', '中下', '下']).toContain(r.fortune);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the nearest interpretation for unknown totals', () => {
|
||||||
|
// 4 + 9 + 9 + 9 = 31 → exact match exists; use an out-of-range input
|
||||||
|
const r = calculateBoneWeight(0, 1, 1, 11); // 12 + 6 + 5 + 6 = 29
|
||||||
|
expect(r.totalWeight).toBe(29);
|
||||||
|
expect(r.interpretation.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('year table matches the mainstream 称骨年表', () => {
|
||||||
|
const cases: [number, number][] = [
|
||||||
|
[0, 12], // 甲子
|
||||||
|
[7, 8], // 辛未
|
||||||
|
[16, 12], // 庚辰
|
||||||
|
[17, 6], // 辛巳
|
||||||
|
[18, 8], // 壬午
|
||||||
|
[21, 15], // 乙酉
|
||||||
|
[51, 8], // 乙卯
|
||||||
|
[54, 19], // 戊午
|
||||||
|
];
|
||||||
|
for (const [idx, wt] of cases) {
|
||||||
|
const r = calculateBoneWeight(idx, 1, 1, 0);
|
||||||
|
expect(r.yearWeight, `year index ${idx}`).toBe(wt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('day table: 初五 is 1两6钱', () => {
|
||||||
|
const r = calculateBoneWeight(0, 1, 5, 0);
|
||||||
|
expect(r.dayWeight).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previously missing poems (5两8钱~7两1钱) resolve exactly, not by nearest match', () => {
|
||||||
|
const cases: [number, number, number, number, number, string][] = [
|
||||||
|
[18, 6, 26, 5, 58, '雁塔题名'],
|
||||||
|
[15, 6, 8, 7, 59, '甲第之中'],
|
||||||
|
[24, 3, 18, 6, 61, '金榜客'],
|
||||||
|
[42, 6, 26, 5, 63, '定中高科'],
|
||||||
|
[54, 9, 18, 6, 65, '安邦'],
|
||||||
|
[54, 6, 8, 5, 67, '田园家业'],
|
||||||
|
[54, 6, 18, 5, 69, '前禄星'],
|
||||||
|
[54, 3, 26, 5, 71, '公侯卿相'],
|
||||||
|
];
|
||||||
|
for (const [y, m, d, h, total, phrase] of cases) {
|
||||||
|
const r = calculateBoneWeight(y, m, d, h);
|
||||||
|
expect(r.totalWeight, `expected total ${total}`).toBe(total);
|
||||||
|
expect(r.interpretation, `poem for ${total}`).toContain(phrase);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getDayInfo, getMonthCalendar, getTodayInfo } from '../index';
|
||||||
|
|
||||||
|
describe('day transformers', () => {
|
||||||
|
it('returns correct lunar info for 2024-02-10 (春节正月初一)', () => {
|
||||||
|
const di = getDayInfo(2024, 2, 10);
|
||||||
|
expect(di.solarDate).toBe('2024-02-10');
|
||||||
|
expect(di.lunarMonth).toBe(1);
|
||||||
|
expect(di.lunarDay).toBe(1);
|
||||||
|
expect(di.lunarDayName).toBe('初一');
|
||||||
|
expect(di.lunarYearGanzhi).toMatch(/^[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]$/);
|
||||||
|
expect(di.weekDayIndex).toBe(6);
|
||||||
|
expect(di.isWeekend).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTodayInfo returns today', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const di = getTodayInfo();
|
||||||
|
expect(di.solarYear).toBe(now.getFullYear());
|
||||||
|
expect(di.solarMonth).toBe(now.getMonth() + 1);
|
||||||
|
expect(di.solarDay).toBe(now.getDate());
|
||||||
|
expect(di.isToday).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getMonthCalendar respects weekStart', () => {
|
||||||
|
// 2024-02-01 is a Thursday
|
||||||
|
const sunFirst = getMonthCalendar(2024, 2, 0);
|
||||||
|
const monFirst = getMonthCalendar(2024, 2, 1);
|
||||||
|
|
||||||
|
for (const row of sunFirst) expect(row).toHaveLength(7);
|
||||||
|
for (const row of monFirst) expect(row).toHaveLength(7);
|
||||||
|
|
||||||
|
expect(sunFirst[0][0].weekDayIndex).toBe(0); // Sunday first
|
||||||
|
expect(monFirst[0][0].weekDayIndex).toBe(1); // Monday first
|
||||||
|
|
||||||
|
// Both grids must contain the 1st of the month
|
||||||
|
const flatSun = sunFirst.flat();
|
||||||
|
const flatMon = monFirst.flat();
|
||||||
|
expect(flatSun.some(d => d.solarDate === '2024-02-01')).toBe(true);
|
||||||
|
expect(flatMon.some(d => d.solarDate === '2024-02-01')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides season, term progress, julian, buddhist era and hijri date', () => {
|
||||||
|
const di = getDayInfo(2026, 8, 3); // 大暑期间,农历六月廿一
|
||||||
|
expect(di.season).toBe('夏季');
|
||||||
|
expect(di.termDayIndex).toBeGreaterThan(0);
|
||||||
|
expect(di.nextSolarTerm).toBe('立秋');
|
||||||
|
expect(di.daysToNextTerm).toBe(4);
|
||||||
|
expect(di.julianDay).toBeGreaterThan(2450000);
|
||||||
|
expect(di.buddhistYear).toBe(2026 + 543);
|
||||||
|
expect(di.hijriDate).toMatch(/^\d{4}年\d{2}月\d{2}日$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks Buddhist festivals by lunar date', () => {
|
||||||
|
// 2024-05-15 is 佛诞(浴佛节)农历四月初八
|
||||||
|
const di = getDayInfo(2024, 5, 15);
|
||||||
|
expect(di.lunarMonth).toBe(4);
|
||||||
|
expect(di.lunarDay).toBe(8);
|
||||||
|
expect(di.buddhistFestival).toBe('释迦牟尼佛圣诞(浴佛节)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { analyzeElementBalance, birthInfoToBazi } from '../index';
|
||||||
|
|
||||||
|
describe('element balance', () => {
|
||||||
|
const eightChar = birthInfoToBazi({
|
||||||
|
year: 1990, month: 5, day: 15, hour: 12, minute: 0, gender: 'male',
|
||||||
|
}).eightChar;
|
||||||
|
|
||||||
|
const profile = analyzeElementBalance(eightChar);
|
||||||
|
|
||||||
|
it('has positive total that matches sum of parts', () => {
|
||||||
|
expect(profile.total).toBeGreaterThan(0);
|
||||||
|
expect(profile.wood + profile.fire + profile.earth + profile.metal + profile.water)
|
||||||
|
.toBeCloseTo(profile.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identifies dominant and weakest elements', () => {
|
||||||
|
expect(['木', '火', '土', '金', '水']).toContain(profile.dominant);
|
||||||
|
expect(['木', '火', '土', '金', '水']).toContain(profile.weakest);
|
||||||
|
expect(profile.isBalanced).toBeTypeOf('boolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { calculateDailyFortune, birthInfoToBazi } from '../index';
|
||||||
|
|
||||||
|
const SCORE_LEVELS = ['great', 'good', 'fair', 'poor', 'bad'] as const;
|
||||||
|
|
||||||
|
describe('daily fortune', () => {
|
||||||
|
const eightChar = birthInfoToBazi({
|
||||||
|
year: 1990, month: 5, day: 15, hour: 12, minute: 0, gender: 'female',
|
||||||
|
}).eightChar;
|
||||||
|
|
||||||
|
const fortune = calculateDailyFortune(eightChar, new Date(2026, 7, 2));
|
||||||
|
|
||||||
|
it('returns bounded score and valid level', () => {
|
||||||
|
expect(fortune.overallScore).toBeGreaterThanOrEqual(-100);
|
||||||
|
expect(fortune.overallScore).toBeLessThanOrEqual(100);
|
||||||
|
expect(SCORE_LEVELS).toContain(fortune.scoreLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evaluates all four pillars', () => {
|
||||||
|
expect(fortune.pillarRelationships).toHaveLength(4);
|
||||||
|
expect(fortune.pillarRelationships.map(r => r.pillar)).toEqual(['year', 'month', 'day', 'hour']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the requested date', () => {
|
||||||
|
expect(fortune.date).toBe('2026-08-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces suggestions and category scores', () => {
|
||||||
|
expect(fortune.suggestions.length).toBeGreaterThan(0);
|
||||||
|
expect(fortune.luckyAspects.length).toBeGreaterThan(0);
|
||||||
|
const { love, career, wealth, health } = fortune.categoryScores;
|
||||||
|
expect([love, career, wealth, health].every(v => v >= -100 && v <= 100)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { analyzeFortuneGanzhi, birthInfoToBazi } from '../index';
|
||||||
|
|
||||||
|
describe('fortune luck (大运/流年 vs 日主)', () => {
|
||||||
|
// 2026-08-03 12:00: 日干己、日支酉
|
||||||
|
const bazi = birthInfoToBazi({ year: 2026, month: 8, day: 3, hour: 12, minute: 0, gender: 'male' });
|
||||||
|
const dayStem = bazi.eightChar.dayPillar.heavenStem;
|
||||||
|
const dayBranch = bazi.eightChar.dayPillar.earthBranch;
|
||||||
|
|
||||||
|
it('甲子 vs 己酉: 正官 + 天干合 + 克我 → 吉', () => {
|
||||||
|
const r = analyzeFortuneGanzhi('甲子', dayStem, dayBranch);
|
||||||
|
expect(r.tenStar).toBe('正官');
|
||||||
|
expect(r.stemCombine).toBe(true);
|
||||||
|
expect(r.elementRelation).toBe('克我');
|
||||||
|
expect(r.level).toBe('吉');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('乙酉 vs 己酉: 七杀 + 自刑 → 凶', () => {
|
||||||
|
const r = analyzeFortuneGanzhi('乙酉', dayStem, dayBranch);
|
||||||
|
expect(r.tenStar).toBe('七杀');
|
||||||
|
expect(r.branchPunish).toBe(true); // 酉酉自刑
|
||||||
|
expect(r.level).toBe('凶');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('丙子 vs 己未: 正印 + 子未六害', () => {
|
||||||
|
const r = analyzeFortuneGanzhi('丙子', '己', '未');
|
||||||
|
expect(r.tenStar).toBe('正印');
|
||||||
|
expect(r.branchHarm).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all decade fortunes produce valid luck info', () => {
|
||||||
|
for (const df of bazi.decadeFortunes) {
|
||||||
|
const r = analyzeFortuneGanzhi(df.ganzhi, dayStem, dayBranch);
|
||||||
|
expect(['比和', '生我', '我生', '克我', '我克']).toContain(r.elementRelation);
|
||||||
|
expect(['吉', '凶', '平']).toContain(r.level);
|
||||||
|
expect(r.score).toBeGreaterThanOrEqual(-10);
|
||||||
|
expect(r.score).toBeLessThanOrEqual(10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { calculatePlumBlossom } from '../index';
|
||||||
|
|
||||||
|
describe('plum blossom', () => {
|
||||||
|
it('produces expected hexagram for known input (2026-08-02 12:00)', () => {
|
||||||
|
const r = calculatePlumBlossom(2026, 8, 2, 12);
|
||||||
|
expect(r.upperTrigram.name).toBe('巽');
|
||||||
|
expect(r.lowerTrigram.name).toBe('兑');
|
||||||
|
expect(r.originalHexagram.name).toBe('风泽中孚');
|
||||||
|
expect(r.originalHexagram.changingLine).toBe(4);
|
||||||
|
expect(r.relationship).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic', () => {
|
||||||
|
const a = calculatePlumBlossom(2024, 3, 5, 8);
|
||||||
|
const b = calculatePlumBlossom(2024, 3, 5, 8);
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dataset covers all 64 hexagram combinations', () => {
|
||||||
|
const srcFile = fileURLToPath(new URL('../calculators/plumBlossom.ts', import.meta.url));
|
||||||
|
const src = fs.readFileSync(srcFile, 'utf8');
|
||||||
|
const keys = [...src.matchAll(/'(\d,\d)':/g)].map(m => m[1]);
|
||||||
|
|
||||||
|
expect(keys).toHaveLength(64);
|
||||||
|
expect(new Set(keys).size).toBe(64);
|
||||||
|
for (let upper = 1; upper <= 8; upper++) {
|
||||||
|
for (let lower = 1; lower <= 8; lower++) {
|
||||||
|
expect(keys).toContain(`${upper},${lower}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never falls back to a placeholder across a broad date grid', () => {
|
||||||
|
for (let y = 2000; y <= 2030; y++) {
|
||||||
|
for (let m = 1; m <= 12; m++) {
|
||||||
|
for (let d = 1; d <= 28; d += 7) {
|
||||||
|
const r = calculatePlumBlossom(y, m, d, 12);
|
||||||
|
// Real hexagram names are 3+ chars; fallback names are 2-char concatenations
|
||||||
|
expect(r.originalHexagram.name.length).toBeGreaterThan(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
getBranchRelationship,
|
||||||
|
getTenStarRelationship,
|
||||||
|
checkStemCombine,
|
||||||
|
checkStemOpposite,
|
||||||
|
} from '../index';
|
||||||
|
|
||||||
|
describe('relationship calculator', () => {
|
||||||
|
it('detects 六合 (子丑合)', () => {
|
||||||
|
expect(getBranchRelationship('子', '丑').combine).toBe(true);
|
||||||
|
expect(getBranchRelationship('子', '午').combine).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects 六冲 (子午冲)', () => {
|
||||||
|
expect(getBranchRelationship('子', '午').opposite).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects 三合 (申子辰水局)', () => {
|
||||||
|
const rel = getBranchRelationship('申', '辰');
|
||||||
|
expect(rel.threeCombine).toBe(true);
|
||||||
|
expect(rel.formation).toBe('水局');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects 六害 (子未害)', () => {
|
||||||
|
expect(getBranchRelationship('子', '未').harm).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects 相刑 (寅巳无恩之刑)', () => {
|
||||||
|
expect(getBranchRelationship('寅', '巳').punish).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects 天干合 (甲己合)', () => {
|
||||||
|
expect(checkStemCombine('甲', '己')).toBe(true);
|
||||||
|
expect(checkStemCombine('甲', '乙')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects 天干冲 (甲庚冲)', () => {
|
||||||
|
expect(checkStemOpposite('甲', '庚')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes ten star for a day master (甲日主见戊土 → 偏财)', () => {
|
||||||
|
expect(getTenStarRelationship('甲', '戊')).toContain('财');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user