refactor(server): 拆分 cmd/mini 与 cmd/web 两个部署入口
Build and Publish Commercial Web / build (push) Failing after 1m25s
Build and Publish Server / build (push) Successful in 3m25s

小程序接口(lunar.neatcn.com)与商业版 Web(kepo.net)此前共用一个
二进制,导致小程序域名暴露商业后台与 Stripe Webhook,商业站点也
暴露了许愿树等小程序接口。现拆为两个入口:

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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
gouki
2026-10-18 11:31:51 +00:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 28309e0257
commit 12fa2b0d19
15 changed files with 872 additions and 212 deletions
+137
View File
@@ -0,0 +1,137 @@
# kepo.net 商业版 Web 拉取式部署配置(一次性)
> 安全原则同 [PULL_DEPLOY.md](./PULL_DEPLOY.md):CI 只构建、不部署;任何 AI 与流水线都不允许 SSH 到服务器执行命令。
> 部署由 kepo 服务器本地定时任务轮询 Gitea Release 自动完成。
> 本文档所有操作均由管理员本人在服务器终端执行,不依赖 AI 远程执行。
## 架构
```
push main → Gitea Actions 构建 cmd/web + web-commercial 前端 → web-deploy.tar.gz 发布为 web-v* Release
↓
kepo 服务器 cron(每分钟)运行 deploy-watch.sh(TAG_PREFIX=web-v)→ 发现新 release → HTTPS 拉取
↓
deploy-web.sh 原子切换 + systemctl restart lunar-web + 健康检查(失败自动回滚)
↓
Cloudflare (HTTPS 终结) → kepo 源站 :8080 lunar-web systemd 服务
```
## 与小程序接口部署的差异
| | lunar.neatcn.com | kepo.net |
|---|---|---|
| 二进制 | `cmd/mini` | `cmd/web` |
| Release 前缀 | `server-v*` | `web-v*` |
| 部署包 | `deploy.tar.gz` | `web-deploy.tar.gz` |
| 运行方式 | Docker 容器 | systemd 服务 `lunar-web` |
| 部署目录 | `/opt/lunar/production` | `/opt/lunar-web/production` |
## 一次性安装步骤(按序执行)
### 1. 准备目录与脚本
```bash
mkdir -p /opt/lunar-web/{production,scripts,backups}
# 从仓库复制脚本(任选其一):
# git clone 后 cp .gitea/scripts/{deploy-watch.sh,deploy-web.sh} /opt/lunar-web/scripts/
# 或直接下载原始文件写入
chmod +x /opt/lunar-web/scripts/*.sh
```
### 2. 生产环境变量
写入 `/opt/lunar-web/production/.env`,权限 `600`:
```ini
SERVER_ENV=production
SERVER_PORT=8080
PUBLIC_BASE_URL=https://kepo.net
WEB_APP_URL=https://kepo.net
CORS_ORIGINS=https://kepo.net,https://www.kepo.net
DB_TYPE=mysql
DB_HOST=<数据库地址>
DB_PORT=3306
DB_USER=<用户名>
DB_PASSWORD=<密码>
DB_NAME=lunar_web # 建议与小程序库分开;若暂时共用,填 lunar
JWT_SECRET=<强随机值,至少32位>
ADMIN_PASSWORD=<后台登录密码>
SMTP_HOST=smtp.qq.com
SMTP_PORT=465
SMTP_USERNAME=<QQ邮箱>
SMTP_PASSWORD=<SMTP授权码>
SMTP_FROM_EMAIL=z@kepo.net
SMTP_TLS_MODE=implicit
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
BINANCE_PAY_API_KEY=
BINANCE_PAY_SECRET_KEY=
BINANCE_PAY_WEBHOOK_PUBLIC_KEY=
```
> kepo 侧不需要 `MINI_APP_ID` / `MINI_APP_SECRET` / `WECHAT_PAY_*`;
> lunar 侧则不需要 SMTP / Stripe / Binance 变量。
### 3. systemd 服务
`/etc/systemd/system/lunar-web.service`:
```ini
[Unit]
Description=Lunar Commercial Web (kepo.net)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/lunar-web/production/current
EnvironmentFile=/opt/lunar-web/production/.env
ExecStart=/opt/lunar-web/production/current/bin/server
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
```bash
systemctl daemon-reload
systemctl enable lunar-web
# 首次启动前需要先手工放一版部署包,或等待第一次 Release 拉取
```
### 4. 定时拉取任务
crontab(`crontab -e`)添加:
```cron
* * * * * TAG_PREFIX=web-v ASSET_NAME=web-deploy.tar.gz \
STATE_FILE=/opt/lunar-web/production/.last-deployed-release \
DEPLOY_SCRIPT=/opt/lunar-web/scripts/deploy-web.sh \
LOG_FILE=/opt/lunar-web/deploy-watch.log \
/opt/lunar-web/scripts/deploy-watch.sh
```
首次运行后会自动部署最新 `web-v*` Release。日志:`/opt/lunar-web/deploy-watch.log`。
### 5. Cloudflare 回源
kepo.net 已走 Cloudflare。确认回源指向本机 8080(或 Nginx 反代到 8080)。
服务只监听 `127.0.0.1` 或由防火墙限制 8080 仅本机访问。
## 验证
```bash
curl -s http://127.0.0.1:8080/api/version # 应返回 version/buildTime/commitSha
curl -s -o /dev/null -w '%{http_code}' https://kepo.net/
curl -s -o /dev/null -w '%{http_code}' https://kepo.net/admin/
```
## 注意
- **数据库**:短期内两边共用 `lunar` 库(后台 `/admin` 在 kepo 侧统一查看订单与小程序数据)。后续如需分离,新建 `lunar_web` 库并改 `DB_NAME`。
- **回滚**:`deploy-web.sh` 健康检查失败会自动恢复 `current.old`;也可手动 `systemctl restart lunar-web`。
- **小程序端**:部署包结构不变(`deploy/bin/server`),doc79 上的 watcher 无需改动。
+12 -5
View File
@@ -13,6 +13,11 @@ set -u
GITEA_URL="${GITEA_URL:-https://gitea.neatcn.com}"
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"])
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# 商业版 Web(kepo.net)自动部署脚本 - systemd 直接运行二进制
# 设计原则:拉取式部署,只在服务器本地执行,CI 不通过 SSH 触发
# 使用方法: ./deploy-web.sh <web-deploy.tar.gz>
#
# 目录约定:
# /opt/lunar-web/production/current 当前运行版本(systemd WorkingDirectory)
# /opt/lunar-web/production/.env 环境变量(systemd EnvironmentFile)
# /opt/lunar-web/backups 历史版本备份
#
# 可通过环境变量覆盖:SERVICE_NAME / DEPLOY_DIR / BACKUP_DIR / HEALTH_URL
set -e
DEPLOY_PACKAGE="$1"
SERVICE_NAME="${SERVICE_NAME:-lunar-web}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/lunar-web/production}"
BACKUP_DIR="${BACKUP_DIR:-/opt/lunar-web/backups}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8080/api/version}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# 回滚:恢复 current.old 并重启服务
rollback() {
if [ -d "$DEPLOY_DIR/current.old" ]; then
rm -rf "$DEPLOY_DIR/current"
mv "$DEPLOY_DIR/current.old" "$DEPLOY_DIR/current"
systemctl restart "$SERVICE_NAME" || log_error "回滚后服务重启失败,需人工介入"
log_warn "已回滚到上一版本"
else
log_error "无可回滚版本,需人工介入"
fi
}
if [ ! -f "$DEPLOY_PACKAGE" ]; then
log_error "部署包不存在: $DEPLOY_PACKAGE"
exit 1
fi
log_info "开始自动部署(商业版 Web)..."
log_info "部署包: $DEPLOY_PACKAGE"
log_info "systemd 服务: $SERVICE_NAME"
log_info "部署目录: $DEPLOY_DIR"
mkdir -p "$DEPLOY_DIR" "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/backup_${TIMESTAMP}.tar.gz"
if [ -d "$DEPLOY_DIR/current" ]; then
log_info "备份当前版本到: $BACKUP_FILE"
tar -czf "$BACKUP_FILE" -C "$DEPLOY_DIR" current/ || log_warn "备份失败,继续部署"
fi
# 解压到暂存目录(不碰 current,避免部署空窗)
log_info "解压部署包..."
TEMP_DIR=$(mktemp -d)
tar -xzf "$DEPLOY_PACKAGE" -C "$TEMP_DIR"
if [ ! -d "$TEMP_DIR/deploy" ]; then
log_error "部署包结构错误:缺少 deploy 目录"
rm -rf "$TEMP_DIR"
exit 1
fi
if [ ! -f "$TEMP_DIR/deploy/bin/server" ]; then
log_error "部署包缺少 bin/server 二进制"
rm -rf "$TEMP_DIR"
exit 1
fi
if [ ! -f "$TEMP_DIR/deploy/frontend/index.html" ]; then
log_error "部署包缺少 frontend/index.html(商业前端未构建?)"
rm -rf "$TEMP_DIR"
exit 1
fi
chmod +x "$TEMP_DIR/deploy/bin/server"
# 目录级交换,接近原子
log_info "部署新版本..."
mv "$TEMP_DIR/deploy" "$DEPLOY_DIR/current.new"
rm -rf "$DEPLOY_DIR/current.old"
if [ -d "$DEPLOY_DIR/current" ]; then
mv "$DEPLOY_DIR/current" "$DEPLOY_DIR/current.old"
fi
mv "$DEPLOY_DIR/current.new" "$DEPLOY_DIR/current"
rm -rf "$TEMP_DIR"
# 重启 systemd 服务
log_info "重启服务: $SERVICE_NAME"
if systemctl restart "$SERVICE_NAME"; then
log_info "服务重启成功"
else
log_error "服务重启失败,回滚到上一版本"
rollback
exit 1
fi
# 健康检查
log_info "执行健康检查: $HEALTH_URL"
HEALTHY=0
for i in $(seq 1 10); do
sleep 2
if curl -fsS --max-time 5 "$HEALTH_URL" > /dev/null 2>&1; then
HEALTHY=1
break
fi
done
if [ "$HEALTHY" = "1" ]; then
log_info "健康检查通过"
curl -fsS --max-time 5 "$HEALTH_URL" && echo ""
rm -rf "$DEPLOY_DIR/current.old"
else
log_error "健康检查失败,回滚到上一版本"
journalctl -u "$SERVICE_NAME" --no-pager -n 50 || true
rollback
exit 1
fi
# 清理旧备份(保留最近 5 个)
ls -t "$BACKUP_DIR"/backup_*.tar.gz 2>/dev/null | tail -n +6 | xargs -r rm -f
log_info "部署完成!"
log_info "备份文件: $BACKUP_FILE"
log_info "部署目录: $DEPLOY_DIR/current"
+5 -5
View File
@@ -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
+270
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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/*` 为后端接口。
## 测试
-199
View File
@@ -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)
}
}
+30
View File
@@ -0,0 +1,30 @@
// 小程序接口服务(lunar.neatcn.com):微信登录、许愿树、百科等。
// 不包含商业版支付、邮箱验证码登录与管理后台。
package main
import (
"log"
"github.com/gouki/lunar-server/internal/app"
"github.com/gouki/lunar-server/internal/handler"
)
// 版本信息由 CI 通过 ldflags 注入:
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
func main() {
handler.Version = Version
handler.BuildTime = BuildTime
handler.CommitSha = CommitSha
log.Printf("lunar-mini-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
cfg := app.Bootstrap()
r := app.NewEngine()
app.SetupMini(r, cfg)
app.Run(r, cfg.Server.Port)
}
+31
View File
@@ -0,0 +1,31 @@
// 商业版 Web 服务(kepo.net):邮箱验证码登录、Stripe / Binance Pay
// 支付、订单权益、报告快照与恢复码、管理后台、商业前端静态站点。
// 不包含小程序接口(微信登录、许愿树、百科同步等)。
package main
import (
"log"
"github.com/gouki/lunar-server/internal/app"
"github.com/gouki/lunar-server/internal/handler"
)
// 版本信息由 CI 通过 ldflags 注入:
// -X main.Version=... -X main.BuildTime=... -X main.CommitSha=...
var (
Version = "dev"
BuildTime = "unknown"
CommitSha = "unknown"
)
func main() {
handler.Version = Version
handler.BuildTime = BuildTime
handler.CommitSha = CommitSha
log.Printf("lunar-web-server %s (commit %s, built at %s)", Version, CommitSha, BuildTime)
cfg := app.Bootstrap()
r := app.NewEngine()
app.SetupWeb(r, cfg)
app.Run(r, cfg.Server.Port)
}
+51
View File
@@ -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)
}
}
+79
View File
@@ -0,0 +1,79 @@
package app
import (
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware"
"github.com/gouki/lunar-server/internal/service"
)
// SetupMini 注册小程序接口路由与定时任务。
// 不包含商业版支付、邮箱登录与管理后台——这些只属于 cmd/web。
func SetupMini(r *gin.Engine, cfg *config.Config) {
// 启动机器人许愿定时任务
wishService := service.NewWishService()
wishService.StartRobotWishJob()
// 启动维基百科同步定时任务(历史上的今天;首次启动数据为空时自动全量抓取)
handler.WikiSync = service.NewWikiSyncService(config.GetDB())
handler.WikiSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 启动节气详情同步定时任务(百度百科;独立于维基同步,首次启动缺详情时自动补齐)
handler.BaikeTermSync = service.NewBaikeTermSyncService(config.GetDB())
handler.BaikeTermSync.StartScheduler(cfg.Wiki.SyncIntervalDays, cfg.Wiki.SyncHour)
// 内嵌图片资源(许愿树背景图等)
MountImages(r)
api := r.Group("/api")
{
api.GET("/version", handler.GetVersion)
user := api.Group("/user")
{
user.POST("/login", handler.UserLogin)
user.POST("/logout", handler.UserLogout)
user.GET("/profile", middleware.Auth(), handler.GetUserProfile)
user.PUT("/profile", middleware.Auth(), handler.UpdateUserProfile)
user.POST("/auth", handler.WechatAuth)
// 生辰档案(八字绑定,每用户免费 3 个)
user.GET("/birth-profiles", middleware.Auth(), handler.GetBirthProfiles)
user.POST("/birth-profiles", middleware.Auth(), handler.CreateBirthProfile)
user.DELETE("/birth-profiles/:id", middleware.Auth(), handler.DeleteBirthProfile)
}
// 支付相关(许愿付费;演示期保留接口占位)
pay := api.Group("/pay")
{
pay.POST("/create", middleware.Auth(), handler.CreateOrder)
pay.POST("/notify", handler.PayNotify)
pay.GET("/status/:orderId", middleware.Auth(), handler.GetPayStatus)
}
order := api.Group("/order")
{
order.GET("/list", middleware.Auth(), handler.GetOrderList)
order.GET("/:id", middleware.Auth(), handler.GetOrderDetail)
order.POST("/cancel/:id", middleware.Auth(), handler.CancelOrder)
}
wish := api.Group("/wish")
{
wish.GET("/trees", handler.GetWishTrees)
wish.GET("/tree", handler.GetWishTree)
wish.GET("/tree/:id/wishes", handler.GetTreeWishes)
wish.POST("/create", middleware.Auth(), handler.CreateWish)
wish.GET("/list", handler.GetWishList)
wish.GET("/:id", handler.GetWishDetail)
wish.DELETE("/:id", middleware.Auth(), handler.DeleteWish)
wish.GET("/products", handler.GetWishProducts)
}
wiki := api.Group("/wiki")
{
wiki.GET("/entries", handler.GetWikiEntries)
wiki.GET("/on-this-day", handler.GetOnThisDay)
}
}
}
+102
View File
@@ -0,0 +1,102 @@
package app
import (
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/gouki/lunar-server/internal/config"
"github.com/gouki/lunar-server/internal/handler"
"github.com/gouki/lunar-server/internal/middleware"
)
// SetupWeb 注册商业版 Web 路由:邮箱登录、支付/订单、报告权益、
// 管理后台与商业前端静态文件。不包含小程序接口(许愿树、微信登录等)。
//
// 静态资源约定(相对 WorkingDirectory):
//
// ./frontend/ 商业前端构建产物(web-commercial/dist)
// ./web/ 管理后台静态资源与模板(server/web)
func SetupWeb(r *gin.Engine, cfg *config.Config) {
// 商业版前端静态文件;后台仍通过 /admin 访问。
r.Static("/assets", "./frontend/assets")
r.GET("/index.html", func(c *gin.Context) { c.File("./frontend/index.html") })
r.GET("/", func(c *gin.Context) { c.File("./frontend/index.html") })
r.Static("/static", "./web/static")
r.LoadHTMLFiles("./web/index.html")
// 内嵌图片资源(管理后台可能引用许愿树图片)
MountImages(r)
api := r.Group("/api")
{
api.GET("/version", handler.GetVersion)
auth := api.Group("/auth")
{
auth.POST("/email/request", middleware.RateLimit(), handler.RequestEmailLoginCode)
auth.POST("/email/verify", middleware.RateLimit(), handler.VerifyEmailLoginCode)
}
commerce := api.Group("/commerce")
{
commerce.POST("/charts", handler.SaveChartRecord)
commerce.POST("/checkout", middleware.Auth(), handler.CreateReportCheckout)
commerce.GET("/orders/:orderNo", middleware.Auth(), handler.GetCommerceOrder)
commerce.GET("/reports/:reportId/access", middleware.Auth(), handler.GetReportAccess)
commerce.GET("/reports", middleware.Auth(), handler.ListPurchasedReports)
commerce.GET("/reports/recover/:accessCode", middleware.Auth(), handler.GetReportByAccessCode)
commerce.POST("/webhooks/stripe", handler.StripeWebhook)
commerce.POST("/webhooks/binance-pay", handler.BinancePayWebhook)
}
}
// 管理后台路由(Inertia.js)
admin := r.Group("/admin")
{
admin.POST("/login", handler.AdminLogin)
admin.POST("/logout", handler.AdminLogout)
authed := admin.Group("")
authed.Use(middleware.AdminAuth())
{
authed.GET("/", handler.AdminDashboard)
authed.GET("/users", handler.AdminUsers)
authed.GET("/orders", handler.AdminOrders)
authed.GET("/charts", handler.AdminCharts)
authed.GET("/wishes", handler.AdminWishes)
authed.GET("/wiki", handler.AdminWiki)
authed.GET("/settings", handler.AdminSettings)
authed.GET("/api/wiki/entries", handler.AdminWikiEntries)
authed.POST("/api/wiki/entries", handler.AdminWikiEntryCreate)
authed.PUT("/api/wiki/entries/:id", handler.AdminWikiEntryUpdate)
authed.DELETE("/api/wiki/entries/:id", handler.AdminWikiEntryDelete)
authed.GET("/api/wiki/sync-status", handler.AdminWikiSyncStatus)
authed.POST("/api/wiki/sync", handler.AdminWikiSyncTrigger)
authed.GET("/api/orders", handler.AdminCommerceOrders)
authed.GET("/api/orders/stats", handler.AdminCommerceStats)
authed.GET("/api/charts", handler.AdminChartRecords)
authed.GET("/api/wiki/term-sync-status", handler.AdminTermSyncStatus)
authed.POST("/api/wiki/term-sync", handler.AdminTermSyncTrigger)
}
}
// SPA 回退:非 /api、/admin、/static、/images、/assets 的 GET 请求
// 一律返回商业前端 index.html,交给前端路由处理。
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
for _, prefix := range []string{"/api/", "/admin", "/static/", "/images/", "/assets/"} {
if strings.HasPrefix(path, prefix) {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "not found"})
return
}
}
if _, err := os.Stat("./frontend/index.html"); err == nil {
c.File("./frontend/index.html")
return
}
c.String(http.StatusNotFound, "frontend not deployed")
})
}
+8
View File
@@ -0,0 +1,8 @@
package assets
import "embed"
// 内嵌图片资源(许愿树背景图等),随二进制一起部署
//
//go:embed images
var FS embed.FS

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 332 KiB