refactor: update match configuration and user matching logic

- Changed the daily free match limit to a lifetime limit, aligning with backend configurations.
- Updated the match price display to include original pricing for better user clarity.
- Refactored match quota handling to sync with the server, ensuring accurate match counts and purchase statuses.
- Enhanced UI messages to reflect changes in match availability and pricing.
- Removed deprecated logic related to daily match counts, streamlining the matching process.
This commit is contained in:
Alex-larget
2026-04-01 17:09:45 +08:00
parent bdd0795434
commit 360bba697b
26 changed files with 374 additions and 781 deletions

View File

@@ -1,40 +1,37 @@
# soul-api Docker 镜像
# 多阶段构建:编译 Go 二进制 → 精简运行镜像
# 构建:在 soul-api 根目录执行 docker build -f deploy/Dockerfile -t soul-api:latest .
# 运行:见 docker-compose.production.yml
# ========== 阶段 1编译 ==========
# 使用本地已缓存的 golang:1.25deploy.py 加 --pull=false 避免拉取
FROM golang:1.25 AS builder
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/*
WORKDIR /src
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-w -s" -o /build/soul-api ./cmd/server
# ========== 阶段 2运行 ==========
# 使用标准引用 alpine:3.19,配合 --pull=false 可使用本地已缓存的镜像
# Build server binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/soul-api ./cmd/server
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata wget
ENV TZ=Asia/Shanghai
RUN adduser -D -g '' appuser
WORKDIR /app
COPY --from=builder /build/soul-api .
RUN apk add --no-cache ca-certificates tzdata wget && \
addgroup -S app && adduser -S -G app app
COPY --from=builder /out/soul-api /app/soul-api
COPY certs/ /app/certs/
ARG ENV_FILE=.env.production
COPY ${ENV_FILE} /app/.env
# Runtime directories used by the app
RUN mkdir -p /app/uploads /app/log && chown -R app:app /app
RUN mkdir -p /app/uploads && chown -R appuser:appuser /app
USER appuser
ENV APP_ENV=production
ENV GIN_MODE=release
ENV PORT=8080
ENV UPLOAD_DIR=/app/uploads
ENV WECHAT_CERT_PATH=/app/certs/apiclient_cert.pem
ENV WECHAT_KEY_PATH=/app/certs/apiclient_key.pem
EXPOSE 8080
ENTRYPOINT ["./soul-api"]
USER app
CMD ["/app/soul-api"]

View File

@@ -1,26 +0,0 @@
# Dockerfile.local - 使用本地 Go 编译后的二进制,无需拉取 golang 镜像
# 使用方式deploy.py --mode docker --local-go
# 依赖:先由本地 go build 生成 soul-api 可执行文件
# 使用标准引用,配合 --pull=false 使用本地缓存的 alpine:3.19
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata wget
ENV TZ=Asia/Shanghai
RUN adduser -D -g '' appuser
WORKDIR /app
COPY soul-api .
# 微信支付等证书(须存在于构建上下文,勿在 .dockerignore 中排除)
COPY certs/ /app/certs/
# 由 devloy 传入 --build-arg ENV_FILE=(如 .env / .env.development
ARG ENV_FILE=.env.production
COPY ${ENV_FILE} /app/.env
RUN mkdir -p /app/uploads && chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENTRYPOINT ["./soul-api"]

View File

@@ -1,28 +0,0 @@
# soul-api Runner 容器
# 红蓝切换在容器内完成,宝塔固定 proxy_pass 到 127.0.0.1:9001
# 使用 network_mode: host无需端口映射避免 iptables 问题
FROM alpine:3.19
RUN apk add --no-cache \
ca-certificates tzdata wget nginx redis \
psmisc \
&& rm -rf /var/cache/apk/*
ENV TZ=Asia/Shanghai
WORKDIR /app
# 复制 Runner 脚本与 nginx 模板context 为 soul-api 根目录)
COPY deploy/runner/entrypoint.sh /app/
COPY deploy/runner/deploy.sh /app/
COPY deploy/runner/nginx.conf.template /app/
RUN chmod +x /app/entrypoint.sh /app/deploy.sh
# 创建目录blue/green 由 deploy.sh 创建)
RUN mkdir -p /app/uploads /app/blue /app/green
EXPOSE 9001
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@@ -1,33 +1,56 @@
# soul-api Docker 部署
# soul-api Docker 部署说明
Docker 相关配置均在此目录,根目录仅保留 `deploy.py`
## 1) 打包运行后必需的文件和目录
## 文件说明
### 必需文件
- `soul-api` 可执行文件Docker 镜像内已包含)
- `.env`(或等价环境变量;本方案使用 `deploy/app.env`
- `certs/apiclient_cert.pem`
- `certs/apiclient_key.pem`
| 文件 | 用途 |
|------|------|
| `Dockerfile` | 多阶段构建golang + alpine需拉取 golang 镜像 |
| `Dockerfile.local` | 仅运行阶段,配合 `deploy.py --local-go` 使用本地 Go 编译 |
| `docker-compose.bluegreen.yml` | 蓝绿部署8081/8082含 Redis 7-alpine |
| `docker-compose.production.yml` | 生产单实例部署 |
| `docker-compose.yml` | 本地开发 Redis |
| `docker-deploy-remote.sh` | 服务器端蓝绿切换脚本 |
### 必需目录
- `uploads/`:上传文件持久化目录
- `log/`:订单同步等日志目录
## 镜像版本
> 说明:`/app/uploads`、`/app/log` 是容器内目录;本方案已挂载到服务器目录:
> - `/www/wwwroot/self/soul-dev/uploads -> /app/uploads`
> - `/www/wwwroot/self/soul-dev/log -> /app/log`
- **soul-api**Go 1.25alpine 3.19
- **Redis**7-alpine与 soul-api 一并打包上传,服务器无需拉取)
- **MySQL**:外部服务,不打包
## 2) 本地/服务器快速启动
## 用法
`soul-api/deploy` 目录下执行:
```bash
# 一键部署(蓝绿)
python deploy.py --mode docker
1. 复制环境文件:
- `cp app.env.example app.env`
- 按实际环境填写 `DB_DSN`、微信和管理端密钥
2. 启动:
- `docker compose up -d --build`
3. 查看状态:
- `docker compose ps`
- `docker compose logs -f app`
4. 健康检查:
- `http://服务器IP:APP_PORT/health`
# 使用本地 Go 编译
python deploy.py --mode docker --local-go
## 3) 给宝塔的推荐方式
# 本地启动 Redis
docker compose -f deploy/docker-compose.yml up -d
```
- 方式 A推荐在宝塔 Docker/Compose 项目里直接使用 `deploy/docker-compose.yml`
- 方式 B`docker build -f deploy/Dockerfile -t soul-api:latest ..`,再在宝塔按镜像创建容器
## 4) 目录结构deploy
- `deploy/Dockerfile`:多阶段构建 Go 应用镜像
- `deploy/docker-compose.yml`:单容器运行与数据卷持久化
- `deploy/app.env.example`:环境变量模板
## 5) 离线镜像部署(不走远端拉取)
你可以把本地导出的 `deploy/soul-api_latest.tar` 上传到服务器,然后:
1. 导入镜像:
- `docker load -i soul-api_latest.tar`
2. 准备配置:
-`deploy/app.env` 放到服务器同目录(或按需改 `env_file`
3. 启动:
- `docker compose up -d --no-build`
> 这种方式不会再从 Docker Hub 拉取 `soul-api` 业务镜像;直接使用你导入的本地镜像运行。

View File

@@ -1,26 +0,0 @@
#!/bin/bash
# soul-api Runner 部署脚本(在宿主机执行)
# 用法:./deploy-runner-remote.sh [path-to-deploy.tar.gz]
# 默认 tar 路径:${DEPLOY_DOCKER_PATH:-/www/wwwroot/self/soul-dev}/soul_api_deploy.tar.gz
# 仅负责:将 tar 拷入容器并触发容器内 deploy.sh不涉及宝塔/Nginx 配置
set -e
CONTAINER="${DEPLOY_RUNNER_CONTAINER:-soul-api-runner}"
DEPLOY_PATH="${DEPLOY_DOCKER_PATH:-/www/wwwroot/self/soul-dev}"
TAR="${1:-$DEPLOY_PATH/soul_api_deploy.tar.gz}"
if [ -z "$TAR" ] || [ ! -f "$TAR" ]; then
echo "[ERROR] 用法: $0 [path-to-deploy.tar.gz]"
echo " 默认: $DEPLOY_PATH/soul_api_deploy.tar.gz"
exit 1
fi
echo "[1/2] 拷贝部署包到容器 ..."
docker cp "$TAR" "$CONTAINER:/tmp/incoming.tar.gz"
echo "[2/2] 执行容器内红蓝切换 ..."
docker exec "$CONTAINER" /app/deploy.sh /tmp/incoming.tar.gz
rm -f "$TAR"
echo ""
echo "[SUCCESS] 部署完成,宝塔代理 9001 无需修改"

View File

@@ -1,62 +0,0 @@
# soul-api 蓝绿部署 - 支持无缝切换
# blue=9001, green=9002部署时先启新实例健康检查通过后切换 Nginx再停旧实例
# 用法:见 deploy.py --mode docker
services:
soul-api-blue:
image: soul-api:latest
container_name: soul-api-blue
restart: "no"
environment:
- REDIS_URL=redis://:soul-docker-redis@redis:6379/0
- GIN_MODE=release
- APP_ENV=production
# 测试/预发布环境可设 SKIP_PROD_SECRET_CHECK=staging正式生产请使用真实密钥并移除此项
- SKIP_PROD_SECRET_CHECK=staging
ports:
- "9001:8080"
volumes:
- soul_uploads:/app/uploads
depends_on:
- redis
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
interval: 5s
timeout: 5s
retries: 10
start_period: 15s
soul-api-green:
image: soul-api:latest
container_name: soul-api-green
restart: "no"
environment:
- REDIS_URL=redis://:soul-docker-redis@redis:6379/0
- GIN_MODE=release
- APP_ENV=production
- SKIP_PROD_SECRET_CHECK=staging
ports:
- "9002:8080"
volumes:
- soul_uploads:/app/uploads
depends_on:
- redis
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
interval: 5s
timeout: 5s
retries: 10
start_period: 15s
redis:
# 与 soul-api 一并打包上传,使用本地 DaoCloud 镜像名(与 pack 中 docker save 一致)
image: docker.m.daocloud.io/library/redis:7-alpine
container_name: soul-redis
command: redis-server --appendonly yes --requirepass "soul-docker-redis"
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
redis_data:
soul_uploads:

View File

@@ -1,42 +0,0 @@
# soul-api 生产环境 Docker 部署
# 用法:在 soul-api 根目录执行
# docker compose -f deploy/docker-compose.production.yml up -d
#
# Redis 7-alpine与宝塔已有 Redis 隔离,仅容器内网使用
services:
soul-api:
build:
context: ..
dockerfile: deploy/Dockerfile
image: soul-api:latest
container_name: soul-api
restart: unless-stopped
environment:
- REDIS_URL=redis://:soul-docker-redis@redis:6379/0
- GIN_MODE=release
- APP_ENV=production
ports:
- "8080:8080"
volumes:
- soul_uploads:/app/uploads
depends_on:
- redis
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
redis:
image: redis:7-alpine
container_name: soul-redis
command: redis-server --appendonly yes --requirepass "soul-docker-redis"
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
redis_data:
soul_uploads:

View File

@@ -1,14 +0,0 @@
# soul-api Runner 部署(仅用已加载镜像,无 build
# 用于 devloy.py --init-runner 推送镜像后启动
services:
soul-api-runner:
image: soul-api-runner:latest
container_name: soul-api-runner
network_mode: host
volumes:
- soul_runner_data:/app
restart: unless-stopped
volumes:
soul_runner_data:

View File

@@ -1,21 +0,0 @@
# soul-api Runner 部署
# 红蓝切换在容器内完成,宝塔固定 proxy_pass 到 127.0.0.1:9001
# 使用 network_mode: host无需端口映射避免 iptables DOCKER 链问题
#
# 首次启动docker compose -f docker-compose.runner.yml up -d
# 部署新版本:上传 tar 后执行 deploy-runner-remote.sh
services:
soul-api-runner:
build:
context: ..
dockerfile: deploy/Dockerfile.runner
image: soul-api-runner:latest
container_name: soul-api-runner
network_mode: host
volumes:
- soul_runner_data:/app
restart: unless-stopped
volumes:
soul_runner_data:

View File

@@ -1,16 +1,23 @@
# soul-api 本地开发用 Redis
# 用法docker compose -f deploy/docker-compose.yml up -d
version: "3.8"
services:
redis:
image: redis:7-alpine
container_name: soul-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
app:
build:
context: ..
dockerfile: deploy/Dockerfile
image: soul-api:latest
container_name: soul-api
restart: unless-stopped
volumes:
redis_data:
env_file:
- ./app.env
ports:
- "${APP_PORT:-8080}:${PORT:-8080}"
volumes:
- "${HOST_UPLOAD_DIR:-./data/uploads}:/app/uploads"
- "${HOST_LOG_DIR:-./data/log}:/app/log"
healthcheck:
test: ["CMD", "sh", "-lc", "wget -qO- http://127.0.0.1:${PORT:-8080}/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s

View File

@@ -1,130 +0,0 @@
#!/bin/bash
# soul-api Docker 蓝绿部署脚本(在服务器上执行)
# 用法:./docker-deploy-remote.sh /tmp/soul_api_image.tar.gz [--skip-nginx]
# --skip-nginx跳过 Nginx 切换,由宝塔 API 在本地执行
set -e
PROJECT_ROOT="${DEPLOY_DOCKER_PATH:-/www/wwwroot/self/soul-dev}"
ACTIVE_FILE="$PROJECT_ROOT/.active"
NGINX_CONF="${DEPLOY_NGINX_CONF:-}"
IMAGE_TAR="${1:-}"
SKIP_NGINX=""
if [ "${2:-}" = "--skip-nginx" ]; then
SKIP_NGINX=1
fi
if [ -z "$IMAGE_TAR" ] || [ ! -f "$IMAGE_TAR" ]; then
echo "[ERROR] usage: $0 <path-to-image.tar.gz> [--skip-nginx]"
exit 1
fi
cd "$PROJECT_ROOT"
# 兼容 docker-compose / docker compose不同系统安装不一致
dc() {
if command -v docker-compose >/dev/null 2>&1; then
docker-compose "$@"
else
docker compose "$@"
fi
}
# 兼容 curl / wget健康检查工具不一定都有
health_ok() {
url="$1"
if command -v curl >/dev/null 2>&1; then
curl -sf "$url" >/dev/null 2>&1
else
wget -qO- "$url" >/dev/null 2>&1
fi
}
# 加载新镜像
echo "[1/5] 加载 Docker 镜像 ..."
gunzip -c "$IMAGE_TAR" | docker load
rm -f "$IMAGE_TAR"
# 确定当前活跃实例与待启动实例
CURRENT="blue"
if [ -f "$ACTIVE_FILE" ]; then
CURRENT=$(cat "$ACTIVE_FILE")
fi
if [ "$CURRENT" = "blue" ]; then
NEW="green"
OLD_PORT=9001
NEW_PORT=9002
else
NEW="blue"
OLD_PORT=9002
NEW_PORT=9001
fi
echo "[2/5] 当前活跃: $CURRENT ($OLD_PORT),将启动: $NEW ($NEW_PORT)"
# 启动新实例
echo "[3/5] 启动 soul-api-$NEW ..."
# --no-deps线上 Redis 已在跑,不再让 compose 拉起/重建依赖
dc -f docker-compose.bluegreen.yml up -d --no-deps "soul-api-$NEW"
# 等待健康检查(镜像已从 tar.gz 加载,无需联网拉取,最多 120 秒)
echo "[4/5] 等待健康检查 ..."
sleep 5
for i in $(seq 1 58); do
if health_ok "http://127.0.0.1:$NEW_PORT/health"; then
echo " 健康检查通过 ($((5 + i * 2))s)"
break
fi
sleep 2
if [ $i -eq 58 ]; then
echo "[ERROR] 健康检查超时120s新实例未就绪。可查看: docker-compose -f docker-compose.bluegreen.yml logs soul-api-$NEW"
dc -f docker-compose.bluegreen.yml stop "soul-api-$NEW"
exit 1
fi
done
# 切换 Nginx若配置了 NGINX_CONF将 proxy_pass 中的端口改为 NEW_PORT
if [ -z "$SKIP_NGINX" ]; then
CONF_TO_EDIT="$NGINX_CONF"
# 自动兜底:如果未传入 DEPLOY_NGINX_CONF则尝试在宝塔默认目录中定位 vhost 配置文件
if [ -z "$CONF_TO_EDIT" ] || [ ! -f "$CONF_TO_EDIT" ]; then
CONF_DIR="${DEPLOY_NGINX_CONF_DIR:-/www/server/panel/vhost/nginx}"
if [ -d "$CONF_DIR" ]; then
# 优先匹配旧/新端口对应的 proxy_pass尽量减少误命中
for p in "$OLD_PORT" "$NEW_PORT"; do
# proxy_pass 前可能带空格;用正则增强匹配容错
match="$(grep -rlE "proxy_pass[[:space:]]+http://(127\\.0\\.0\\.1|localhost|0\\.0\\.0\\.0):${p}" "$CONF_DIR" 2>/dev/null | sed -n '1p')"
if [ -n "$match" ]; then
CONF_TO_EDIT="$match"
break
fi
done
# 如果仍未匹配尝试按域名关键字可选DEPLOY_DOMAIN
if [ -z "$CONF_TO_EDIT" ] && [ -n "${DEPLOY_DOMAIN:-}" ]; then
match="$(grep -rl "${DEPLOY_DOMAIN}" "$CONF_DIR" 2>/dev/null | head -n 1)"
if [ -n "$match" ]; then
CONF_TO_EDIT="$match"
fi
fi
fi
fi
if [ -n "$CONF_TO_EDIT" ] && [ -f "$CONF_TO_EDIT" ]; then
echo "[5/5] 切换 Nginx 到 $NEW_PORT ...(编辑: $CONF_TO_EDIT"
# 只在同一个 vhost 配置里替换 proxy_pass 上游端口
sed -i.bak "s|proxy_pass http://127.0.0.1:[0-9]*|proxy_pass http://127.0.0.1:$NEW_PORT|g" "$CONF_TO_EDIT"
sed -i.bak "s|proxy_pass http://localhost:[0-9]*|proxy_pass http://127.0.0.1:$NEW_PORT|g" "$CONF_TO_EDIT"
sed -i.bak "s|proxy_pass http://0.0.0.0:[0-9]*|proxy_pass http://127.0.0.1:$NEW_PORT|g" "$CONF_TO_EDIT"
nginx -t && nginx -s reload
echo " Nginx 已重载"
else
echo "[5/5] 未找到可编辑的 nginx 配置文件,跳过 Nginx 切换。请手动将 proxy_pass 改为 127.0.0.1:$NEW_PORT"
fi
else
echo "[5/5] 已跳过 Nginx 切换(--skip-nginx"
fi
# 停止旧实例(首次部署时可能不存在,忽略错误)
dc -f docker-compose.bluegreen.yml stop "soul-api-$CURRENT" 2>/dev/null || true
echo "$NEW" > "$ACTIVE_FILE"
echo ""
echo "[SUCCESS] 部署完成,当前活跃: $NEW (端口 $NEW_PORT)"

View File

@@ -1,37 +0,0 @@
#!/bin/bash
# soul-api Runner 首次初始化(在宿主机执行)
# 构建并启动 Runner 容器,之后用 devloy.py --mode runner 部署
#
# 用法:在 soul-api 根目录执行
# cd /path/to/soul-api
# bash deploy/runner-init.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT"
echo "=== soul-api Runner 初始化 ==="
echo " 项目目录: $ROOT"
echo ""
# 兼容 docker-compose / docker compose
dc() {
if command -v docker-compose >/dev/null 2>&1; then
docker-compose "$@"
else
docker compose "$@"
fi
}
echo "[1/2] 构建 Runner 镜像 ..."
docker build -f deploy/Dockerfile.runner -t soul-api-runner:latest .
echo "[2/2] 启动 Runner 容器 ..."
dc -f deploy/docker-compose.runner.yml up -d
echo ""
echo "[SUCCESS] Runner 已启动"
echo " 宝塔反向代理保持 proxy_pass http://127.0.0.1:9001"
echo " 首次部署: python devloy.py --mode runner"
echo ""

View File

@@ -1,113 +0,0 @@
#!/bin/bash
# soul-api Runner 容器内红蓝切换脚本
# 用法: /app/deploy.sh /tmp/incoming.tar.gz
# 将新版本解压到非活跃目录,健康检查通过后切换 nginx 并停旧实例
set -e
INCOMING="${1:-}"
APP_ROOT="/app"
BLUE="$APP_ROOT/blue"
GREEN="$APP_ROOT/green"
ACTIVE_FILE="$APP_ROOT/.active"
NGINX_CONF="$APP_ROOT/nginx.conf"
NGINX_PID="/tmp/nginx.pid"
REDIS_PASS="soul-docker-redis"
health_ok() {
local url="$1"
if command -v wget >/dev/null 2>&1; then
wget -qO- "$url" >/dev/null 2>&1
else
[ -x /usr/bin/wget ] && /usr/bin/wget -qO- "$url" >/dev/null 2>&1
fi
}
if [ -z "$INCOMING" ] || [ ! -f "$INCOMING" ]; then
echo "[ERROR] 用法: $0 <path-to-deploy.tar.gz>"
exit 1
fi
# 确定当前活跃与待部署目录
CURRENT="blue"
[ -f "$ACTIVE_FILE" ] && CURRENT=$(cat "$ACTIVE_FILE")
[ "$CURRENT" != "blue" ] && [ "$CURRENT" != "green" ] && CURRENT="blue"
if [ "$CURRENT" = "blue" ]; then
NEW="green"
NEW_PORT=18082
OLD_PORT=18081
else
NEW="blue"
NEW_PORT=18081
OLD_PORT=18082
fi
NEW_DIR="$APP_ROOT/$NEW"
echo "[1/5] 当前活跃: $CURRENT ($OLD_PORT),将部署到: $NEW ($NEW_PORT)"
# 解压到新目录
echo "[2/5] 解压到 $NEW_DIR ..."
rm -rf "$NEW_DIR"
mkdir -p "$NEW_DIR"
tar -xzf "$INCOMING" -C "$NEW_DIR"
rm -f "$INCOMING"
# 设置 PORT 和 REDIS_URL
ENV_FILE="$NEW_DIR/.env"
if [ -f "$ENV_FILE" ]; then
sed -i "s/^PORT=.*/PORT=$NEW_PORT/" "$ENV_FILE"
grep -q "^REDIS_URL=" "$ENV_FILE" || echo "REDIS_URL=redis://:${REDIS_PASS}@127.0.0.1:6379/0" >> "$ENV_FILE"
sed -i "s|^REDIS_URL=.*|REDIS_URL=redis://:${REDIS_PASS}@127.0.0.1:6379/0|" "$ENV_FILE"
fi
chmod +x "$NEW_DIR/soul-api" 2>/dev/null || true
# 启动新实例
echo "[3/5] 启动 soul-api-$NEW (端口 $NEW_PORT) ..."
cd "$NEW_DIR"
export PORT=$NEW_PORT
export REDIS_URL="redis://:${REDIS_PASS}@127.0.0.1:6379/0"
nohup ./soul-api >> soul-api.log 2>&1 &
NEW_PID=$!
echo $NEW_PID > "$APP_ROOT/.pid.$NEW"
cd - >/dev/null
# 等待健康检查(最多 120 秒)
echo "[4/5] 等待健康检查 ..."
sleep 5
for i in $(seq 1 58); do
if health_ok "http://127.0.0.1:$NEW_PORT/health"; then
echo " 健康检查通过"
break
fi
sleep 2
if [ $i -eq 58 ]; then
echo "[ERROR] 健康检查超时,新实例未就绪"
kill $NEW_PID 2>/dev/null || true
exit 1
fi
done
# 更新 nginx 配置并重载
echo "[5/5] 切换 nginx 到 $NEW_PORT ..."
sed "s/__BACKEND_PORT__/$NEW_PORT/g" "$APP_ROOT/nginx.conf.template" > "$NGINX_CONF"
nginx -s reload 2>/dev/null || nginx -c "$NGINX_CONF" 2>/dev/null || true
# 停止旧实例(通过 PID 文件或端口)
OLD_PID_FILE="$APP_ROOT/.pid.$CURRENT"
if [ -f "$OLD_PID_FILE" ]; then
OLD_PID=$(cat "$OLD_PID_FILE")
if kill -0 "$OLD_PID" 2>/dev/null; then
echo " 停止旧实例 (PID $OLD_PID)"
kill "$OLD_PID" 2>/dev/null || true
sleep 2
fi
rm -f "$OLD_PID_FILE"
fi
# 兜底通过端口杀进程Alpine 可用 fuser 或 ss
if command -v fuser >/dev/null 2>&1; then
fuser -k "$OLD_PORT/tcp" 2>/dev/null || true
fi
echo "$NEW" > "$ACTIVE_FILE"
echo ""
echo "[SUCCESS] 部署完成,当前活跃: $NEW (端口 $NEW_PORT)"

View File

@@ -1,36 +0,0 @@
#!/bin/sh
# soul-api Runner 容器入口
# 启动 Redis、Nginx首次部署时需外部调用 deploy.sh
set -e
APP_ROOT="/app"
REDIS_PASS="soul-docker-redis"
# 启动 Redis后台
if ! pgrep -x redis-server >/dev/null 2>&1; then
redis-server --requirepass "$REDIS_PASS" --daemonize yes
fi
# 生成初始 nginx 配置(默认指向 blue 18081若 blue 未部署则 18082
BACKEND=18081
[ -f "$APP_ROOT/.active" ] && [ "$(cat $APP_ROOT/.active)" = "green" ] && BACKEND=18082
sed "s/__BACKEND_PORT__/$BACKEND/g" "$APP_ROOT/nginx.conf.template" > "$APP_ROOT/nginx.conf"
# 若已有活跃实例,启动它
if [ -f "$APP_ROOT/.active" ]; then
ACTIVE=$(cat "$APP_ROOT/.active")
ACTIVE_DIR="$APP_ROOT/$ACTIVE"
if [ -d "$ACTIVE_DIR" ] && [ -x "$ACTIVE_DIR/soul-api" ]; then
PORT=18081
[ "$ACTIVE" = "green" ] && PORT=18082
cd "$ACTIVE_DIR"
export PORT=$PORT
export REDIS_URL="redis://:${REDIS_PASS}@127.0.0.1:6379/0"
nohup ./soul-api >> soul-api.log 2>&1 &
echo $! > "$APP_ROOT/.pid.$ACTIVE"
cd - >/dev/null
fi
fi
# 启动 Nginx前台保持容器运行
exec nginx -c "$APP_ROOT/nginx.conf" -g "daemon off;"

View File

@@ -1,31 +0,0 @@
# soul-api Runner - Nginx 反向代理
# 监听 9001代理到当前活跃实例blue=18081, green=18082
# 宝塔固定 proxy_pass 到 127.0.0.1:9001无需改配置
worker_processes 1;
error_log /dev/stderr warn;
pid /tmp/nginx.pid;
events { worker_connections 64; }
http {
access_log /dev/stdout;
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
listen 9001;
server_name _;
location / {
proxy_pass http://127.0.0.1:__BACKEND_PORT__;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
}
}

View File

@@ -14,18 +14,30 @@ import (
"gorm.io/gorm"
)
const defaultFreeMatchLimit = 3
const defaultFreeMatchLimit = 1
const completeProfileSQL = "((phone IS NOT NULL AND phone != '') AND (nickname IS NOT NULL AND nickname != '' AND nickname != '微信用户') AND (avatar IS NOT NULL AND avatar != ''))"
// MatchQuota 匹配次数配额(纯计算:订单 + match_records
// 免费次数为「终身」额度不按自然日重置RemainToday JSON 字段名历史遗留,语义为「当前剩余可匹配次数」。
type MatchQuota struct {
PurchasedTotal int64 `json:"purchasedTotal"`
PurchasedUsed int64 `json:"purchasedUsed"`
MatchesUsedToday int64 `json:"matchesUsedToday"`
FreeRemainToday int64 `json:"freeRemainToday"`
MatchesUsedToday int64 `json:"matchesUsedToday"` // 今日已匹配次数(统计用)
FreeRemainToday int64 `json:"freeRemainToday"` // 终身免费剩余次数(字段名保留)
PurchasedRemain int64 `json:"purchasedRemain"`
RemainToday int64 `json:"remainToday"` // 今日剩余可匹配次数
RemainToday int64 `json:"remainToday"` // 当前剩余可匹配次数(免费剩余 + 已购剩余)
}
// normalizeFreeMatchLimit 产品规则:终身免费匹配仅 1 次(不按日重置);配置大于 1 时按 1 生效
func normalizeFreeMatchLimit(n int) int {
if n <= 0 {
return defaultFreeMatchLimit
}
if n > 1 {
return 1
}
return n
}
func getFreeMatchLimit(db *gorm.DB) int {
@@ -38,35 +50,31 @@ func getFreeMatchLimit(db *gorm.DB) int {
return defaultFreeMatchLimit
}
if v, ok := config["freeMatchLimit"].(float64); ok && v > 0 {
return int(v)
return normalizeFreeMatchLimit(int(v))
}
return defaultFreeMatchLimit
}
// GetMatchQuota 根据订单和 match_records 纯计算用户匹配配额
// GetMatchQuota 根据订单和 match_records 纯计算用户匹配配额(免费次数为终身额度,不按日重置)
func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
if freeLimit <= 0 {
freeLimit = defaultFreeMatchLimit
}
freeLimit = normalizeFreeMatchLimit(freeLimit)
var purchasedTotal int64
db.Model(&model.Order{}).Where("user_id = ? AND product_type = ? AND status = ?", userID, "match", "paid").Count(&purchasedTotal)
var matchesToday int64
db.Model(&model.MatchRecord{}).Where("user_id = ? AND created_at >= CURDATE()", userID).Count(&matchesToday)
// 历史每日超出免费部分之和 = 已消耗的购买次数
var purchasedUsed int64
db.Raw(`
SELECT COALESCE(SUM(cnt - ?), 0) FROM (
SELECT DATE(created_at) AS d, COUNT(*) AS cnt
FROM match_records WHERE user_id = ?
GROUP BY DATE(created_at)
HAVING cnt > ?
) t
`, freeLimit, userID, freeLimit).Scan(&purchasedUsed)
freeUsed := matchesToday
if freeUsed > int64(freeLimit) {
freeUsed = int64(freeLimit)
var lifetimeMatches int64
db.Model(&model.MatchRecord{}).Where("user_id = ?", userID).Count(&lifetimeMatches)
fl := int64(freeLimit)
beyondFree := lifetimeMatches - fl
if beyondFree < 0 {
beyondFree = 0
}
freeRemain := int64(freeLimit) - freeUsed
purchasedUsed := beyondFree
if purchasedUsed > purchasedTotal {
purchasedUsed = purchasedTotal
}
freeRemain := fl - lifetimeMatches
if freeRemain < 0 {
freeRemain = 0
}
@@ -74,14 +82,17 @@ func GetMatchQuota(db *gorm.DB, userID string, freeLimit int) MatchQuota {
if purchasedRemain < 0 {
purchasedRemain = 0
}
remainToday := freeRemain + purchasedRemain
remainTotal := freeRemain + purchasedRemain
if remainTotal < 0 {
remainTotal = 0
}
return MatchQuota{
PurchasedTotal: purchasedTotal,
PurchasedUsed: purchasedUsed,
MatchesUsedToday: matchesToday,
FreeRemainToday: freeRemain,
PurchasedRemain: purchasedRemain,
RemainToday: remainToday,
RemainToday: remainTotal,
}
}
@@ -100,10 +111,11 @@ func MatchConfigGet(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"matchTypes": defaultMatchTypes,
"freeMatchLimit": 3,
"matchPrice": 1,
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
"matchTypes": defaultMatchTypes,
"freeMatchLimit": 1,
"matchPrice": 1,
"matchPriceOriginal": 9.9,
"settings": gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10},
},
"source": "default",
})
@@ -129,14 +141,18 @@ func MatchConfigGet(c *gin.Context) {
matchTypes = defaultMatchTypes
}
}
freeMatchLimit := 3
if v, ok := config["freeMatchLimit"].(float64); ok {
freeMatchLimit = int(v)
freeMatchLimit := defaultFreeMatchLimit
if v, ok := config["freeMatchLimit"].(float64); ok && int(v) > 0 {
freeMatchLimit = normalizeFreeMatchLimit(int(v))
}
matchPrice := 1
if v, ok := config["matchPrice"].(float64); ok {
matchPrice = int(v)
}
matchPriceOriginal := 9.9
if v, ok := config["matchPriceOriginal"].(float64); ok && v > 0 {
matchPriceOriginal = v
}
settings := gin.H{"enableFreeMatches": true, "enablePaidMatches": true, "maxMatchesPerDay": 10}
if s, ok := config["settings"].(map[string]interface{}); ok {
for k, v := range s {
@@ -144,7 +160,11 @@ func MatchConfigGet(c *gin.Context) {
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
"matchTypes": matchTypes, "freeMatchLimit": freeMatchLimit, "matchPrice": matchPrice, "settings": settings,
"matchTypes": matchTypes,
"freeMatchLimit": freeMatchLimit,
"matchPrice": matchPrice,
"matchPriceOriginal": matchPriceOriginal,
"settings": settings,
}, "source": "database"})
}
@@ -188,7 +208,7 @@ func MatchUsers(c *gin.Context) {
if quota.RemainToday <= 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "今日匹配次数已用完,请购买更多次数",
"message": "免费次数已用完,请购买匹配次数后再试",
"code": "QUOTA_EXCEEDED",
})
return