Compare commits
8
Commits
server-v1.1.86
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b67d893b6 | ||
|
|
df63a06aea | ||
|
|
a024d5dcf6 | ||
|
|
98d6a67a5a | ||
|
|
7c7880bbd8 | ||
|
|
69b8907711 | ||
|
|
51d1a1999f | ||
|
|
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"
|
||||
@@ -60,6 +60,8 @@ jobs:
|
||||
working-directory: server
|
||||
run: |
|
||||
export HOME="$RUNNER_WORKSPACE"
|
||||
# 国内 runner 优先 goproxy.cn,海外 runner 自动回落官方源
|
||||
export GOPROXY="https://goproxy.cn,https://proxy.golang.org,direct"
|
||||
"$LUNAR_GO" mod download
|
||||
|
||||
- name: Run tests
|
||||
@@ -85,11 +87,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 +111,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,268 @@
|
||||
name: Build and Publish Commercial Web
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "server/**"
|
||||
- "web/**"
|
||||
- "web-commercial/**"
|
||||
- ".gitea/workflows/web-deploy.yml"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# 标准标签:hk / bt-runner 均带 ubuntu-latest,任一空闲 runner 接单
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# 精简 runner(Alpine)无 node,无法跑 action-checkout;直接用 git 浅克隆
|
||||
- name: Checkout
|
||||
env:
|
||||
REPO_URL: ${{ gitea.server_url }}/${{ gitea.repository }}
|
||||
REF_NAME: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "$REPO_URL"
|
||||
git fetch -q --depth 20 origin "$REF_NAME"
|
||||
git checkout -q FETCH_HEAD
|
||||
git log --oneline -3
|
||||
|
||||
- name: Ensure curl available
|
||||
run: |
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
if command -v apk >/dev/null 2>&1; then apk add --no-cache curl
|
||||
elif command -v apt-get >/dev/null 2>&1; then apt-get update -qq && apt-get install -y -qq curl
|
||||
elif command -v dnf >/dev/null 2>&1; then dnf install -y -q curl
|
||||
else echo "无法安装 curl"; exit 1; fi
|
||||
fi
|
||||
curl --version | head -1
|
||||
|
||||
- name: Prepare Go toolchain
|
||||
run: |
|
||||
TOOLDIR="$RUNNER_WORKSPACE/.lunar-toolchain"
|
||||
mkdir -p "$TOOLDIR"
|
||||
if [ -x "$TOOLDIR/go/bin/go" ]; then
|
||||
echo "复用缓存 Go 工具链"
|
||||
else
|
||||
wget -q --tries=2 --timeout=60 -O "$TOOLDIR/go.tgz" \
|
||||
"https://go.dev/dl/go1.22.10.linux-amd64.tar.gz" \
|
||||
|| wget -q --tries=2 --timeout=60 -O "$TOOLDIR/go.tgz" \
|
||||
"https://mirrors.aliyun.com/golang/go1.22.10.linux-amd64.tar.gz"
|
||||
rm -rf "$TOOLDIR/go"
|
||||
tar -C "$TOOLDIR" -xzf "$TOOLDIR/go.tgz"
|
||||
rm -f "$TOOLDIR/go.tgz"
|
||||
fi
|
||||
"$TOOLDIR/go/bin/go" version
|
||||
echo "LUNAR_GO=$TOOLDIR/go/bin/go" >> "$GITEA_ENV"
|
||||
|
||||
- name: Prepare Node toolchain
|
||||
run: |
|
||||
TOOLDIR="$RUNNER_WORKSPACE/.lunar-toolchain"
|
||||
mkdir -p "$TOOLDIR"
|
||||
if [ -x "$TOOLDIR/node/bin/node" ] && "$TOOLDIR/node/bin/node" --version >/dev/null 2>&1; then
|
||||
echo "复用缓存 Node 工具链"
|
||||
else
|
||||
rm -rf "$TOOLDIR/node" "$TOOLDIR/node.tgz"
|
||||
mkdir -p "$TOOLDIR"
|
||||
if ldd --version 2>&1 | grep -qi musl || [ ! -e /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 ]; then
|
||||
NODE_DIST="node-v22.12.0-linux-x64-musl"
|
||||
NODE_SHA="8d96de0293fc3978d09e2a3fee68c66bca4efcca90f52105e6f7463636e97308"
|
||||
URLS="https://registry.npmmirror.com/-/binary/node-unofficial-builds/v22.12.0/$NODE_DIST.tar.gz
|
||||
https://unofficial-builds.nodejs.org/download/release/v22.12.0/$NODE_DIST.tar.gz"
|
||||
else
|
||||
NODE_DIST="node-v22.12.0-linux-x64"
|
||||
NODE_SHA="e05a4d65232ae2b27b3d77da2e368522fb46b923335b8e0d5f77624c32484044"
|
||||
URLS="https://nodejs.org/dist/v22.12.0/$NODE_DIST.tar.gz
|
||||
https://mirrors.aliyun.com/nodejs-release/v22.12.0/$NODE_DIST.tar.gz"
|
||||
fi
|
||||
DL_OK=""
|
||||
for url in $URLS; do
|
||||
echo "尝试下载: $url"
|
||||
if wget -t 2 -T 120 -q -O "$TOOLDIR/node.tgz" "$url"; then
|
||||
if [ "$(sha256sum "$TOOLDIR/node.tgz" | cut -d' ' -f1)" = "$NODE_SHA" ]; then
|
||||
echo "下载成功且校验通过"
|
||||
DL_OK=1
|
||||
break
|
||||
fi
|
||||
echo "sha256 校验失败,尝试下一源"
|
||||
else
|
||||
echo "wget 下载失败,尝试下一源"
|
||||
fi
|
||||
rm -f "$TOOLDIR/node.tgz"
|
||||
done
|
||||
test -n "$DL_OK" || { echo "::error::所有下载源均失败或校验不通过"; exit 1; }
|
||||
tar -C "$TOOLDIR" -xzf "$TOOLDIR/node.tgz"
|
||||
mv "$TOOLDIR/$NODE_DIST" "$TOOLDIR/node"
|
||||
rm -f "$TOOLDIR/node.tgz"
|
||||
if [ "$NODE_DIST" = "node-v22.12.0-linux-x64-musl" ] && [ ! -e /usr/lib/libstdc++.so.6 ] && [ ! -e /lib/libstdc++.so.6 ]; then
|
||||
if command -v apk >/dev/null 2>&1; then apk add --no-cache libstdc++ libgcc || echo "::warning::libstdc++/libgcc 安装失败"; fi
|
||||
fi
|
||||
fi
|
||||
if ! "$TOOLDIR/node/bin/node" --version; then
|
||||
echo "::error::Node 二进制无法执行"
|
||||
rm -rf "$TOOLDIR/node"
|
||||
exit 1
|
||||
fi
|
||||
echo "PATH=$TOOLDIR/node/bin:$PATH" >> "$GITEA_ENV"
|
||||
|
||||
- name: Run Go tests
|
||||
working-directory: server
|
||||
run: |
|
||||
export HOME="$RUNNER_WORKSPACE"
|
||||
# 国内 runner 优先 goproxy.cn,海外 runner 自动回落官方源
|
||||
export GOPROXY="https://goproxy.cn,https://proxy.golang.org,direct"
|
||||
"$LUNAR_GO" mod download
|
||||
"$LUNAR_GO" test ./...
|
||||
|
||||
- name: Build commercial frontend
|
||||
env:
|
||||
CI: "true"
|
||||
run: |
|
||||
export HOME="$RUNNER_WORKSPACE"
|
||||
# 依赖下载走 npmmirror;npm/pnpm 全局限定在部分 runner 不稳定,
|
||||
# 故不使用 pnpm 工作区——core 是独立包,npm install 即可构建
|
||||
npm config set registry https://registry.npmmirror.com || true
|
||||
# @lunar/core:web-commercial 的 file: 依赖与 vite/tsconfig 别名目标
|
||||
# --legacy-peer-deps:npm10 的 arborist 在 vitest 可选 peer 上会
|
||||
# edgesOut 崩溃,跳过 peer 解析即可(构建不需要)
|
||||
cd web/packages/core
|
||||
npm install --legacy-peer-deps
|
||||
npx tsup
|
||||
test -f dist/index.js
|
||||
cd ../../../web-commercial
|
||||
npm install --legacy-peer-deps
|
||||
npx tsc -b
|
||||
npx vite build
|
||||
test -f dist/index.html
|
||||
|
||||
- name: Build web server binary
|
||||
working-directory: server
|
||||
env:
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
|
||||
run: |
|
||||
export HOME="$RUNNER_WORKSPACE"
|
||||
mkdir -p bin
|
||||
|
||||
VERSION="1.1.${GITEA_RUN_NUMBER}"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
COMMIT_SHORT="${GITEA_SHA:0:7}"
|
||||
|
||||
echo "Building web server version: $VERSION"
|
||||
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 "$LUNAR_GO" build -ldflags "\
|
||||
-X main.Version=$VERSION \
|
||||
-X main.BuildTime=$BUILD_TIME \
|
||||
-X main.CommitSha=$COMMIT_SHORT" \
|
||||
-o bin/server ./cmd/web
|
||||
|
||||
cat > bin/version.env <<EOF
|
||||
APP_VERSION=$VERSION
|
||||
APP_BUILD_TIME=$BUILD_TIME
|
||||
APP_COMMIT_SHA=$COMMIT_SHORT
|
||||
EOF
|
||||
cat bin/version.env
|
||||
|
||||
- name: Create deployment package
|
||||
working-directory: server
|
||||
run: |
|
||||
mkdir -p deploy
|
||||
cp -r bin/ deploy/
|
||||
cp -r migrations/ deploy/
|
||||
cp -r scripts/ deploy/
|
||||
# 商业前端产物:web 二进制以相对路径 ./frontend 提供
|
||||
cp -r ../web-commercial/dist deploy/frontend/
|
||||
# 管理后台静态资源与模板(服务以相对路径 ./web 提供)
|
||||
cp -r web/ deploy/web/
|
||||
tar -czf web-deploy.tar.gz deploy/
|
||||
sha256sum web-deploy.tar.gz
|
||||
|
||||
- name: Publish release with deployment package
|
||||
env:
|
||||
GITEA_SHA: ${{ gitea.sha }}
|
||||
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
|
||||
LUNAR_PUBLISH_TOKEN: ${{ secrets.LUNAR_PUBLISH_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
VERSION="1.1.${GITEA_RUN_NUMBER}"
|
||||
TAG="web-v${VERSION}"
|
||||
API="https://gitea.neatcn.com/api/v1/repos/gouki/lunar-mini"
|
||||
TOKEN="${LUNAR_PUBLISH_TOKEN:-$GITEA_TOKEN}"
|
||||
test -n "$TOKEN"
|
||||
AUTH="Authorization: token $TOKEN"
|
||||
|
||||
EXISTING=$(curl -sS -H "$AUTH" "$API/releases/tags/$TAG" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
if [ -n "$EXISTING" ]; then
|
||||
curl -sS -X DELETE -H "$AUTH" "$API/releases/$EXISTING" -o /dev/null -w "删除旧 release: HTTP %{http_code}\n"
|
||||
fi
|
||||
|
||||
CREATE_RESP=$(curl -sS -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"web $VERSION\",\"target_commitish\":\"main\",\"body\":\"CI 自动构建,commit ${GITEA_SHA:0:7}。kepo 服务器由 deploy-watch.sh(TAG_PREFIX=web-v)拉取本附件完成部署。\"}" \
|
||||
"$API/releases")
|
||||
RELEASE_ID=$(printf '%s' "$CREATE_RESP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "创建 release 失败,API 响应: $CREATE_RESP"
|
||||
exit 1
|
||||
fi
|
||||
echo "Release created: id=$RELEASE_ID tag=$TAG"
|
||||
|
||||
curl -sS -X POST -H "$AUTH" \
|
||||
-F "attachment=@server/web-deploy.tar.gz" \
|
||||
"$API/releases/$RELEASE_ID/assets" \
|
||||
-o /dev/null -w "附件上传: HTTP %{http_code}\n"
|
||||
echo "发布地址: https://gitea.neatcn.com/gouki/lunar-mini/releases/tag/$TAG"
|
||||
|
||||
- name: Cleanup old web releases (keep latest 10)
|
||||
env:
|
||||
LUNAR_PUBLISH_TOKEN: ${{ secrets.LUNAR_PUBLISH_TOKEN }}
|
||||
run: |
|
||||
API="https://gitea.neatcn.com/api/v1/repos/gouki/lunar-mini"
|
||||
TOKEN="${LUNAR_PUBLISH_TOKEN:-$GITEA_TOKEN}"
|
||||
AUTH="Authorization: token $TOKEN"
|
||||
KEEP=10
|
||||
|
||||
PAIRS=$(curl -sS -H "$AUTH" "$API/releases?limit=50" \
|
||||
| grep -oE '"id":[0-9]+,"tag_name":"web-v[^"]*"' || true)
|
||||
TOTAL=$(printf '%s\n' "$PAIRS" | grep -c '"id"' || true)
|
||||
if [ "$TOTAL" -le "$KEEP" ]; then
|
||||
echo "当前 $TOTAL 个发布版本,未超过 $KEEP,无需清理"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "$PAIRS" | tail -n +$((KEEP + 1)) | while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
RID=$(printf '%s' "$line" | grep -oE '"id":[0-9]+' | cut -d: -f2)
|
||||
RTAG=$(printf '%s' "$line" | sed 's/.*"tag_name":"\([^"]*\)".*/\1/')
|
||||
curl -sS -X DELETE -H "$AUTH" "$API/releases/$RID" -o /dev/null -w "删除旧发布 $RTAG: HTTP %{http_code}\n"
|
||||
curl -sS -X DELETE -H "$AUTH" "$API/tags/$RTAG" -o /dev/null -w "删除 tag $RTAG: HTTP %{http_code}\n"
|
||||
done
|
||||
|
||||
- name: Send success notification
|
||||
if: success()
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
GITEA_RUN_NUMBER: ${{ gitea.run_number }}
|
||||
run: |
|
||||
VERSION="1.1.${GITEA_RUN_NUMBER}"
|
||||
bash .gitea/scripts/notify.sh success \
|
||||
"商业版 Web 构建发布成功" \
|
||||
"版本: v${VERSION}\n产物: releases/tag/web-v${VERSION}\nkepo 服务器将自动拉取部署" || echo "通知发送失败,不影响主流程"
|
||||
|
||||
- name: Send failure notification
|
||||
if: failure()
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
run: |
|
||||
bash .gitea/scripts/notify.sh failure \
|
||||
"商业版 Web 构建失败" \
|
||||
"请检查 Actions 日志了解失败原因" || echo "通知发送失败"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
rm -rf server/deploy/
|
||||
rm -f server/web-deploy.tar.gz
|
||||
@@ -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` |
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 变更日志
|
||||
|
||||
## [1.1.88+] 2026-10-18 — 部署形态拆分与小程序演示版
|
||||
|
||||
### 后端拆分(双入口)
|
||||
|
||||
- 后端拆为两个部署入口:`cmd/mini`(小程序接口,lunar.neatcn.com)与 `cmd/web`(商业版,kepo.net)。
|
||||
- `cmd/mini` 仅保留微信登录、生辰档案、许愿树、百科;不再暴露邮箱登录、commerce、`/admin` 后台与支付 Webhook。
|
||||
- `cmd/web` 仅保留邮箱验证码登录、commerce、订单/报告权益、`/admin` 后台、商业前端静态托管与 SPA 回退;不再暴露微信登录与许愿树接口。
|
||||
- 共用代码收敛到 `internal/app`(启动/路由注册)与 `internal/assets`(内嵌图片)。
|
||||
|
||||
### CI/CD
|
||||
|
||||
- `server-deploy.yml` 改构建 `cmd/mini`,部署包不再携带后台静态资源。
|
||||
- 新增 `web-deploy.yml`:构建 `cmd/web` + web-commercial 前端,发布 `web-v*` Release。
|
||||
- `deploy-watch.sh` 支持 `TAG_PREFIX`/`ASSET_NAME` 环境变量;新增 `deploy-web.sh`(systemd 版,含健康检查与自动回滚)。
|
||||
- 新增 kepo 一次性配置文档:`.gitea/docs/PULL_DEPLOY_WEB.md`。
|
||||
|
||||
### 安全加固
|
||||
|
||||
- 生产环境 `JWT_SECRET` 未设置、为默认值或短于 32 位时拒绝启动。
|
||||
- 许愿发布前接入微信 `msgSecCheck` v2 内容安全检测(access_token 内存缓存)。
|
||||
- 修复许愿字数按字节计算的问题(中文原只能写约 6 字),改为按字符计算。
|
||||
|
||||
### 小程序(演示版)
|
||||
|
||||
- 许愿树移除付费许愿、商品选择与微信支付调用,全部走免费许愿。
|
||||
- 「占卜」页更名「民俗趣算」,「称骨算命」改为「称骨测算」;占卜页与每日运势页新增文化声明,明确"传统文化知识、仅供娱乐参考"。
|
||||
|
||||
### 分支整理
|
||||
|
||||
- SMTP2GO 未提交改动备份至 `smtp2go-backup` 分支,`main` 保留已验证可用的 QQ SMTP。
|
||||
- 合并 `privacy-chart-consent`:`web/` 排盘核心纳入仓库(修复 web-commercial 干净构建),排盘保存需用户显式同意并限流。
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "占卜",
|
||||
"navigationBarTitleText": "民俗趣算",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<view class="container">
|
||||
<!-- 文化声明 -->
|
||||
<view class="culture-card">
|
||||
<view class="culture-title">民俗趣算 · 传统文化</view>
|
||||
<view class="culture-text">梅花易数与称骨歌诀源自古代典籍与民间传统文化,属于传统民俗知识与趣味体验,仅供学习参考与娱乐,不构成任何决策建议,请理性看待,切勿迷信。</view>
|
||||
</view>
|
||||
|
||||
<!-- 梅花易数 -->
|
||||
<view class="card">
|
||||
<view class="section-title">梅花易数</view>
|
||||
@@ -48,9 +54,9 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 称骨算命 -->
|
||||
<!-- 称骨测算 -->
|
||||
<view class="card mt-2">
|
||||
<view class="section-title">称骨算命</view>
|
||||
<view class="section-title">称骨测算</view>
|
||||
<view class="form-item">
|
||||
<text class="form-label">出生年份(干支)</text>
|
||||
<picker mode="selector" range="{{yearGanzhiList}}" value="{{boneYearIndex}}" bindchange="onBoneYearChange">
|
||||
@@ -75,7 +81,7 @@
|
||||
<view class="picker-input">{{hourList[boneHourIndex]}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="btn-primary" bindtap="calcBone">算命</view>
|
||||
<view class="btn-primary" bindtap="calcBone">测算</view>
|
||||
</view>
|
||||
|
||||
<!-- 称骨结果 -->
|
||||
|
||||
@@ -50,3 +50,24 @@
|
||||
.trigram-name {
|
||||
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="culture-card">
|
||||
<view class="culture-title">每日运势 · 传统文化参考</view>
|
||||
<view class="culture-text">本页内容基于传统干支历法与五行文化的趣味化呈现,仅供休闲娱乐与传统文化学习参考,不构成任何人生决策建议,请理性看待,切勿迷信。</view>
|
||||
</view>
|
||||
|
||||
<!-- 无八字提示 -->
|
||||
<view class="card text-center" wx:if="{{!hasBazi}}">
|
||||
<text class="text-lg text-muted">请先排八字以查看每日运势</text>
|
||||
|
||||
@@ -45,3 +45,24 @@
|
||||
.suggestion-item {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<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>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="navigateTo" data-url="/pages/solar-terms/solar-terms">
|
||||
|
||||
@@ -54,11 +54,7 @@ Page({
|
||||
fireflies: [],
|
||||
showCreateModal: false,
|
||||
wishContent: '',
|
||||
wishType: 'free',
|
||||
maxFreeLength: 20,
|
||||
maxPaidLength: 100,
|
||||
products: [],
|
||||
selectedProduct: null,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -72,7 +68,6 @@ Page({
|
||||
|
||||
this.initAmbiance();
|
||||
this.loadTrees();
|
||||
this.loadProducts();
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
@@ -407,7 +402,7 @@ Page({
|
||||
id: i + 1,
|
||||
content: texts[i % texts.length],
|
||||
author: authors[i % authors.length],
|
||||
type: i % 5 === 0 ? 'paid' : 'free',
|
||||
type: 'free',
|
||||
position: i === 0 ? 100 : 0,
|
||||
isTop: i === 0,
|
||||
createdAt: new Date(Date.now() - i * 3600 * 1000).toISOString(),
|
||||
@@ -427,26 +422,6 @@ Page({
|
||||
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() {
|
||||
this.setData({ showCreateModal: true });
|
||||
@@ -456,8 +431,6 @@ Page({
|
||||
this.setData({
|
||||
showCreateModal: false,
|
||||
wishContent: '',
|
||||
wishType: 'free',
|
||||
selectedProduct: null,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -465,16 +438,6 @@ Page({
|
||||
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
|
||||
currentTreeId() {
|
||||
const { trees, currentTreeIndex } = this.data;
|
||||
@@ -484,54 +447,32 @@ Page({
|
||||
|
||||
// 提交许愿
|
||||
async submitWish() {
|
||||
const { wishContent, wishType, selectedProduct, maxFreeLength, maxPaidLength } = this.data;
|
||||
const { wishContent, maxFreeLength } = this.data;
|
||||
|
||||
if (!wishContent.trim()) {
|
||||
wx.showToast({ title: '请输入许愿内容', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const maxLength = wishType === 'free' ? maxFreeLength : maxPaidLength;
|
||||
if (wishContent.length > maxLength) {
|
||||
wx.showToast({ title: `最多输入${maxLength}个字`, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (wishType === 'paid' && !selectedProduct) {
|
||||
wx.showToast({ title: '请选择许愿商品', icon: 'none' });
|
||||
if (wishContent.length > maxFreeLength) {
|
||||
wx.showToast({ title: `最多输入${maxFreeLength}个字`, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
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({
|
||||
url: '/api/wish/create',
|
||||
method: 'POST',
|
||||
data: {
|
||||
content: wishContent,
|
||||
type: 'free',
|
||||
treeId: this.currentTreeId(),
|
||||
},
|
||||
});
|
||||
if (res.code === 0) {
|
||||
wx.showToast({ title: '许愿成功', icon: 'success' });
|
||||
this.closeCreateModal();
|
||||
this.loadTreeWishes(this.data.currentTreeIndex, true);
|
||||
}
|
||||
const res = await request({
|
||||
url: '/api/wish/create',
|
||||
method: 'POST',
|
||||
data: {
|
||||
content: wishContent,
|
||||
type: 'free',
|
||||
treeId: this.currentTreeId(),
|
||||
},
|
||||
});
|
||||
if (res.code === 0) {
|
||||
wx.showToast({ title: '许愿成功', icon: 'success' });
|
||||
this.closeCreateModal();
|
||||
this.loadTreeWishes(this.data.currentTreeIndex, true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('许愿失败:', err);
|
||||
@@ -539,30 +480,6 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
// 微信支付
|
||||
async wxPay(orderData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.requestPayment({
|
||||
timeStamp: orderData.timeStamp,
|
||||
nonceStr: orderData.nonceStr,
|
||||
package: orderData.package,
|
||||
signType: orderData.signType,
|
||||
paySign: orderData.paySign,
|
||||
success: () => {
|
||||
wx.showToast({ title: '支付成功', icon: 'success' });
|
||||
this.closeCreateModal();
|
||||
this.loadTreeWishes(this.data.currentTreeIndex, true);
|
||||
resolve();
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('支付失败:', err);
|
||||
wx.showToast({ title: '支付失败', icon: 'none' });
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// 分享
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
|
||||
@@ -122,64 +122,22 @@
|
||||
</view>
|
||||
|
||||
<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
|
||||
class="wish-input"
|
||||
placeholder="请输入你的愿望..."
|
||||
maxlength="{{wishType === 'free' ? maxFreeLength : maxPaidLength}}"
|
||||
maxlength="{{maxFreeLength}}"
|
||||
bindinput="onInputContent"
|
||||
value="{{wishContent}}"
|
||||
></textarea>
|
||||
<view class="input-count">
|
||||
{{wishContent.length}}/{{wishType === 'free' ? maxFreeLength : maxPaidLength}}
|
||||
</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>
|
||||
{{wishContent.length}}/{{maxFreeLength}}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal-footer">
|
||||
<button class="btn-outline" bindtap="closeCreateModal">取消</button>
|
||||
<button class="btn-primary" bindtap="submitWish">
|
||||
{{wishType === 'paid' ? '支付并许愿' : '提交许愿'}}
|
||||
</button>
|
||||
<button class="btn-primary" bindtap="submitWish">提交许愿</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
+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,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 |
@@ -9,6 +9,9 @@ import (
|
||||
"github.com/gouki/lunar-server/internal/service"
|
||||
)
|
||||
|
||||
// 内容安全检测服务(微信 msgSecCheck),access_token 内部缓存
|
||||
var securityService = service.NewContentSecurityService()
|
||||
|
||||
// GetWishTrees 获取许愿树列表
|
||||
func GetWishTrees(c *gin.Context) {
|
||||
wishService := service.NewWishService()
|
||||
@@ -79,12 +82,12 @@ func CreateWish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查内容长度
|
||||
// 检查内容长度(按字符数,中文按 1 字计)
|
||||
maxLength := 20
|
||||
if req.Type == "paid" {
|
||||
maxLength = 100
|
||||
}
|
||||
if len(req.Content) > maxLength {
|
||||
if len([]rune(req.Content)) > maxLength {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"code": 400,
|
||||
"msg": "内容长度超过限制",
|
||||
@@ -92,6 +95,18 @@ func CreateWish(c *gin.Context) {
|
||||
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()
|
||||
wish := &model.Wish{
|
||||
UserID: userID.(uint),
|
||||
|
||||
@@ -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("内容不符合社区规范")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user