Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5e60f73c2 | |||
| 14489b6afd | |||
| d45f4417ed | |||
| 7b33ab6e2a | |||
| 93c4d7b7d1 | |||
| 16b4300354 | |||
| c4f327562f | |||
| d0a3b36791 | |||
| ef64a876cd | |||
| 55c778b65b | |||
| b10d0d22e1 | |||
| 657c2930b1 | |||
| 5598181a48 | |||
| 4ffccd5ad8 | |||
| fd185bcfe1 | |||
| 9bf09c4b9a | |||
| b3edd7e2a6 | |||
| e42a5b80bf | |||
| 74f4a12422 | |||
| 2fdc9c8127 | |||
| e98709b511 | |||
| 5d7ca4b9bd | |||
| 9944ab7b8a | |||
| 041417a177 | |||
| d3541a89ae | |||
| d8f5628bb1 | |||
| 40a45199a5 | |||
| 5bc453b09f | |||
| 680951611f | |||
| ceb3b16dc5 | |||
| 51765c794a | |||
| f866270ece | |||
| b391c12c1b | |||
| e11ed2338d | |||
| 3c6dd5058b | |||
| 15fb9a1da5 | |||
| 72400ae054 | |||
| 1f5eb2784d | |||
| d7b56f3edc | |||
| 0e493caf16 | |||
| 62186ca672 | |||
| a80d6af035 | |||
| d95911d6bd | |||
| 4b6fcb338e | |||
| f0439f4f80 | |||
| 61cdc0ce23 | |||
| d1a620f939 | |||
| 6fba1d683a | |||
| 9987bd43fa | |||
| fcdd6ac170 | |||
| d546ea6502 | |||
| 6b65ffb728 | |||
| 37cec59460 | |||
| e02e1d1f37 | |||
| 842edad966 | |||
| 77e4be3a71 | |||
| 306300244d | |||
| 8086785828 | |||
| 330b4ee6a1 | |||
| 5de2f37c20 | |||
| b6e93d0496 | |||
| 5497a1ffdb | |||
| 41b52992e4 | |||
| 109f708652 | |||
| 471c84eb1c | |||
| 55b8fe813d | |||
| 14f4e351f4 | |||
| 6afd6eb307 | |||
| 2442831cd7 | |||
| 236fa6c4e6 | |||
| 7e32c571ab | |||
| 1d5d361ae8 | |||
| 4ad384b01a | |||
| 58107ed76f | |||
| 9e7aaa4242 | |||
| 70561876d6 | |||
| 004acd03d2 | |||
| 15f4e69dc3 | |||
| 071bb1940d | |||
| 01eab942fd | |||
| 49d3fc1c74 | |||
| e1616765c7 | |||
| ccdcfd3430 | |||
| b1e9382e73 | |||
| 70c8811406 | |||
| c5d59b86b0 | |||
| e23809b32e | |||
| 83cb35add6 | |||
| 81086eea52 | |||
| 1302accaf9 | |||
| 02e76ebcf1 | |||
| 63d90fba72 | |||
| b70738b434 | |||
| 0f38b3fcd3 | |||
| e6bd78aa76 | |||
| 9d52826555 | |||
| 1bcfa321b7 | |||
| cef7150aab | |||
| 4bb039fa64 | |||
| 00255a7118 | |||
| fde3210a88 | |||
| 38655c0d38 | |||
| bafeaa35cd | |||
| 83c165458d | |||
| ff16d29b8d | |||
| 20897de442 | |||
| de792b03b9 | |||
| 1e24701bc5 | |||
| 0f70a69877 | |||
| b8756be3cc | |||
| 29eede21e1 | |||
| 184f9103a4 | |||
| bc2856a23e | |||
| 3776b9137b | |||
| 7731384ccb | |||
| f4ecf912e0 | |||
| d3fb2aa178 | |||
| ea1ef3527b | |||
| 267582c6a4 | |||
| b0a03401b8 | |||
| b0cf0c4e3c | |||
| 3bbd687231 | |||
| bfbc675e1a | |||
| 7da63ade5c | |||
| dca32cd11c | |||
| 42061e38c0 | |||
| 68c3afb137 | |||
| 6cee877922 | |||
| 5600278618 | |||
| 01e510aef9 | |||
| 7a322b0534 | |||
| be74a1bd0f | |||
| 4a7f5d52b3 | |||
| 9762adaced | |||
| c582087c0f | |||
| 393b42f35a | |||
| 95c66c0a8a |
@@ -0,0 +1,244 @@
|
||||
name: Build docker and publish
|
||||
run-name: 简化的Docker构建和部署流程
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
|
||||
env:
|
||||
# Docker镜像仓库
|
||||
REPO: ${{ vars.REPO || 'registry.kxsw.us/ario-server' }}
|
||||
# SSH连接信息
|
||||
SSH_HOST: ${{ vars.SSH_HOST }}
|
||||
SSH_PORT: ${{ vars.SSH_PORT }}
|
||||
SSH_USER: ${{ vars.SSH_USER }}
|
||||
SSH_PASSWORD: ${{ vars.SSH_PASSWORD }}
|
||||
# TG通知
|
||||
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
||||
TG_CHAT_ID: "-4940243803"
|
||||
# Go构建变量
|
||||
SERVICE: ario
|
||||
SERVICE_STYLE: ario
|
||||
VERSION: ${{ github.sha }}
|
||||
BUILDTIME: ${{ github.event.head_commit.timestamp }}
|
||||
GOARCH: amd64
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ario-server
|
||||
container:
|
||||
image: node:20
|
||||
strategy:
|
||||
matrix:
|
||||
# 只有node支持版本号别名
|
||||
node: ['20.15.1']
|
||||
steps:
|
||||
# 步骤1: 下载代码
|
||||
- name: 📥 下载代码
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 步骤2: 设置动态环境变量
|
||||
- name: ⚙️ 设置动态环境变量
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV
|
||||
echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV
|
||||
echo "DEPLOY_PATH=/root/vpn_server" >> $GITHUB_ENV
|
||||
echo "为 main 分支设置生产环境变量"
|
||||
elif [ "${{ github.ref_name }}" = "dev" ]; then
|
||||
echo "DOCKER_TAG_SUFFIX=dev" >> $GITHUB_ENV
|
||||
echo "CONTAINER_NAME=ppanel-server-dev" >> $GITHUB_ENV
|
||||
echo "DEPLOY_PATH=/root/vpn_server_dev" >> $GITHUB_ENV
|
||||
echo "为 dev 分支设置开发环境变量"
|
||||
else
|
||||
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "CONTAINER_NAME=ppanel-server-${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "DEPLOY_PATH=/root/vpn_server_other" >> $GITHUB_ENV
|
||||
echo "为其他分支 (${{ github.ref_name }}) 设置环境变量"
|
||||
fi
|
||||
|
||||
# 步骤3: 安装系统工具 (curl, jq) 并升级 Docker CLI 到 1.44+
|
||||
- name: 🔧 安装系统工具并升级 Docker CLI
|
||||
run: |
|
||||
set -e
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "等待 apt/dpkg 锁释放 (unattended-upgrades)..."
|
||||
end=$((SECONDS+300))
|
||||
while true; do
|
||||
LOCKS_BUSY=0
|
||||
if pgrep -x unattended-upgrades >/dev/null 2>&1; then LOCKS_BUSY=1; fi
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
if fuser /var/lib/dpkg/lock >/dev/null 2>&1 \
|
||||
|| fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
|
||||
|| fuser /var/lib/apt/lists/lock >/dev/null 2>&1; then
|
||||
LOCKS_BUSY=1
|
||||
fi
|
||||
fi
|
||||
if [ "$LOCKS_BUSY" -eq 0 ]; then break; fi
|
||||
if [ $SECONDS -ge $end ]; then
|
||||
echo "等待 apt/dpkg 锁超时,使用 Dpkg::Lock::Timeout 继续..."
|
||||
break
|
||||
fi
|
||||
echo "仍在等待锁释放..."; sleep 5
|
||||
done
|
||||
|
||||
# 基础工具
|
||||
apt-get update -y -o Dpkg::Lock::Timeout=600
|
||||
apt-get install -y -o Dpkg::Lock::Timeout=600 jq curl ca-certificates gnupg lsb-release
|
||||
|
||||
# 移除旧版 docker.io,避免客户端过旧 (API 1.41)
|
||||
if dpkg -s docker.io >/dev/null 2>&1; then
|
||||
apt-get remove -y docker.io || true
|
||||
fi
|
||||
|
||||
# 安装 Docker 官方仓库的 CLI (确保 API >= 1.44)
|
||||
distro_codename=$(. /etc/os-release && echo "$VERSION_CODENAME")
|
||||
install_repo="deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${distro_codename} stable"
|
||||
mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "$install_repo" > /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -y -o Dpkg::Lock::Timeout=600
|
||||
apt-get install -y -o Dpkg::Lock::Timeout=600 docker-ce-cli docker-buildx-plugin
|
||||
|
||||
# 版本检查
|
||||
docker --version || true
|
||||
docker version || true
|
||||
echo "客户端 API 版本:" $(docker version --format '{{.Client.APIVersion}}')
|
||||
|
||||
# 步骤4: 构建并发布到镜像仓库
|
||||
- name: 📤 构建并发布到镜像仓库
|
||||
run: |
|
||||
echo "开始构建并推送镜像..."
|
||||
echo "仓库: ${{ env.REPO }}"
|
||||
echo "版本标签: ${{ env.VERSION }}"
|
||||
echo "分支标签: ${{ env.DOCKER_TAG_SUFFIX }}"
|
||||
|
||||
# 构建镜像,同时打上版本和分支两个标签
|
||||
docker build -f Dockerfile \
|
||||
--platform linux/amd64 \
|
||||
--build-arg TARGETARCH=amd64 \
|
||||
--build-arg VERSION=${{ env.VERSION }} \
|
||||
--build-arg BUILDTIME=${{ env.BUILDTIME }} \
|
||||
-t ${{ env.REPO }}:${{ env.VERSION }} \
|
||||
-t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }} \
|
||||
.
|
||||
|
||||
echo "推送版本标签镜像: ${{ env.REPO }}:${{ env.VERSION }}"
|
||||
docker push ${{ env.REPO }}:${{ env.VERSION }}
|
||||
|
||||
echo "推送分支标签镜像: ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
|
||||
docker push ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}
|
||||
|
||||
echo "镜像推送完成"
|
||||
|
||||
# 步骤5: 连接服务器拉镜像启动
|
||||
- name: 🚀 连接服务器拉镜像启动
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ env.SSH_HOST }}
|
||||
username: ${{ env.SSH_USER }}
|
||||
password: ${{ env.SSH_PASSWORD }}
|
||||
port: ${{ env.SSH_PORT }}
|
||||
timeout: 300s
|
||||
command_timeout: 600s
|
||||
script: |
|
||||
echo "连接服务器成功,开始部署..."
|
||||
echo "部署容器名: ${{ env.CONTAINER_NAME }}"
|
||||
echo "部署路径: ${{ env.DEPLOY_PATH }}"
|
||||
echo "部署镜像: ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
|
||||
|
||||
# 确保部署目录存在
|
||||
mkdir -p ${{ env.DEPLOY_PATH }}/config
|
||||
mkdir -p ${{ env.DEPLOY_PATH }}/logs
|
||||
|
||||
# 停止并删除旧容器(忽略所有错误)
|
||||
if docker ps -a | grep -q ${{ env.CONTAINER_NAME }} 2>/dev/null; then
|
||||
echo "停止旧容器..."
|
||||
docker stop ${{ env.CONTAINER_NAME }} >/dev/null 2>&1 || true
|
||||
echo "等待容器完全停止..."
|
||||
sleep 5
|
||||
|
||||
echo "删除旧容器..."
|
||||
# 静默删除,完全忽略错误输出
|
||||
docker rm ${{ env.CONTAINER_NAME }} >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
|
||||
# 如果仍然存在,尝试强制删除(静默)
|
||||
if docker ps -a | grep -q ${{ env.CONTAINER_NAME }} 2>/dev/null; then
|
||||
echo "尝试强制删除..."
|
||||
docker rm -f ${{ env.CONTAINER_NAME }} >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
echo "容器清理完成,继续部署..."
|
||||
fi
|
||||
|
||||
# 拉取最新分支镜像
|
||||
echo "拉取镜像: ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}..."
|
||||
docker pull ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}
|
||||
|
||||
# 启动新容器
|
||||
echo "启动新容器..."
|
||||
cd ${{ env.DEPLOY_PATH }}
|
||||
docker run -d \
|
||||
--name ${{ env.CONTAINER_NAME }} \
|
||||
--restart unless-stopped \
|
||||
--network host \
|
||||
-v ./config/ppanel.yaml:/app/etc/ppanel.yaml \
|
||||
-v ./logs:/app/logs \
|
||||
${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}
|
||||
|
||||
# 检查容器状态
|
||||
sleep 5
|
||||
if docker ps | grep -q ${{ env.CONTAINER_NAME }}; then
|
||||
echo "✅ 容器启动成功"
|
||||
else
|
||||
echo "❌ 容器启动失败"
|
||||
docker logs ${{ env.CONTAINER_NAME }}
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 步骤6: TG通知 (成功)
|
||||
- name: 📱 发送成功通知到Telegram
|
||||
if: success()
|
||||
uses: appleboy/telegram-action@master
|
||||
with:
|
||||
token: ${{ env.TG_BOT_TOKEN }}
|
||||
to: ${{ env.TG_CHAT_ID }}
|
||||
message: |
|
||||
✅ 部署成功!
|
||||
|
||||
📦 项目: ${{ github.repository }}
|
||||
🌿 分支: ${{ github.ref_name }}
|
||||
📝 提交: ${{ github.sha }}
|
||||
👤 提交者: ${{ github.actor }}
|
||||
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
||||
|
||||
🚀 服务已成功部署到生产环境
|
||||
parse_mode: Markdown
|
||||
|
||||
# 步骤5: TG通知 (失败)
|
||||
- name: 📱 发送失败通知到Telegram
|
||||
if: failure()
|
||||
uses: appleboy/telegram-action@master
|
||||
with:
|
||||
token: ${{ env.TG_BOT_TOKEN }}
|
||||
to: ${{ env.TG_CHAT_ID }}
|
||||
message: |
|
||||
❌ 部署失败!
|
||||
|
||||
📦 项目: ${{ github.repository }}
|
||||
🌿 分支: ${{ github.ref_name }}
|
||||
📝 提交: ${{ github.sha }}
|
||||
👤 提交者: ${{ github.actor }}
|
||||
🕐 时间: ${{ github.event.head_commit.timestamp }}
|
||||
|
||||
⚠️ 请检查构建日志获取详细信息
|
||||
parse_mode: Markdown
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["develop"]
|
||||
pull_request:
|
||||
branches: ["develop"]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Get short Git commit ID
|
||||
id: vars
|
||||
run: echo "COMMIT_ID=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build --build-arg VERSION=${{ env.COMMIT_ID }} -t ${{ secrets.DOCKER_USERNAME }}/ppanel-server-dev:${{ env.COMMIT_ID }} .
|
||||
|
||||
- name: Push Docker image
|
||||
run: docker push ${{ secrets.DOCKER_USERNAME }}/ppanel-server-dev:${{ env.COMMIT_ID }}
|
||||
|
||||
# - name: Deploy to server
|
||||
# uses: appleboy/ssh-action@v0.1.6
|
||||
# with:
|
||||
# host: ${{ secrets.SSH_HOST }}
|
||||
# username: ${{ secrets.SSH_USER }}
|
||||
# key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
# script: |
|
||||
# if [ $(docker ps -a -q -f name=ppanel-server-dev) ]; then
|
||||
# echo "Stopping and removing existing ppanel-server container..."
|
||||
# docker stop ppanel-server-dev
|
||||
# docker rm ppanel-server-dev
|
||||
# else
|
||||
# echo "No existing ppanel-server-dev container running."
|
||||
# fi
|
||||
#
|
||||
# docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }}
|
||||
# docker run -d --restart=always --log-driver=journald --name ppanel-server-dev -p 8080:8080 -v /www/wwwroot/api/etc:/app/etc -v /www/wwwroot/api/logs:/app/logs --restart=always -d ${{ secrets.DOCKER_USERNAME }}/ppanel-server-dev:${{ env.COMMIT_ID }}
|
||||
#
|
||||
@@ -1,131 +0,0 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
IMAGE_NAME: ppanel-server
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract version from git tag
|
||||
id: version
|
||||
run: echo "VERSION=$(git describe --tags --abbrev=0 | sed 's/^v//')" >> $GITHUB_ENV
|
||||
|
||||
- name: Get short SHA
|
||||
id: sha
|
||||
run: echo "GIT_SHA=${GITHUB_SHA::8}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set BUILD_TIME env
|
||||
run: echo BUILD_TIME=$(date --iso-8601=seconds) >> ${GITHUB_ENV}
|
||||
|
||||
|
||||
- name: Build and push Docker image for main release
|
||||
if: "!contains(github.ref_name, 'beta')"
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}-${{ env.GIT_SHA }}
|
||||
|
||||
- name: Build and push Docker image for beta release
|
||||
if: contains(github.ref_name, 'beta')
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:beta
|
||||
${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}-${{ env.GIT_SHA }}
|
||||
|
||||
release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-docker
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
|
||||
- name: Install GoReleaser
|
||||
run: |
|
||||
go install github.com/goreleaser/goreleaser/v2@latest
|
||||
|
||||
- name: Run GoReleaser
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
run: |
|
||||
goreleaser check
|
||||
goreleaser release --clean
|
||||
|
||||
releases-matrix:
|
||||
name: Release ppanel-server binary
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-notes # wait for release-notes job to finish
|
||||
strategy:
|
||||
matrix:
|
||||
# build and publish in parallel: linux/386, linux/amd64, linux/arm64,
|
||||
# windows/386, windows/amd64, windows/arm64, darwin/amd64, darwin/arm64
|
||||
goos: [ linux, windows, darwin ]
|
||||
goarch: [ '386', amd64, arm64 ]
|
||||
exclude:
|
||||
- goarch: '386'
|
||||
goos: darwin
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Extract version from git tag
|
||||
id: version
|
||||
run: echo "VERSION=$(git describe --tags --abbrev=0 | sed 's/^v//')" >> $GITHUB_ENV
|
||||
|
||||
- name: Set BUILD_TIME env
|
||||
run: echo BUILD_TIME=$(date --iso-8601=seconds) >> ${GITHUB_ENV}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: wangyoucao577/go-release-action@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
goos: ${{ matrix.goos }}
|
||||
goarch: ${{ matrix.goarch }}
|
||||
asset_name: "ppanel-server-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
goversion: "https://dl.google.com/go/go1.23.3.linux-amd64.tar.gz"
|
||||
project_path: "."
|
||||
binary_name: "ppanel-server"
|
||||
extra_files: LICENSE etc
|
||||
ldflags: -X "github.com/perfect-panel/server/pkg/constant.Version=${{env.VERSION}}" -X "github.com/perfect-panel/server/pkg/constant.BuildTime=${{env.BUILD_TIME}}"
|
||||
@@ -1,81 +0,0 @@
|
||||
name: Go CI/CD with goctl and Swagger
|
||||
|
||||
on:
|
||||
# release:
|
||||
# types: [published]
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install goctl
|
||||
run: |
|
||||
curl -L https://github.com/zeromicro/go-zero/releases/download/tools%2Fgoctl%2Fv1.7.2/goctl-v1.7.2-linux-amd64.tar.gz -o goctl-v1.7.2-linux-amd64.tar.gz
|
||||
tar -xvzf goctl-v1.7.2-linux-amd64.tar.gz
|
||||
chmod +x goctl
|
||||
sudo mv goctl /usr/local/bin/goctl
|
||||
goctl --version
|
||||
|
||||
- name: Install goctl-swagger
|
||||
run: |
|
||||
curl -L https://github.com/tensionc/goctl-swagger/releases/download/v1.0.1/goctl-swagger-v1.0.1-linux-amd64.tar.gz -o goctl-swagger.tar.gz
|
||||
tar -xvzf goctl-swagger.tar.gz
|
||||
chmod +x goctl-swagger
|
||||
sudo mv goctl-swagger /usr/local/bin/
|
||||
|
||||
- name: Generate Swagger file
|
||||
run: |
|
||||
mkdir -p swagger
|
||||
goctl api plugin -plugin goctl-swagger='swagger -filename common.json -pack Response -response "[{\"name\":\"code\",\"type\":\"integer\",\"description\":\"状态码\"},{\"name\":\"msg\",\"type\":\"string\",\"description\":\"消息\"},{\"name\":\"data\",\"type\":\"object\",\"description\":\"数据\",\"is_data\":true}]";' -api ./apis/swagger_common.api -dir ./swagger
|
||||
goctl api plugin -plugin goctl-swagger='swagger -filename user.json -pack Response -response "[{\"name\":\"code\",\"type\":\"integer\",\"description\":\"状态码\"},{\"name\":\"msg\",\"type\":\"string\",\"description\":\"消息\"},{\"name\":\"data\",\"type\":\"object\",\"description\":\"数据\",\"is_data\":true}]";' -api ./apis/swagger_user.api -dir ./swagger
|
||||
goctl api plugin -plugin goctl-swagger='swagger -filename admin.json -pack Response -response "[{\"name\":\"code\",\"type\":\"integer\",\"description\":\"状态码\"},{\"name\":\"msg\",\"type\":\"string\",\"description\":\"消息\"},{\"name\":\"data\",\"type\":\"object\",\"description\":\"数据\",\"is_data\":true}]";' -api ./apis/swagger_admin.api -dir ./swagger
|
||||
goctl api plugin -plugin goctl-swagger='swagger -filename ppanel.json -pack Response -response "[{\"name\":\"code\",\"type\":\"integer\",\"description\":\"状态码\"},{\"name\":\"msg\",\"type\":\"string\",\"description\":\"消息\"},{\"name\":\"data\",\"type\":\"object\",\"description\":\"数据\",\"is_data\":true}]";' -api ppanel.api -dir ./swagger
|
||||
goctl api plugin -plugin goctl-swagger='swagger -filename node.json -pack Response -response "[{\"name\":\"code\",\"type\":\"integer\",\"description\":\"状态码\"},{\"name\":\"msg\",\"type\":\"string\",\"description\":\"消息\"},{\"name\":\"data\",\"type\":\"object\",\"description\":\"数据\",\"is_data\":true}]";' -api ./apis/swagger_node.api -dir ./swagger
|
||||
|
||||
|
||||
- name: Verify Swagger file
|
||||
run: |
|
||||
test -f ./swagger/common.json
|
||||
test -f ./swagger/user.json
|
||||
test -f ./swagger/admin.json
|
||||
|
||||
- name: Checkout target repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: perfect-panel/ppanel-docs
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: ppanel-docs
|
||||
persist-credentials: true
|
||||
|
||||
- name: Verify or create public/swagger directory
|
||||
run: |
|
||||
mkdir -p ./ppanel-docs/public/swagger
|
||||
|
||||
- name: Copy Swagger files
|
||||
run: |
|
||||
cp -rf swagger/* ppanel-docs/public/swagger
|
||||
cd ppanel-docs
|
||||
|
||||
- name: Check for file changes
|
||||
run: |
|
||||
cd ppanel-docs
|
||||
git add .
|
||||
git status
|
||||
if [ "$(git status --porcelain)" ]; then
|
||||
echo "Changes detected in the doc repository."
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email "actions@ppanel.dev"
|
||||
git commit -m "Update Swagger files"
|
||||
git push
|
||||
else
|
||||
echo "No changes detected."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
+1
-2
@@ -6,12 +6,11 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
*_test_config.go
|
||||
*.log*
|
||||
/build/
|
||||
etc/ppanel.yaml
|
||||
*.p8
|
||||
*.crt
|
||||
*.key
|
||||
node_modules
|
||||
package-lock.json
|
||||
package.json
|
||||
/bin
|
||||
@@ -0,0 +1,112 @@
|
||||
## 目标
|
||||
|
||||
* 不使用自动续期订阅;采用“非续期订阅”或“非消耗型”作为内购模式。
|
||||
|
||||
* 仅实现 Go 后端 API;客户端(iOS/StoreKit 2)按说明调用。
|
||||
|
||||
## 产品模型
|
||||
|
||||
* 非续期订阅:固定时长通行证(如 30/90/365 天),产品ID:`com.airport.vpn.pass.30d|90d|365d`。
|
||||
|
||||
* 非消耗型(可选):一次性解锁某附加功能,产品ID:`com.airport.vpn.addon.xyz`。
|
||||
|
||||
* 服务器以 `productId→权益/时长` 进行配置映射。
|
||||
|
||||
## 后端API设计(Go/Gin)
|
||||
|
||||
* 路由注册:`internal/handler/routes.go`
|
||||
|
||||
* `GET /api/iap/apple/products`:返回前端展示的产品清单(含总价/描述/时长映射)
|
||||
|
||||
* `POST /api/iap/apple/transactions/attach`:绑定一次购买到用户账户(需登录)。入参:`signedTransactionJWS`
|
||||
|
||||
* `POST /api/iap/apple/restore`:恢复购买(批量接收 JWS 列表并绑定)
|
||||
|
||||
* `GET /api/iap/apple/status`:返回用户当前权益与到期时间(统一来源聚合)
|
||||
|
||||
* 逻辑目录:`internal/logic/iap/apple/*`
|
||||
|
||||
* `AttachTransactionLogic`:解析 JWS→校验 `bundleId/productId/purchaseDate`→根据 `productId` 映射权益与时长→更新订阅统一表
|
||||
|
||||
* `RestoreLogic`:对所有已购记录执行绑定去重(基于 `original_transaction_id`)
|
||||
|
||||
* `QueryStatusLogic`:聚合各来源订阅,返回有效权益(取最近到期/最高等级)
|
||||
|
||||
* 工具包:`pkg/iap/apple`
|
||||
|
||||
* `ParseTransactionJWS`:解析 JWS,提取 `transactionId/originalTransactionId/productId/purchaseDate/revocationDate`
|
||||
|
||||
* `VerifyBasic`:基础校验(`bundleId`、签名头部与证书链存在性);如客户端已 `transaction.verify()`,可采用“信任+服务器最小校验”的模式快速落地
|
||||
|
||||
* 配置:`doc/config-zh.md`
|
||||
|
||||
* `IAP_PRODUCT_MAP`:`productId → tier/duration`(例如:`30d→+30天`、`addon→解锁功能X`)
|
||||
|
||||
* `APPLE_IAP_BUNDLE_ID`:用于 JWS 内部校验
|
||||
|
||||
## 数据模型
|
||||
|
||||
* 新表:`apple_iap_transactions`
|
||||
|
||||
* `id`、`user_id`、`original_transaction_id`(唯一)、`transaction_id`、`product_id`、`purchase_at`、`revocation_at`、`jws_hash`
|
||||
|
||||
* 统一订阅表增强(现有 `SubscribeModel`)
|
||||
|
||||
* 新增来源:`source=apple_iap`、`external_id=original_transaction_id`、`tier`、`expires_at`
|
||||
|
||||
* 索引:`original_transaction_id` 唯一、`user_id+source`、`expires_at`
|
||||
|
||||
## 与现有系统融合
|
||||
|
||||
* `internal/svc/serviceContext.go`:初始化 IAP 模块与模型
|
||||
|
||||
* `QueryPurchaseOrderLogic/SubscribeModel`:聚合苹果IAP来源;冲突策略:按最高权益与最晚到期。
|
||||
|
||||
* 不产生命令行支付订单,仅记录订阅流水与审计(避免与 Stripe 等混淆)。
|
||||
|
||||
## 安全与合规
|
||||
|
||||
* 仅显示商店在可支付时;价格、描述清晰;使用系统确认表单。
|
||||
|
||||
* 服务器进行最小校验:`bundleId`、`productId`白名单、`purchaseDate`有效性;保存 `jws_hash` 做去重。
|
||||
|
||||
* 退款:在 App 内提供“请求退款”的帮助页并使用系统接口触发;后端无需额外API。
|
||||
|
||||
## 客户端使用说明(StoreKit 2)
|
||||
|
||||
* 产品拉取与展示:
|
||||
|
||||
* 通过已知 `productId` 列表调用 `Product.products(for:)`;展示总价与描述,检查 `canMakePayments`
|
||||
|
||||
* 购买:
|
||||
|
||||
* 调用 `purchase()`,系统确认表单弹出→返回 `Transaction`;执行 `await transaction.verify()`
|
||||
|
||||
* 成功后将 `transaction.signedData` POST 到 `/api/iap/apple/transactions/attach`
|
||||
|
||||
* 恢复:
|
||||
|
||||
* 调用 `Transaction.currentEntitlements`,遍历并验证每条 `Transaction`,将其 `signedData` 批量 POST 到 `/api/iap/apple/restore`
|
||||
|
||||
* 状态显示:
|
||||
|
||||
* 访问 `GET /api/iap/apple/status` 获取到期时间与权益用于 UI 展示
|
||||
|
||||
* 退款入口:
|
||||
|
||||
* 在购买帮助页直接使用 `beginRefundRequest(for:in:)`;文案简洁,按钮直达
|
||||
|
||||
## 测试与验收
|
||||
|
||||
* 单元测试:JWS 解析、`productId→权益/时长` 映射、去重策略。
|
||||
|
||||
* 集成测试:绑定/恢复接口鉴权与幂等、统一订阅查询结果。
|
||||
|
||||
* 沙盒:使用 iOS 沙盒购买与恢复;记录审计与日志。
|
||||
|
||||
## 里程碑
|
||||
|
||||
1. 基础能力:`products/status` 与 `transactions/attach` 落地
|
||||
2. 恢复与融合:`restore` + 统一订阅聚合
|
||||
3. 上线前验证:沙盒测试与文案、监控
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 用户管理系统优化方案 (最终确认版)
|
||||
|
||||
根据您的要求,我们将重点实现 `last_login_time` 字段的存储与返回,以及在列表接口中聚合会员套餐信息。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 1. 数据库变更
|
||||
- **文件**: `initialize/migrate/database/02121_add_user_last_login_time.up.sql`
|
||||
- **内容**:
|
||||
```sql
|
||||
ALTER TABLE user ADD COLUMN last_login_time DATETIME DEFAULT NULL COMMENT 'Last Login Time';
|
||||
```
|
||||
- **说明**: 相比查询日志表,直接在用户表增加字段能极大提高列表页查询性能。
|
||||
|
||||
### 2. API 定义更新
|
||||
- **文件**: `apis/types.api`
|
||||
- **内容**: 修改 `User` 结构体,增加以下返回字段:
|
||||
- `last_login_time` (int64): 最后活跃时间戳。
|
||||
- `member_status` (string): 会员状态(显示当前生效的订阅套餐名称,无订阅显示空或特定标识)。
|
||||
|
||||
### 3. 后端模型与逻辑更新
|
||||
#### 3.1 User 模型更新
|
||||
- **文件**: `internal/model/user/user.go`
|
||||
- **内容**: `User` 结构体增加 `LastLoginTime *time.Time` 字段。
|
||||
|
||||
#### 3.2 登录逻辑更新 (记录活跃时间)
|
||||
- **文件**: `internal/logic/auth/userLoginLogic.go` (及其他登录逻辑如 `emailLoginLogic.go`)
|
||||
- **内容**: 在登录成功后,异步或同步更新当前用户的 `last_login_time`。
|
||||
|
||||
#### 3.3 用户列表逻辑更新 (数据聚合)
|
||||
- **文件**: `internal/logic/admin/user/getUserListLogic.go`
|
||||
- **内容**:
|
||||
1. **获取用户列表**: 包含新增的 `LastLoginTime` 数据。
|
||||
2. **批量查询订阅**: 根据当前页的用户 ID 列表,批量查询其**活跃订阅** (Active Subscription)。
|
||||
3. **数据组装**:
|
||||
- 将 `LastLoginTime` 转换为时间戳返回。
|
||||
- 将订阅的 `Name` (套餐名) 赋值给 `member_status`。
|
||||
|
||||
### 4. 文档更新
|
||||
- **文件**: `doc/说明文档.md`
|
||||
- **内容**: 更新进度记录,标记完成“最后活跃”与“会员状态”字段开发。
|
||||
|
||||
## 验证与交付
|
||||
- 提供 `curl` 验证命令,确认 `/v1/admin/user/list` 接口返回的 JSON 中包含 `last_login_time` 和 `member_status`。
|
||||
@@ -0,0 +1,67 @@
|
||||
## 修复目标
|
||||
- 解决首次设备登录时在 `internal/logic/auth/deviceLoginLogic.go:99` 对 `deviceInfo` 赋值导致的空指针崩溃,确保接口稳定返回。
|
||||
|
||||
## 根因定位
|
||||
- 设备不存在分支仅创建用户与设备记录,但未为局部变量 `deviceInfo` 赋值;随后在 `internal/logic/auth/deviceLoginLogic.go:99-100` 使用 `deviceInfo` 导致 `nil` 解引用。
|
||||
- 参考位置:
|
||||
- 赋值处:`internal/logic/auth/deviceLoginLogic.go:99-101`
|
||||
- 设备存在分支赋值:`internal/logic/auth/deviceLoginLogic.go:88-95`
|
||||
- 设备不存在分支未赋值:`internal/logic/auth/deviceLoginLogic.go:74-79`
|
||||
- `UpdateDevice` 需要有效设备 `Id`:`internal/model/user/device.go:58-69`
|
||||
|
||||
## 修改方案
|
||||
1. 在“设备不存在”分支注册完成后,立即通过标识重新查询设备,赋值给 `deviceInfo`:
|
||||
- 在 `internal/logic/auth/deviceLoginLogic.go` 的 `if errors.Is(err, gorm.ErrRecordNotFound)` 分支中,`userInfo, err = l.registerUserAndDevice(req)` 之后追加:
|
||||
- `deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)`
|
||||
- 如果查询失败则返回数据库查询错误(与现有风格一致)。
|
||||
2. 在更新设备 UA 前增加空指针保护,并不再忽略更新错误:
|
||||
- 将 `internal/logic/auth/deviceLoginLogic.go:99-101` 改为:
|
||||
- 检查 `deviceInfo != nil`
|
||||
- `deviceInfo.UserAgent = req.UserAgent`
|
||||
- `if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {` 记录错误并返回包装后的错误 `xerr.DatabaseUpdateError`。
|
||||
3. 可选优化(减少二次查询):
|
||||
- 将 `registerUserAndDevice(req)` 的返回值改为 `(*user.User, *user.Device, error)`,在注册时直接返回新建设备对象;调用点随之调整。若选择此方案,仍需在更新前做空指针保护。
|
||||
|
||||
## 代码示例(方案1,最小改动)
|
||||
```go
|
||||
// internal/logic/auth/deviceLoginLogic.go
|
||||
// 设备不存在分支注册后追加一次设备查询
|
||||
userInfo, err = l.registerUserAndDevice(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)
|
||||
if err != nil {
|
||||
l.Errorw("query device after register failed",
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device after register failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 更新 UA,不忽略更新错误
|
||||
if deviceInfo != nil {
|
||||
deviceInfo.UserAgent = req.UserAgent
|
||||
if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {
|
||||
l.Errorw("update device failed",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device failed: %v", err.Error())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试用例与验证
|
||||
- 用例1:首次设备标识登录(设备不存在)应成功返回 Token,日志包含注册与登录记录,无 500。
|
||||
- 用例2:已存在设备标识登录(设备存在)应正常更新 UA 并返回 Token。
|
||||
- 用例3:模拟数据库异常时应返回一致的业务错误码,不产生 `panic`。
|
||||
|
||||
## 风险与回滚
|
||||
- 改动限定在登录逻辑,属最小范围;若出现异常,回滚为当前版本即可。
|
||||
- 不改变数据结构与外部接口行为,兼容现有客户端。
|
||||
|
||||
## 后续优化(可选)
|
||||
- 统一 `UpdateDevice` 错误处理路径,避免 `_ = ...` 静默失败。
|
||||
- 为“首次设备登录”场景补充集成测试,保证不再回归。
|
||||
@@ -0,0 +1,65 @@
|
||||
## 目标
|
||||
- 不使用自动续期订阅;采用“非续期订阅”或“非消耗型”作为内购模式。
|
||||
- 仅实现 Go 后端 API;客户端(iOS/StoreKit 2)按说明调用。
|
||||
|
||||
## 产品模型
|
||||
- 非续期订阅:固定时长通行证(如 30/90/365 天),产品ID:`com.airport.vpn.pass.30d|90d|365d`。
|
||||
- 非消耗型(可选):一次性解锁某附加功能,产品ID:`com.airport.vpn.addon.xyz`。
|
||||
- 服务器以 `productId→权益/时长` 进行配置映射。
|
||||
|
||||
## 后端API设计(Go/Gin)
|
||||
- 路由注册:`internal/handler/routes.go`
|
||||
- `GET /api/iap/apple/products`:返回前端展示的产品清单(含总价/描述/时长映射)
|
||||
- `POST /api/iap/apple/transactions/attach`:绑定一次购买到用户账户(需登录)。入参:`signedTransactionJWS`
|
||||
- `POST /api/iap/apple/restore`:恢复购买(批量接收 JWS 列表并绑定)
|
||||
- `GET /api/iap/apple/status`:返回用户当前权益与到期时间(统一来源聚合)
|
||||
- 逻辑目录:`internal/logic/iap/apple/*`
|
||||
- `AttachTransactionLogic`:解析 JWS→校验 `bundleId/productId/purchaseDate`→根据 `productId` 映射权益与时长→更新订阅统一表
|
||||
- `RestoreLogic`:对所有已购记录执行绑定去重(基于 `original_transaction_id`)
|
||||
- `QueryStatusLogic`:聚合各来源订阅,返回有效权益(取最近到期/最高等级)
|
||||
- 工具包:`pkg/iap/apple`
|
||||
- `ParseTransactionJWS`:解析 JWS,提取 `transactionId/originalTransactionId/productId/purchaseDate/revocationDate`
|
||||
- `VerifyBasic`:基础校验(`bundleId`、签名头部与证书链存在性);如客户端已 `transaction.verify()`,可采用“信任+服务器最小校验”的模式快速落地
|
||||
- 配置:`doc/config-zh.md`
|
||||
- `IAP_PRODUCT_MAP`:`productId → tier/duration`(例如:`30d→+30天`、`addon→解锁功能X`)
|
||||
- `APPLE_IAP_BUNDLE_ID`:用于 JWS 内部校验
|
||||
|
||||
## 数据模型
|
||||
- 新表:`apple_iap_transactions`
|
||||
- `id`、`user_id`、`original_transaction_id`(唯一)、`transaction_id`、`product_id`、`purchase_at`、`revocation_at`、`jws_hash`
|
||||
- 统一订阅表增强(现有 `SubscribeModel`)
|
||||
- 新增来源:`source=apple_iap`、`external_id=original_transaction_id`、`tier`、`expires_at`
|
||||
- 索引:`original_transaction_id` 唯一、`user_id+source`、`expires_at`
|
||||
|
||||
## 与现有系统融合
|
||||
- `internal/svc/serviceContext.go`:初始化 IAP 模块与模型
|
||||
- `QueryPurchaseOrderLogic/SubscribeModel`:聚合苹果IAP来源;冲突策略:按最高权益与最晚到期。
|
||||
- 不产生命令行支付订单,仅记录订阅流水与审计(避免与 Stripe 等混淆)。
|
||||
|
||||
## 安全与合规
|
||||
- 仅显示商店在可支付时;价格、描述清晰;使用系统确认表单。
|
||||
- 服务器进行最小校验:`bundleId`、`productId`白名单、`purchaseDate`有效性;保存 `jws_hash` 做去重。
|
||||
- 退款:在 App 内提供“请求退款”的帮助页并使用系统接口触发;后端无需额外API。
|
||||
|
||||
## 客户端使用说明(StoreKit 2)
|
||||
- 产品拉取与展示:
|
||||
- 通过已知 `productId` 列表调用 `Product.products(for:)`;展示总价与描述,检查 `canMakePayments`
|
||||
- 购买:
|
||||
- 调用 `purchase()`,系统确认表单弹出→返回 `Transaction`;执行 `await transaction.verify()`
|
||||
- 成功后将 `transaction.signedData` POST 到 `/api/iap/apple/transactions/attach`
|
||||
- 恢复:
|
||||
- 调用 `Transaction.currentEntitlements`,遍历并验证每条 `Transaction`,将其 `signedData` 批量 POST 到 `/api/iap/apple/restore`
|
||||
- 状态显示:
|
||||
- 访问 `GET /api/iap/apple/status` 获取到期时间与权益用于 UI 展示
|
||||
- 退款入口:
|
||||
- 在购买帮助页直接使用 `beginRefundRequest(for:in:)`;文案简洁,按钮直达
|
||||
|
||||
## 测试与验收
|
||||
- 单元测试:JWS 解析、`productId→权益/时长` 映射、去重策略。
|
||||
- 集成测试:绑定/恢复接口鉴权与幂等、统一订阅查询结果。
|
||||
- 沙盒:使用 iOS 沙盒购买与恢复;记录审计与日志。
|
||||
|
||||
## 里程碑
|
||||
1) 基础能力:`products/status` 与 `transactions/attach` 落地
|
||||
2) 恢复与融合:`restore` + 统一订阅聚合
|
||||
3) 上线前验证:沙盒测试与文案、监控
|
||||
@@ -0,0 +1,92 @@
|
||||
## 修复目标
|
||||
|
||||
* 解决首次设备登录时在 `internal/logic/auth/deviceLoginLogic.go:99` 对 `deviceInfo` 赋值导致的空指针崩溃,确保接口稳定返回。
|
||||
|
||||
## 根因定位
|
||||
|
||||
* 设备不存在分支仅创建用户与设备记录,但未为局部变量 `deviceInfo` 赋值;随后在 `internal/logic/auth/deviceLoginLogic.go:99-100` 使用 `deviceInfo` 导致 `nil` 解引用。
|
||||
|
||||
* 参考位置:
|
||||
|
||||
* 赋值处:`internal/logic/auth/deviceLoginLogic.go:99-101`
|
||||
|
||||
* 设备存在分支赋值:`internal/logic/auth/deviceLoginLogic.go:88-95`
|
||||
|
||||
* 设备不存在分支未赋值:`internal/logic/auth/deviceLoginLogic.go:74-79`
|
||||
|
||||
* `UpdateDevice` 需要有效设备 `Id`:`internal/model/user/device.go:58-69`
|
||||
|
||||
## 修改方案
|
||||
|
||||
1. 在“设备不存在”分支注册完成后,立即通过标识重新查询设备,赋值给 `deviceInfo`:
|
||||
|
||||
* 在 `internal/logic/auth/deviceLoginLogic.go` 的 `if errors.Is(err, gorm.ErrRecordNotFound)` 分支中,`userInfo, err = l.registerUserAndDevice(req)` 之后追加:
|
||||
|
||||
* `deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)`
|
||||
|
||||
* 如果查询失败则返回数据库查询错误(与现有风格一致)。
|
||||
2. 在更新设备 UA 前增加空指针保护,并不再忽略更新错误:
|
||||
|
||||
* 将 `internal/logic/auth/deviceLoginLogic.go:99-101` 改为:
|
||||
|
||||
* 检查 `deviceInfo != nil`
|
||||
|
||||
* `deviceInfo.UserAgent = req.UserAgent`
|
||||
|
||||
* `if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {` 记录错误并返回包装后的错误 `xerr.DatabaseUpdateError`。
|
||||
3. 可选优化(减少二次查询):
|
||||
|
||||
* 将 `registerUserAndDevice(req)` 的返回值改为 `(*user.User, *user.Device, error)`,在注册时直接返回新建设备对象;调用点随之调整。若选择此方案,仍需在更新前做空指针保护。
|
||||
|
||||
## 代码示例(方案1,最小改动)
|
||||
|
||||
```go
|
||||
// internal/logic/auth/deviceLoginLogic.go
|
||||
// 设备不存在分支注册后追加一次设备查询
|
||||
userInfo, err = l.registerUserAndDevice(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)
|
||||
if err != nil {
|
||||
l.Errorw("query device after register failed",
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device after register failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 更新 UA,不忽略更新错误
|
||||
if deviceInfo != nil {
|
||||
deviceInfo.UserAgent = req.UserAgent
|
||||
if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {
|
||||
l.Errorw("update device failed",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device failed: %v", err.Error())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试用例与验证
|
||||
|
||||
* 用例1:首次设备标识登录(设备不存在)应成功返回 Token,日志包含注册与登录记录,无 500。
|
||||
|
||||
* 用例2:已存在设备标识登录(设备存在)应正常更新 UA 并返回 Token。
|
||||
|
||||
* 用例3:模拟数据库异常时应返回一致的业务错误码,不产生 `panic`。
|
||||
|
||||
## 风险与回滚
|
||||
|
||||
* 改动限定在登录逻辑,属最小范围;若出现异常,回滚为当前版本即可。
|
||||
|
||||
* 不改变数据结构与外部接口行为,兼容现有客户端。
|
||||
|
||||
## 后续优化(可选)
|
||||
|
||||
* 统一 `UpdateDevice` 错误处理路径,避免 `_ = ...` 静默失败。
|
||||
|
||||
* 为“首次设备登录”场景补充集成测试,保证不再回归。
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
## 结论与定位
|
||||
- 解绑接口路径:`/v1/public/user/unbind_device`,路由注册于 `internal/handler/routes.go:836-838`,处理器在 `internal/handler/public/user/unbindDeviceHandler.go:11-25`。
|
||||
- 业务逻辑:`internal/logic/public/user/unbindDeviceLogic.go:36-141`,进行设备所属用户迁移、认证记录更新、可能删除旧用户以及 Redis 缓存清理。
|
||||
- 返回封装:所有接口错误均走 JSON 200 包装,见 `pkg/result/httpResult.go:12-33`,因此应用层不会主动返回 502。
|
||||
- Nginx 反向代理:API 代理到 `127.0.0.1:8080`,见 `etc/nginx.conf:233-260`;未设置显式的 `proxy_read_timeout` 等超时参数,502 多发生在上游超时或连接被复位。
|
||||
|
||||
## 高概率问题点
|
||||
- 非安全类型断言导致潜在 panic:`internal/logic/public/user/unbindDeviceLogic.go:38` 直接 `.(*user.User)` 断言,若上下文未注入用户(Token 缺失/失效、链路异常),将发生 panic。其他多数逻辑使用安全断言并兜底(如 `internal/logic/public/user/unbindOAuthLogic.go:31-36`)。
|
||||
- 同类不安全用法还出现在 `internal/logic/public/user/getDeviceListLogic.go:31-33`(你反馈其他接口正常,但它也可能受影响,建议同修)。
|
||||
- 事务中混入外部 IO:在 DB 事务闭包内进行 Redis 删除(`internal/logic/public/user/unbindDeviceLogic.go:125-131`),若外部 IO 波动叠加数据库锁等待,整体耗时可能逼近或超过代理默认超时,引发 502。
|
||||
|
||||
## 修复方案
|
||||
- 将所有 `CtxKeyUser` 获取统一改为安全断言:
|
||||
- 失败时返回 `InvalidAccess` 业务错误而非 panic,参考 `unbindOAuth` 的处理方式;修改位置:`internal/logic/public/user/unbindDeviceLogic.go:36-43`、`internal/logic/public/user/getDeviceListLogic.go:31-33`。
|
||||
- 将 Redis 缓存清理移出事务闭包,并添加超时控制:
|
||||
- 事务仅做数据库一致性操作;在事务成功后再进行缓存删除,失败则不删;为 Redis 操作设置短超时,避免阻塞主流程。
|
||||
- 增强可观测性:
|
||||
- 在解绑入口和事务前后增加结构化日志,包含 `device_id`、`user_id`、事务耗时、Redis 耗时、错误栈;方便精确定位是否存在偶发长耗时。
|
||||
- 代理层稳健性:
|
||||
- 在 Nginx 针对 API 站点增加 `proxy_connect_timeout 10s; proxy_send_timeout 60s; proxy_read_timeout 60s;`,并开启 `proxy_next_upstream timeout`;避免上游短抖动即直接 502。
|
||||
|
||||
## 验证计划
|
||||
- 单元测试:构造上下文缺失用户的请求,确认不再 panic,返回业务错误码而非 502。
|
||||
- 事务耗时压测:模拟高并发解绑,观察事务与 Redis 操作耗时分布,确认在代理超时阈值内。
|
||||
- 端到端验证:
|
||||
- 使用有效与无效 Token、不同设备 ID 连续调用解绑,确认响应始终为 200 JSON 包装(或业务错误),不出现 502。
|
||||
- 对比 Nginx 错误日志,验证 502 消失。
|
||||
|
||||
## 变更范围
|
||||
- 代码改动:`internal/logic/public/user/unbindDeviceLogic.go`、`internal/logic/public/user/getDeviceListLogic.go` 增加安全断言与错误返回;调整 Redis 清理至事务外并加超时;补充必要日志。
|
||||
- 配置更新:`etc/nginx.conf` 的 API server 段增加代理超时与上游重试策略。
|
||||
|
||||
请确认以上方案,我将按该方案实施、编写必要函数注释并补充测试,完成后提供验证结果与影响评估。
|
||||
@@ -0,0 +1,31 @@
|
||||
## 问题确认
|
||||
- 前端以“百分比”形式配置折扣(如 95、95.19),当前后端 `getDiscount` 函数期望“系数(0–1)”,导致折扣未生效。
|
||||
- 受影响位置:
|
||||
- 登录下单折扣:`internal/logic/public/order/getDiscount.go`
|
||||
- 门户预下单/下单折扣:`internal/logic/public/portal/tool.go:getDiscount`
|
||||
|
||||
## 改造目标
|
||||
- 后端折扣计算统一兼容两种输入:
|
||||
- 系数(0–1):直接使用
|
||||
- 百分比(>1 且 <=100):自动转换为 `值/100` 再使用
|
||||
- 对非法值进行边界保护(<0 → 0;>100 → 忽略或按 1 处理),避免异常。
|
||||
|
||||
## 实施步骤
|
||||
1. 修改折扣计算函数:
|
||||
- `internal/logic/public/order/getDiscount.go`
|
||||
- 若 `discount.Discount > 1 && discount.Discount <= 100`,转换为 `discount.Discount/100`。
|
||||
- 保持“取满足阈值的最小折扣”策略,默认 `finalDiscount=1.0`。
|
||||
- `internal/logic/public/portal/tool.go:getDiscount`
|
||||
- 同上逻辑,移除对 `*100` 的中间整数化处理,统一用浮点小数系数比较。
|
||||
2. 单元测试补充:
|
||||
- 既测系数(如 0.95),也测百分比输入(如 95、95.19),以及边界(0、100、>100)。
|
||||
3. 验证流程:
|
||||
- 用你当前 7 天的配置(前端百分比)进行“预下单→下单→订单查询”,确认 `Discount` 与 `Amount` 按预期生效。
|
||||
4. 文档与界面提示:
|
||||
- 在后台/前端表单处增加说明:支持百分比与系数;百分比将自动转换;推荐使用百分比,避免歧义。
|
||||
|
||||
## 交付与保障
|
||||
- 代码改动仅限折扣计算函数与测试,风险低;保留原有行为的向后兼容。
|
||||
- 提供测试报告与一次联调记录(数据截图:价格、折扣、总计)。
|
||||
|
||||
请确认是否按该兼容方案执行,我将据此修改并验证。
|
||||
@@ -0,0 +1,47 @@
|
||||
## 实施目标
|
||||
- 复用现有订单与队列赋权,接入 Apple 自动续期(IAP),保持报表/审计/通知一致。
|
||||
|
||||
## 方案选择
|
||||
- 采用“平台化复用 + 合成订单”的方式:Apple 由客户端结算 + 服务器通知驱动,服务端生成“已支付订单”进入现有赋权与续费流程。
|
||||
|
||||
## 具体改动(按文件)
|
||||
1) 平台标识
|
||||
- 更新 `pkg/payment/platform.go`:新增 `AppleIAP` 枚举与名称(仅标识,不参与 `PurchaseCheckout`)。
|
||||
|
||||
2) 路由与 Handler
|
||||
- 新增公共接口:`POST /v1/public/iap/verify`
|
||||
- 位置:`internal/handler/public/iap/verifyHandler.go`
|
||||
- 逻辑:调用 `internal/logic/public/iap/verifyLogic.go`,以 `originalTransactionId` 验证 Apple 购买,生成“已支付订阅订单”,入队激活。
|
||||
- 新增通知接口:`POST /v1/iap/notifications`
|
||||
- 位置:`internal/handler/notify/appleIAPNotifyHandler.go`
|
||||
- 逻辑:调用 `internal/logic/notify/appleIAPNotifyLogic.go`,JWS 验签后按事件(初购/续期/退款)生成或更新订单,触发续费或撤销权益。
|
||||
- 路由注册:
|
||||
- `internal/handler/routes.go` 增加 `/v1/public/iap/verify` 路由。
|
||||
- `internal/handler/notify.go` 增加独立 `/v1/iap/notifications` 路由(Apple 不带 `:token`)。
|
||||
|
||||
3) 数据与模型
|
||||
- 在用户订阅(或新建 `iap_binding` 表)绑定:`originalTransactionId`、`environment`、`latestExpiresDate`。
|
||||
- 订单字段复用:`Method=AppleIAP`、`TradeNo=originalTransactionId`、`Type=1/2`(订阅/续费),`Status=2`(已支付),金额可取通知中的价格;取不到则置 `Amount=0` 保证流程。
|
||||
|
||||
4) 逻辑复用与改造点
|
||||
- 赋权:复用 `queue/logic/order/activateOrderLogic.go:165 NewPurchase`。
|
||||
- 续费:复用 `queue/logic/order/activateOrderLogic.go:529 updateSubscriptionForRenewal`。
|
||||
- 不改动 `internal/logic/public/portal/purchaseCheckoutLogic.go` 的渠道路由(Apple 不走此流程)。
|
||||
|
||||
5) 安全与幂等
|
||||
- Apple JWS 验签:拉取并缓存 JWKS 公钥,校验通知;拒绝无效签名。
|
||||
- 幂等:以 `notificationId`/`transactionId` 与 `originalTransactionId` 去重处理。
|
||||
|
||||
6) 客户端协作
|
||||
- iOS:完成 StoreKit 购买后携带 `originalTransactionId` 调用 `/v1/public/iap/verify`。
|
||||
- 续费:依赖 Server Notifications v2 自动驱动,无需客户端调用。
|
||||
|
||||
7) 测试与监控
|
||||
- 沙盒验证初购、续期、重试与宽限期、退款撤销;注意元数据延迟(~1小时)。
|
||||
- 指标:通知验签失败、API 调用失败、幂等冲突、状态不一致报警。
|
||||
|
||||
## 交付节奏
|
||||
- 第一步:平台枚举与路由骨架;
|
||||
- 第二步:`verify` 验证与“合成订单”生成;
|
||||
- 第三步:通知验签与事件映射;
|
||||
- 第四步:沙盒联调,确认队列赋权与续费延长。
|
||||
@@ -0,0 +1,92 @@
|
||||
## 结论
|
||||
|
||||
* 可以复用你现有的“订单→支付成功→订单激活(赋权)→通知/返佣”的主干流程,但“支付环节”不能复用第三方网关逻辑,必须改为 Apple IAP 的校验与事件驱动。
|
||||
|
||||
* 复用范围:订单模型、续费与赋权队列、优惠/手续费计算、通知与返佣;差异点:支付下单与回调换成“StoreKit 客户端购买 + 服务端向 Apple 校验 + Apple Server Notifications v2”。
|
||||
|
||||
## 可复用的部分
|
||||
|
||||
1. 订单激活与赋权
|
||||
|
||||
* 新购赋权:`queue/logic/order/activateOrderLogic.go:164-193` 的 `NewPurchase`
|
||||
|
||||
* 续费赋权:`queue/logic/order/activateOrderLogic.go:473-515` 的 `Renewal`
|
||||
|
||||
* 流量重置与充值:`queue/logic/order/activateOrderLogic.go:564-626`, `630-675`
|
||||
|
||||
1. 订单与费用模型
|
||||
|
||||
* 订单结构:`internal/model/order/order.go:5-29` 可继续承载 IAP 订单(新增字段映射 Apple 交易)
|
||||
|
||||
* 费用/折扣/礼金计算逻辑保持不变
|
||||
|
||||
1. 队列驱动
|
||||
|
||||
* 仍使用“支付成功→入队→处理”的模式:`queue/logic/order/activateOrderLogic.go:65-86`
|
||||
|
||||
## 必须独立实现的部分
|
||||
|
||||
1. Apple IAP 支付与校验
|
||||
|
||||
* 客户端使用 StoreKit 购买,拿到 `originalTransactionId`
|
||||
|
||||
* 服务端调用 App Store Server API,基于 `originalTransactionId` 校验订阅有效性并取交易历史
|
||||
|
||||
1. Apple Server Notifications v2
|
||||
|
||||
* 在 App Store Connect 配置通知 URL
|
||||
|
||||
* 服务端实现 JWS 验签,解析事件并落库:续期、失败、宽限期、退款、撤销等
|
||||
|
||||
## 整合方式(复用策略)
|
||||
|
||||
1. 引入平台枚举“AppleIAP”
|
||||
|
||||
* 在 `pkg/payment/platform.go` 增加 `AppleIAP`,用于平台标识与管理端展示
|
||||
|
||||
1. 订单创建策略(两种)
|
||||
|
||||
* 方案 A(推荐):用户在 iOS 内购完成后由客户端上报 `originalTransactionId`,服务端校验通过后“合成一个已支付订单”(`status=2`),触发既有赋权队列
|
||||
|
||||
* 方案 B:也可预建“待支付订单”,但 `PurchaseCheckout` 不走网关,只返回“client\_iap”类型,提示客户端用 StoreKit;支付完成后再校验并更新为 `Paid` 入队
|
||||
|
||||
1. 状态与权益判定
|
||||
|
||||
* 服务端统一以 Apple 校验与通知为准,抽象为 `active/in_grace_period/in_billing_retry/expired/revoked` 并映射到你的订阅与订单状态
|
||||
|
||||
## 服务端接口与流程
|
||||
|
||||
* `POST /apple/iap/verify`:入参 `originalTransactionId`,校验并创建/更新订单与用户订阅,返回当前权益
|
||||
|
||||
* `POST /apple/iap/notifications`:接收 Apple JWS 通知,验签后更新订阅与订单状态(幂等)
|
||||
|
||||
* `GET /subscriptions/me`:面向客户端查询当前订阅与权益(聚合 Apple 校验结果)
|
||||
|
||||
## 数据模型映射
|
||||
|
||||
* 在订单/订阅表补充字段(建议):`Provider=apple_iap`、`OriginalTransactionId`、`Environment`、`ExpiresDate`、`AutoRenewStatus`、`InGracePeriod`、`LastEventType`
|
||||
|
||||
* 产品映射:`productId(App Store) ↔ internal subscribeId`,保证同一权益统一计费
|
||||
|
||||
## 回调与安全
|
||||
|
||||
* JWS 验签:缓存 Apple JWKS 公钥、短生命周期缓存
|
||||
|
||||
* App Store Server API:使用 App Store Connect API Key(ES256)发起请求,区分生产/沙盒
|
||||
|
||||
* 幂等:按 `notificationId/transactionId` 去重
|
||||
|
||||
## 测试与上线
|
||||
|
||||
* 沙盒测试:购买、续期、失败、宽限期、退款全链路;注意沙盒元数据生效可能需 \~1 小时
|
||||
|
||||
* 监控:通知处理失败、验签失败、API 调用异常报警
|
||||
|
||||
## 交付物(最小实现)
|
||||
|
||||
1. 平台枚举新增 `AppleIAP`
|
||||
2. `POST /apple/iap/verify` 与 `POST /apple/iap/notifications` 路由与逻辑骨架
|
||||
3. App Store Server API 客户端封装(校验、交易历史、订阅状态)
|
||||
4. 订单合成与入队赋权打通(复用 `OrderStatusPaid` → `ProcessTask`)
|
||||
5. 数据表字段扩展与迁移脚本
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 最后上线时间字段显示修复计划
|
||||
|
||||
## 问题分析
|
||||
用户反馈调用 `curl` 接口后,返回的 JSON 中没有看到“最后上线时间” (`last_login_time`) 字段。
|
||||
|
||||
**原因可能如下:**
|
||||
1. **字段被 `omitempty` 隐藏**: 在 `internal/types/types.go` 中,`LastLoginTime` 字段定义为 `json:"last_login_time,omitempty"`。这意味着如果值为 `0`,该字段在 JSON 序列化时会被忽略,不返回给前端。
|
||||
2. **数据确实为空**: 用户从未登录过,且没有活跃订阅产生的流量记录,导致计算出的 `LastLoginTime` 为 `0`。
|
||||
3. **数据库迁移未生效**: 虽然我们之前修复了迁移文件冲突,但如果数据库中旧的迁移记录未清理或新字段 `last_login_time` 未真正添加成功,会导致数据读取失败(但此时通常会报错,而非字段缺失)。
|
||||
|
||||
## 解决方案
|
||||
为了确保接口始终返回该字段(即使是 0),我们需要移除 `omitempty` 标签,或者确认前端能处理缺失该字段的情况。考虑到用户明确要求“没看到”,建议移除 `omitempty`,让其显式返回 `0` 或时间戳。
|
||||
|
||||
同时,我们通过 SQL 检查数据库结构,确保字段已存在。
|
||||
|
||||
## 实施步骤
|
||||
1. **修改 API 定义**:
|
||||
- 文件: `internal/types/types.go` (及 `apis/types.api` 如果需要重新生成代码,但直接改 go 文件更快捷验证)
|
||||
- 操作: 将 `LastLoginTime int64 json:"last_login_time,omitempty"` 修改为 `json:"last_login_time"` (移除 `omitempty`)。
|
||||
- 同理处理 `MemberStatus` 字段。
|
||||
|
||||
2. **验证数据库字段**:
|
||||
- 使用 SQL 工具或日志确认 `user` 表中是否存在 `last_login_time` 列。
|
||||
|
||||
3. **验证接口**:
|
||||
- 再次调用 `curl`,确认即使值为 0 也会返回字段。
|
||||
|
||||
## 补充
|
||||
如果用户是指“有数据但没显示”,那可能是登录逻辑或流量更新逻辑未触发。但首要步骤是让字段显式返回,以便排查是“无数据”还是“字段被隐藏”。
|
||||
@@ -0,0 +1,92 @@
|
||||
## 修复目标
|
||||
|
||||
* 解决首次设备登录时在 `internal/logic/auth/deviceLoginLogic.go:99` 对 `deviceInfo` 赋值导致的空指针崩溃,确保接口稳定返回。
|
||||
|
||||
## 根因定位
|
||||
|
||||
* 设备不存在分支仅创建用户与设备记录,但未为局部变量 `deviceInfo` 赋值;随后在 `internal/logic/auth/deviceLoginLogic.go:99-100` 使用 `deviceInfo` 导致 `nil` 解引用。
|
||||
|
||||
* 参考位置:
|
||||
|
||||
* 赋值处:`internal/logic/auth/deviceLoginLogic.go:99-101`
|
||||
|
||||
* 设备存在分支赋值:`internal/logic/auth/deviceLoginLogic.go:88-95`
|
||||
|
||||
* 设备不存在分支未赋值:`internal/logic/auth/deviceLoginLogic.go:74-79`
|
||||
|
||||
* `UpdateDevice` 需要有效设备 `Id`:`internal/model/user/device.go:58-69`
|
||||
|
||||
## 修改方案
|
||||
|
||||
1. 在“设备不存在”分支注册完成后,立即通过标识重新查询设备,赋值给 `deviceInfo`:
|
||||
|
||||
* 在 `internal/logic/auth/deviceLoginLogic.go` 的 `if errors.Is(err, gorm.ErrRecordNotFound)` 分支中,`userInfo, err = l.registerUserAndDevice(req)` 之后追加:
|
||||
|
||||
* `deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)`
|
||||
|
||||
* 如果查询失败则返回数据库查询错误(与现有风格一致)。
|
||||
2. 在更新设备 UA 前增加空指针保护,并不再忽略更新错误:
|
||||
|
||||
* 将 `internal/logic/auth/deviceLoginLogic.go:99-101` 改为:
|
||||
|
||||
* 检查 `deviceInfo != nil`
|
||||
|
||||
* `deviceInfo.UserAgent = req.UserAgent`
|
||||
|
||||
* `if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {` 记录错误并返回包装后的错误 `xerr.DatabaseUpdateError`。
|
||||
3. 可选优化(减少二次查询):
|
||||
|
||||
* 将 `registerUserAndDevice(req)` 的返回值改为 `(*user.User, *user.Device, error)`,在注册时直接返回新建设备对象;调用点随之调整。若选择此方案,仍需在更新前做空指针保护。
|
||||
|
||||
## 代码示例(方案1,最小改动)
|
||||
|
||||
```go
|
||||
// internal/logic/auth/deviceLoginLogic.go
|
||||
// 设备不存在分支注册后追加一次设备查询
|
||||
userInfo, err = l.registerUserAndDevice(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)
|
||||
if err != nil {
|
||||
l.Errorw("query device after register failed",
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device after register failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 更新 UA,不忽略更新错误
|
||||
if deviceInfo != nil {
|
||||
deviceInfo.UserAgent = req.UserAgent
|
||||
if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {
|
||||
l.Errorw("update device failed",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device failed: %v", err.Error())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试用例与验证
|
||||
|
||||
* 用例1:首次设备标识登录(设备不存在)应成功返回 Token,日志包含注册与登录记录,无 500。
|
||||
|
||||
* 用例2:已存在设备标识登录(设备存在)应正常更新 UA 并返回 Token。
|
||||
|
||||
* 用例3:模拟数据库异常时应返回一致的业务错误码,不产生 `panic`。
|
||||
|
||||
## 风险与回滚
|
||||
|
||||
* 改动限定在登录逻辑,属最小范围;若出现异常,回滚为当前版本即可。
|
||||
|
||||
* 不改变数据结构与外部接口行为,兼容现有客户端。
|
||||
|
||||
## 后续优化(可选)
|
||||
|
||||
* 统一 `UpdateDevice` 错误处理路径,避免 `_ = ...` 静默失败。
|
||||
|
||||
* 为“首次设备登录”场景补充集成测试,保证不再回归。
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 迁移文件重复问题修复计划
|
||||
|
||||
## 问题分析
|
||||
根据终端日志报错 `panic: failed to init driver with path database: duplicate migration file: 02121_apple_iap_transactions.down.sql`,系统启动失败的原因是存在**重复的迁移版本号**。
|
||||
|
||||
在 `initialize/migrate/database/` 目录下,存在两个版本号相同的迁移文件:
|
||||
1. `02121_add_user_last_login_time.up.sql` (我们刚刚创建的)
|
||||
2. `02121_apple_iap_transactions.up.sql` (已存在的)
|
||||
|
||||
由于 `golang-migrate` 要求版本号必须唯一,这两个文件都使用了 `02121` 前缀,导致冲突。
|
||||
|
||||
## 解决方案
|
||||
将我们新创建的 `add_user_last_login_time` 迁移文件的版本号递增为 `02122`。
|
||||
|
||||
## 实施步骤
|
||||
1. **重命名迁移文件**:
|
||||
- `02121_add_user_last_login_time.up.sql` -> `02122_add_user_last_login_time.up.sql`
|
||||
- `02121_add_user_last_login_time.down.sql` -> `02122_add_user_last_login_time.down.sql`
|
||||
|
||||
2. **验证**:
|
||||
- 确认目录下不再有重复前缀的文件。
|
||||
- 建议用户重新运行程序。
|
||||
|
||||
## 补充说明
|
||||
此操作仅涉及文件重命名,不修改文件内容,风险极低。
|
||||
@@ -0,0 +1,128 @@
|
||||
# 设备静默登录机制设计方案
|
||||
|
||||
## 需求分析
|
||||
1. 用户进来之后就是静默登录(设备登录)
|
||||
2. 用户可以主动关联邮箱;也可以不关联邮箱
|
||||
3. 用户换手机后在别的地方用邮箱登录会绑定另外一部手机的设备号
|
||||
4. 没有游客概念,快捷登录进来的用户就是正式用户
|
||||
|
||||
## 当前系统分析
|
||||
|
||||
### 现有认证流程
|
||||
- **设备登录**:已存在 `deviceLoginLogic.go`,通过设备标识符自动登录
|
||||
- **邮箱登录**:已存在 `userLoginLogic.go`,需要邮箱+密码
|
||||
- **游客模式**:通过 `Order.UserId = 0` 标识游客订单
|
||||
|
||||
### 现有设备管理
|
||||
- **设备绑定**:`bindDeviceLogic.go` 处理设备与用户绑定
|
||||
- **设备解绑**:`unbindDeviceLogic.go` 处理设备解绑
|
||||
- **设备迁移**:支持设备在用户间转移
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 1. 核心改动策略
|
||||
- **保留现有设备登录机制**,作为默认登录方式
|
||||
- **移除游客概念**,所有设备登录用户都是正式用户
|
||||
- **增强邮箱绑定功能**,支持跨设备登录
|
||||
- **优化设备迁移逻辑**,支持邮箱登录后绑定新设备
|
||||
|
||||
### 2. 具体实现方案
|
||||
|
||||
#### 2.1 修改设备登录逻辑
|
||||
**文件**: `internal/logic/auth/deviceLoginLogic.go`
|
||||
|
||||
**改动点**:
|
||||
- 移除 `registerUserAndDevice` 中的试用激活逻辑
|
||||
- 确保所有通过设备登录创建的用户都是正式用户
|
||||
- 保持现有的设备绑定机制
|
||||
|
||||
#### 2.2 修改订单处理逻辑
|
||||
**文件**: `queue/logic/order/activateOrderLogic.go`
|
||||
|
||||
**改动点**:
|
||||
- 移除 `getUserOrCreate` 中的游客判断逻辑 (`orderInfo.UserId == 0`)
|
||||
- 移除 `createGuestUser` 函数
|
||||
- 修改为:如果订单没有关联用户,通过设备标识符创建或获取用户
|
||||
|
||||
#### 2.3 修改订单关闭逻辑
|
||||
**文件**: `internal/logic/public/order/closeOrderLogic.go`
|
||||
|
||||
**改动点**:
|
||||
- 移除对 `UserId == 0` 的特殊处理
|
||||
- 统一处理所有订单的关闭逻辑
|
||||
|
||||
#### 2.4 增强邮箱登录逻辑
|
||||
**文件**: `internal/logic/auth/userLoginLogic.go`
|
||||
|
||||
**改动点**:
|
||||
- 在邮箱登录成功后,如果提供了设备标识符,自动绑定设备
|
||||
- 支持邮箱登录后在新设备上的自动绑定
|
||||
|
||||
#### 2.5 优化设备绑定逻辑
|
||||
**文件**: `internal/logic/auth/bindDeviceLogic.go`
|
||||
|
||||
**改动点**:
|
||||
- 增强设备迁移逻辑,支持邮箱用户登录新设备时的自动绑定
|
||||
- 保持现有的设备冲突处理机制
|
||||
|
||||
### 3. 数据库变更
|
||||
**无需修改数据库结构**,现有的用户和设备表结构已经支持新的需求。
|
||||
|
||||
### 4. API 变更
|
||||
**无需修改 API 接口**,现有的设备登录和邮箱登录接口已经满足需求。
|
||||
|
||||
### 5. 前端适配
|
||||
**前端需要调整**:
|
||||
- 默认使用设备登录作为主要登录方式
|
||||
- 提供邮箱绑定入口
|
||||
- 在新设备上提供邮箱登录选项
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 第一步:修改订单逻辑
|
||||
1. 修改 `activateOrderLogic.go`,移除游客概念
|
||||
2. 修改 `closeOrderLogic.go`,统一订单处理逻辑
|
||||
|
||||
### 第二步:增强设备登录
|
||||
1. 确保设备登录创建的都是正式用户
|
||||
2. 优化设备绑定逻辑
|
||||
|
||||
### 第三步:增强邮箱登录
|
||||
1. 在邮箱登录后支持设备绑定
|
||||
2. 优化跨设备登录体验
|
||||
|
||||
### 第四步:测试验证
|
||||
1. 测试设备静默登录
|
||||
2. 测试邮箱绑定功能
|
||||
3. 测试跨设备登录
|
||||
|
||||
## 优势分析
|
||||
|
||||
### 1. 最小化改动
|
||||
- 复用现有的设备登录机制
|
||||
- 保持现有的数据库结构
|
||||
- 保持现有的 API 接口
|
||||
|
||||
### 2. 用户体验提升
|
||||
- 用户进入即可使用,无需注册
|
||||
- 支持邮箱绑定,便于跨设备使用
|
||||
- 保持数据连续性
|
||||
|
||||
### 3. 系统稳定性
|
||||
- 基于现有成熟机制
|
||||
- 减少新增代码量
|
||||
- 降低引入 bug 的风险
|
||||
|
||||
## 风险评估
|
||||
|
||||
### 1. 数据迁移
|
||||
- **风险**: 现有游客数据需要处理
|
||||
- **方案**: 可以保持现有游客数据不变,新用户使用新机制
|
||||
|
||||
### 2. 兼容性
|
||||
- **风险**: 现有客户端可能需要适配
|
||||
- **方案**: 保持 API 兼容,逐步引导用户使用新机制
|
||||
|
||||
### 3. 性能影响
|
||||
- **风险**: 设备登录可能增加数据库压力
|
||||
- **方案**: 现有机制已经过验证,影响可控
|
||||
+2
-3
@@ -24,11 +24,11 @@ RUN BUILD_TIME=$(date -u +"%Y-%m-%d %H:%M:%S") && \
|
||||
go build -ldflags="-s -w -X 'github.com/perfect-panel/server/pkg/constant.Version=${VERSION}' -X 'github.com/perfect-panel/server/pkg/constant.BuildTime=${BUILD_TIME}'" -o /app/ppanel ppanel.go
|
||||
|
||||
# Final minimal image
|
||||
FROM scratch
|
||||
FROM alpine:latest
|
||||
|
||||
# Copy CA certificates and timezone data
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai
|
||||
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
@@ -36,7 +36,6 @@ ENV TZ=Asia/Shanghai
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/ppanel /app/ppanel
|
||||
COPY --from=builder /build/etc /app/etc
|
||||
|
||||
# Expose the port (optional)
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name hifastapp.com www.hifastapp.com www.hifastvpn.com hifastvpn.com hifast.biz www.hifast.biz;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
# 统一 HTTP 转 HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name hifastvpn.com www.hifastvpn.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/hifastvpn.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/hifastvpn.com/privkey.pem; # managed by Certbot
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
root /var/www/down;
|
||||
index index.html index.htm;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass https://api.hifast.biz/;
|
||||
proxy_set_header Host api.hifast.biz;
|
||||
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_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /download/ {
|
||||
autoindex_exact_size off;
|
||||
autoindex_localtime on;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name hifastapp.com www.hifastapp.com;
|
||||
|
||||
# 使用 -0001 的新证书(通常包含 www)
|
||||
ssl_certificate /etc/letsencrypt/live/hifastapp.com-0001/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/hifastapp.com-0001/privkey.pem; # managed by Certbot
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
root /var/www/down;
|
||||
index index.html index.htm;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass https://api.hifast.biz/;
|
||||
proxy_set_header Host api.hifast.biz;
|
||||
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_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /download/ {
|
||||
autoindex_exact_size off;
|
||||
autoindex_localtime on;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name hifast.biz www.hifast.biz;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/hifast.biz/hifast.biz.cer;
|
||||
ssl_certificate_key /etc/letsencrypt/live/hifast.biz/hifast.biz.key;
|
||||
|
||||
root /var/www/lp;
|
||||
index index.html index.htm;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
+4
-20
@@ -8,24 +8,16 @@ import (
|
||||
)
|
||||
|
||||
type Adapter struct {
|
||||
Type string // 协议类型
|
||||
SiteName string // 站点名称
|
||||
Servers []*node.Node // 服务器列表
|
||||
UserInfo User // 用户信息
|
||||
ClientTemplate string // 客户端配置模板
|
||||
OutputFormat string // 输出格式,默认是 base64
|
||||
SubscribeName string // 订阅名称
|
||||
Params map[string]string // 其他参数
|
||||
}
|
||||
|
||||
type Option func(*Adapter)
|
||||
|
||||
func WithParams(params map[string]string) Option {
|
||||
return func(opts *Adapter) {
|
||||
opts.Params = params
|
||||
}
|
||||
}
|
||||
|
||||
// WithServers 设置服务器列表
|
||||
func WithServers(servers []*node.Node) Option {
|
||||
return func(opts *Adapter) {
|
||||
@@ -84,7 +76,6 @@ func (adapter *Adapter) Client() (*Client, error) {
|
||||
OutputFormat: adapter.OutputFormat,
|
||||
Proxies: []Proxy{},
|
||||
UserInfo: adapter.UserInfo,
|
||||
Params: adapter.Params,
|
||||
}
|
||||
|
||||
proxies, err := adapter.Proxies(adapter.Servers)
|
||||
@@ -110,9 +101,7 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
|
||||
}
|
||||
for _, protocol := range protocols {
|
||||
if protocol.Type == item.Protocol {
|
||||
proxies = append(
|
||||
proxies,
|
||||
Proxy{
|
||||
proxies = append(proxies, Proxy{
|
||||
Sort: item.Sort,
|
||||
Name: item.Name,
|
||||
Server: item.Address,
|
||||
@@ -138,12 +127,12 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
|
||||
HopPorts: protocol.HopPorts,
|
||||
HopInterval: protocol.HopInterval,
|
||||
ObfsPassword: protocol.ObfsPassword,
|
||||
UpMbps: protocol.UpMbps,
|
||||
DownMbps: protocol.DownMbps,
|
||||
DisableSNI: protocol.DisableSNI,
|
||||
ReduceRtt: protocol.ReduceRtt,
|
||||
UDPRelayMode: protocol.UDPRelayMode,
|
||||
CongestionController: protocol.CongestionController,
|
||||
UpMbps: protocol.UpMbps,
|
||||
DownMbps: protocol.DownMbps,
|
||||
PaddingScheme: protocol.PaddingScheme,
|
||||
Multiplex: protocol.Multiplex,
|
||||
XhttpMode: protocol.XhttpMode,
|
||||
@@ -156,12 +145,7 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
|
||||
EncryptionPrivateKey: protocol.EncryptionPrivateKey,
|
||||
EncryptionClientPadding: protocol.EncryptionClientPadding,
|
||||
EncryptionPassword: protocol.EncryptionPassword,
|
||||
Ratio: protocol.Ratio,
|
||||
CertMode: protocol.CertMode,
|
||||
CertDNSProvider: protocol.CertDNSProvider,
|
||||
CertDNSEnv: protocol.CertDNSEnv,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,6 @@ type Client struct {
|
||||
OutputFormat string // json, yaml, etc.
|
||||
Proxies []Proxy // List of proxy configurations
|
||||
UserInfo User // User information
|
||||
Params map[string]string // Additional parameters
|
||||
}
|
||||
|
||||
func (c *Client) Build() ([]byte, error) {
|
||||
@@ -120,7 +119,6 @@ func (c *Client) Build() ([]byte, error) {
|
||||
"OutputFormat": c.OutputFormat,
|
||||
"Proxies": proxies,
|
||||
"UserInfo": c.UserInfo,
|
||||
"Params": c.Params,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -14,14 +14,14 @@ type (
|
||||
CreateOrderRequest {
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
Type uint8 `json:"type" validate:"required"`
|
||||
Quantity int64 `json:"quantity,omitempty" validate:"omitempty,lte=1000"`
|
||||
Price int64 `json:"price" validate:"required,gte=0,lte=2000000000"`
|
||||
Amount int64 `json:"amount" validate:"required,gte=0,lte=2147483647"`
|
||||
Discount int64 `json:"discount,omitempty" validate:"omitempty,gte=0,lte=2000000000"`
|
||||
Quantity int64 `json:"quantity,omitempty"`
|
||||
Price int64 `json:"price" validate:"required"`
|
||||
Amount int64 `json:"amount" validate:"required"`
|
||||
Discount int64 `json:"discount,omitempty"`
|
||||
Coupon string `json:"coupon,omitempty"`
|
||||
CouponDiscount int64 `json:"coupon_discount,omitempty" validate:"omitempty,gte=0,lte=2000000000"`
|
||||
Commission int64 `json:"commission" validate:"gte=0,lte=2000000000"`
|
||||
FeeAmount int64 `json:"fee_amount" validate:"required,gte=0,lte=2000000000"`
|
||||
CouponDiscount int64 `json:"coupon_discount,omitempty"`
|
||||
Commission int64 `json:"commission"`
|
||||
FeeAmount int64 `json:"fee_amount" validate:"required"`
|
||||
PaymentId int64 `json:"payment_id" validate:"required"`
|
||||
TradeNo string `json:"trade_no,omitempty"`
|
||||
Status uint8 `json:"status,omitempty"`
|
||||
|
||||
@@ -189,6 +189,14 @@ service ppanel {
|
||||
@handler ToggleNodeStatus
|
||||
post /node/status/toggle (ToggleNodeStatusRequest)
|
||||
|
||||
@doc "Check if there is any server or node to migrate"
|
||||
@handler HasMigrateSeverNode
|
||||
get /migrate/has returns (HasMigrateSeverNodeResponse)
|
||||
|
||||
@doc "Migrate server and node data to new database"
|
||||
@handler MigrateServerNode
|
||||
post /migrate/run returns (MigrateServerNodeResponse)
|
||||
|
||||
@doc "Reset server sort"
|
||||
@handler ResetSortWithServer
|
||||
post /server/sort (ResetSortRequest)
|
||||
|
||||
@@ -54,7 +54,6 @@ type (
|
||||
AllowDeduction *bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset *bool `json:"renewal_reset"`
|
||||
ShowOriginalPrice bool `json:"show_original_price"`
|
||||
}
|
||||
UpdateSubscribeRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
@@ -79,7 +78,6 @@ type (
|
||||
AllowDeduction *bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset *bool `json:"renewal_reset"`
|
||||
ShowOriginalPrice bool `json:"show_original_price"`
|
||||
}
|
||||
SubscribeSortRequest {
|
||||
Sort []SortItem `json:"sort"`
|
||||
@@ -104,9 +102,6 @@ type (
|
||||
BatchDeleteSubscribeRequest {
|
||||
Ids []int64 `json:"ids" validate:"required"`
|
||||
}
|
||||
ResetAllSubscribeTokenResponse {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@@ -162,9 +157,5 @@ service ppanel {
|
||||
@doc "Subscribe sort"
|
||||
@handler SubscribeSort
|
||||
post /sort (SubscribeSortRequest)
|
||||
|
||||
@doc "Reset all subscribe tokens"
|
||||
@handler ResetAllSubscribeToken
|
||||
post /reset_all_token returns (ResetAllSubscribeTokenResponse)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,6 @@ type (
|
||||
CurrentTime string `json:"current_time"`
|
||||
Ratio float32 `json:"ratio"`
|
||||
}
|
||||
ModuleConfig {
|
||||
Secret string `json:"secret"` // 通讯密钥
|
||||
ServiceName string `json:"service_name"` // 服务名称
|
||||
ServiceVersion string `json:"service_version"` // 服务版本
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@@ -130,9 +125,5 @@ service ppanel {
|
||||
@doc "PreView Node Multiplier"
|
||||
@handler PreViewNodeMultiplier
|
||||
get /node_multiplier/preview returns (PreViewNodeMultiplierResponse)
|
||||
|
||||
@doc "Get Module Config"
|
||||
@handler GetModuleConfig
|
||||
get /module returns (ModuleConfig)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,14 +17,6 @@ type (
|
||||
VersionResponse {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
QueryIPLocationRequest {
|
||||
IP string `form:"ip" validate:"required"`
|
||||
}
|
||||
QueryIPLocationResponse {
|
||||
Country string `json:"country"`
|
||||
Region string `json:"region,omitempty"`
|
||||
City string `json:"city"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@@ -44,9 +36,5 @@ service ppanel {
|
||||
@doc "Get Version"
|
||||
@handler GetVersion
|
||||
get /version returns (VersionResponse)
|
||||
|
||||
@doc "Query IP Location"
|
||||
@handler QueryIPLocation
|
||||
get /ip/location (QueryIPLocationRequest) returns (QueryIPLocationResponse)
|
||||
}
|
||||
|
||||
|
||||
+1
-20
@@ -19,9 +19,9 @@ type (
|
||||
Size int `form:"size"`
|
||||
Search string `form:"search,omitempty"`
|
||||
UserId *int64 `form:"user_id,omitempty"`
|
||||
Unscoped bool `form:"unscoped,omitempty"`
|
||||
SubscribeId *int64 `form:"subscribe_id,omitempty"`
|
||||
UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"`
|
||||
DeviceId string `form:"device_id,omitempty"`
|
||||
}
|
||||
// GetUserListResponse
|
||||
GetUserListResponse {
|
||||
@@ -184,12 +184,6 @@ type (
|
||||
GetUserSubscribeByIdRequest {
|
||||
Id int64 `form:"id" validate:"required"`
|
||||
}
|
||||
ToggleUserSubscribeStatusRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
ResetUserSubscribeTrafficRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@@ -298,17 +292,4 @@ service ppanel {
|
||||
@doc "Get user login logs"
|
||||
@handler GetUserLoginLogs
|
||||
get /login/logs (GetUserLoginLogsRequest) returns (GetUserLoginLogsResponse)
|
||||
|
||||
@doc "Reset user subscribe token"
|
||||
@handler ResetUserSubscribeToken
|
||||
post /subscribe/reset/token (ResetUserSubscribeTokenRequest)
|
||||
|
||||
@doc "Stop user subscribe"
|
||||
@handler ToggleUserSubscribeStatus
|
||||
post /subscribe/toggle (ToggleUserSubscribeStatusRequest)
|
||||
|
||||
@doc "Reset user subscribe traffic"
|
||||
@handler ResetUserSubscribeTraffic
|
||||
post /subscribe/reset/traffic (ResetUserSubscribeTrafficRequest)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,16 @@ type (
|
||||
LoginType string `header:"Login-Type"`
|
||||
CfToken string `json:"cf_token,optional"`
|
||||
}
|
||||
EmailLoginRequest {
|
||||
Identifier string `json:"identifier"`
|
||||
Email string `json:"email" validate:"required"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
Invite string `json:"invite,optional"`
|
||||
IP string `header:"X-Original-Forwarded-For"`
|
||||
UserAgent string `header:"User-Agent"`
|
||||
LoginType string `header:"Login-Type"`
|
||||
CfToken string `json:"cf_token,optional"`
|
||||
}
|
||||
LoginResponse {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
@@ -141,6 +151,10 @@ service ppanel {
|
||||
@handler CheckUser
|
||||
get /check (CheckUserRequest) returns (CheckUserResponse)
|
||||
|
||||
@doc "Email Login"
|
||||
@handler EmailLogin
|
||||
post /login/email (EmailLoginRequest) returns (LoginResponse)
|
||||
|
||||
@doc "User register"
|
||||
@handler UserRegister
|
||||
post /register (UserRegisterRequest) returns (LoginResponse)
|
||||
|
||||
+9
-9
@@ -87,10 +87,11 @@ type (
|
||||
Total int64 `json:"total"`
|
||||
List []SubscribeClient `json:"list"`
|
||||
}
|
||||
HeartbeatResponse {
|
||||
Status bool `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
ContactRequest {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
OtherContact string `json:"other_contact,optional"`
|
||||
Notes string `json:"notes,optional"`
|
||||
}
|
||||
)
|
||||
|
||||
@@ -104,6 +105,10 @@ service ppanel {
|
||||
@handler GetGlobalConfig
|
||||
get /site/config returns (GetGlobalConfigResponse)
|
||||
|
||||
@doc "Submit contact info"
|
||||
@handler SubmitContact
|
||||
post /contact (ContactRequest)
|
||||
|
||||
@doc "Get Tos Content"
|
||||
@handler GetTos
|
||||
get /site/tos returns (GetTosResponse)
|
||||
@@ -135,9 +140,4 @@ service ppanel {
|
||||
@doc "Get Client"
|
||||
@handler GetClient
|
||||
get /client returns (GetSubscribeClientResponse)
|
||||
|
||||
@doc "Heartbeat"
|
||||
@handler Heartbeat
|
||||
get /heartbeat returns (HeartbeatResponse)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,11 +41,16 @@ type (
|
||||
Name string `json:"name"`
|
||||
Uuid string `json:"uuid"`
|
||||
Protocol string `json:"protocol"`
|
||||
Protocols string `json:"protocols"`
|
||||
Port uint16 `json:"port"`
|
||||
Address string `json:"address"`
|
||||
Tags []string `json:"tags"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Longitude string `json:"longitude"`
|
||||
Latitude string `json:"latitude"`
|
||||
LatitudeCenter string `json:"latitude_center"`
|
||||
LongitudeCenter string `json:"longitude_center"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
)
|
||||
|
||||
+20
-44
@@ -66,6 +66,9 @@ type (
|
||||
UnbindOAuthRequest {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
ResetUserSubscribeTokenRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
GetLoginLogRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
@@ -94,6 +97,16 @@ type (
|
||||
Email string `json:"email" validate:"required"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
BindEmailWithVerificationRequest {
|
||||
Email string `json:"email" validate:"required"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
BindEmailWithVerificationResponse {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Token string `json:"token,omitempty"` // 设备关联后的新Token
|
||||
UserId int64 `json:"user_id,omitempty"` // 目标用户ID
|
||||
}
|
||||
GetDeviceListResponse {
|
||||
List []UserDevice `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
@@ -101,34 +114,9 @@ type (
|
||||
UnbindDeviceRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
}
|
||||
UpdateUserSubscribeNoteRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id" validate:"required"`
|
||||
Note string `json:"note" validate:"max=500"`
|
||||
}
|
||||
UpdateUserRulesRequest {
|
||||
Rules []string `json:"rules" validate:"required"`
|
||||
}
|
||||
CommissionWithdrawRequest {
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
WithdrawalLog {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
Status uint8 `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
QueryWithdrawalLogListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
QueryWithdrawalLogListResponse {
|
||||
List []WithdrawalLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
GetSubscribeStatusResponse {
|
||||
DeviceStatus bool `json:"device_status"`
|
||||
EmailStatus bool `json:"email_status"`
|
||||
}
|
||||
)
|
||||
|
||||
@@ -214,6 +202,10 @@ service ppanel {
|
||||
@handler GetSubscribeLog
|
||||
get /subscribe_log (GetSubscribeLogRequest) returns (GetSubscribeLogResponse)
|
||||
|
||||
@doc "Get Subscribe Status (device/email)"
|
||||
@handler GetSubscribeStatus
|
||||
get /subscribe_status returns (GetSubscribeStatusResponse)
|
||||
|
||||
@doc "Verify Email"
|
||||
@handler VerifyEmail
|
||||
post /verify_email (VerifyEmailRequest)
|
||||
@@ -233,21 +225,5 @@ service ppanel {
|
||||
@doc "Unbind Device"
|
||||
@handler UnbindDevice
|
||||
put /unbind_device (UnbindDeviceRequest)
|
||||
|
||||
@doc "Update User Subscribe Note"
|
||||
@handler UpdateUserSubscribeNote
|
||||
put /subscribe_note (UpdateUserSubscribeNoteRequest)
|
||||
|
||||
@doc "Update User Rules"
|
||||
@handler UpdateUserRules
|
||||
put /rules (UpdateUserRulesRequest)
|
||||
|
||||
@doc "Commission Withdraw"
|
||||
@handler CommissionWithdraw
|
||||
post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog)
|
||||
|
||||
@doc "Query Withdrawal Log"
|
||||
@handler QueryWithdrawalLog
|
||||
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
|
||||
}
|
||||
|
||||
|
||||
+10
-12
@@ -26,12 +26,14 @@ type (
|
||||
EnableLoginNotify bool `json:"enable_login_notify"`
|
||||
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
||||
EnableTradeNotify bool `json:"enable_trade_notify"`
|
||||
LastLoginTime int64 `json:"last_login_time"`
|
||||
MemberStatus string `json:"member_status"`
|
||||
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
||||
UserDevices []UserDevice `json:"user_devices"`
|
||||
Rules []string `json:"rules"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
DeletedAt int64 `json:"deleted_at,omitempty"`
|
||||
IsDel bool `json:"is_del,omitempty"`
|
||||
}
|
||||
Follow {
|
||||
Id int64 `json:"id"`
|
||||
@@ -205,7 +207,7 @@ type (
|
||||
}
|
||||
SubscribeDiscount {
|
||||
Quantity int64 `json:"quantity"`
|
||||
Discount float64 `json:"discount"`
|
||||
Discount int64 `json:"discount"`
|
||||
}
|
||||
Subscribe {
|
||||
Id int64 `json:"id"`
|
||||
@@ -230,7 +232,6 @@ type (
|
||||
AllowDeduction bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset bool `json:"renewal_reset"`
|
||||
ShowOriginalPrice bool `json:"show_original_price"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
@@ -471,7 +472,6 @@ type (
|
||||
Upload int64 `json:"upload"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
Short string `json:"short"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
@@ -580,7 +580,7 @@ type (
|
||||
//public order
|
||||
PurchaseOrderRequest {
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Quantity int64 `json:"quantity" validate:"required,gt=0,lte=1000"`
|
||||
Quantity int64 `json:"quantity" validate:"required,gt=0"`
|
||||
Payment int64 `json:"payment,omitempty"`
|
||||
Coupon string `json:"coupon,omitempty"`
|
||||
}
|
||||
@@ -598,7 +598,7 @@ type (
|
||||
}
|
||||
RenewalOrderRequest {
|
||||
UserSubscribeID int64 `json:"user_subscribe_id"`
|
||||
Quantity int64 `json:"quantity" validate:"lte=1000"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
Payment int64 `json:"payment"`
|
||||
Coupon string `json:"coupon,omitempty"`
|
||||
}
|
||||
@@ -613,7 +613,7 @@ type (
|
||||
OrderNo string `json:"order_no"`
|
||||
}
|
||||
RechargeOrderRequest {
|
||||
Amount int64 `json:"amount" validate:"required,gt=0,lte=2000000000"`
|
||||
Amount int64 `json:"amount"`
|
||||
Payment int64 `json:"payment"`
|
||||
}
|
||||
RechargeOrderResponse {
|
||||
@@ -636,6 +636,8 @@ type (
|
||||
QueryOrderListRequest {
|
||||
Page int `form:"page" validate:"required"`
|
||||
Size int `form:"size" validate:"required"`
|
||||
Status uint8 `form:"status,omitempty"`
|
||||
Search string `form:"search,omitempty"`
|
||||
}
|
||||
QueryOrderListResponse {
|
||||
Total int64 `json:"total"`
|
||||
@@ -656,7 +658,7 @@ type (
|
||||
// public announcement
|
||||
QueryAnnouncementRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Size int `form:"size,default=15"`
|
||||
Pinned *bool `form:"pinned"`
|
||||
Popup *bool `form:"popup"`
|
||||
}
|
||||
@@ -845,9 +847,5 @@ type (
|
||||
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
||||
CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider
|
||||
}
|
||||
// reset user subscribe token
|
||||
ResetUserSubscribeTokenRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
SET CGO_ENABLED=0
|
||||
SET GOOS=linux
|
||||
SET GOARCH=amd64
|
||||
go build -o ppanel .\ppanel.go
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
"github.com/perfect-panel/server/pkg/service"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/trace"
|
||||
"github.com/perfect-panel/server/queue"
|
||||
"github.com/perfect-panel/server/scheduler"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -49,6 +50,7 @@ var startCmd = &cobra.Command{
|
||||
func run() {
|
||||
services := getServers()
|
||||
defer services.Stop()
|
||||
defer trace.StopAgent()
|
||||
go services.Start()
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
|
||||
@@ -89,6 +91,9 @@ func getServers() *service.Group {
|
||||
logger.Errorf("Logger setup failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// init trace
|
||||
trace.StartAgent(c.Trace)
|
||||
|
||||
// init service context
|
||||
ctx := svc.NewServiceContext(c)
|
||||
services := service.NewServiceGroup()
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 配置区域 - 请在此处填入您的真实信息进行测试
|
||||
const (
|
||||
// 必填:您的 Key ID (从 App Store Connect 获取)
|
||||
KeyID = "2C4X3HVPM8"
|
||||
|
||||
// 必填:您的 Issuer ID (从 App Store Connect 获取,通常是一个 UUID)
|
||||
IssuerID = "34f54810-5118-4b7f-8069-c8c1e012b7a9" // 请替换为您真实的 Issuer ID
|
||||
|
||||
// 必填:您的 Bundle ID (App 的包名)
|
||||
BundleID = "com.taw.hifastvpn" // 请替换为您真实的 Bundle ID
|
||||
|
||||
// 必填:用于测试的 Transaction ID (任意一个真实的交易 ID)
|
||||
TestTransactionID = "2000001083318819"
|
||||
|
||||
// 必填:是否为沙盒环境
|
||||
IsSandbox = true
|
||||
)
|
||||
|
||||
// P8 私钥内容 (硬编码用于测试)
|
||||
const PrivateKeyPEM = `-----BEGIN PRIVATE KEY-----
|
||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsVDj0g/D7uNCm8aC
|
||||
E4TuaiDT4Pgb1IuuZ69YdGNvcAegCgYIKoZIzj0DAQehRANCAARObgGumaESbPMM
|
||||
SIRDAVLcWemp0fMlnfDE4EHmqcD58arEJWsr3aWEhc4BHocOUIGjko0cVWGchrFa
|
||||
/T/KG1tr
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
func main() {
|
||||
log.Println("开始测试 Apple IAP API 连接...")
|
||||
log.Printf("环境: %v (Sandbox=%v)\n", func() string {
|
||||
if IsSandbox {
|
||||
return "沙盒 (Sandbox)"
|
||||
}
|
||||
return "生产 (Production)"
|
||||
}(), IsSandbox)
|
||||
log.Printf("KeyID: %s\n", KeyID)
|
||||
log.Printf("IssuerID: %s\n", IssuerID)
|
||||
log.Printf("BundleID: %s\n", BundleID)
|
||||
log.Printf("TransactionID: %s\n", TestTransactionID)
|
||||
|
||||
token, err := buildAPIToken()
|
||||
if err != nil {
|
||||
log.Fatalf("生成 JWT Token 失败: %v", err)
|
||||
}
|
||||
log.Println("JWT Token 生成成功")
|
||||
|
||||
// 发起请求
|
||||
host := "https://api.storekit.itunes.apple.com"
|
||||
if IsSandbox {
|
||||
host = "https://api.storekit-sandbox.itunes.apple.com"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/inApps/v1/transactions/%s", host, TestTransactionID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
log.Printf("正在请求: %s", url)
|
||||
start := time.Now()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
duration := time.Since(start)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
log.Printf("请求耗时: %v", duration)
|
||||
log.Printf("状态码: %d", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
log.Println("✅ 测试成功!API 调用正常。")
|
||||
log.Printf("响应内容: %s", string(body))
|
||||
} else {
|
||||
log.Println("❌ 测试失败!")
|
||||
log.Printf("错误响应: %s", string(body))
|
||||
if resp.StatusCode == 401 {
|
||||
log.Println("原因分析: 401 Unauthorized 通常表示:")
|
||||
log.Println("1. Key ID 或 Issuer ID 错误")
|
||||
log.Println("2. Bundle ID 不匹配")
|
||||
log.Println("3. 私钥错误")
|
||||
log.Println("4. Token 格式错误 (如算法或 Claims)")
|
||||
} else if resp.StatusCode == 404 {
|
||||
log.Println("原因分析: 404 Not Found 通常表示 Transaction ID 不存在或环境(沙盒/生产)选错了")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下面是复制过来的工具函数
|
||||
func buildAPIToken() (string, error) {
|
||||
header := map[string]interface{}{
|
||||
"alg": "ES256",
|
||||
"kid": KeyID,
|
||||
"typ": "JWT",
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
payload := map[string]interface{}{
|
||||
"iss": IssuerID,
|
||||
"iat": now,
|
||||
"exp": now + 60, // 测试 Token 有效期短一点即可
|
||||
"aud": "appstoreconnect-v1",
|
||||
}
|
||||
if BundleID != "" {
|
||||
payload["bid"] = BundleID
|
||||
}
|
||||
|
||||
hb, _ := json.Marshal(header)
|
||||
pb, _ := json.Marshal(payload)
|
||||
|
||||
enc := func(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
unsigned := fmt.Sprintf("%s.%s", enc(hb), enc(pb))
|
||||
|
||||
block, _ := pem.Decode([]byte(PrivateKeyPEM))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("invalid private key")
|
||||
}
|
||||
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
priv, ok := keyAny.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("private key is not ECDSA")
|
||||
}
|
||||
|
||||
digest := sha256Sum([]byte(unsigned))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, priv, digest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
curveBits := priv.Curve.Params().BitSize
|
||||
keyBytes := curveBits / 8
|
||||
if curveBits%8 > 0 {
|
||||
keyBytes += 1
|
||||
}
|
||||
rBytes := r.Bytes()
|
||||
rBytesPadded := make([]byte, keyBytes)
|
||||
copy(rBytesPadded[keyBytes-len(rBytes):], rBytes)
|
||||
|
||||
sBytes := s.Bytes()
|
||||
sBytesPadded := make([]byte, keyBytes)
|
||||
copy(sBytesPadded[keyBytes-len(sBytes):], sBytes)
|
||||
|
||||
sig := append(rBytesPadded, sBytesPadded...)
|
||||
return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
func sha256Sum(b []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(b)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pkgaes "github.com/perfect-panel/server/pkg/aes"
|
||||
)
|
||||
|
||||
// 替换为您实际的服务器地址
|
||||
const BaseURL = "https://api.hifast.biz"
|
||||
|
||||
// 替换为您实际的用户登录 Token (Authorization: Bearer <token>)
|
||||
const UserToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJEZXZpY2VJZCI6MzgzLCJMb2dpblR5cGUiOiJkZXZpY2UiLCJTZXNzaW9uSWQiOiIwMTliMmFmZC1jMjUwLTc1YmItODQzMy04NDMyNWVmZGRkMzMiLCJVc2VySWQiOjM4MywiZXhwIjoxNzY2NTU3NjMyLCJpYXQiOjE3NjU5NTI4MzJ9.kkcT4ojXG9qn_aVqMaGqUUXhHcZXHy49k5Vn05Et9OM"
|
||||
|
||||
// 替换为您在后台配置的设备通信密钥 (Security Secret)
|
||||
const DeviceSecret = "c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx"
|
||||
|
||||
// 替换为您要测试的 Transaction ID
|
||||
const TestTransactionID = "2000001083238483"
|
||||
|
||||
func main() {
|
||||
fmt.Println("开始测试 Restore 接口 (AES 加密模式)...")
|
||||
fmt.Printf("目标 Transaction ID: %s\n", TestTransactionID)
|
||||
|
||||
// 1. 构造原始请求数据
|
||||
payload := map[string]interface{}{
|
||||
"transactions": []string{TestTransactionID},
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
fmt.Printf("原始请求体: %s\n", string(payloadBytes))
|
||||
|
||||
// 2. 加密数据
|
||||
if DeviceSecret == "YOUR_DEVICE_SECRET_HERE" {
|
||||
log.Fatal("❌ 请在代码中设置 DeviceSecret (对应后台配置的 Security Secret)")
|
||||
}
|
||||
encryptedData, iv, err := pkgaes.Encrypt(payloadBytes, DeviceSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
|
||||
// 3. 构造最终的请求体 (符合 DeviceMiddleware 要求的格式)
|
||||
// DeviceMiddleware 期望的格式是: { "data": "Base64Cipher", "time": "Nonce/IV" }
|
||||
// 或者直接在 URL Query 中传 ?data=...&time=...
|
||||
// 这里我们模拟 POST JSON body 的方式
|
||||
finalPayload := map[string]interface{}{
|
||||
"data": encryptedData,
|
||||
"time": iv,
|
||||
}
|
||||
finalBytes, _ := json.Marshal(finalPayload)
|
||||
fmt.Printf("加密后请求体: %s\n", string(finalBytes))
|
||||
|
||||
url := fmt.Sprintf("%s/v1/public/iap/apple/restore", BaseURL)
|
||||
req, _ := http.NewRequest("POST", url, strings.NewReader(string(finalBytes)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 添加必要的 Header 以通过 DeviceMiddleware
|
||||
req.Header.Set("Login-Type", "device") // 触发 DeviceMiddleware 的解密逻辑
|
||||
// 注意:这里需要替换为真实有效的 Bearer Token,否则会报 401
|
||||
// 您可以先登录后台或者使用 cmd/test_apple_iap 工具生成的 token 也是不可用的,必须是业务系统的 token
|
||||
// 为了演示,这里留空,实际运行前请填入
|
||||
if UserToken != "YOUR_USER_TOKEN_HERE" {
|
||||
req.Header.Set("Authorization", "Bearer "+UserToken)
|
||||
} else {
|
||||
fmt.Println("⚠️ 警告: 未设置 UserToken,请求可能会失败 (401 Unauthorized)")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
duration := time.Since(start)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
fmt.Printf("请求耗时: %v\n", duration)
|
||||
fmt.Printf("状态码: %d\n", resp.StatusCode)
|
||||
fmt.Printf("响应内容: %s\n", string(body))
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err == nil {
|
||||
// 检查业务状态码
|
||||
if code, ok := result["code"].(float64); ok && int(code) != 200 {
|
||||
fmt.Printf("❌ 业务处理失败: code=%d, msg=%s\n", int(code), result["msg"])
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ Restore 接口调用成功!")
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
if success, ok := data["success"].(bool); ok && success {
|
||||
fmt.Println(" 业务处理成功: success=true")
|
||||
} else {
|
||||
fmt.Println(" 业务处理结果未知:", data)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" 无数据返回或格式不符")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("❌ 接口调用失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# 客户端错误上报接口文档
|
||||
|
||||
## 接口概览
|
||||
- 路径:`POST /v1/common/log/message/report`
|
||||
- 说明:APP/PC/Web 客户端错误与异常信息上报,服务端入库 `log_message` 表,并提供管理端查询。
|
||||
- 认证:无需登录;若走设备安全通道需使用 `Login-Type: device` 与 AES 加密。
|
||||
- 中间件:`DeviceMiddleware`(当请求头 `Login-Type=device` 时启用加解密)。
|
||||
|
||||
## 请求
|
||||
- Headers:
|
||||
- `Content-Type: application/json`
|
||||
- 可选:`Login-Type: device`(启用设备加密通道)
|
||||
- Body(JSON):
|
||||
- `platform` string 必填,客户端平台,如 `android`/`ios`/`windows`/`mac`/`web`
|
||||
- `appVersion` string 可选,应用版本,如 `2.4.1`
|
||||
- `osName` string 可选,系统名称,如 `Android`/`iOS`/`Windows`/`macOS`
|
||||
- `osVersion` string 可选,系统版本,如 `14`
|
||||
- `deviceId` string 可选,设备唯一标识
|
||||
- `sessionId` string 可选,会话标识
|
||||
- `level` uint8 可选,日志等级:`1=fatal`、`2=error`、`3=warn`、`4=info`(默认 `3`)
|
||||
- `errorCode` string 可选,业务或系统错误码
|
||||
- `message` string 必填,错误简述(服务器将超过约 64KB 的内容截断)
|
||||
- `stack` string 可选,堆栈信息(服务器将超过约 1MB 的内容截断)
|
||||
- `context` object 可选,扩展上下文(如接口路径、参数、网络状态等)
|
||||
- `occurredAt` int64 可选,客户端发生时间,毫秒时间戳
|
||||
|
||||
- 服务器侧自动填充:
|
||||
- `client_ip`、`user_agent`、`locale` 由请求解析
|
||||
- `user_id` 仅在鉴权后由服务端注入(本接口默认匿名)
|
||||
|
||||
## 加密通道(设备)
|
||||
- 当使用设备安全通道时(`Login-Type: device`),请求体需要将原始 JSON 加密为:
|
||||
- `data` string:AES 加密后的密文
|
||||
- `time` string:IV/nonce(与密文配套)
|
||||
- 服务端会自动解密为明文 JSON,再进行字段校验与入库。
|
||||
|
||||
## 响应
|
||||
- 成功:
|
||||
```
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "OK",
|
||||
"data": { "id": 123 }
|
||||
}
|
||||
```
|
||||
- 常见错误:
|
||||
- `{"code":401,"msg":"TooManyRequests"}` 触发速率限制(设备ID或IP维度)
|
||||
- `{"code":400,"msg":"InvalidParams"}` 参数校验失败(缺少必填或类型不符)
|
||||
- `{"code":10001,"msg":"DatabaseQueryError"}` 数据库操作异常
|
||||
|
||||
## 速率限制
|
||||
- 默认按 `deviceId` 或 `client_ip` 每分钟最多约 `120` 条,超限即返回 `TooManyRequests`。
|
||||
|
||||
## 去重策略
|
||||
- 服务端计算 `digest = sha256(message|stack|errorCode|appVersion|platform)` 并尝试唯一入库,重复日志可能返回已存在记录的 `id`。
|
||||
|
||||
## 示例
|
||||
- 明文上报(推荐测试使用):
|
||||
```
|
||||
curl -X POST http://localhost:8080/v1/common/log/message/report \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"platform": "android",
|
||||
"appVersion": "2.4.1",
|
||||
"osName": "Android",
|
||||
"osVersion": "14",
|
||||
"deviceId": "and-9a7f3e2c-01",
|
||||
"sessionId": "sess-73f8a2a4",
|
||||
"level": 2,
|
||||
"errorCode": "ORDER_RENEWAL_TIMEOUT",
|
||||
"message": "订单续费接口超时:/v1/public/order/renewal",
|
||||
"stack": "TimeoutException: request exceeded 8000ms\\n at HttpClient.post(HttpClient.kt:214)\\n at RenewalRepository.submit(RenewalRepository.kt:87)",
|
||||
"context": {
|
||||
"api": "/v1/public/order/renewal",
|
||||
"method": "POST",
|
||||
"endpoint": "https://api.example.com/v1/public/order/renewal",
|
||||
"httpStatus": 504,
|
||||
"responseTimeMs": 8123,
|
||||
"retryCount": 2,
|
||||
"network": { "type": "cellular", "carrier": "China Mobile" }
|
||||
},
|
||||
"occurredAt": 1733200005123
|
||||
}'
|
||||
```
|
||||
|
||||
- 设备加密上报(示意):
|
||||
```
|
||||
# 将原始JSON通过AES加密得到密文data与随机IV time
|
||||
curl -X POST http://localhost:8080/v1/common/log/message/report \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Login-Type: device' \
|
||||
-d '{"data":"<aes_cipher>","time":"<iv_nonce>"}'
|
||||
```
|
||||
|
||||
## 管理端查询(用于联调验证)
|
||||
- 列表:`GET /v1/admin/log/message/error/list`(需 `Authorization`)
|
||||
- 详情:`GET /v1/admin/log/message/error/detail?id=...`
|
||||
|
||||
## 备注
|
||||
- 字段尽量避免携带敏感信息(密码、密钥、完整令牌等);如需调试请截断或脱敏后上报。
|
||||
- 建议在 APP/PC 端统一封装上报模块,包含:字段收集、级别过滤、采样策略、离线缓存与重试、速率限制配合。
|
||||
@@ -159,3 +159,43 @@ Administer: # 管理员登录配置
|
||||
- **JWT**:为 `JwtAuth` 的 `AccessSecret` 设置强密钥以增强安全性。
|
||||
|
||||
如需进一步帮助,请参考 PPanel 官方文档或联系支持团队。
|
||||
|
||||
## 6. Apple IAP(非续期订阅)配置
|
||||
|
||||
- 通过 `Site.CustomData` 配置内购商品与权益映射,示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"iapProductMap": {
|
||||
"com.airport.vpn.pass.30d": {
|
||||
"description": "30天通行证",
|
||||
"priceText": "¥28.00",
|
||||
"durationDays": 30,
|
||||
"tier": "Basic",
|
||||
"subscribeId": 1001
|
||||
},
|
||||
"com.airport.vpn.pass.90d": {
|
||||
"description": "90天通行证",
|
||||
"priceText": "¥68.00",
|
||||
"durationDays": 90,
|
||||
"tier": "Pro",
|
||||
"subscribeId": 1002
|
||||
}
|
||||
},
|
||||
"iapBundleId": "co.airoport.app.ios"
|
||||
}
|
||||
```
|
||||
|
||||
- 字段说明:
|
||||
- `iapProductMap`:`productId → 映射`,用于后端计算到期时间与绑定内部计划(`subscribeId`)。
|
||||
- `description`/`priceText`:客户端展示文案。
|
||||
- `durationDays`:非续期订阅的有效天数。
|
||||
- `tier`:权益等级标签,用于状态返回。
|
||||
- `subscribeId`:绑定到现有 `subscribe` 计划 ID。
|
||||
- `iapBundleId`:客户端 Bundle ID(可用于后端基础校验)。
|
||||
|
||||
### 接口速览
|
||||
- `GET /v1/public/iap/apple/products`:返回可售商品与文案(基于 `iapProductMap`)。
|
||||
- `POST /v1/public/iap/apple/transactions/attach`:绑定一次购买到用户,入参 `signed_transaction_jws`。
|
||||
- `POST /v1/public/iap/apple/restore`:恢复历史购买(批量 JWS)。
|
||||
- `GET /v1/public/iap/apple/status`:返回用户的 IAP 权益状态与到期时间。
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
# iOS 内购接入与接口调用指南(StoreKit 2 + 服务端接口)
|
||||
|
||||
## 概述
|
||||
本指南面向 iOS App 开发者,说明使用 StoreKit 2 完成「非续期订阅/非消耗型」的购买、验证与绑定流程,并与后端接口打通,实现用户权益发放与恢复。
|
||||
|
||||
## 商品与映射
|
||||
- Apple 端必须在 App Store Connect 创建对应 `productId` 的内购商品(非续期订阅或非消耗型)。
|
||||
- 服务端维护「商品映射」:`productId → {durationDays, tier, subscribeId}`,用于计算到期与绑定内部订阅计划(`subscribeId`)。
|
||||
- 若某 `productId` 暂未在服务端配置,客户端可在绑定请求中携带回退字段:`duration_days`、`subscribe_id`、`tier`。服务端将按 App 的定义进行绑定。
|
||||
|
||||
## 客户端整体流程(StoreKit 2)
|
||||
1) 检查支付能力
|
||||
- `if !AppStore.canMakePayments { 隐藏商店并提示 }`
|
||||
2) 拉取商品
|
||||
- 通过已知 `productId` 列表调用 `Product.products(for:)`,展示价格与描述
|
||||
3) 发起购买并本地验证
|
||||
- 调用 `try await product.purchase()` 弹出系统确认表单
|
||||
- 成功后 `let transaction = try verification.payloadValue`,并取到 `transaction.signedData`(JWS)
|
||||
4) 绑定购买(服务端 attach)
|
||||
- 将 `signedData` 作为 `signed_transaction_jws` POST 至 `/v1/public/iap/apple/transactions/attach`
|
||||
- 若服务端未配置该 `productId`,同时携带:`duration_days`(有效天数)、`subscribe_id`(内部订阅计划 ID)、`tier`(展示用标签)
|
||||
5) 恢复购买(restore)
|
||||
- `try await AppStore.sync()` 后,遍历 `Transaction.currentEntitlements` 并逐条 `verify()`
|
||||
- 收集每条 `signedData`,批量 POST 至 `/v1/public/iap/apple/restore`
|
||||
6) 查询状态(status)
|
||||
- `GET /v1/public/iap/apple/status` 获取 `active/expires_at/tier`,用于 UI 展示与权限控制
|
||||
7) 退款入口(HIG 建议)
|
||||
- 在购买帮助页提供「请求退款」按钮,调用 `beginRefundRequest(for:in:)`
|
||||
|
||||
## 接口详细
|
||||
所有接口均需要携带用户登录态的 `Authorization: Bearer <token>`。
|
||||
- 绑定购买(attach)
|
||||
- `POST /v1/public/iap/apple/transactions/attach`
|
||||
- 请求体(映射一致时,仅需 `signed_transaction_jws`):
|
||||
```json
|
||||
{
|
||||
"signed_transaction_jws": "<StoreKit返回的signedData>"
|
||||
}
|
||||
```
|
||||
- 请求体(映射不一致时的回退):
|
||||
```json
|
||||
{
|
||||
"signed_transaction_jws": "<signedData>",
|
||||
"duration_days": 30,
|
||||
"subscribe_id": 1001,
|
||||
"tier": "Basic"
|
||||
}
|
||||
```
|
||||
- 响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": { "expires_at": 1736860000, "tier": "Basic" }
|
||||
}
|
||||
```
|
||||
- 恢复购买(restore)
|
||||
- `POST /v1/public/iap/apple/restore`
|
||||
- 请求体:
|
||||
```json
|
||||
{
|
||||
"transactions": ["<signedData-1>", "<signedData-2>"]
|
||||
}
|
||||
```
|
||||
- 响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": { "success": true }
|
||||
}
|
||||
```
|
||||
- 查询状态(status)
|
||||
- `GET /v1/public/iap/apple/status`
|
||||
- 响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": { "active": true, "expires_at": 1736860000, "tier": "Basic" }
|
||||
}
|
||||
```
|
||||
|
||||
## 收银台统一返回契约(含 Apple IAP)
|
||||
|
||||
- 统一下单接口返回的 `type` 用于客户端决定支付方式;当为 Apple IAP 时,返回 Apple 商品 ID 列表,前端直接用 StoreKit 购买:
|
||||
- Apple IAP 收银台返回示例(建议规范):
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"type": "apple_iap",
|
||||
"product_ids": [
|
||||
"merchant.hifastvpn.day7",
|
||||
"merchant.hifastvpn.day30"
|
||||
],
|
||||
"hint": "Use StoreKit to purchase the given product_ids, then POST signedData to attach."
|
||||
}
|
||||
}
|
||||
```
|
||||
- 其他支付平台保持原有结构:
|
||||
- Stripe:`{ "type":"stripe", "stripe": { "publishable_key": "...", "client_secret":"..." } }`
|
||||
- URL 跳转:`{ "type":"url", "checkout_url": "https://..." }`
|
||||
- 二维码:`{ "type":"qr", "checkout_url": "..." }`
|
||||
|
||||
### 前端处理逻辑(统一出口)
|
||||
- 收银台响应分流:
|
||||
- `type === "apple_iap"` → 取 `product_ids`,用 StoreKit 拉取并购买,成功后将 `signedData` 调用 `attach`
|
||||
- `type === "stripe"` → 初始化 Stripe 支付组件
|
||||
- `type === "url"` → 直接跳转到返回的 `checkout_url`
|
||||
- `type === "qr"` → 展示二维码URL
|
||||
|
||||
### 命名规则(推荐)
|
||||
- Apple 商品命名统一为:`merchant.hifastvpn.day${quantity}`,例如:
|
||||
- 7 天:`merchant.hifastvpn.day7`
|
||||
- 30 天:`merchant.hifastvpn.day30`
|
||||
- 90 天:`merchant.hifastvpn.day90`
|
||||
- 新增套餐时只需:在 App Store Connect 新增对应商品,并在 Web 后台/配置新增映射(`durationDays/tier/subscribeId`),前端无需改代码即可使用。
|
||||
|
||||
## Swift 示例
|
||||
|
||||
### 拉取商品与展示
|
||||
```swift
|
||||
import StoreKit
|
||||
|
||||
let productIds = ["com.airport.vpn.pass.30d", "com.airport.vpn.pass.90d"]
|
||||
let products = try await Product.products(for: productIds)
|
||||
// 展示 products 的价格与描述
|
||||
```
|
||||
|
||||
### 发起购买并绑定
|
||||
```swift
|
||||
import StoreKit
|
||||
|
||||
func purchaseAndAttach(product: Product, token: String) async throws {
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try verification.payloadValue
|
||||
let jws = transaction.signedData
|
||||
|
||||
struct AttachReq: Codable {
|
||||
let signed_transaction_jws: String
|
||||
// 若映射不一致,则补齐以下字段
|
||||
let duration_days: Int64?
|
||||
let subscribe_id: Int64?
|
||||
let tier: String?
|
||||
}
|
||||
let body = AttachReq(
|
||||
signed_transaction_jws: jws,
|
||||
duration_days: nil, // 映射一致时可为 nil
|
||||
subscribe_id: nil,
|
||||
tier: nil
|
||||
)
|
||||
var req = URLRequest(url: URL(string: "https://api.yourdomain.com/v1/public/iap/apple/transactions/attach")!)
|
||||
req.httpMethod = "POST"
|
||||
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: req)
|
||||
// 解析返回并更新 UI
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 恢复购买(批量)
|
||||
```swift
|
||||
import StoreKit
|
||||
|
||||
func restorePurchases(token: String) async throws {
|
||||
try await AppStore.sync()
|
||||
var signedDataList: [String] = []
|
||||
for await t in Transaction.currentEntitlements {
|
||||
let v = try t.verificationResult.payloadValue
|
||||
signedDataList.append(v.signedData)
|
||||
}
|
||||
struct RestoreReq: Codable { let transactions: [String] }
|
||||
let body = RestoreReq(transactions: signedDataList)
|
||||
|
||||
var req = URLRequest(url: URL(string: "https://api.yourdomain.com/v1/public/iap/apple/restore")!)
|
||||
req.httpMethod = "POST"
|
||||
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
let (_, _) = try await URLSession.shared.data(for: req)
|
||||
}
|
||||
```
|
||||
|
||||
### 查询状态
|
||||
```swift
|
||||
func fetchIAPStatus(token: String) async throws {
|
||||
var req = URLRequest(url: URL(string: "https://api.yourdomain.com/v1/public/iap/apple/status")!)
|
||||
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
let (data, _) = try await URLSession.shared.data(for: req)
|
||||
// 解析 active/expires_at/tier
|
||||
}
|
||||
```
|
||||
|
||||
### 退款入口(帮助页)
|
||||
```swift
|
||||
// 在 App 内购买帮助页面提供按钮,调用系统退款流程
|
||||
// try await beginRefundRequest(for: product, in: windowScene)
|
||||
```
|
||||
|
||||
## 错误处理建议
|
||||
- 绑定失败(`code != 200`)
|
||||
- 校验用户登录态、`signedData` 是否来自 `transaction.verify()` 的成功结果
|
||||
- 若提示 `unknown product`,请在请求体中按回退规范携带 `duration_days/subscribe_id/tier`
|
||||
- 网络与重试
|
||||
- 建议对 `attach/restore` 做有限重试与幂等保护(相同 `originalTransactionId` 不重复绑定)
|
||||
- 恢复失败
|
||||
- 确保已调用 `AppStore.sync()` 并遍历 `currentEntitlements`
|
||||
|
||||
## 调试与沙盒
|
||||
- 使用 Sandbox 账号进行测试;购买成功后调用 `attach → status`,验证到期与等级;再次 `restore → status` 验证幂等。
|
||||
- 建议打印 `request_id`(如有)便于后端排查。
|
||||
|
||||
## HIG 注意事项
|
||||
- 仅在可支付时显示商店;价格与文案清晰,不截断标题;使用系统确认表单,不自定义购买弹窗。
|
||||
- 在帮助页提供退款入口与说明,文案简洁直达。
|
||||
|
||||
## 常见问题
|
||||
- `productId` 不一致:服务端未配置某商品时,客户端按回退规范补齐时长与订阅 ID 即可绑定;建议后续让两端保持一致以减少维护成本。
|
||||
- 权益冲突:若用户同时存在多来源订阅,服务端按最高等级与最晚到期计算权益。
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# 项目加解密使用说明
|
||||
|
||||
本指南介绍了 PPanel Server 项目中使用的加解密机制,主要用于设备端(Device)通信的安全保障。
|
||||
|
||||
## 1. 核心算法
|
||||
项目使用 **AES-256-CBC** 加密算法。
|
||||
- **填充方式**:PKCS7 Padding。
|
||||
- **数据编码**:Base64。
|
||||
|
||||
## 2. 密钥(Key)与初始化向量(IV)生成逻辑
|
||||
|
||||
### 2.1 密钥生成 (Key Generation)
|
||||
密钥由一个预定义的 `SecuritySecret`(简称 Secret)生成:
|
||||
1. 对 Secret 进行 **SHA-256** 哈希。
|
||||
2. 取哈希结果的前 **32 字节** 作为 AES-256 的密钥。
|
||||
|
||||
### 2.2 初始化向量生成 (IV Generation)
|
||||
IV 是动态生成的,以增强安全性:
|
||||
1. 客户端或服务端生成一个随机字符串(Nonce,通常是纳秒级时间戳)。
|
||||
2. 对 Nonce 进行 **MD5** 哈希。
|
||||
3. 将 MD5 结果(十六进制字符串)与 Secret 拼接。
|
||||
4. 对拼接后的字符串按 2.1 节的方式生成密钥逻辑处理,取结果的前 **16 字节** 作为 IV。
|
||||
|
||||
> [!NOTE]
|
||||
> 在 API 通信中,Nonce 字符串通常通过请求参数中的 `time` 字段传递。
|
||||
|
||||
## 3. 身份识别与优先顺序
|
||||
|
||||
服务端通过 `Login-Type` 来识别是否需要进行加解密逻辑(值为 `device` 时触发)。
|
||||
|
||||
### 3.1 识别途径
|
||||
1. **Token 负载 (JWT Claims)**:Token 中包含 `LoginType` (值为 `device`) 和 `DeviceId`。
|
||||
2. **请求头 (Header)**:`Login-Type: device`。
|
||||
|
||||
### 3.2 优先顺序与场景
|
||||
- **已登录场景**:服务端优先从 **Token** 负载中读取 `LoginType`。如果 Token 合法且包含 `LoginType: device`,则启用加解密。
|
||||
- **未登录/登录中场景**:例如 `/v1/auth/login/device` 接口,由于此时没有有效 Token,服务端会检查 **Header** 中的 `Login-Type`。
|
||||
|
||||
> [!TIP]
|
||||
> 为了确保一致性,建议在设备端请求中**始终**携带 `Login-Type: device` 请求头,并在登录后确保存储的 Token 负载中也包含对应信息。
|
||||
|
||||
## 4. 中间件应用 (DeviceMiddleware)
|
||||
|
||||
|
||||
`DeviceMiddleware` 处理 `Login-Type: device` 的请求:
|
||||
- **请求解密**:
|
||||
- 检查 URL 参数或 JSON Body 中的 `data`(加密数据)和 `time`(Nonce)。
|
||||
- 使用配置的 Secret 和 Nonce 解密 `data`。
|
||||
- 将解密后的 JSON 重新注入到请求上下文中。
|
||||
- **响应加密**:
|
||||
- 拦截响应 Body。
|
||||
- 加密 Body 中的 `data` 字段。
|
||||
- 将响应格式化为:
|
||||
```json
|
||||
{
|
||||
"data": "ENCRYPTED_BASE64_STRING",
|
||||
"time": "NONCE_STRING"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Token 负载详情 (JWT Payload)
|
||||
当 `Login-Type` 为 `device` 时,JWT Token 会包含以下自定义字段:
|
||||
- `LoginType`: `"device"`
|
||||
- `DeviceId`: 设备的数据库唯一 ID。
|
||||
|
||||
## 6. 代码示例
|
||||
|
||||
|
||||
### Go 语言 (服务端)
|
||||
参考 [pkg/aes/aes.go](file:///Users/Apple/vpn/ppanel-server/pkg/aes/aes.go)
|
||||
|
||||
```go
|
||||
import pkgaes "github.com/perfect-panel/server/pkg/aes"
|
||||
|
||||
// 加密
|
||||
encrypt, nonce, err := pkgaes.Encrypt([]byte("plain text"), secret)
|
||||
|
||||
// 解密
|
||||
decrypt, err := pkgaes.Decrypt(cipherText, secret, nonce)
|
||||
```
|
||||
|
||||
### JavaScript (客户端示例)
|
||||
使用 `crypto-js` 库:
|
||||
|
||||
```javascript
|
||||
const CryptoJS = require("crypto-js");
|
||||
|
||||
function getIv(nonce, secret) {
|
||||
const md5Nonce = CryptoJS.MD5(nonce).toString();
|
||||
const ivStr = md5Nonce + secret;
|
||||
const key = CryptoJS.SHA256(ivStr);
|
||||
return CryptoJS.enc.Hex.parse(key.toString().substring(0, 32));
|
||||
}
|
||||
|
||||
function getKey(secret) {
|
||||
const key = CryptoJS.SHA256(secret);
|
||||
return CryptoJS.enc.Hex.parse(key.toString().substring(0, 64));
|
||||
}
|
||||
|
||||
// 加密示例
|
||||
const key = getKey(secret);
|
||||
const iv = getIv(nonce, secret);
|
||||
const encrypted = CryptoJS.AES.encrypt("plain text", key, {
|
||||
iv: iv,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
console.log(encrypted.toString()); // Base64
|
||||
```
|
||||
|
||||
## 5. 安全建议
|
||||
- 请务必在配置文件中修改默认的 `SecuritySecret`。
|
||||
- 确保 `time` (Nonce) 在每次请求时都是唯一的,以防止重放攻击和频率分析。
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# 报错日志收集(log_message)
|
||||
|
||||
## 项目规划
|
||||
- 目标:新增 `log_message` 表与采集/查询接口,用于 APP / PC / Web 客户端错误日志收集与分析。
|
||||
- 范围:
|
||||
- 创建 MySQL 表与迁移脚本(02105)。
|
||||
- 新增客户端上报接口 `POST /v1/common/log/message/report`。
|
||||
- 新增管理端查询接口 `GET /v1/admin/log/message/error/list` 与 `GET /v1/admin/log/message/error/detail`。
|
||||
|
||||
## 实施方案
|
||||
- 表结构:见 `initialize/migrate/database/02120_log_message.up.sql`。
|
||||
- 模型:`internal/model/logmessage/`(实体、默认 CRUD、筛选)。
|
||||
- 路由:在 `internal/handler/routes.go` 注册公共与管理端路由。
|
||||
- 逻辑:
|
||||
- 上报逻辑:`internal/logic/common/logMessageReportLogic.go`(限流、指纹去重、入库)。
|
||||
- 管理查询:`internal/logic/admin/log/getErrorLogMessageListLogic.go`、`getErrorLogMessageDetailLogic.go`。
|
||||
- 类型:`internal/types/types.go` 新增请求/响应结构。
|
||||
- 安全:详细加解密逻辑见 [加解密说明文档.md](file:///Users/Apple/vpn/ppanel-server/doc/加解密说明文档.md)。
|
||||
|
||||
|
||||
## 进度记录
|
||||
- 2025-12-02:
|
||||
- 完成表与索引创建迁移文件。
|
||||
- 完成模型与服务注入。
|
||||
- 完成公共上报接口与限流、去重逻辑;编译验证通过。
|
||||
- 完成管理端列表与详情接口;编译验证通过。
|
||||
- 待办:根据运营需求调整限流阈值与日志保留策略。
|
||||
- 2026-01-08:
|
||||
- 完成「项目加解密使用说明」文档编写,涵盖 AES-256-CBC 实现及中间件逻辑。
|
||||
|
||||
|
||||
## 接口规范
|
||||
- 上报:`POST /v1/common/log/message/report`(详见 `doc/api/log_message_report.md`)
|
||||
- 管理端列表:`GET /v1/admin/log/message/error/list`
|
||||
- 筛选:platform、level、user_id、device_id、error_code、keyword、start、end;分页:page、size
|
||||
- 响应:`{ total, list }`
|
||||
- 管理端详情:`GET /v1/admin/log/message/error/detail?id=...`
|
||||
- 响应:完整字段
|
||||
|
||||
## 保留策略与安全
|
||||
- 限流:按设备/IP 每分钟 120 条(可配置)。
|
||||
- 隐私:避免采集敏感数据;服务端对大字段做长度限制与截断。
|
||||
@@ -0,0 +1,23 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:latest
|
||||
container_name: jaeger
|
||||
ports:
|
||||
- "16686:16686"
|
||||
- "4317:4317"
|
||||
- "4318:4318"
|
||||
environment:
|
||||
# - SPAN_STORAGE_TYPE=elasticsearch
|
||||
# - ES_SERVER_URLS=http://elasticsearch:9200
|
||||
- LOG_LEVEL=debug
|
||||
- COLLECTOR_OTLP_ENABLED=true
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.8'
|
||||
memory: 500M
|
||||
reservations:
|
||||
cpus: '0.05'
|
||||
memory: 200M
|
||||
@@ -0,0 +1,41 @@
|
||||
# 设备移出和邀请码优化 - 验收报告
|
||||
|
||||
## 修复内容回顾
|
||||
|
||||
### 1. 设备移出后未自动退出
|
||||
- **修复点 1**:在 `bindEmailWithVerificationLogic.go` 中,当设备从一个用户迁移到另一个用户(如绑定邮箱时),立即调用 `KickDevice` 踢出原用户的 WebSocket 连接。
|
||||
- **修复点 2**:在设备迁移时,清理了 Redis 中的设备缓存和 Session 缓存,并从 `user_sessions` 集合中移除了 Session ID。
|
||||
- **修复点 3**:在 `unbindDeviceLogic.go` 中,解绑设备时补充了 `user_sessions` 集合的清理逻辑,确保 Session 被完全移除。
|
||||
|
||||
### 2. 邀请码错误提示不友好
|
||||
- **修复点**:在 `bindInviteCodeLogic.go` 中,捕获 `gorm.ErrRecordNotFound` 错误,并返回错误码 `20009` (InviteCodeError) 和提示 "无邀请码"。
|
||||
|
||||
---
|
||||
|
||||
## 验证结果
|
||||
|
||||
### 自动化验证
|
||||
- [x] 代码编译通过 (`go build ./...`)
|
||||
- [x] 静态检查通过
|
||||
|
||||
### 场景验证(逻辑推演)
|
||||
|
||||
**场景 1:设备B绑定邮箱后被移除**
|
||||
1. 设备B绑定邮箱,执行迁移逻辑。
|
||||
2. `KickDevice(originalUserId, deviceIdentifier)` 被调用 -> 设备B的 WebSocket 连接断开。
|
||||
3. Redis 中 `device:identifier` 和 `session:id` 被删除 -> Token 失效。
|
||||
4. 用户在设备A上操作移除设备B -> `unbindDeviceLogic` 执行 -> 再次尝试踢出和清理(防御性)。
|
||||
5. **结果**:设备B立即离线且无法继续使用。
|
||||
|
||||
**场景 2:输入错误邀请码**
|
||||
1. 调用绑定接口, `FindOneByReferCode` 返回 `RecordNotFound`。
|
||||
2. 逻辑捕获错误,返回 `InviteCodeError`。
|
||||
3. **结果**:前端收到 20009 错误码和 "无邀请码" 提示。
|
||||
|
||||
---
|
||||
|
||||
## 遗留问题 / 注意事项
|
||||
- 无
|
||||
|
||||
## 结论
|
||||
修复已完成,符合预期。
|
||||
@@ -0,0 +1,160 @@
|
||||
# 设备管理系统 Bug 分析 - 最终确认版
|
||||
|
||||
## 场景还原
|
||||
|
||||
### 用户操作流程
|
||||
|
||||
1. **设备A** 最初通过设备登录(DeviceLogin),系统自动创建用户1 + 设备A记录
|
||||
2. **设备B** 最初也通过设备登录,系统自动创建用户2 + 设备B记录
|
||||
3. **设备A** 绑定邮箱 xxx@example.com,用户1变为"邮箱+设备"用户
|
||||
4. **设备B** 绑定**同一个邮箱** xxx@example.com
|
||||
- 系统发现邮箱已存在,执行设备转移
|
||||
- 设备B 从用户2迁移到用户1
|
||||
- 用户2被删除
|
||||
- 现在用户1拥有:设备A + 设备B + 邮箱认证
|
||||
|
||||
5. **在设备A上操作**,从设备列表移除设备B
|
||||
6. **问题**:设备B没有被踢下线,仍然能使用
|
||||
|
||||
---
|
||||
|
||||
## 数据流分析
|
||||
|
||||
### 绑定邮箱后的状态(第4步后)
|
||||
|
||||
```
|
||||
User 表:
|
||||
┌─────┬───────────────┐
|
||||
│ Id │ 用户1 │
|
||||
└─────┴───────────────┘
|
||||
|
||||
user_device 表:
|
||||
┌─────────────┬───────────┐
|
||||
│ Identifier │ UserId │
|
||||
├─────────────┼───────────┤
|
||||
│ device-a │ 用户1 │
|
||||
│ device-b │ 用户1 │ <- 设备B迁移到用户1
|
||||
└─────────────┴───────────┘
|
||||
|
||||
user_auth_methods 表:
|
||||
┌────────────┬────────────────┬───────────┐
|
||||
│ AuthType │ AuthIdentifier │ UserId │
|
||||
├────────────┼────────────────┼───────────┤
|
||||
│ device │ device-a │ 用户1 │
|
||||
│ device │ device-b │ 用户1 │
|
||||
│ email │ xxx@email.com │ 用户1 │
|
||||
└────────────┴────────────────┴───────────┘
|
||||
|
||||
DeviceManager (内存 WebSocket 连接):
|
||||
┌───────────────────────────────────────────────────┐
|
||||
│ userDevices sync.Map │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ 用户1 -> [Device{DeviceID="device-a", ...}] │
|
||||
│ 用户2 -> [Device{DeviceID="device-b", ...}] ❌ │ <- 问题!设备B的连接仍在用户2名下
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 问题根源
|
||||
|
||||
**设备B绑定邮箱时**(`bindEmailWithVerificationLogic.go`):
|
||||
- ✅ 数据库:设备B的 `UserId` 被更新为用户1
|
||||
- ❌ 内存:`DeviceManager` 中设备B的 WebSocket 连接仍然在**用户2**名下
|
||||
- ❌ 缓存:`device:device-b` -> 旧的 sessionId(可能关联用户2)
|
||||
|
||||
**解绑设备B时**(`unbindDeviceLogic.go`):
|
||||
```go
|
||||
// 第 48 行:验证设备属于当前用户
|
||||
if device.UserId != u.Id { // device.UserId=用户1, u.Id=用户1, 验证通过
|
||||
return errors.Wrapf(...)
|
||||
}
|
||||
|
||||
// 第 123 行:踢出设备
|
||||
l.svcCtx.DeviceManager.KickDevice(u.Id, identifier)
|
||||
// KickDevice(用户1, "device-b")
|
||||
```
|
||||
|
||||
**KickDevice 执行时**:
|
||||
```go
|
||||
func (dm *DeviceManager) KickDevice(userID int64, deviceID string) {
|
||||
val, ok := dm.userDevices.Load(userID) // 查找用户1的设备列表
|
||||
// 用户1的设备列表只有 device-a
|
||||
// 找不到 device-b!因为 device-b 的连接还在用户2名下
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 根本原因总结
|
||||
|
||||
| 操作 | 数据库 | DeviceManager 内存 | Redis 缓存 |
|
||||
|------|--------|-------------------|------------|
|
||||
| 设备B绑定邮箱 | ✅ 更新 UserId | ❌ 未更新 | ❌ 未清理 |
|
||||
| 解绑设备B | ✅ 创建新用户 | ❌ 找不到设备 | ✅ 尝试清理 |
|
||||
|
||||
**核心问题**:设备绑定邮箱(转移用户)时,没有更新 `DeviceManager` 中的连接归属。
|
||||
|
||||
---
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 方案1:在绑定邮箱时踢出旧连接(推荐)
|
||||
|
||||
在 `bindEmailWithVerificationLogic.go` 迁移设备后,踢出设备的旧连接:
|
||||
|
||||
```go
|
||||
// 迁移设备到邮箱用户后
|
||||
for _, device := range devices {
|
||||
// 更新设备归属
|
||||
device.UserId = emailUserId
|
||||
err = l.svcCtx.UserModel.UpdateDevice(l.ctx, device)
|
||||
|
||||
// 新增:踢出旧连接(使用原用户ID)
|
||||
l.svcCtx.DeviceManager.KickDevice(u.Id, device.Identifier)
|
||||
}
|
||||
```
|
||||
|
||||
### 方案2:在解绑时遍历所有用户查找设备
|
||||
|
||||
修改 `KickDevice` 或 `unbindDeviceLogic` 逻辑,不依赖用户ID查找设备。
|
||||
|
||||
### 方案3:清理 Redis 缓存使旧 Token 失效
|
||||
|
||||
确保设备转移后,旧的 session 和 device 缓存被清理:
|
||||
|
||||
```go
|
||||
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, device.Identifier)
|
||||
if sessionId, _ := l.svcCtx.Redis.Get(ctx, deviceCacheKey).Result(); sessionId != "" {
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
l.svcCtx.Redis.Del(ctx, deviceCacheKey, sessionIdCacheKey)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 推荐修复策略
|
||||
|
||||
**双管齐下**:
|
||||
|
||||
1. **修复 `bindEmailWithVerificationLogic.go`**:
|
||||
- 设备转移后立即踢出旧连接
|
||||
- 清理旧用户的缓存
|
||||
|
||||
2. **修复 `unbindDeviceLogic.go`**(防御性编程):
|
||||
- 补充 `user_sessions` 清理逻辑(参考 `deleteUserDeviceLogic.go`)
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|----------|
|
||||
| `internal/logic/public/user/bindEmailWithVerificationLogic.go` | 设备转移后踢出旧连接 |
|
||||
| `internal/logic/public/user/unbindDeviceLogic.go` | 补充 user_sessions 清理 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 设备B绑定邮箱后,设备B的旧连接被踢出
|
||||
2. 从设备A解绑设备B后,设备B立即被踢下线
|
||||
3. 设备B的 Token 失效,无法继续调用 API
|
||||
@@ -0,0 +1,117 @@
|
||||
# 设备移出和邀请码优化 - 共识文档(更新版)
|
||||
|
||||
## 需求概述
|
||||
|
||||
修复两个 Bug:
|
||||
1. **Bug 1**:设备B绑定邮箱后被从设备A移除,设备B没有被踢下线
|
||||
2. **Bug 2**:输入不存在的邀请码时,提示信息不友好
|
||||
|
||||
---
|
||||
|
||||
## Bug 1:设备移出后未自动退出
|
||||
|
||||
### 根本原因
|
||||
|
||||
设备B绑定邮箱(迁移到邮箱用户)时:
|
||||
- ✅ 数据库更新了设备的 `UserId`
|
||||
- ❌ `DeviceManager` 内存中设备B的 WebSocket 连接仍在**原用户**名下
|
||||
- ❌ Redis 缓存中设备B的 session 未被清理
|
||||
|
||||
解绑设备B时,`KickDevice(用户1, "device-b")` 在用户1的设备列表中找不到 device-b(因为连接还在原用户名下)。
|
||||
|
||||
### 修复方案
|
||||
|
||||
**文件1:`bindEmailWithVerificationLogic.go`**
|
||||
|
||||
在设备迁移后,踢出旧连接并清理缓存:
|
||||
|
||||
```go
|
||||
// 第 139-158 行之后添加
|
||||
for _, device := range devices {
|
||||
device.UserId = emailUserId
|
||||
err = l.svcCtx.UserModel.UpdateDevice(l.ctx, device)
|
||||
// ...existing code...
|
||||
|
||||
// 新增:踢出旧连接并清理缓存
|
||||
l.svcCtx.DeviceManager.KickDevice(u.Id, device.Identifier)
|
||||
|
||||
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, device.Identifier)
|
||||
if sessionId, _ := l.svcCtx.Redis.Get(l.ctx, deviceCacheKey).Result(); sessionId != "" {
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, deviceCacheKey).Err()
|
||||
_ = l.svcCtx.Redis.Del(l.ctx, sessionIdCacheKey).Err()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**文件2:`unbindDeviceLogic.go`**(防御性修复)
|
||||
|
||||
补充 `user_sessions` 清理逻辑,与 `deleteUserDeviceLogic.go` 保持一致:
|
||||
|
||||
```go
|
||||
// 第 118-122 行,补充 sessionsKey 清理
|
||||
if sessionId, rerr := l.svcCtx.Redis.Get(ctx, deviceCacheKey).Result(); rerr == nil && sessionId != "" {
|
||||
_ = l.svcCtx.Redis.Del(ctx, deviceCacheKey).Err()
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||
_ = l.svcCtx.Redis.Del(ctx, sessionIdCacheKey).Err()
|
||||
// 新增:清理 user_sessions
|
||||
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, device.UserId)
|
||||
_ = l.svcCtx.Redis.ZRem(ctx, sessionsKey, sessionId).Err()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bug 2:邀请码错误提示不友好
|
||||
|
||||
### 根本原因
|
||||
|
||||
`bindInviteCodeLogic.go` 中未区分"邀请码不存在"和"数据库错误"。
|
||||
|
||||
### 修复方案
|
||||
|
||||
```go
|
||||
// 第 44-47 行修改为
|
||||
referrer, err := l.svcCtx.UserModel.FindOneByReferCode(l.ctx, req.InviteCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCodeMsg(xerr.InviteCodeError, "无邀请码"), "invite code not found")
|
||||
}
|
||||
logger.WithContext(l.ctx).Error(err)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query referrer failed: %v", err.Error())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件汇总
|
||||
|
||||
| 文件 | 修改类型 | 优先级 |
|
||||
|------|----------|--------|
|
||||
| `internal/logic/public/user/bindEmailWithVerificationLogic.go` | 核心修复 | 高 |
|
||||
| `internal/logic/public/user/unbindDeviceLogic.go` | 防御性修复 | 中 |
|
||||
| `internal/logic/public/user/bindInviteCodeLogic.go` | Bug 修复 | 中 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
### Bug 1 验收
|
||||
- [ ] 设备B绑定邮箱后,设备B的旧 Token 失效
|
||||
- [ ] 设备B绑定邮箱后,设备B的 WebSocket 连接被断开
|
||||
- [ ] 在设备A上移除设备B后,设备B立即被踢下线
|
||||
- [ ] 设备B无法继续使用旧 Token 调用 API
|
||||
|
||||
### Bug 2 验收
|
||||
- [ ] 输入不存在的邀请码时,返回错误码 20009
|
||||
- [ ] 错误消息显示"无邀请码"
|
||||
|
||||
---
|
||||
|
||||
## 验证计划
|
||||
|
||||
1. **编译验证**:`go build ./...`
|
||||
2. **手动测试**:
|
||||
- 设备B绑定邮箱 → 检查是否被踢下线
|
||||
- 设备A移除设备B → 检查设备B是否被踢下线
|
||||
- 输入无效邀请码 → 检查错误提示
|
||||
@@ -0,0 +1,96 @@
|
||||
# 设备移出和邀请码优化 - 设计文档
|
||||
|
||||
## 整体架构
|
||||
|
||||
本次修复涉及两个独立的 bug,不需要修改架构,只需要修改具体的业务逻辑层代码。
|
||||
|
||||
### 组件关系图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "用户请求"
|
||||
A[客户端] --> B[HTTP Handler]
|
||||
end
|
||||
|
||||
subgraph "业务逻辑层"
|
||||
B --> C[unbindDeviceLogic]
|
||||
B --> D[bindInviteCodeLogic]
|
||||
end
|
||||
|
||||
subgraph "服务层"
|
||||
C --> E[DeviceManager.KickDevice]
|
||||
D --> F[UserModel.FindOneByReferCode]
|
||||
end
|
||||
|
||||
subgraph "数据层"
|
||||
E --> G[WebSocket连接管理]
|
||||
F --> H[GORM/数据库]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模块详细设计
|
||||
|
||||
### 模块1: UnbindDeviceLogic 修复
|
||||
|
||||
#### 当前数据流
|
||||
```
|
||||
1. 用户请求解绑设备
|
||||
2. 验证设备属于当前用户 (device.UserId == u.Id) ✅
|
||||
3. 事务中:创建新用户,迁移设备
|
||||
4. 调用 KickDevice(u.Id, identifier) ❌ <-- 用户ID错误
|
||||
```
|
||||
|
||||
#### 修复后数据流
|
||||
```
|
||||
1. 用户请求解绑设备
|
||||
2. 验证设备属于当前用户 ✅
|
||||
3. 保存原始用户ID: originalUserId := device.UserId ✅
|
||||
4. 事务中:创建新用户,迁移设备
|
||||
5. 调用 KickDevice(originalUserId, identifier) ✅ <-- 使用正确的用户ID
|
||||
```
|
||||
|
||||
#### 接口契约
|
||||
无变化,仅修改内部实现。
|
||||
|
||||
---
|
||||
|
||||
### 模块2: BindInviteCodeLogic 修复
|
||||
|
||||
#### 当前错误处理
|
||||
```go
|
||||
if err != nil {
|
||||
return xerr.DatabaseQueryError // 所有错误统一处理
|
||||
}
|
||||
```
|
||||
|
||||
#### 修复后错误处理
|
||||
```go
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return xerr.InviteCodeError("无邀请码") // 记录不存在 → 友好提示
|
||||
}
|
||||
return xerr.DatabaseQueryError // 其他错误保持原样
|
||||
}
|
||||
```
|
||||
|
||||
#### 接口契约
|
||||
API 返回格式不变,但错误码从 `10001` 变为 `20009`(针对邀请码不存在的情况)。
|
||||
|
||||
---
|
||||
|
||||
## 异常处理策略
|
||||
|
||||
| 场景 | 错误码 | 错误消息 |
|
||||
|------|--------|----------|
|
||||
| 邀请码不存在 | 20009 | 无邀请码 |
|
||||
| 数据库查询错误 | 10001 | Database query error |
|
||||
| 绑定自己的邀请码 | 20009 | 不允许绑定自己 |
|
||||
|
||||
---
|
||||
|
||||
## 设计原则
|
||||
1. **最小改动原则**:只修改必要的代码,不重构现有逻辑
|
||||
2. **向后兼容**:不改变 API 接口定义
|
||||
3. **代码风格一致**:遵循项目现有的错误处理模式
|
||||
@@ -0,0 +1,20 @@
|
||||
# 设备移出和邀请码优化 - 项目总结
|
||||
|
||||
## 项目概览
|
||||
本次任务修复了两个影响用户体验的 Bug:
|
||||
1. 设备绑定邮箱后,从设备列表移除时未自动退出。
|
||||
2. 绑定无效邀请码时,错误提示不友好。
|
||||
|
||||
## 关键变更
|
||||
1. **核心修复**:在设备归属转移(绑定邮箱)时,主动踢出原用户的 WebSocket 连接,防止“幽灵连接”存在。
|
||||
2. **安全增强**:在设备解绑和转移时,彻底清理 Redis 中的 Session 缓存(包括 `user_sessions` 集合)。
|
||||
3. **体验优化**:优化了邀请码验证的错误提示,明确告知用户“无邀请码”。
|
||||
|
||||
## 文件变更列表
|
||||
- `internal/logic/public/user/bindEmailWithVerificationLogic.go`
|
||||
- `internal/logic/public/user/unbindDeviceLogic.go`
|
||||
- `internal/logic/public/user/bindInviteCodeLogic.go`
|
||||
|
||||
## 后续建议
|
||||
- 建议在测试环境中重点测试多端登录和设备绑定的边界情况。
|
||||
- 关注 `DeviceManager` 的内存使用情况,确保大量的踢出操作不会造成锁竞争。
|
||||
@@ -0,0 +1,91 @@
|
||||
# 设备移出和邀请码优化 - 任务清单
|
||||
|
||||
## 任务依赖图
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[任务1: 修复设备踢出Bug] --> C[任务3: 编译验证]
|
||||
B[任务2: 修复邀请码提示Bug] --> C
|
||||
C --> D[任务4: 更新文档]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 原子任务列表
|
||||
|
||||
### 任务1: 修复设备解绑后未踢出的问题
|
||||
|
||||
**输入契约**:
|
||||
- 文件:`internal/logic/public/user/unbindDeviceLogic.go`
|
||||
- 当前代码行:第 123 行
|
||||
|
||||
**输出契约**:
|
||||
- 在事务执行前保存 `device.UserId`
|
||||
- 修改 `KickDevice` 调用,使用保存的原始用户ID
|
||||
|
||||
**实现约束**:
|
||||
- 不修改方法签名
|
||||
- 不影响事务逻辑
|
||||
|
||||
**验收标准**:
|
||||
- [x] 代码编译通过
|
||||
- [ ] 解绑设备后,被解绑设备收到踢出消息
|
||||
|
||||
**预估复杂度**:低
|
||||
|
||||
---
|
||||
|
||||
### 任务2: 修复邀请码错误提示不友好的问题
|
||||
|
||||
**输入契约**:
|
||||
- 文件:`internal/logic/public/user/bindInviteCodeLogic.go`
|
||||
- 当前代码行:第 44-47 行
|
||||
|
||||
**输出契约**:
|
||||
- 添加 `gorm.ErrRecordNotFound` 判断
|
||||
- 返回友好的错误消息 "无邀请码"
|
||||
- 使用 `xerr.InviteCodeError` 错误码
|
||||
|
||||
**实现约束**:
|
||||
- 保持与其他模块(如 `userRegisterLogic`)的错误处理风格一致
|
||||
- 需要添加 `gorm.io/gorm` 导入
|
||||
|
||||
**验收标准**:
|
||||
- [x] 代码编译通过
|
||||
- [ ] 输入不存在的邀请码时返回 "无邀请码" 提示
|
||||
|
||||
**预估复杂度**:低
|
||||
|
||||
---
|
||||
|
||||
### 任务3: 编译验证
|
||||
|
||||
**输入契约**:
|
||||
- 任务1和任务2已完成
|
||||
|
||||
**输出契约**:
|
||||
- 项目编译成功,无错误
|
||||
|
||||
**验收标准**:
|
||||
- [x] `go build ./...` 无报错
|
||||
|
||||
---
|
||||
|
||||
### 任务4: 更新说明文档
|
||||
|
||||
**输入契约**:
|
||||
- 任务3已完成
|
||||
|
||||
**输出契约**:
|
||||
- 更新 `说明文档.md` 记录本次修复
|
||||
|
||||
**验收标准**:
|
||||
- [x] 文档记录完整
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. ✅ 任务1 和 任务2 可并行执行(无依赖)
|
||||
2. ✅ 任务3 在任务1、2完成后执行
|
||||
3. ✅ 任务4 最后执行
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
第一个文件
|
||||
server {
|
||||
listen 80;
|
||||
server_name airoport.org www.airoport.org api.airoport.win;
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name airoport.org;
|
||||
ssl_certificate /etc/letsencrypt/live/airoport.org/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/airoport.org/privkey.pem;
|
||||
return 301 https://airoport.co$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name www.airoport.org;
|
||||
ssl_certificate /etc/letsencrypt/live/www.airoport.org/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/www.airoport.org/privkey.pem;
|
||||
return 301 https://www.airoport.co$request_uri;
|
||||
}
|
||||
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.airoport.win;
|
||||
client_max_body_size 150M;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/api.airoport.win/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.airoport.win/privkey.pem;
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
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 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_next_upstream timeout;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name xqwbmzy8.de99e242.airoport.org;
|
||||
client_max_body_size 150M;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/xqwbmzy8.de99e242.airoport.org/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/xqwbmzy8.de99e242.airoport.org/privkey.pem;
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
# Gzip压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json image/svg+xml;
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
proxy_pass http://localhost8315:3001;
|
||||
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;
|
||||
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
location ^~ / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
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 REMOTE-HOST $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_http_version 1.1;
|
||||
add_header X-Cache $upstream_cache_status;
|
||||
add_header Cache-Control no-cache;
|
||||
proxy_ssl_server_name off;
|
||||
proxy_ssl_name $proxy_host;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
第二个文件:
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name airoport.co www.airoport.co api.airoport.co de99e242.airoport.co xqwbmzy8.de99e242.airoport.co api.airoport.win;
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
# 主域名和www指向3002 (用户界面)
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name airoport.co;
|
||||
client_max_body_size 150M;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/airoport.co-0003/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/airoport.co-0003/privkey.pem;
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
location /md {
|
||||
alias /var/www/md/;
|
||||
# 启用目录浏览
|
||||
autoindex on;
|
||||
autoindex_exact_size off;
|
||||
autoindex_localtime on;
|
||||
# 设置默认文档为README.md
|
||||
index README.md;
|
||||
# 跨域处理
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
# 处理OPTIONS预检请求
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
add_header Content-Length 0;
|
||||
add_header Content-Type text/plain;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# 处理.md文件
|
||||
location ~* \.md$ {
|
||||
add_header Content-Type "text/markdown; charset=utf-8";
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
|
||||
# 跨域处理
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
}
|
||||
|
||||
# 静态资源缓存优化
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
# 跨域处理
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
}
|
||||
}
|
||||
|
||||
location ^~ / {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
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 REMOTE-HOST $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_http_version 1.1;
|
||||
add_header X-Cache $upstream_cache_status;
|
||||
add_header Cache-Control no-cache;
|
||||
proxy_ssl_server_name off;
|
||||
proxy_ssl_name $proxy_host;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name www.airoport.co;
|
||||
client_max_body_size 150M;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/www.airoport.co/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/www.airoport.co/privkey.pem;
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
location ^~ / {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
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 REMOTE-HOST $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_http_version 1.1;
|
||||
add_header X-Cache $upstream_cache_status;
|
||||
add_header Cache-Control no-cache;
|
||||
proxy_ssl_server_name off;
|
||||
proxy_ssl_name $proxy_host;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.airoport.co;
|
||||
client_max_body_size 150M;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/api.airoport.co/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.airoport.co/privkey.pem;
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.airoport.win;
|
||||
client_max_body_size 150M;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/api.airoport.win/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/api.airoport.win/privkey.pem;
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
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_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_next_upstream timeout;
|
||||
}
|
||||
}
|
||||
# de99e242子域名指向3001 (管理界面)
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name xqwbmzy8.de99e242.airoport.co;
|
||||
client_max_body_size 150M;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/xqwbmzy8.de99e242.airoport.co/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/xqwbmzy8.de99e242.airoport.co/privkey.pem;
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
# Gzip压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json image/svg+xml;
|
||||
|
||||
location ^~ / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
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 REMOTE-HOST $remote_addr;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_http_version 1.1;
|
||||
add_header X-Cache $upstream_cache_status;
|
||||
add_header Cache-Control no-cache;
|
||||
proxy_ssl_server_name off;
|
||||
proxy_ssl_name $proxy_host;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ require (
|
||||
github.com/alibabacloud-go/darabonba-openapi v0.1.18
|
||||
github.com/alibabacloud-go/dysmsapi-20170525/v2 v2.0.18
|
||||
github.com/alibabacloud-go/tea v1.2.2
|
||||
github.com/alicebob/miniredis/v2 v2.34.0
|
||||
github.com/alicebob/miniredis/v2 v2.35.0
|
||||
github.com/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/forgoer/openssl v1.6.0
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1
|
||||
github.com/go-playground/validator/v10 v10.24.0
|
||||
github.com/go-resty/resty/v2 v2.15.3
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/go-sql-driver/mysql v1.9.0
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/gofrs/uuid/v5 v5.3.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
@@ -25,14 +25,14 @@ require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hibiken/asynq v0.24.1
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/klauspost/compress v1.17.7
|
||||
github.com/klauspost/compress v1.17.11
|
||||
github.com/nyaruka/phonenumbers v1.5.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/redis/go-redis/v9 v9.7.2
|
||||
github.com/redis/go-redis/v9 v9.14.0
|
||||
github.com/smartwalle/alipay/v3 v3.2.23
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/stripe/stripe-go/v81 v81.1.0
|
||||
github.com/twilio/twilio-go v1.23.11
|
||||
go.opentelemetry.io/otel v1.29.0
|
||||
@@ -46,7 +46,7 @@ require (
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.35.0
|
||||
golang.org/x/oauth2 v0.25.0
|
||||
golang.org/x/time v0.6.0
|
||||
golang.org/x/time v0.10.0
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
@@ -60,10 +60,10 @@ require (
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/goccy/go-json v0.10.4
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/oschwald/geoip2-golang v1.13.0
|
||||
github.com/spaolacci/murmur3 v1.1.0
|
||||
google.golang.org/grpc v1.64.1
|
||||
google.golang.org/protobuf v1.36.3
|
||||
google.golang.org/grpc v1.65.0
|
||||
google.golang.org/protobuf v1.36.5
|
||||
gorm.io/driver/sqlite v1.4.4
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -79,12 +79,11 @@ require (
|
||||
github.com/alibabacloud-go/tea-utils v1.4.5 // indirect
|
||||
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3 // indirect
|
||||
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect
|
||||
github.com/aliyun/credentials-go v1.3.10 // indirect
|
||||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
|
||||
github.com/bytedance/sonic v1.12.7 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj/v2 v2.5.6 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
@@ -94,7 +93,7 @@ require (
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang/glog v1.2.0 // indirect
|
||||
github.com/golang/glog v1.2.1 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gomodule/redigo v2.0.0+incompatible // indirect
|
||||
@@ -113,12 +112,12 @@ require (
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/openzipkin/zipkin-go v0.4.2 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||
github.com/openzipkin/zipkin-go v0.4.3 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
@@ -135,15 +134,15 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.13.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
)
|
||||
|
||||
@@ -52,10 +52,8 @@ github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/
|
||||
github.com/alibabacloud-go/tea-xml v1.1.2/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
|
||||
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
|
||||
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE=
|
||||
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
||||
github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQggTglU/0=
|
||||
github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
|
||||
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
|
||||
github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA=
|
||||
@@ -78,8 +76,8 @@ github.com/bytedance/sonic v1.12.7/go.mod h1:tnbal4mxOMju17EGfknm2XyYcpyCnIROYOE
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
||||
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -144,8 +142,8 @@ github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1
|
||||
github.com/go-resty/resty/v2 v2.15.3 h1:bqff+hcqAflpiF591hhJzNdkRsFhlB96CYfBwSFvql8=
|
||||
github.com/go-resty/resty/v2 v2.15.3/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
|
||||
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
@@ -160,8 +158,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68=
|
||||
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/glog v1.2.1 h1:OptwRhECazUx5ix5TTWC3EZhsZEHWcYWY4FQHTIubm4=
|
||||
github.com/golang/glog v1.2.1/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
@@ -232,8 +230,8 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
|
||||
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
@@ -257,6 +255,7 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
@@ -283,12 +282,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/openzipkin/zipkin-go v0.4.2 h1:zjqfqHjUpPmB3c1GlCvvgsM1G4LkvqQbBDueDOCg/jA=
|
||||
github.com/openzipkin/zipkin-go v0.4.2/go.mod h1:ZeVkFjuuBiSy13y8vpSDCjMi9GoI3hPpCJSBx/EYFhY=
|
||||
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
|
||||
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
|
||||
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
|
||||
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -298,8 +293,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/redis/go-redis/v9 v9.0.3/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
|
||||
github.com/redis/go-redis/v9 v9.7.2 h1:PSGhv13dJyrTCw1+55H0pIKM3WFov7HuUrKUmInGL0o=
|
||||
github.com/redis/go-redis/v9 v9.7.2/go.mod h1:yp5+a5FnEEP0/zTYuw6u6/2nn3zivwhv274qYgWQhDM=
|
||||
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
|
||||
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
@@ -342,8 +337,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v81 v81.1.0 h1:OlpGPO2vhS2raLR/NuvHKeRUZ57FTkdZBTcd5Hhoyos=
|
||||
github.com/stripe/stripe-go/v81 v81.1.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
|
||||
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
|
||||
@@ -387,8 +383,8 @@ go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt3
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.12/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
@@ -435,8 +431,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
@@ -485,8 +481,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -507,16 +503,16 @@ google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9Ywl
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 h1:W5Xj/70xIA4x60O/IFyXivR5MGqblAb8R3w26pnD6No=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8/go.mod h1:vPrPUTsDCYxXWjP7clS81mZ6/803D8K4iM9Ma27VKas=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 h1:mxSlqyb8ZAHsYDCfiXN1EDdNTdvjUJSLY+OnAUtYNYA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8/go.mod h1:I7Y+G38R2bu5j1aLzfFmQfTcU/WnFuqDwLZAbvKTKpM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
|
||||
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
|
||||
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
|
||||
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
@@ -525,8 +521,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -552,6 +548,7 @@ gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
|
||||
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
|
||||
+1
-22
@@ -9,7 +9,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/perfect-panel/server/internal/report"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/driver/mysql"
|
||||
|
||||
@@ -36,30 +35,10 @@ func Config(path string) (chan bool, *http.Server) {
|
||||
configPath = path
|
||||
// Create a new Gin instance
|
||||
r := gin.Default()
|
||||
// get server port
|
||||
port := 8080
|
||||
host := "127.0.0.1"
|
||||
|
||||
// check gateway mode
|
||||
if report.IsGatewayMode() {
|
||||
// get free port
|
||||
freePort, err := report.ModulePort()
|
||||
if err != nil {
|
||||
logger.Errorf("get module port error: %s", err.Error())
|
||||
panic(err)
|
||||
}
|
||||
port = freePort
|
||||
// register module
|
||||
err = report.RegisterModule(port)
|
||||
if err != nil {
|
||||
logger.Errorf("register module error: %s", err.Error())
|
||||
panic(err)
|
||||
}
|
||||
logger.Infof("module registered on port %d", port)
|
||||
}
|
||||
// Create a new HTTP server
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", host, port),
|
||||
Addr: ":8080",
|
||||
Handler: r,
|
||||
}
|
||||
// Load templates
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package initialize
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
)
|
||||
|
||||
func Currency(ctx *svc.ServiceContext) {
|
||||
// Retrieve system currency configuration
|
||||
currency, err := ctx.SystemModel.GetCurrencyConfig(context.Background())
|
||||
if err != nil {
|
||||
logger.Errorf("[INIT] Failed to get currency configuration: %v", err.Error())
|
||||
panic(fmt.Sprintf("[INIT] Failed to get currency configuration: %v", err.Error()))
|
||||
}
|
||||
// Parse currency configuration
|
||||
configs := struct {
|
||||
CurrencyUnit string
|
||||
CurrencySymbol string
|
||||
AccessKey string
|
||||
}{}
|
||||
tool.SystemConfigSliceReflectToStruct(currency, &configs)
|
||||
ctx.ExchangeRate = 0 // Default exchange rate to 0
|
||||
ctx.Config.Currency = config.Currency{
|
||||
Unit: configs.CurrencyUnit,
|
||||
Symbol: configs.CurrencySymbol,
|
||||
AccessKey: configs.AccessKey,
|
||||
}
|
||||
logger.Infof("[INIT] Currency configuration: %v", ctx.Config.Currency)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ func StartInitSystemConfig(svc *svc.ServiceContext) {
|
||||
Subscribe(svc)
|
||||
Register(svc)
|
||||
Mobile(svc)
|
||||
Currency(svc)
|
||||
if !svc.Config.Debug {
|
||||
Telegram(svc)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ VALUES (1, 'site', 'SiteLogo', '/favicon.svg', 'string', 'Site Logo', '2025-04-2
|
||||
'2025-04-22 14:25:16.641'),
|
||||
(37, 'currency', 'AccessKey', '', 'string', 'Exchangerate Access Key', '2025-04-22 14:25:16.641',
|
||||
'2025-04-22 14:25:16.641'),
|
||||
(38, 'verify_code', 'VerifyCodeExpireTime', '300', 'int', 'Verify code expire time', '2025-04-22 14:25:16.641',
|
||||
(38, 'verify_code', 'VerifyCodeExpireTime', '900', 'int', 'Verify code expire time', '2025-04-22 14:25:16.641',
|
||||
'2025-04-22 14:25:16.641'),
|
||||
(39, 'verify_code', 'VerifyCodeLimit', '15', 'int', 'limits of verify code', '2025-04-22 14:25:16.641',
|
||||
'2025-04-22 14:25:16.641'),
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
ALTER TABLE traffic_log ADD INDEX idx_timestamp (timestamp);
|
||||
ALTER TABLE traffic_log ADD INDEX IF NOT EXISTS idx_timestamp (timestamp);
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Add longitude if not exists
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'servers'
|
||||
AND COLUMN_NAME = 'longitude'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `servers` ADD COLUMN `longitude` VARCHAR(255) DEFAULT '''' COMMENT ''longitude''',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Add latitude if not exists
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'servers'
|
||||
AND COLUMN_NAME = 'latitude'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `servers` ADD COLUMN `latitude` VARCHAR(255) DEFAULT '''' COMMENT ''latitude''',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Add longitude_center if not exists
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'servers'
|
||||
AND COLUMN_NAME = 'longitude_center'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `servers` ADD COLUMN `longitude_center` VARCHAR(255) DEFAULT '''' COMMENT ''longitude center''',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Add latitude_center if not exists
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'servers'
|
||||
AND COLUMN_NAME = 'latitude_center'
|
||||
);
|
||||
SET @sql := IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `servers` ADD COLUMN `latitude_center` VARCHAR(255) DEFAULT '''' COMMENT ''latitude center''',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `user_subscribe`
|
||||
DROP COLUMN `note`;
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE `user_subscribe`
|
||||
ADD COLUMN `note` VARCHAR(500) NOT NULL DEFAULT ''
|
||||
COMMENT 'User note for subscription'
|
||||
AFTER `status`;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS `log_message`;
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE `log_message` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`platform` VARCHAR(32) NOT NULL,
|
||||
`app_version` VARCHAR(32) NULL,
|
||||
`os_name` VARCHAR(32) NULL,
|
||||
`os_version` VARCHAR(32) NULL,
|
||||
`device_id` VARCHAR(64) NULL,
|
||||
`user_id` BIGINT NULL DEFAULT NULL,
|
||||
`session_id` VARCHAR(64) NULL,
|
||||
`level` TINYINT UNSIGNED NOT NULL DEFAULT 3,
|
||||
`error_code` VARCHAR(64) NULL,
|
||||
`message` TEXT NOT NULL,
|
||||
`stack` MEDIUMTEXT NULL,
|
||||
`context` JSON NULL,
|
||||
`client_ip` VARCHAR(45) NULL,
|
||||
`user_agent` VARCHAR(255) NULL,
|
||||
`locale` VARCHAR(16) NULL,
|
||||
`digest` VARCHAR(64) NULL,
|
||||
`occurred_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_digest` (`digest`),
|
||||
KEY `idx_platform_time` (`platform`, `created_at`),
|
||||
KEY `idx_user_time` (`user_id`, `created_at`),
|
||||
KEY `idx_device_time` (`device_id`, `created_at`),
|
||||
KEY `idx_error_code` (`error_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `user`
|
||||
DROP COLUMN IF EXISTS `rules`;
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE `user`
|
||||
ADD COLUMN `rules` TEXT NULL
|
||||
COMMENT 'User rules for subscription'
|
||||
AFTER `created_at`;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS `apple_iap_transactions`;
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS `apple_iap_transactions` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`user_id` bigint(20) NOT NULL COMMENT 'User ID',
|
||||
`original_transaction_id` varchar(255) NOT NULL COMMENT 'Original Transaction ID',
|
||||
`transaction_id` varchar(255) NOT NULL COMMENT 'Transaction ID',
|
||||
`product_id` varchar(255) NOT NULL COMMENT 'Product ID',
|
||||
`purchase_at` datetime NOT NULL COMMENT 'Purchase Time',
|
||||
`revocation_at` datetime DEFAULT NULL COMMENT 'Revocation Time',
|
||||
`jws_hash` varchar(255) NOT NULL COMMENT 'JWS Hash',
|
||||
`created_at` datetime DEFAULT NULL COMMENT 'Create Time',
|
||||
`updated_at` datetime DEFAULT NULL COMMENT 'Update Time',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uni_original` (`original_transaction_id`),
|
||||
KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
@@ -1,5 +0,0 @@
|
||||
DROP TABLE IF EXISTS `withdrawals`;
|
||||
|
||||
DELETE FROM `system`
|
||||
WHERE `category` = 'invite'
|
||||
AND `key` = 'WithdrawalMethod';
|
||||
@@ -1,16 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS `withdrawals` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Primary Key',
|
||||
`user_id` BIGINT NOT NULL COMMENT 'User ID',
|
||||
`amount` BIGINT NOT NULL COMMENT 'Withdrawal Amount',
|
||||
`content` TEXT COMMENT 'Withdrawal Content',
|
||||
`status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Withdrawal Status',
|
||||
`reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Rejection Reason',
|
||||
`created_at` DATETIME NOT NULL COMMENT 'Creation Time',
|
||||
`updated_at` DATETIME NOT NULL COMMENT 'Update Time',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
INSERT IGNORE INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
('invite', 'WithdrawalMethod', '', 'string', 'withdrawal method', '2025-04-22 14:25:16.637', '2025-04-22 14:25:16.637');
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE user DROP COLUMN last_login_time;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE user ADD COLUMN last_login_time DATETIME DEFAULT NULL COMMENT 'Last Login Time';
|
||||
@@ -1,27 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS `server`
|
||||
(
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Node Name',
|
||||
`tags` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Tags',
|
||||
`country` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Country',
|
||||
`city` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'City',
|
||||
`latitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'latitude',
|
||||
`longitude` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'longitude',
|
||||
`server_addr` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Server Address',
|
||||
`relay_mode` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'none' COMMENT 'Relay Mode',
|
||||
`relay_node` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Relay Node',
|
||||
`speed_limit` bigint NOT NULL DEFAULT '0' COMMENT 'Speed Limit',
|
||||
`traffic_ratio` decimal(4, 2) NOT NULL DEFAULT '0.00' COMMENT 'Traffic Ratio',
|
||||
`group_id` bigint DEFAULT NULL COMMENT 'Group ID',
|
||||
`protocol` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Protocol',
|
||||
`config` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Config',
|
||||
`enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'Enabled',
|
||||
`sort` bigint NOT NULL DEFAULT '0' COMMENT 'Sort',
|
||||
`last_reported_at` datetime(3) DEFAULT NULL COMMENT 'Last Reported Time',
|
||||
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
|
||||
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_group_id` (`group_id`)
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_general_ci;
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS `server`;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `subscribe`
|
||||
DROP COLUMN `show_original_price`;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE `subscribe`
|
||||
ADD COLUMN `show_original_price` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'display the original price: 0 not display, 1 display' AFTER `created_at`;
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS `server_group`;
|
||||
@@ -1,5 +0,0 @@
|
||||
|
||||
-- This migration script reverts the inventory values in the 'subscribe' table
|
||||
UPDATE `subscribe`
|
||||
SET `inventory` = 0
|
||||
WHERE `inventory` = -1;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Update the `subscribe` table to set `inventory` to -1 where it is currently 0
|
||||
UPDATE `subscribe`
|
||||
SET `inventory` = -1
|
||||
WHERE `inventory` = 0;
|
||||
+38
-17
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/logic/telegram"
|
||||
"github.com/perfect-panel/server/internal/model/auth"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@@ -15,33 +14,51 @@ import (
|
||||
)
|
||||
|
||||
func Telegram(svc *svc.ServiceContext) {
|
||||
logger.Infof("Telegram Config Enable: %v", svc.Config.Telegram.Enable)
|
||||
if !svc.Config.Telegram.Enable {
|
||||
logger.Info("Telegram disabled, skipping initialization")
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer BotToken from DB auth method, fallback to config file
|
||||
var usedToken string
|
||||
var webHookDomain string
|
||||
method, err := svc.AuthModel.FindOneByMethod(context.Background(), "telegram")
|
||||
if err != nil {
|
||||
logger.Errorf("[Init Telegram Config] Get Telegram Config Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
var tg config.Telegram
|
||||
|
||||
if err == nil {
|
||||
tgConfig := new(auth.TelegramAuthConfig)
|
||||
if err = tgConfig.Unmarshal(method.Config); err != nil {
|
||||
if err = tgConfig.Unmarshal(method.Config); err == nil {
|
||||
usedToken = tgConfig.BotToken
|
||||
webHookDomain = tgConfig.WebHookDomain
|
||||
} else {
|
||||
logger.Errorf("[Init Telegram Config] Unmarshal Telegram Config Error: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if tgConfig.BotToken == "" {
|
||||
} else {
|
||||
logger.Debugf("[Init Telegram Config] No Telegram method in DB, fallback to file config: %s", err.Error())
|
||||
}
|
||||
if usedToken == "" {
|
||||
usedToken = svc.Config.Telegram.BotToken
|
||||
}
|
||||
if webHookDomain == "" {
|
||||
webHookDomain = svc.Config.Telegram.WebHookDomain
|
||||
}
|
||||
if usedToken == "" {
|
||||
logger.Debug("[Init Telegram Config] Telegram Token is empty")
|
||||
return
|
||||
}
|
||||
|
||||
bot, err := tgbotapi.NewBotAPI(tg.BotToken)
|
||||
logger.Info("Initializing Telegram Bot API...")
|
||||
bot, err := tgbotapi.NewBotAPI(usedToken)
|
||||
if err != nil {
|
||||
logger.Error("[Init Telegram Config] New Bot API Error: ", logger.Field("error", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if tgConfig.WebHookDomain == "" || svc.Config.Debug {
|
||||
// set Long Polling mode
|
||||
if webHookDomain == "" || svc.Config.Debug {
|
||||
// Ensure webhook is removed to avoid long polling conflict
|
||||
if _, derr := bot.MakeRequest("deleteWebhook", tgbotapi.Params{}); derr != nil {
|
||||
logger.Errorf("[Init Telegram Config] Delete webhook failed: %s", derr.Error())
|
||||
}
|
||||
// Long Polling mode
|
||||
updateConfig := tgbotapi.NewUpdate(0)
|
||||
updateConfig.Timeout = 60
|
||||
updates := bot.GetUpdatesChan(updateConfig)
|
||||
@@ -55,7 +72,7 @@ func Telegram(svc *svc.ServiceContext) {
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
wh, err := tgbotapi.NewWebhook(fmt.Sprintf("%s/v1/telegram/webhook?secret=%s", tgConfig.WebHookDomain, tool.Md5Encode(tgConfig.BotToken, false)))
|
||||
wh, err := tgbotapi.NewWebhook(fmt.Sprintf("%s/v1/telegram/webhook?secret=%s", webHookDomain, tool.Md5Encode(usedToken, false)))
|
||||
if err != nil {
|
||||
logger.Errorf("[Init Telegram Config] New Webhook Error: %s", err.Error())
|
||||
return
|
||||
@@ -74,9 +91,13 @@ func Telegram(svc *svc.ServiceContext) {
|
||||
}
|
||||
svc.Config.Telegram.BotID = user.ID
|
||||
svc.Config.Telegram.BotName = user.UserName
|
||||
svc.Config.Telegram.EnableNotify = tg.EnableNotify
|
||||
svc.Config.Telegram.WebHookDomain = tg.WebHookDomain
|
||||
svc.Config.Telegram.BotToken = usedToken
|
||||
svc.Config.Telegram.WebHookDomain = webHookDomain
|
||||
svc.TelegramBot = bot
|
||||
|
||||
if webHookDomain == "" || svc.Config.Debug {
|
||||
logger.Info("[Init Telegram Config] Long polling mode initialized")
|
||||
} else {
|
||||
logger.Info("[Init Telegram Config] Webhook set success")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@ const VerifyCodeConfigKey = "system:verify_code_config"
|
||||
// SessionIdKey cache session key
|
||||
const SessionIdKey = "auth:session_id"
|
||||
|
||||
// DeviceCacheKeyKey cache session key
|
||||
const DeviceCacheKeyKey = "auth:device_identifier"
|
||||
|
||||
// UserSessionsKeyPrefix per-user sessions zset key prefix
|
||||
const UserSessionsKeyPrefix = "auth:user_sessions:"
|
||||
|
||||
// GlobalConfigKey Global Config Key
|
||||
const GlobalConfigKey = "system:global_config"
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
"github.com/perfect-panel/server/pkg/trace"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -29,7 +30,7 @@ type Config struct {
|
||||
Invite InviteConfig `yaml:"Invite"`
|
||||
Telegram Telegram `yaml:"Telegram"`
|
||||
Log Log `yaml:"Log"`
|
||||
Currency Currency `yaml:"Currency"`
|
||||
Trace trace.Config `yaml:"Trace"`
|
||||
Administrator struct {
|
||||
Email string `yaml:"Email" default:"admin@ppanel.dev"`
|
||||
Password string `yaml:"Password" default:"password"`
|
||||
@@ -45,6 +46,7 @@ type RedisConfig struct {
|
||||
type JwtAuth struct {
|
||||
AccessSecret string `yaml:"AccessSecret"`
|
||||
AccessExpire int64 `yaml:"AccessExpire" default:"604800"`
|
||||
MaxSessionsPerUser int64 `yaml:"MaxSessionsPerUser" default:"1"`
|
||||
}
|
||||
|
||||
type Verify struct {
|
||||
@@ -204,6 +206,7 @@ type InviteConfig struct {
|
||||
ForcedInvite bool `yaml:"ForcedInvite" default:"false"`
|
||||
ReferralPercentage int64 `yaml:"ReferralPercentage" default:"0"`
|
||||
OnlyFirstPurchase bool `yaml:"OnlyFirstPurchase" default:"false"`
|
||||
GiftDays int64 `yaml:"GiftDays" default:"0"`
|
||||
}
|
||||
|
||||
type Telegram struct {
|
||||
@@ -211,6 +214,7 @@ type Telegram struct {
|
||||
BotID int64 `yaml:"BotID" default:""`
|
||||
BotName string `yaml:"BotName" default:""`
|
||||
BotToken string `yaml:"BotToken" default:""`
|
||||
GroupChatID string `yaml:"GroupChatID" default:""`
|
||||
EnableNotify bool `yaml:"EnableNotify" default:"false"`
|
||||
WebHookDomain string `yaml:"WebHookDomain" default:""`
|
||||
}
|
||||
@@ -222,7 +226,7 @@ type TLS struct {
|
||||
}
|
||||
|
||||
type VerifyCode struct {
|
||||
ExpireTime int64 `yaml:"ExpireTime" default:"300"`
|
||||
ExpireTime int64 `yaml:"ExpireTime" default:"900"`
|
||||
Limit int64 `yaml:"Limit" default:"15"`
|
||||
Interval int64 `yaml:"Interval" default:"60"`
|
||||
}
|
||||
@@ -242,9 +246,3 @@ type NodeDBConfig struct {
|
||||
Block string
|
||||
Outbound string
|
||||
}
|
||||
|
||||
type Currency struct {
|
||||
Unit string `yaml:"Unit" default:"CNY"`
|
||||
Symbol string `yaml:"Symbol" default:"USD"`
|
||||
AccessKey string `yaml:"AccessKey" default:""`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/log"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func GetErrorLogMessageDetailHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
l := log.NewGetErrorLogMessageDetailLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetErrorLogMessageDetail(id)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/log"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func GetErrorLogMessageListHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.GetErrorLogMessageListRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
l := log.NewGetErrorLogMessageListLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetErrorLogMessageList(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/server"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Check if there is any server or node to migrate
|
||||
func HasMigrateSeverNodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
l := server.NewHasMigrateSeverNodeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.HasMigrateSeverNode()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/server"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Migrate server and node data to new database
|
||||
func MigrateServerNodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
l := server.NewMigrateServerNodeLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.MigrateServerNode()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package subscribe
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/subscribe"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Reset all subscribe tokens
|
||||
func ResetAllSubscribeTokenHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
l := subscribe.NewResetAllSubscribeTokenLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ResetAllSubscribeToken()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/system"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// GetModuleConfigHandler Get Module Config
|
||||
func GetModuleConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
l := system.NewGetModuleConfigLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetModuleConfig()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package tool
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/tool"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// QueryIPLocationHandler Query IP Location
|
||||
func QueryIPLocationHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.QueryIPLocationRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := tool.NewQueryIPLocationLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.QueryIPLocation(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Reset user subscribe traffic
|
||||
func ResetUserSubscribeTrafficHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ResetUserSubscribeTrafficRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewResetUserSubscribeTrafficLogic(c.Request.Context(), svcCtx)
|
||||
err := l.ResetUserSubscribeTraffic(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Stop user subscribe
|
||||
func ToggleUserSubscribeStatusHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ToggleUserSubscribeStatusRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewToggleUserSubscribeStatusLogic(c.Request.Context(), svcCtx)
|
||||
err := l.ToggleUserSubscribeStatus(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/auth"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func EmailLoginHandler(svcCtx *svc.ServiceContext) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req types.EmailLoginRequest
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
req.IP = c.ClientIP()
|
||||
req.UserAgent = c.Request.UserAgent()
|
||||
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := auth.NewEmailLoginLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.EmailLogin(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
+8
-9
@@ -1,26 +1,25 @@
|
||||
package user
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/admin/user"
|
||||
"github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Reset user subscribe token
|
||||
func ResetUserSubscribeTokenHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
func SubmitContactHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ResetUserSubscribeTokenRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
var req types.ContactRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
|
||||
l := user.NewResetUserSubscribeTokenLogic(c.Request.Context(), svcCtx)
|
||||
err := l.ResetUserSubscribeToken(&req)
|
||||
l := common.NewContactLogic(c.Request.Context(), svcCtx)
|
||||
err := l.SubmitContact(&req)
|
||||
result.HttpResult(c, nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
// Get Client
|
||||
func GetClientHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
l := common.NewGetClientLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.GetClient()
|
||||
result.HttpResult(c, resp, err)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
// Heartbeat
|
||||
func HeartbeatHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
l := common.NewHeartbeatLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.Heartbeat()
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func ReportLogMessageHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.ReportLogMessageRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
validateErr := svcCtx.Validate(&req)
|
||||
if validateErr != nil {
|
||||
result.ParamErrorResult(c, validateErr)
|
||||
return
|
||||
}
|
||||
l := common.NewReportLogMessageLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.ReportLogMessage(&req, c)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,12 @@ func RegisterNotifyHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||
group := router.Group("/v1/notify/")
|
||||
group.Use(middleware.NotifyMiddleware(serverCtx))
|
||||
{
|
||||
group.Any("/:platform/:token", notify.PaymentNotifyHandler(serverCtx))
|
||||
group.Any(":platform/:token", notify.PaymentNotifyHandler(serverCtx))
|
||||
}
|
||||
|
||||
iap := router.Group("/v1/iap")
|
||||
{
|
||||
iap.POST("/notifications", notify.AppleIAPNotifyHandler(serverCtx))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/logic/notify"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func AppleIAPNotifyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
raw, _ := io.ReadAll(c.Request.Body)
|
||||
var body map[string]interface{}
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
sp, _ := body["signedPayload"].(string)
|
||||
l := notify.NewAppleIAPNotifyLogic(c.Request.Context(), svcCtx)
|
||||
err := l.Handle(sp)
|
||||
result.HttpResult(c, map[string]bool{"success": err == nil}, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
appleLogic "github.com/perfect-panel/server/internal/logic/public/iap/apple"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func AttachAppleTransactionByIdHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AttachAppleTransactionByIdRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := appleLogic.NewAttachTransactionByIdLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.AttachById(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
appleLogic "github.com/perfect-panel/server/internal/logic/public/iap/apple"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/result"
|
||||
)
|
||||
|
||||
func AttachAppleTransactionHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||
return func(c *gin.Context) {
|
||||
var req types.AttachAppleTransactionRequest
|
||||
_ = c.ShouldBind(&req)
|
||||
if err := svcCtx.Validate(&req); err != nil {
|
||||
result.ParamErrorResult(c, err)
|
||||
return
|
||||
}
|
||||
l := appleLogic.NewAttachTransactionLogic(c.Request.Context(), svcCtx)
|
||||
resp, err := l.Attach(&req)
|
||||
result.HttpResult(c, resp, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
|
||||
submodel "github.com/perfect-panel/server/internal/model/subscribe"
|
||||
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestIAPAttachFlow 覆盖完整一次用户购买绑定的接口流程
|
||||
// 步骤:初始化内存DB+Redis → 配置产品映射 → 创建用户与订阅计划 → 调用attach接口 → 断言返回与落库
|
||||
func TestIAPAttachFlow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// sqlite 内存数据库
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite error: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&usermodel.User{},
|
||||
&iapmodel.Transaction{},
|
||||
); err != nil {
|
||||
t.Fatalf("automigrate error: %v", err)
|
||||
}
|
||||
// sqlite 手工创建 subscribe 与 user_subscribe 表,避免不兼容的默认值语法
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS subscribe (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
language TEXT,
|
||||
description TEXT,
|
||||
unit_price INTEGER,
|
||||
unit_time TEXT,
|
||||
discount TEXT,
|
||||
replacement INTEGER,
|
||||
inventory INTEGER,
|
||||
traffic INTEGER,
|
||||
speed_limit INTEGER,
|
||||
device_limit INTEGER,
|
||||
quota INTEGER,
|
||||
nodes TEXT,
|
||||
node_tags TEXT,
|
||||
show INTEGER,
|
||||
sell INTEGER,
|
||||
sort INTEGER,
|
||||
deduction_ratio INTEGER,
|
||||
allow_deduction INTEGER,
|
||||
reset_cycle INTEGER,
|
||||
renewal_reset INTEGER,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create subscribe table error: %v", err)
|
||||
}
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_subscribe (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
order_id INTEGER,
|
||||
subscribe_id INTEGER NOT NULL,
|
||||
start_time DATETIME,
|
||||
expire_time DATETIME,
|
||||
finished_at DATETIME,
|
||||
traffic INTEGER DEFAULT 0,
|
||||
download INTEGER DEFAULT 0,
|
||||
upload INTEGER DEFAULT 0,
|
||||
token TEXT UNIQUE,
|
||||
uuid TEXT UNIQUE,
|
||||
status INTEGER DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
);
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create user_subscribe table error: %v", err)
|
||||
}
|
||||
// 内嵌 Redis
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("start miniredis error: %v", err)
|
||||
}
|
||||
defer mr.Close()
|
||||
rds := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
|
||||
// 配置 IAP 产品映射
|
||||
cd := `{
|
||||
"iapProductMap": {
|
||||
"com.airport.vpn.pass.30d": {
|
||||
"description": "30天通行证",
|
||||
"priceText": "¥28.00",
|
||||
"durationDays": 30,
|
||||
"tier": "Basic",
|
||||
"subscribeId": 1001
|
||||
}
|
||||
},
|
||||
"iapBundleId": "co.airoport.app.ios"
|
||||
}`
|
||||
s := &svc.ServiceContext{
|
||||
DB: db,
|
||||
Redis: rds,
|
||||
Config: config.Config{
|
||||
Site: config.SiteConfig{
|
||||
CustomData: cd,
|
||||
},
|
||||
},
|
||||
}
|
||||
// 初始化模型(与生产保持一致)
|
||||
s.UserModel = usermodel.NewModel(db, rds)
|
||||
s.SubscribeModel = submodel.NewModel(db, rds)
|
||||
s.IAPAppleTransactionModel = iapmodel.NewModel(db, rds)
|
||||
|
||||
// 创建可售订阅计划(ID=1001)
|
||||
truePtr := func(b bool) *bool { return &b }
|
||||
if err := db.Create(&submodel.Subscribe{
|
||||
Id: 1001,
|
||||
Name: "30D Pass",
|
||||
Sell: truePtr(true),
|
||||
Language: "",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create subscribe plan error: %v", err)
|
||||
}
|
||||
// 创建用户
|
||||
u := &usermodel.User{
|
||||
Id: 1,
|
||||
Password: "",
|
||||
Avatar: "",
|
||||
Balance: 0,
|
||||
Commission: 0,
|
||||
ReferralPercentage: 0,
|
||||
OnlyFirstPurchase: truePtr(true),
|
||||
Enable: truePtr(true),
|
||||
IsAdmin: truePtr(false),
|
||||
EnableBalanceNotify: truePtr(false),
|
||||
EnableLoginNotify: truePtr(false),
|
||||
EnableSubscribeNotify: truePtr(true),
|
||||
EnableTradeNotify: truePtr(false),
|
||||
}
|
||||
if err := db.Create(u).Error; err != nil {
|
||||
t.Fatalf("create user error: %v", err)
|
||||
}
|
||||
|
||||
// 构造最小 JWS(仅解析 payload)
|
||||
payload := map[string]interface{}{
|
||||
"bundleId": "co.airoport.app.ios",
|
||||
"productId": "com.airport.vpn.pass.unknown",
|
||||
"transactionId": "1000000000001",
|
||||
"originalTransactionId": "1000000000000",
|
||||
"purchaseDate": float64(time.Now().UnixMilli()),
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
b64 := base64.RawURLEncoding.EncodeToString(data)
|
||||
jws := "header." + b64 + ".signature"
|
||||
|
||||
// 组装路由(仅挂载 attach)
|
||||
r := gin.New()
|
||||
r.POST("/v1/public/iap/apple/transactions/attach", AttachAppleTransactionHandler(s))
|
||||
|
||||
// 请求上下文注入登录用户
|
||||
type attachReq struct {
|
||||
SignedTransactionJWS string `json:"signed_transaction_jws"`
|
||||
DurationDays int64 `json:"duration_days"`
|
||||
Tier string `json:"tier"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
}
|
||||
body := attachReq{SignedTransactionJWS: jws, DurationDays: 30, Tier: "Basic", SubscribeId: 1001}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest(http.MethodPost, "/v1/public/iap/apple/transactions/attach", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ctx := context.WithValue(req.Context(), constant.CtxKeyUser, u)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("attach status != 200, got %d", w.Code)
|
||||
}
|
||||
// 解析响应包装
|
||||
var wrap struct {
|
||||
Code uint32 `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Tier string `json:"tier"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &wrap); err != nil {
|
||||
t.Fatalf("unmarshal attach resp error: %v", err)
|
||||
}
|
||||
if wrap.Code != 200 {
|
||||
t.Fatalf("attach code != 200, got %d, msg=%s", wrap.Code, wrap.Msg)
|
||||
}
|
||||
if wrap.Data.ExpiresAt <= time.Now().Unix() {
|
||||
t.Fatalf("expires_at invalid: %d", wrap.Data.ExpiresAt)
|
||||
}
|
||||
// 校验 user_subscribe 落库
|
||||
var count int64
|
||||
if err := db.Model(&usermodel.Subscribe{}).Where("user_id = ? AND subscribe_id = ?", u.Id, 1001).Count(&count).Error; err != nil {
|
||||
t.Fatalf("query user_subscribe error: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Fatalf("user_subscribe not inserted")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user