refactor(server): 拆分 cmd/mini 与 cmd/web 两个部署入口
小程序接口(lunar.neatcn.com)与商业版 Web(kepo.net)此前共用一个 二进制,导致小程序域名暴露商业后台与 Stripe Webhook,商业站点也 暴露了许愿树等小程序接口。现拆为两个入口: - cmd/mini:微信登录、生辰档案、许愿树、百科、版本号 - cmd/web:邮箱登录、commerce、报告/订单、/admin 后台、商业前端 静态托管与 SPA 回退 - internal/app 承载共用启动与路由注册;图片资源移至 internal/assets CI:server-deploy.yml 改构建 cmd/mini 并随包移除后台静态资源; 新增 web-deploy.yml 构建 cmd/web + web-commercial 前端,发布 web-v* Release;deploy-watch.sh 支持 TAG_PREFIX/ASSET_NAME 变量; 新增 deploy-web.sh(systemd 版)与 kepo 一次性配置文档 .gitea/docs/PULL_DEPLOY_WEB.md。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
28309e0257
commit
12fa2b0d19
@@ -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}"
|
||||
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}"
|
||||
DEPLOY_SCRIPT="${DEPLOY_SCRIPT:-/opt/lunar/scripts/deploy.sh}"
|
||||
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}")
|
||||
fi
|
||||
|
||||
# 查询最新 20 个 release,取 tag 前缀为 server-v 且带 deploy.tar.gz 附件的最新一个
|
||||
# 查询最新 20 个 release,取 tag 前缀匹配且带部署包附件的最新一个
|
||||
RELEASE_JSON=$(curl -fsS --connect-timeout 10 --max-time 30 \
|
||||
"${AUTH_ARGS[@]}" \
|
||||
"$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
|
||||
}
|
||||
|
||||
PARSED=$(python3 - "$WORK_DIR/releases.json" <<'PY'
|
||||
import json, sys
|
||||
PARSED=$(TAG_PREFIX="$TAG_PREFIX" ASSET_NAME="$ASSET_NAME" python3 - "$WORK_DIR/releases.json" <<'PY'
|
||||
import json, os, sys
|
||||
tag_prefix = os.environ["TAG_PREFIX"]
|
||||
asset_name = os.environ["ASSET_NAME"]
|
||||
releases = json.load(open(sys.argv[1]))
|
||||
for r in releases:
|
||||
if not r.get("tag_name", "").startswith("server-v"):
|
||||
if not r.get("tag_name", "").startswith(tag_prefix):
|
||||
continue
|
||||
if r.get("draft") or r.get("prerelease"):
|
||||
continue
|
||||
for a in r.get("assets", []):
|
||||
if a.get("name") == "deploy.tar.gz":
|
||||
if a.get("name") == asset_name:
|
||||
print(r["id"])
|
||||
print(r["tag_name"])
|
||||
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"
|
||||
|
||||
# ldflags 注入版本信息;目标平台为 doc79(linux/amd64)
|
||||
# 本流水线构建的是小程序接口二进制(cmd/mini);
|
||||
# 商业版 Web(cmd/web)由 web-deploy.yml 单独构建发布
|
||||
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/main.go
|
||||
-o bin/server ./cmd/mini
|
||||
|
||||
# 版本元数据随包分发,便于服务器侧追溯
|
||||
cat > bin/version.env <<EOF
|
||||
@@ -107,10 +109,8 @@ jobs:
|
||||
cp -r bin/ deploy/
|
||||
cp -r migrations/ deploy/
|
||||
cp -r scripts/ deploy/
|
||||
# 管理后台静态资源必须随包发布(服务以相对路径 ./web 提供)
|
||||
if [ -d "web" ]; then
|
||||
cp -r web/ deploy/web/
|
||||
fi
|
||||
# 管理后台已迁移到商业版 Web 服务(cmd/web),
|
||||
# 小程序接口二进制不再注册 /admin,无需随包发布 web/ 静态资源
|
||||
tar -czf deploy.tar.gz deploy/
|
||||
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
|
||||
@@ -13,6 +13,9 @@ lunar 是一个融合中国传统文化(农历、八字、许愿树)的现
|
||||
|
||||
| 路径 | 说明 | 关键文件 |
|
||||
|------|------|----------|
|
||||
| `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` |
|
||||
|
||||
+14
-3
@@ -1,6 +1,11 @@
|
||||
# 知命 / 祈福后端服务
|
||||
|
||||
基于 Go、Gin、GORM 的统一后端,提供微信小程序能力、商业版八字报告、邮箱登录、支付、订单权益和管理后台。
|
||||
基于 Go、Gin、GORM 的后端仓库,包含两个部署入口:
|
||||
|
||||
- `cmd/mini`:小程序接口(lunar.neatcn.com)——微信登录、生辰档案、许愿树、百科。
|
||||
- `cmd/web`:商业版 Web(kepo.net)——邮箱验证码登录、Stripe / Binance Pay、订单权益、报告快照、管理后台与商业前端静态站点。
|
||||
|
||||
两侧共用 `internal/` 下的配置、数据库、模型与中间件,但路由互不暴露。
|
||||
|
||||
## 功能模块
|
||||
|
||||
@@ -20,12 +25,18 @@
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
go mod download
|
||||
go run ./cmd
|
||||
go run ./cmd/mini # 小程序接口
|
||||
go run ./cmd/web # 商业版 Web(本地需 ../web-commercial/dist 与 ./web 静态资源)
|
||||
```
|
||||
|
||||
默认地址:`http://localhost:8080`。
|
||||
|
||||
生产部署可使用 systemd 直接运行二进制,商业前端静态文件放在服务目录的 `frontend/`,根路径 `/` 为商业站点,`/admin/orders` 为管理后台,`/api/*` 为后端接口。这样无需 Docker 或 Nginx,适合小内存服务器;Cloudflare 可在边缘终结 HTTPS 并回源到 80 端口。
|
||||
生产部署:
|
||||
|
||||
- **小程序接口**(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/*` 为后端接口。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@@ -1,199 +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())
|
||||
|
||||
// 商业版前端静态文件、旧管理后台静态资源与模板
|
||||
// 商业站点部署在根路径;后台仍通过 /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")
|
||||
|
||||
// 内嵌图片资源(许愿树背景图等)
|
||||
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)
|
||||
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/email/request", middleware.RateLimit(), handler.RequestEmailLoginCode)
|
||||
auth.POST("/email/verify", middleware.RateLimit(), handler.VerifyEmailLoginCode)
|
||||
}
|
||||
|
||||
commerce := api.Group("/commerce")
|
||||
{
|
||||
commerce.POST("/charts", middleware.RateLimit(), handler.SaveChartRecord)
|
||||
commerce.POST("/checkout", middleware.Auth(), handler.CreateReportCheckout)
|
||||
commerce.GET("/orders/:orderNo", middleware.Auth(), handler.GetCommerceOrder)
|
||||
commerce.GET("/reports/:reportId/access", middleware.Auth(), handler.GetReportAccess)
|
||||
commerce.GET("/reports", middleware.Auth(), handler.ListPurchasedReports)
|
||||
commerce.GET("/reports/recover/:accessCode", middleware.Auth(), handler.GetReportByAccessCode)
|
||||
commerce.POST("/webhooks/stripe", handler.StripeWebhook)
|
||||
commerce.POST("/webhooks/binance-pay", handler.BinancePayWebhook)
|
||||
}
|
||||
|
||||
// 用户相关
|
||||
user := api.Group("/user")
|
||||
{
|
||||
user.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)
|
||||
}
|
||||
}
|
||||
|
||||
// 管理后台路由(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("/charts", handler.AdminCharts)
|
||||
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/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)
|
||||
}
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
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,51 @@
|
||||
// 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()
|
||||
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 |
Reference in New Issue
Block a user