Compare commits
447 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3594097d47 | |||
| 2215df8c0b | |||
| 3415262017 | |||
| 4752f844ef | |||
| 149dfe1ac3 | |||
| a01570b59d | |||
| 6c8f22adc8 | |||
| b08202fd08 | |||
| 6c370485d1 | |||
| 0c544268e5 | |||
| 82626dd749 | |||
| 8bdc8afea3 | |||
| f773fe1d6d | |||
| 3a0d6dcd5a | |||
| b0eb6595ac | |||
| d220516183 | |||
| 4d8516b2e1 | |||
| 7d46b31866 | |||
| 31e75efacb | |||
| 2d4d926924 | |||
| 7197f5dcf6 | |||
| 9ad602aabe | |||
| bacdf2f301 | |||
| 0883fb9370 | |||
| 34372fe0b3 | |||
| 8022710720 | |||
| b6a1739efa | |||
| ea94f3c9f9 | |||
| 701d47ed21 | |||
| 7e08a07e29 | |||
| 64023dfd1d | |||
| 37200698ab | |||
| ffe589ff77 | |||
| 5f1a546bbe | |||
| 5f55b1242e | |||
| 7d4a19c9a3 | |||
| 2a1ae2e1cc | |||
| 3359704a45 | |||
| 1995987f00 | |||
| 7d84cf858b | |||
| 58caa497c3 | |||
| ed669d0620 | |||
| 076e5e584b | |||
| d3e18af08e | |||
| 69ec491d0a | |||
| d2e9a837cc | |||
| f452838c63 | |||
| 3eb40bd5e4 | |||
| 8a804eec0c | |||
| 8f783b162c | |||
| 24c7fc8857 | |||
| 23ef9dbff1 | |||
| ec0a0f968e | |||
| 3f3b0ae6ad | |||
| 518595b058 | |||
| 5beff61e91 | |||
| 90e2f24d46 | |||
| 80ee9a6acf | |||
| 798fb9e245 | |||
| 780e71441d | |||
| 24f3c29fad | |||
| 77a5373d44 | |||
| 8436c2d6ee | |||
| ff2fa573a0 | |||
| d4c6aa0528 | |||
| 532a5ab009 | |||
| e8084e9d2c | |||
| 577490749c | |||
| 518294a528 | |||
| fb6adc9ae3 | |||
| 7a2000f696 | |||
| 495c4529ed | |||
| bbc3703404 | |||
| ec829452c1 | |||
| 21f77e141b | |||
| d332e760f8 | |||
| 31e634ba66 | |||
| 67f16ead82 | |||
| a9c832cb7c | |||
| e027cbb5de | |||
| ec510b66fb | |||
| 47c41d1d14 | |||
| 39db154e53 | |||
| 76ff9a658d | |||
| 0e7cbf4396 | |||
| 5d632608ab | |||
| 338d962618 | |||
| f1794b26b1 | |||
| 4cd24e7600 | |||
| e18809f9b7 | |||
| 143445a2fc | |||
| 7277438b07 | |||
| 5c2d0be8e2 | |||
| 429e535dd4 | |||
| 5e46357104 | |||
| 35f60df62e | |||
| b4893b7acc | |||
| adc4a4ff2c | |||
| b8a4b73bb0 | |||
| e062dc1ab0 | |||
| 0d57e7eae3 | |||
| 5d18c7bb7d | |||
| f0f29deef1 | |||
| 22d03a100a | |||
| e81a11cd59 | |||
| b29e5c8cb5 | |||
| 128791f43e | |||
| d22659ff04 | |||
| c4166cef6b | |||
| 95ddba2332 | |||
| c1efb23354 | |||
| e3999ba75f | |||
| 8a4cfcbdb3 | |||
| ff2d3f85f3 | |||
| 0fb92f380f | |||
| 3635d3e224 | |||
| c8b3683cf2 | |||
| 1172ecc2f1 | |||
| 750a33cca2 | |||
| d0aad68bb0 | |||
| cb5bf5aae3 | |||
| 87b743a2a2 | |||
| 2305562a7c | |||
| 60d584a052 | |||
| b9d3446407 | |||
| 902608b2e0 | |||
| d7aa9a44b7 | |||
| b7eafd0892 | |||
| d1a8662095 | |||
| 8cce9b95b4 | |||
| 066f5d6538 | |||
| de6661cc16 | |||
| 52ce054b35 | |||
| eb25095623 | |||
| a9c98b67f1 | |||
| 2fd119d697 | |||
| 2ed379d5e8 | |||
| 2605d22f8e | |||
| 829d5f3ffd | |||
| 7b8e71ade2 | |||
| aaea4183c2 | |||
| d8e2e81688 | |||
| e0003ea074 | |||
| 4312e20a5c | |||
| 2edc0ef1c8 | |||
| b974b9a56b | |||
| 9ab63dff88 | |||
| a5abbc1dd3 | |||
| 528ed63a55 | |||
| b2045a6e1b | |||
| cd3b9d4fc8 | |||
| b099331302 | |||
| d78d79fa2d | |||
| 81748abce4 | |||
| aa5e472184 | |||
| a15e08d8f3 | |||
| 6db4c1b826 | |||
| 396862f59d | |||
| 2dae837323 | |||
| 1b76794251 | |||
| 8b48286365 | |||
| 643d234a88 | |||
| eacf675671 | |||
| 96808d531a | |||
| adbe9a06d8 | |||
| fb226a0fc1 | |||
| 571628710b | |||
| f67c2e34dc | |||
| 15beff410b | |||
| f0e2633ef6 | |||
| b80c7caddd | |||
| 37ad4c8443 | |||
| 640b8c0805 | |||
| e99058b969 | |||
| 39306f3043 | |||
| 9ea5c626e9 | |||
| 8c776cdbac | |||
| 3efa68d3ff | |||
| 46e6a9784d | |||
| f3bc933a99 | |||
| 71018eb2f4 | |||
| 8562734fde | |||
| de4386ff68 | |||
| a273f8d883 | |||
| 6dcd75a39b | |||
| 29dd54546d | |||
| 41d8de42c0 | |||
| b97e2d650e | |||
| c5be0081ca | |||
| 1a6cced814 | |||
| bf24e1756a | |||
| 4e7a6e9e41 | |||
| 6c7d45a640 | |||
| c835e751ac | |||
| 3fb23e3106 | |||
| 766e210f06 | |||
| 2de76104ad | |||
| 453ad18303 | |||
| 02f0d711ba | |||
| d6fcff1fee | |||
| 25c7eb3927 | |||
| 9777a0c61a | |||
| d74d636735 | |||
| 5aa9bb61b7 | |||
| 42a31048b4 | |||
| 0179e9837c | |||
| 0d1df5f905 | |||
| 64f0bcc55a | |||
| d2ca3eb961 | |||
| c1c324878b | |||
| 5ca4179285 | |||
| 9ce6141b5a | |||
| 56352de870 | |||
| 4ad18a8b70 | |||
| 596434454c | |||
| b5c756af35 | |||
| 621eda41c2 | |||
| 900096ed12 | |||
| 26fe430d7c | |||
| 0c55d173cb | |||
| 537dab1c3b | |||
| 4b2976a670 | |||
| 38491ddc0d | |||
| e895180388 | |||
| 435283f962 | |||
| b0ffb15825 | |||
| 2c5efa2026 | |||
| c1c913cb00 | |||
| cf4792cdc8 | |||
| 9b0e1b1e0f | |||
| 6bc0fb4d90 | |||
| 32633920b5 | |||
| d55bad712c | |||
| 3f5aac239b | |||
| 83c1c14b01 | |||
| 1c1365d862 | |||
| d1be5febc3 | |||
| f4c6bd919b | |||
| 31cdcd2cb9 | |||
| 4d95834c22 | |||
| 47446ef410 | |||
| 0872099e98 | |||
| d5ed82955e | |||
| d477ba4772 | |||
| 6460f2f2e1 | |||
| 0199dc7fa2 | |||
| 1d878592ed | |||
| d0d03e724e | |||
| 2f4cc46a38 | |||
| 3199dc5141 | |||
| 71d32d73bb | |||
| 584067375b | |||
| b9ef8f4572 | |||
| f3c73a5224 | |||
| 51335f4c19 | |||
| 7912d2908f | |||
| e913f974bb | |||
| e8d3ebba68 | |||
| b4a05166a4 | |||
| 712e31cc60 | |||
| 60988bc2ba | |||
| 61ac829c37 | |||
| 805fca90e0 | |||
| ef7491d025 | |||
| a2d8d7d0bd | |||
| e798f8de1b | |||
| 0636a4bddf | |||
| 8f11380e7a | |||
| 7ecf955eb7 | |||
| 10757612f5 | |||
| f632ea2c89 | |||
| f8e6cf515e | |||
| 73ffd0d1c7 | |||
| 1f824fd5a9 | |||
| 42df466104 | |||
| 68000574b2 | |||
| 64366d0dec | |||
| 949efdad34 | |||
| 107fe82f10 | |||
| ca344a1ce9 | |||
| 3e5284c4ec | |||
| fe629e59dd | |||
| d578dca17e | |||
| ff574b3955 | |||
| 26b2afe06d | |||
| d4850a73f3 | |||
| 367ef9d2e7 | |||
| e910d0e345 | |||
| aea20ffd5e | |||
| 4438b05272 | |||
| 1745c7194b | |||
| 4432115844 | |||
| b6cae7bbb5 | |||
| 4184a32c0f | |||
| 9ff965bbea | |||
| 8a37472e61 | |||
| 3012a68339 | |||
| 6dc1bee14c | |||
| 82fd674ae4 | |||
| 88cbfbb7d6 | |||
| 92466d62a4 | |||
| c7884d94aa | |||
| 9b3cdbbb4f | |||
| ad4f3df74e | |||
| b5e244d83d | |||
| 2acbdbc9e0 | |||
| 1ccbdc18b1 | |||
| 630098bfbb | |||
| 7bd026a600 | |||
| d5460d0cd1 | |||
| d33f4cd1ce | |||
| 6b1b365734 | |||
| 062533412a | |||
| ec4e95451b | |||
| fcdb9f72ef | |||
| 7c707f2ecf | |||
| 70815c0d50 | |||
| 38706c0b29 | |||
| 5e903868c4 | |||
| c04923015e | |||
| 87c771bbd4 | |||
| 4f32d67113 | |||
| bc1e6315a8 | |||
| 3a98616093 | |||
| 41c51fbbff | |||
| 3bb83edf05 | |||
| 245f347d22 | |||
| 70a0fd96ab | |||
| 8996a62b54 | |||
| 14a86c50b2 | |||
| 5d80d95568 | |||
| 6580cc9d44 | |||
| 4f2aafd8b2 | |||
| 81a46d1f13 | |||
| 2dfb1030e1 | |||
| 2be1c4f6ed | |||
| 740dd48763 | |||
| 41d660bb9e | |||
| c8de30f78c | |||
| 46ab012b6c | |||
| 8976724c1e | |||
| db63281250 | |||
| f3a8f94b4b | |||
| f4cd567492 | |||
| c08fadb347 | |||
| a155a4f8cd | |||
| 1b78e04113 | |||
| f6ce7b2cd3 | |||
| 6034a32e85 | |||
| 0e8e2d442e | |||
| b83ce5090a | |||
| 224365ce79 | |||
| 82e447c55e | |||
| 8c4c7d0773 | |||
| 97024dd1df | |||
| 979e39b9e5 | |||
| 94d316ec52 | |||
| 994cc4bebb | |||
| 40c24fbc85 | |||
| 59348c1643 | |||
| 9691257bad | |||
| 76816ca8ea | |||
| e279119524 | |||
| 626c3ef05c | |||
| 4f49dea769 | |||
| 1a849fd461 | |||
| 987e25e7ac | |||
| 189f62781e | |||
| 9fc367a323 | |||
| 0825ac525d | |||
| d26bc7f40b | |||
| be7cdcd4a6 | |||
| b6a2f134bb | |||
| 76063bc071 | |||
| 565eb8cbbf | |||
| 617768a4a1 | |||
| aae4a10947 | |||
| 6027102d29 | |||
| 5ad3202626 | |||
| 0dca618f85 | |||
| 852bde35dc | |||
| b4a9bb23e9 | |||
| 0fb0aac2fa | |||
| 1a21984c27 | |||
| ddf740600f | |||
| 77f55f8708 | |||
| 56f6bd2542 | |||
| 3ee43b14c6 | |||
| a4eca9f9a6 | |||
| e0a4bb028b | |||
| 581e3ee48a | |||
| dbb585ddb5 | |||
| 207ffc854e | |||
| d4089a801d | |||
| 5cca0e7aa3 | |||
| 42a99c4925 | |||
| 9e20b270e7 | |||
| 155c6ba290 | |||
| f4050ff42e | |||
| ae784cbbe4 | |||
| a198c770db | |||
| a2e4c6d09a | |||
| d6f5160be1 | |||
| d5f14bf665 | |||
| e625d9aed5 | |||
| 3cadd9e621 | |||
| 38bcacc9e7 | |||
| ea74e4b7d5 | |||
| ead1a9a8a6 | |||
| aef567c06f | |||
| 6912e281f3 | |||
| c5b0507187 | |||
| 9a1942f8cf | |||
| f856dc7c01 | |||
| cdc3d8b8f1 | |||
| 55ef348135 | |||
| 6d13060aeb | |||
| 94416d59a6 | |||
| bbe7b8c223 | |||
| fdf51a60e9 | |||
| fd1e2857c8 | |||
| de74bd1d70 | |||
| fd1104784b | |||
| f4f0756ba6 | |||
| 08bc2f6331 | |||
| dafe211ec7 | |||
| 1fee981a58 | |||
| 2aaf4cf618 | |||
| 105ee9cc46 | |||
| 3fd71a472b | |||
| 24e2aaeb61 | |||
| a1016eef25 | |||
| af6fe2eeed | |||
| b2dd1a3758 | |||
| 9a747596e6 | |||
| 4124dd43ce | |||
| bc3f40fd69 | |||
| 027596e3a8 | |||
| 5b972514aa | |||
| 5cccb1f8e3 | |||
| 579e32abd9 | |||
| f27b94dddf | |||
| 81bf74e808 | |||
| 34c09c4618 | |||
| 52063049c5 | |||
| da4fd72105 | |||
| 8addcc584b |
+59
-67
@@ -5,26 +5,26 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- internal
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- internal
|
||||
|
||||
env:
|
||||
# Docker镜像仓库
|
||||
REPO: ${{ vars.REPO || 'registry.kxsw.us/ario-server' }}
|
||||
# SSH连接信息
|
||||
SSH_HOST: ${{ vars.SSH_HOST }}
|
||||
REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }}
|
||||
# SSH连接信息 (根据分支自动选择)
|
||||
SSH_HOST: ${{ github.ref_name == 'main' && vars.SSH_HOST || vars.DEV_SSH_HOST }}
|
||||
SSH_PORT: ${{ vars.SSH_PORT }}
|
||||
SSH_USER: ${{ vars.SSH_USER }}
|
||||
SSH_PASSWORD: ${{ vars.SSH_PASSWORD }}
|
||||
SSH_PASSWORD: ${{ github.ref_name == 'main' && vars.SSH_PASSWORD || vars.DEV_SSH_PASSWORD }}
|
||||
# TG通知
|
||||
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0
|
||||
TG_CHAT_ID: "-4940243803"
|
||||
# Go构建变量
|
||||
SERVICE: ario
|
||||
SERVICE_STYLE: ario
|
||||
SERVICE: vpn
|
||||
SERVICE_STYLE: vpn
|
||||
VERSION: ${{ github.sha }}
|
||||
BUILDTIME: ${{ github.event.head_commit.timestamp }}
|
||||
GOARCH: amd64
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
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 "DEPLOY_PATH=/root/bindbox" >> $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 分支设置开发环境变量"
|
||||
elif [ "${{ github.ref_name }}" = "internal" ]; then
|
||||
echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV
|
||||
echo "CONTAINER_NAME=ppanel-server-internal" >> $GITHUB_ENV
|
||||
echo "DEPLOY_PATH=/root/bindbox" >> $GITHUB_ENV
|
||||
echo "为 internal 分支设置开发环境变量"
|
||||
else
|
||||
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "CONTAINER_NAME=ppanel-server-${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
@@ -137,8 +137,33 @@ jobs:
|
||||
|
||||
echo "镜像推送完成"
|
||||
|
||||
# 步骤5: 连接服务器拉镜像启动
|
||||
- name: 🚀 连接服务器拉镜像启动
|
||||
# 调试: 打印 SSH 连接信息
|
||||
- name: 🔍 调试 - 打印 SSH 连接信息
|
||||
run: |
|
||||
echo "========== SSH 连接信息调试 =========="
|
||||
echo "当前分支: ${{ github.ref_name }}"
|
||||
echo "SSH_HOST: ${{ env.SSH_HOST }}"
|
||||
echo "SSH_PORT: ${{ env.SSH_PORT }}"
|
||||
echo "SSH_USER: ${{ env.SSH_USER }}"
|
||||
echo "SSH_PASSWORD 长度: ${#SSH_PASSWORD}"
|
||||
echo "SSH_PASSWORD 前3位: $(echo "$SSH_PASSWORD" | cut -c1-3)***"
|
||||
echo "SSH_PASSWORD 完整值: ${{ env.SSH_PASSWORD }}"
|
||||
echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}"
|
||||
echo "====================================="
|
||||
|
||||
# 步骤5: 传输配置文件
|
||||
- name: 📂 传输配置文件
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ env.SSH_HOST }}
|
||||
username: ${{ env.SSH_USER }}
|
||||
password: ${{ env.SSH_PASSWORD }}
|
||||
port: ${{ env.SSH_PORT }}
|
||||
source: "docker-compose.cloud.yml"
|
||||
target: "${{ env.DEPLOY_PATH }}/"
|
||||
|
||||
# 步骤6: 连接服务器更新并启动
|
||||
- name: 🚀 连接服务器更新并启动
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ env.SSH_HOST }}
|
||||
@@ -149,60 +174,27 @@ jobs:
|
||||
command_timeout: 600s
|
||||
script: |
|
||||
echo "连接服务器成功,开始部署..."
|
||||
echo "部署容器名: ${{ env.CONTAINER_NAME }}"
|
||||
echo "部署路径: ${{ env.DEPLOY_PATH }}"
|
||||
echo "部署镜像: ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
|
||||
echo "部署目录: ${{ env.DEPLOY_PATH }}"
|
||||
echo "部署标签: ${{ 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
|
||||
# 创建/更新环境变量文件
|
||||
echo "PPANEL_SERVER_TAG=${{ env.DOCKER_TAG_SUFFIX }}" > .env
|
||||
|
||||
# 拉取最新镜像
|
||||
echo "📥 拉取镜像..."
|
||||
docker-compose -f docker-compose.cloud.yml pull ppanel-server
|
||||
|
||||
# 启动服务
|
||||
echo "🚀 启动服务..."
|
||||
docker-compose -f docker-compose.cloud.yml up -d ppanel-server
|
||||
|
||||
# 清理未使用的镜像
|
||||
docker image prune -f || true
|
||||
|
||||
echo "✅ 部署命令执行完成"
|
||||
|
||||
# 步骤6: TG通知 (成功)
|
||||
- name: 📱 发送成功通知到Telegram
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Production Environment Configuration for GitHub Actions
|
||||
# This file defines production-specific deployment settings
|
||||
|
||||
environment:
|
||||
name: production
|
||||
url: https://api.ppanel.example.com
|
||||
protection_rules:
|
||||
- type: wait_timer
|
||||
minutes: 5
|
||||
- type: reviewers
|
||||
reviewers:
|
||||
- "@admin-team"
|
||||
- "@devops-team"
|
||||
variables:
|
||||
ENVIRONMENT: production
|
||||
LOG_LEVEL: info
|
||||
DEPLOY_TIMEOUT: 300
|
||||
|
||||
# Environment-specific secrets required:
|
||||
# PRODUCTION_HOST - Production server hostname/IP
|
||||
# PRODUCTION_USER - SSH username for production server
|
||||
# PRODUCTION_SSH_KEY - SSH private key for production server
|
||||
# PRODUCTION_PORT - SSH port (default: 22)
|
||||
# PRODUCTION_URL - Application URL for health checks
|
||||
# DATABASE_PASSWORD - Production database password
|
||||
# REDIS_PASSWORD - Production Redis password
|
||||
# JWT_SECRET - JWT secret key for production
|
||||
@@ -0,0 +1,23 @@
|
||||
# Staging Environment Configuration for GitHub Actions
|
||||
# This file defines staging-specific deployment settings
|
||||
|
||||
environment:
|
||||
name: staging
|
||||
url: https://staging-api.ppanel.example.com
|
||||
protection_rules:
|
||||
- type: wait_timer
|
||||
minutes: 2
|
||||
variables:
|
||||
ENVIRONMENT: staging
|
||||
LOG_LEVEL: debug
|
||||
DEPLOY_TIMEOUT: 180
|
||||
|
||||
# Environment-specific secrets required:
|
||||
# STAGING_HOST - Staging server hostname/IP
|
||||
# STAGING_USER - SSH username for staging server
|
||||
# STAGING_SSH_KEY - SSH private key for staging server
|
||||
# STAGING_PORT - SSH port (default: 22)
|
||||
# STAGING_URL - Application URL for health checks
|
||||
# DATABASE_PASSWORD - Staging database password
|
||||
# REDIS_PASSWORD - Staging Redis password
|
||||
# JWT_SECRET - JWT secret key for staging
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Build Linux Binary
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to build (leave empty for auto)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Linux Binary
|
||||
runs-on: ario-server
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23.3'
|
||||
cache: true
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: linux
|
||||
GOARCH: amd64
|
||||
run: |
|
||||
VERSION=${{ github.event.inputs.version }}
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
fi
|
||||
|
||||
echo "Building ppanel-server $VERSION"
|
||||
BUILD_TIME=$(date +"%Y-%m-%d_%H:%M:%S")
|
||||
go build -ldflags="-w -s -X github.com/perfect-panel/server/pkg/constant.Version=$VERSION -X github.com/perfect-panel/server/pkg/constant.BuildTime=$BUILD_TIME" -o ppanel-server ./ppanel.go
|
||||
tar -czf ppanel-server-${VERSION}-linux-amd64.tar.gz ppanel-server
|
||||
sha256sum ppanel-server ppanel-server-${VERSION}-linux-amd64.tar.gz > checksum.txt
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ppanel-server-linux-amd64
|
||||
path: |
|
||||
ppanel-server
|
||||
ppanel-server-*-linux-amd64.tar.gz
|
||||
checksum.txt
|
||||
|
||||
- name: Create Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
|
||||
# Check if release exists
|
||||
if gh release view $VERSION >/dev/null 2>&1; then
|
||||
echo "Release $VERSION already exists, deleting old assets..."
|
||||
# Delete existing assets if they exist
|
||||
gh release delete-asset $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz --yes 2>/dev/null || true
|
||||
gh release delete-asset $VERSION checksum.txt --yes 2>/dev/null || true
|
||||
else
|
||||
echo "Creating new release $VERSION..."
|
||||
gh release create $VERSION --title "PPanel Server $VERSION" --notes "Release $VERSION"
|
||||
fi
|
||||
|
||||
# Upload assets (will overwrite if --clobber is supported, otherwise will fail gracefully)
|
||||
echo "Uploading assets..."
|
||||
gh release upload $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz checksum.txt --clobber
|
||||
+7
-1
@@ -4,13 +4,19 @@
|
||||
*.local.yaml
|
||||
/test/
|
||||
*.log
|
||||
*.sh
|
||||
script/*.sh
|
||||
.DS_Store
|
||||
*_test_config.go
|
||||
*.log*
|
||||
/build/
|
||||
etc/ppanel.yaml
|
||||
*.p8
|
||||
*.crt
|
||||
*.key
|
||||
node_modules
|
||||
package-lock.json
|
||||
package.json
|
||||
/bin
|
||||
.claude
|
||||
./github
|
||||
./run
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- "386"
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: "386"
|
||||
binary: ppanel-server
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X "github.com/perfect-panel/server/pkg/constant.Version={{.Version}}"
|
||||
- -X "github.com/perfect-panel/server/pkg/constant.BuildTime={{.Date}}"
|
||||
- -X "github.com/perfect-panel/server/pkg/constant.GitCommit={{.Commit}}"
|
||||
main: ./ppanel.go
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
name_template: >-
|
||||
{{ .ProjectName }}-
|
||||
{{- .Version }}-
|
||||
{{- title .Os }}-
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else if eq .Arch "386" }}i386
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
files:
|
||||
- LICENSE
|
||||
- etc/*
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
use: github
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^chore:"
|
||||
- Merge pull request
|
||||
groups:
|
||||
- title: Features
|
||||
regexp: "^.*feat[(\\w)]*:+.*$"
|
||||
order: 0
|
||||
- title: 'Bug fixes'
|
||||
regexp: "^.*fix[(\\w)]*:+.*$"
|
||||
order: 1
|
||||
- title: Others
|
||||
order: 999
|
||||
|
||||
dockers:
|
||||
- image_templates:
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:{{ .Tag }}"
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:v{{ .Major }}"
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:v{{ .Major }}.{{ .Minor }}"
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:latest"
|
||||
dockerfile: Dockerfile
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--platform=linux/amd64"
|
||||
use: docker
|
||||
extra_files:
|
||||
- etc/
|
||||
|
||||
- image_templates:
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:{{ .Tag }}-arm64"
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:v{{ .Major }}.{{ .Minor }}-arm64"
|
||||
dockerfile: Dockerfile
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--platform=linux/arm64"
|
||||
use: docker
|
||||
goarch: arm64
|
||||
extra_files:
|
||||
- etc/
|
||||
|
||||
docker_signs:
|
||||
- cmd: cosign
|
||||
args:
|
||||
- "sign"
|
||||
- "${artifact}@${digest}"
|
||||
env:
|
||||
- COSIGN_EXPERIMENTAL=1
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: perfect-panel
|
||||
name: server
|
||||
draft: false
|
||||
prerelease: auto
|
||||
name_template: "{{.ProjectName}} v{{.Version}}"
|
||||
header: |
|
||||
## ppanel-server {{.Version}}
|
||||
|
||||
Welcome to this new release!
|
||||
footer: |
|
||||
Docker images are available at:
|
||||
- `{{ .Env.DOCKER_USERNAME }}/ppanel-server:{{ .Tag }}`
|
||||
- `{{ .Env.DOCKER_USERNAME }}/ppanel-server:latest`
|
||||
|
||||
For more information, visit our documentation.
|
||||
@@ -0,0 +1,12 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="go build github.com/perfect-panel/server" type="GoApplicationRunConfiguration" factoryName="Go Application" nameIsGenerated="true">
|
||||
<module name="server" />
|
||||
<working_directory value="$PROJECT_DIR$" />
|
||||
<parameters value="run --config etc/ppanel-dev.yaml" />
|
||||
<kind value="PACKAGE" />
|
||||
<package value="github.com/perfect-panel/server" />
|
||||
<directory value="$PROJECT_DIR$" />
|
||||
<filePath value="$PROJECT_DIR$/ppanel.go" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,112 +0,0 @@
|
||||
## 目标
|
||||
|
||||
* 不使用自动续期订阅;采用“非续期订阅”或“非消耗型”作为内购模式。
|
||||
|
||||
* 仅实现 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. 上线前验证:沙盒测试与文案、监控
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# 用户管理系统优化方案 (最终确认版)
|
||||
|
||||
根据您的要求,我们将重点实现 `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`。
|
||||
@@ -1,67 +0,0 @@
|
||||
## 修复目标
|
||||
- 解决首次设备登录时在 `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` 错误处理路径,避免 `_ = ...` 静默失败。
|
||||
- 为“首次设备登录”场景补充集成测试,保证不再回归。
|
||||
@@ -1,65 +0,0 @@
|
||||
## 目标
|
||||
- 不使用自动续期订阅;采用“非续期订阅”或“非消耗型”作为内购模式。
|
||||
- 仅实现 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) 上线前验证:沙盒测试与文案、监控
|
||||
@@ -1,92 +0,0 @@
|
||||
## 修复目标
|
||||
|
||||
* 解决首次设备登录时在 `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` 错误处理路径,避免 `_ = ...` 静默失败。
|
||||
|
||||
* 为“首次设备登录”场景补充集成测试,保证不再回归。
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
## 结论与定位
|
||||
- 解绑接口路径:`/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 段增加代理超时与上游重试策略。
|
||||
|
||||
请确认以上方案,我将按该方案实施、编写必要函数注释并补充测试,完成后提供验证结果与影响评估。
|
||||
@@ -1,31 +0,0 @@
|
||||
## 问题确认
|
||||
- 前端以“百分比”形式配置折扣(如 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. 文档与界面提示:
|
||||
- 在后台/前端表单处增加说明:支持百分比与系数;百分比将自动转换;推荐使用百分比,避免歧义。
|
||||
|
||||
## 交付与保障
|
||||
- 代码改动仅限折扣计算函数与测试,风险低;保留原有行为的向后兼容。
|
||||
- 提供测试报告与一次联调记录(数据截图:价格、折扣、总计)。
|
||||
|
||||
请确认是否按该兼容方案执行,我将据此修改并验证。
|
||||
@@ -1,47 +0,0 @@
|
||||
## 实施目标
|
||||
- 复用现有订单与队列赋权,接入 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` 验证与“合成订单”生成;
|
||||
- 第三步:通知验签与事件映射;
|
||||
- 第四步:沙盒联调,确认队列赋权与续费延长。
|
||||
@@ -1,92 +0,0 @@
|
||||
## 结论
|
||||
|
||||
* 可以复用你现有的“订单→支付成功→订单激活(赋权)→通知/返佣”的主干流程,但“支付环节”不能复用第三方网关逻辑,必须改为 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. 数据表字段扩展与迁移脚本
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# 最后上线时间字段显示修复计划
|
||||
|
||||
## 问题分析
|
||||
用户反馈调用 `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 也会返回字段。
|
||||
|
||||
## 补充
|
||||
如果用户是指“有数据但没显示”,那可能是登录逻辑或流量更新逻辑未触发。但首要步骤是让字段显式返回,以便排查是“无数据”还是“字段被隐藏”。
|
||||
@@ -1,92 +0,0 @@
|
||||
## 修复目标
|
||||
|
||||
* 解决首次设备登录时在 `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` 错误处理路径,避免 `_ = ...` 静默失败。
|
||||
|
||||
* 为“首次设备登录”场景补充集成测试,保证不再回归。
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# 迁移文件重复问题修复计划
|
||||
|
||||
## 问题分析
|
||||
根据终端日志报错 `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. **验证**:
|
||||
- 确认目录下不再有重复前缀的文件。
|
||||
- 建议用户重新运行程序。
|
||||
|
||||
## 补充说明
|
||||
此操作仅涉及文件重命名,不修改文件内容,风险极低。
|
||||
@@ -1,128 +0,0 @@
|
||||
# 设备静默登录机制设计方案
|
||||
|
||||
## 需求分析
|
||||
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. 性能影响
|
||||
- **风险**: 设备登录可能增加数据库压力
|
||||
- **方案**: 现有机制已经过验证,影响可控
|
||||
+3
-2
@@ -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 alpine:latest
|
||||
FROM scratch
|
||||
|
||||
# Copy CA certificates and timezone data
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
@@ -36,6 +36,7 @@ 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
|
||||
|
||||
@@ -45,6 +45,15 @@ proxy services. Built with Go, it emphasizes performance, security, and scalabil
|
||||
- **Node Management**: Monitor and control server nodes.
|
||||
- **API Framework**: Comprehensive RESTful APIs for frontend integration.
|
||||
|
||||
### Subscription Mode Behavior
|
||||
|
||||
The subscription behavior can be switched by the backend config `Subscribe.SingleModel`:
|
||||
|
||||
- `false` (**multi-subscription mode**): each successful `purchase` creates a new `user_subscribe` record.
|
||||
- `true` (**single-subscription mode**): `purchase` is auto-routed to renewal semantics when the user already has a paid subscription:
|
||||
- a new order is still created,
|
||||
- but the existing subscription is extended (instead of creating another `user_subscribe`).
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
@@ -1,111 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+67
-51
@@ -8,16 +8,24 @@ import (
|
||||
)
|
||||
|
||||
type Adapter struct {
|
||||
SiteName string // 站点名称
|
||||
Servers []*node.Node // 服务器列表
|
||||
UserInfo User // 用户信息
|
||||
ClientTemplate string // 客户端配置模板
|
||||
OutputFormat string // 输出格式,默认是 base64
|
||||
SubscribeName string // 订阅名称
|
||||
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) {
|
||||
@@ -76,6 +84,7 @@ func (adapter *Adapter) Client() (*Client, error) {
|
||||
OutputFormat: adapter.OutputFormat,
|
||||
Proxies: []Proxy{},
|
||||
UserInfo: adapter.UserInfo,
|
||||
Params: adapter.Params,
|
||||
}
|
||||
|
||||
proxies, err := adapter.Proxies(adapter.Servers)
|
||||
@@ -101,51 +110,58 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
|
||||
}
|
||||
for _, protocol := range protocols {
|
||||
if protocol.Type == item.Protocol {
|
||||
proxies = append(proxies, Proxy{
|
||||
Sort: item.Sort,
|
||||
Name: item.Name,
|
||||
Server: item.Address,
|
||||
Port: item.Port,
|
||||
Type: item.Protocol,
|
||||
Tags: strings.Split(item.Tags, ","),
|
||||
Security: protocol.Security,
|
||||
SNI: protocol.SNI,
|
||||
AllowInsecure: protocol.AllowInsecure,
|
||||
Fingerprint: protocol.Fingerprint,
|
||||
RealityServerAddr: protocol.RealityServerAddr,
|
||||
RealityServerPort: protocol.RealityServerPort,
|
||||
RealityPrivateKey: protocol.RealityPrivateKey,
|
||||
RealityPublicKey: protocol.RealityPublicKey,
|
||||
RealityShortId: protocol.RealityShortId,
|
||||
Transport: protocol.Transport,
|
||||
Host: protocol.Host,
|
||||
Path: protocol.Path,
|
||||
ServiceName: protocol.ServiceName,
|
||||
Method: protocol.Cipher,
|
||||
ServerKey: protocol.ServerKey,
|
||||
Flow: protocol.Flow,
|
||||
HopPorts: protocol.HopPorts,
|
||||
HopInterval: protocol.HopInterval,
|
||||
ObfsPassword: protocol.ObfsPassword,
|
||||
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,
|
||||
XhttpExtra: protocol.XhttpExtra,
|
||||
Encryption: protocol.Encryption,
|
||||
EncryptionMode: protocol.EncryptionMode,
|
||||
EncryptionRtt: protocol.EncryptionRtt,
|
||||
EncryptionTicket: protocol.EncryptionTicket,
|
||||
EncryptionServerPadding: protocol.EncryptionServerPadding,
|
||||
EncryptionPrivateKey: protocol.EncryptionPrivateKey,
|
||||
EncryptionClientPadding: protocol.EncryptionClientPadding,
|
||||
EncryptionPassword: protocol.EncryptionPassword,
|
||||
})
|
||||
proxies = append(
|
||||
proxies,
|
||||
Proxy{
|
||||
Sort: item.Sort,
|
||||
Name: item.Name,
|
||||
Server: item.Address,
|
||||
Port: item.Port,
|
||||
Type: item.Protocol,
|
||||
Tags: strings.Split(item.Tags, ","),
|
||||
Security: protocol.Security,
|
||||
SNI: protocol.SNI,
|
||||
AllowInsecure: protocol.AllowInsecure,
|
||||
Fingerprint: protocol.Fingerprint,
|
||||
RealityServerAddr: protocol.RealityServerAddr,
|
||||
RealityServerPort: protocol.RealityServerPort,
|
||||
RealityPrivateKey: protocol.RealityPrivateKey,
|
||||
RealityPublicKey: protocol.RealityPublicKey,
|
||||
RealityShortId: protocol.RealityShortId,
|
||||
Transport: protocol.Transport,
|
||||
Host: protocol.Host,
|
||||
Path: protocol.Path,
|
||||
ServiceName: protocol.ServiceName,
|
||||
Method: protocol.Cipher,
|
||||
ServerKey: protocol.ServerKey,
|
||||
Flow: protocol.Flow,
|
||||
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,
|
||||
PaddingScheme: protocol.PaddingScheme,
|
||||
Multiplex: protocol.Multiplex,
|
||||
XhttpMode: protocol.XhttpMode,
|
||||
XhttpExtra: protocol.XhttpExtra,
|
||||
Encryption: protocol.Encryption,
|
||||
EncryptionMode: protocol.EncryptionMode,
|
||||
EncryptionRtt: protocol.EncryptionRtt,
|
||||
EncryptionTicket: protocol.EncryptionTicket,
|
||||
EncryptionServerPadding: protocol.EncryptionServerPadding,
|
||||
EncryptionPrivateKey: protocol.EncryptionPrivateKey,
|
||||
EncryptionClientPadding: protocol.EncryptionClientPadding,
|
||||
EncryptionPassword: protocol.EncryptionPassword,
|
||||
Ratio: protocol.Ratio,
|
||||
CertMode: protocol.CertMode,
|
||||
CertDNSProvider: protocol.CertDNSProvider,
|
||||
CertDNSEnv: protocol.CertDNSEnv,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -93,12 +93,13 @@ type User struct {
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
SiteName string // Name of the site
|
||||
SubscribeName string // Name of the subscription
|
||||
ClientTemplate string // Template for the entire client configuration
|
||||
OutputFormat string // json, yaml, etc.
|
||||
Proxies []Proxy // List of proxy configurations
|
||||
UserInfo User // User information
|
||||
SiteName string // Name of the site
|
||||
SubscribeName string // Name of the subscription
|
||||
ClientTemplate string // Template for the entire client configuration
|
||||
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) {
|
||||
@@ -119,6 +120,7 @@ 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"`
|
||||
Price int64 `json:"price" validate:"required"`
|
||||
Amount int64 `json:"amount" validate:"required"`
|
||||
Discount int64 `json:"discount,omitempty"`
|
||||
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"`
|
||||
Coupon string `json:"coupon,omitempty"`
|
||||
CouponDiscount int64 `json:"coupon_discount,omitempty"`
|
||||
Commission int64 `json:"commission"`
|
||||
FeeAmount int64 `json:"fee_amount" validate:"required"`
|
||||
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"`
|
||||
PaymentId int64 `json:"payment_id" validate:"required"`
|
||||
TradeNo string `json:"trade_no,omitempty"`
|
||||
Status uint8 `json:"status,omitempty"`
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "redemption API"
|
||||
desc: "API for redemption code management"
|
||||
author: "Tension"
|
||||
email: "tension@ppanel.com"
|
||||
version: "0.0.1"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
type (
|
||||
CreateRedemptionCodeRequest {
|
||||
TotalCount int64 `json:"total_count" validate:"required"`
|
||||
SubscribePlan int64 `json:"subscribe_plan" validate:"required"`
|
||||
UnitTime string `json:"unit_time" validate:"required,oneof=day month quarter half_year year"`
|
||||
Quantity int64 `json:"quantity" validate:"required"`
|
||||
BatchCount int64 `json:"batch_count" validate:"required,min=1"`
|
||||
}
|
||||
UpdateRedemptionCodeRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
TotalCount int64 `json:"total_count,omitempty"`
|
||||
SubscribePlan int64 `json:"subscribe_plan,omitempty"`
|
||||
UnitTime string `json:"unit_time,omitempty" validate:"omitempty,oneof=day month quarter half_year year"`
|
||||
Quantity int64 `json:"quantity,omitempty"`
|
||||
Status int64 `json:"status,omitempty" validate:"omitempty,oneof=0 1"`
|
||||
}
|
||||
ToggleRedemptionCodeStatusRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Status int64 `json:"status" validate:"oneof=0 1"`
|
||||
}
|
||||
DeleteRedemptionCodeRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
}
|
||||
BatchDeleteRedemptionCodeRequest {
|
||||
Ids []int64 `json:"ids" validate:"required"`
|
||||
}
|
||||
GetRedemptionCodeListRequest {
|
||||
Page int64 `form:"page" validate:"required"`
|
||||
Size int64 `form:"size" validate:"required"`
|
||||
SubscribePlan int64 `form:"subscribe_plan,omitempty"`
|
||||
UnitTime string `form:"unit_time,omitempty"`
|
||||
Code string `form:"code,omitempty"`
|
||||
}
|
||||
GetRedemptionCodeListResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []RedemptionCode `json:"list"`
|
||||
}
|
||||
GetRedemptionRecordListRequest {
|
||||
Page int64 `form:"page" validate:"required"`
|
||||
Size int64 `form:"size" validate:"required"`
|
||||
UserId int64 `form:"user_id,omitempty"`
|
||||
CodeId int64 `form:"code_id,omitempty"`
|
||||
}
|
||||
GetRedemptionRecordListResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []RedemptionRecord `json:"list"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
prefix: v1/admin/redemption
|
||||
group: admin/redemption
|
||||
middleware: AuthMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Create redemption code"
|
||||
@handler CreateRedemptionCode
|
||||
post /code (CreateRedemptionCodeRequest)
|
||||
|
||||
@doc "Update redemption code"
|
||||
@handler UpdateRedemptionCode
|
||||
put /code (UpdateRedemptionCodeRequest)
|
||||
|
||||
@doc "Toggle redemption code status"
|
||||
@handler ToggleRedemptionCodeStatus
|
||||
put /code/status (ToggleRedemptionCodeStatusRequest)
|
||||
|
||||
@doc "Delete redemption code"
|
||||
@handler DeleteRedemptionCode
|
||||
delete /code (DeleteRedemptionCodeRequest)
|
||||
|
||||
@doc "Batch delete redemption code"
|
||||
@handler BatchDeleteRedemptionCode
|
||||
delete /code/batch (BatchDeleteRedemptionCodeRequest)
|
||||
|
||||
@doc "Get redemption code list"
|
||||
@handler GetRedemptionCodeList
|
||||
get /code/list (GetRedemptionCodeListRequest) returns (GetRedemptionCodeListResponse)
|
||||
|
||||
@doc "Get redemption record list"
|
||||
@handler GetRedemptionRecordList
|
||||
get /record/list (GetRedemptionRecordListRequest) returns (GetRedemptionRecordListResponse)
|
||||
}
|
||||
@@ -189,14 +189,6 @@ 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)
|
||||
|
||||
+51
-42
@@ -34,50 +34,52 @@ type (
|
||||
Ids []int64 `json:"ids" validate:"required"`
|
||||
}
|
||||
CreateSubscribeRequest {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
AllowDeduction *bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset *bool `json:"renewal_reset"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
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"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
AllowDeduction *bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset *bool `json:"renewal_reset"`
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
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"`
|
||||
@@ -102,6 +104,9 @@ type (
|
||||
BatchDeleteSubscribeRequest {
|
||||
Ids []int64 `json:"ids" validate:"required"`
|
||||
}
|
||||
ResetAllSubscribeTokenResponse {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@@ -157,5 +162,9 @@ service ppanel {
|
||||
@doc "Subscribe sort"
|
||||
@handler SubscribeSort
|
||||
post /sort (SubscribeSortRequest)
|
||||
|
||||
@doc "Reset all subscribe tokens"
|
||||
@handler ResetAllSubscribeToken
|
||||
post /reset_all_token returns (ResetAllSubscribeTokenResponse)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ 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 (
|
||||
@@ -125,5 +130,9 @@ 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,6 +17,14 @@ 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 (
|
||||
@@ -36,5 +44,9 @@ service ppanel {
|
||||
@doc "Get Version"
|
||||
@handler GetVersion
|
||||
get /version returns (VersionResponse)
|
||||
|
||||
@doc "Query IP Location"
|
||||
@handler QueryIPLocation
|
||||
get /ip/location (QueryIPLocationRequest) returns (QueryIPLocationResponse)
|
||||
}
|
||||
|
||||
|
||||
+75
-2
@@ -19,9 +19,14 @@ 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"`
|
||||
ShortCode string `form:"short_code,omitempty"`
|
||||
FamilyJoined *bool `form:"family_joined,omitempty"`
|
||||
FamilyStatus string `form:"family_status,omitempty"`
|
||||
FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"`
|
||||
FamilyId *int64 `form:"family_id,omitempty"`
|
||||
}
|
||||
// GetUserListResponse
|
||||
GetUserListResponse {
|
||||
@@ -46,6 +51,7 @@ type (
|
||||
RefererId int64 `json:"referer_id"`
|
||||
Enable bool `json:"enable"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
UpdateUserNotifySettingRequest {
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
@@ -179,11 +185,46 @@ type (
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
DeleteUserSubscribeRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
UserSubscribeId int64 `json:"user_subscribe_id" validate:"required,gt=0"`
|
||||
}
|
||||
GetUserSubscribeByIdRequest {
|
||||
Id int64 `form:"id" validate:"required"`
|
||||
}
|
||||
ToggleUserSubscribeStatusRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
ResetUserSubscribeTrafficRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
GetFamilyListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Keyword string `form:"keyword,omitempty"`
|
||||
Status string `form:"status,omitempty"`
|
||||
OwnerUserId *int64 `form:"owner_user_id,omitempty"`
|
||||
FamilyId *int64 `form:"family_id,omitempty"`
|
||||
UserId *int64 `form:"user_id,omitempty"`
|
||||
}
|
||||
GetFamilyListResponse {
|
||||
List []FamilySummary `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
GetFamilyDetailRequest {
|
||||
Id int64 `form:"id" validate:"required"`
|
||||
}
|
||||
UpdateFamilyMaxMembersRequest {
|
||||
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
|
||||
MaxMembers int64 `json:"max_members" validate:"required,gt=0"`
|
||||
}
|
||||
RemoveFamilyMemberRequest {
|
||||
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
|
||||
UserId int64 `json:"user_id" validate:"required,gt=0"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
DissolveFamilyRequest {
|
||||
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@@ -292,4 +333,36 @@ 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)
|
||||
|
||||
@doc "Get family list"
|
||||
@handler GetFamilyList
|
||||
get /family/list (GetFamilyListRequest) returns (GetFamilyListResponse)
|
||||
|
||||
@doc "Get family detail"
|
||||
@handler GetFamilyDetail
|
||||
get /family/detail (GetFamilyDetailRequest) returns (FamilyDetail)
|
||||
|
||||
@doc "Update family max members"
|
||||
@handler UpdateFamilyMaxMembers
|
||||
put /family/max_members (UpdateFamilyMaxMembersRequest)
|
||||
|
||||
@doc "Remove family member"
|
||||
@handler RemoveFamilyMember
|
||||
put /family/member/remove (RemoveFamilyMemberRequest)
|
||||
|
||||
@doc "Dissolve family"
|
||||
@handler DissolveFamily
|
||||
put /family/dissolve (DissolveFamilyRequest)
|
||||
}
|
||||
|
||||
+1
-14
@@ -50,16 +50,6 @@ 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"`
|
||||
}
|
||||
@@ -134,6 +124,7 @@ type (
|
||||
IP string `header:"X-Original-Forwarded-For"`
|
||||
UserAgent string `json:"user_agent" validate:"required"`
|
||||
CfToken string `json:"cf_token,optional"`
|
||||
ShortCode string `json:"short_code,optional"`
|
||||
}
|
||||
)
|
||||
|
||||
@@ -151,10 +142,6 @@ 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,11 +87,10 @@ type (
|
||||
Total int64 `json:"total"`
|
||||
List []SubscribeClient `json:"list"`
|
||||
}
|
||||
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"`
|
||||
HeartbeatResponse {
|
||||
Status bool `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
@@ -105,10 +104,6 @@ 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)
|
||||
@@ -140,4 +135,9 @@ service ppanel {
|
||||
@doc "Get Client"
|
||||
@handler GetClient
|
||||
get /client returns (GetSubscribeClientResponse)
|
||||
|
||||
@doc "Heartbeat"
|
||||
@handler Heartbeat
|
||||
get /heartbeat returns (HeartbeatResponse)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "redemption API"
|
||||
desc: "API for redemption"
|
||||
author: "Tension"
|
||||
email: "tension@ppanel.com"
|
||||
version: "0.0.1"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
type (
|
||||
RedeemCodeRequest {
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
RedeemCodeResponse {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
prefix: v1/public/redemption
|
||||
group: public/redemption
|
||||
jwt: JwtAuth
|
||||
middleware: AuthMiddleware,DeviceMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Redeem code"
|
||||
@handler RedeemCode
|
||||
post / (RedeemCodeRequest) returns (RedeemCodeResponse)
|
||||
}
|
||||
+42
-39
@@ -14,45 +14,48 @@ type (
|
||||
QuerySubscribeListRequest {
|
||||
Language string `form:"language"`
|
||||
}
|
||||
QueryUserSubscribeNodeListResponse {
|
||||
List []UserSubscribeInfo `json:"list"`
|
||||
}
|
||||
UserSubscribeInfo {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
OrderId int64 `json:"order_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
FinishedAt int64 `json:"finished_at"`
|
||||
ResetTime int64 `json:"reset_time"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
IsTryOut bool `json:"is_try_out"`
|
||||
Nodes []*UserSubscribeNodeInfo `json:"nodes"`
|
||||
}
|
||||
UserSubscribeNodeInfo {
|
||||
Id int64 `json:"id"`
|
||||
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"`
|
||||
|
||||
QueryUserSubscribeNodeListResponse {
|
||||
List []UserSubscribeInfo `json:"list"`
|
||||
}
|
||||
|
||||
UserSubscribeInfo {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
OrderId int64 `json:"order_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
FinishedAt int64 `json:"finished_at"`
|
||||
ResetTime int64 `json:"reset_time"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
IsTryOut bool `json:"is_try_out"`
|
||||
Nodes []*UserSubscribeNodeInfo `json:"nodes"`
|
||||
}
|
||||
|
||||
UserSubscribeNodeInfo{
|
||||
Id int64 `json:"id"`
|
||||
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"`
|
||||
}
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@@ -65,8 +68,8 @@ service ppanel {
|
||||
@handler QuerySubscribeList
|
||||
get /list (QuerySubscribeListRequest) returns (QuerySubscribeListResponse)
|
||||
|
||||
@doc "Get user subscribe node info"
|
||||
@handler QueryUserSubscribeNodeList
|
||||
get /node/list returns (QueryUserSubscribeNodeListResponse)
|
||||
@doc "Get user subscribe node info"
|
||||
@handler QueryUserSubscribeNodeList
|
||||
get /node/list returns (QueryUserSubscribeNodeListResponse)
|
||||
}
|
||||
|
||||
|
||||
+92
-27
@@ -66,9 +66,7 @@ type (
|
||||
UnbindOAuthRequest {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
ResetUserSubscribeTokenRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
|
||||
GetLoginLogRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
@@ -97,27 +95,63 @@ 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"`
|
||||
|
||||
GetDeviceListResponse {
|
||||
List []UserDevice `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
UnbindDeviceRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
}
|
||||
|
||||
UpdateUserSubscribeNoteRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id" validate:"required"`
|
||||
Note string `json:"note" validate:"max=500"`
|
||||
}
|
||||
BindEmailWithVerificationResponse {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Token string `json:"token,omitempty"` // 设备关联后的新Token
|
||||
UserId int64 `json:"user_id,omitempty"` // 目标用户ID
|
||||
|
||||
UpdateUserRulesRequest {
|
||||
Rules []string `json:"rules" validate:"required"`
|
||||
}
|
||||
GetDeviceListResponse {
|
||||
List []UserDevice `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
CommissionWithdrawRequest {
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
UnbindDeviceRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
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"`
|
||||
}
|
||||
GetSubscribeStatusResponse {
|
||||
DeviceStatus bool `json:"device_status"`
|
||||
EmailStatus bool `json:"email_status"`
|
||||
QueryWithdrawalLogListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
QueryWithdrawalLogListResponse {
|
||||
List []WithdrawalLog `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
|
||||
GetDeviceOnlineStatsResponse {
|
||||
WeeklyStats []WeeklyStat `json:"weekly_stats"`
|
||||
ConnectionRecords ConnectionRecords `json:"connection_records"`
|
||||
}
|
||||
|
||||
WeeklyStat {
|
||||
Day int `json:"day"`
|
||||
DayName string `json:"day_name"`
|
||||
Hours float64 `json:"hours"`
|
||||
}
|
||||
ConnectionRecords {
|
||||
CurrentContinuousDays int64 `json:"current_continuous_days"`
|
||||
HistoryContinuousDays int64 `json:"history_continuous_days"`
|
||||
LongestSingleConnection int64 `json:"longest_single_connection"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@@ -202,10 +236,6 @@ 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)
|
||||
@@ -218,12 +248,47 @@ service ppanel {
|
||||
@handler UpdateBindEmail
|
||||
put /bind_email (UpdateBindEmailRequest)
|
||||
|
||||
@doc "Get Device List"
|
||||
@handler GetDeviceList
|
||||
get /devices returns (GetDeviceListResponse)
|
||||
@doc "Get Device List"
|
||||
@handler GetDeviceList
|
||||
get /devices returns (GetDeviceListResponse)
|
||||
|
||||
@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)
|
||||
|
||||
@doc "Device Online Statistics"
|
||||
@handler DeviceOnlineStatistics
|
||||
get /device_online_statistics returns (GetDeviceOnlineStatsResponse)
|
||||
|
||||
@doc "Delete Current User Account"
|
||||
@handler DeleteCurrentUserAccount
|
||||
delete /current_user_account
|
||||
|
||||
}
|
||||
@server(
|
||||
prefix: v1/public/user
|
||||
group: public/user/ws
|
||||
middleware: AuthMiddleware
|
||||
)
|
||||
|
||||
service ppanel {
|
||||
@doc "Webosocket Device Connect"
|
||||
@handler DeviceWsConnect
|
||||
get /device_ws_connect
|
||||
}
|
||||
|
||||
+98
-35
@@ -26,14 +26,23 @@ 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"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
PurchasedPackage string `json:"purchased_package,omitempty"`
|
||||
FamilyJoined bool `json:"family_joined,omitempty"`
|
||||
FamilyId int64 `json:"family_id,omitempty"`
|
||||
FamilyRole uint8 `json:"family_role,omitempty"`
|
||||
FamilyRoleName string `json:"family_role_name,omitempty"`
|
||||
FamilyOwnerUserId int64 `json:"family_owner_user_id,omitempty"`
|
||||
FamilyStatus string `json:"family_status,omitempty"`
|
||||
FamilyMemberCount int64 `json:"family_member_count,omitempty"`
|
||||
FamilyMaxMembers int64 `json:"family_max_members,omitempty"`
|
||||
}
|
||||
Follow {
|
||||
Id int64 `json:"id"`
|
||||
@@ -152,6 +161,7 @@ type (
|
||||
EnableIpRegisterLimit bool `json:"enable_ip_register_limit"`
|
||||
IpRegisterLimit int64 `json:"ip_register_limit"`
|
||||
IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
}
|
||||
VerifyConfig {
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
@@ -187,6 +197,7 @@ type (
|
||||
ForcedInvite bool `json:"forced_invite"`
|
||||
ReferralPercentage int64 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase bool `json:"only_first_purchase"`
|
||||
GiftDays int64 `json:"gift_days"`
|
||||
}
|
||||
TelegramConfig {
|
||||
TelegramBotToken string `json:"telegram_bot_token"`
|
||||
@@ -207,33 +218,35 @@ type (
|
||||
}
|
||||
SubscribeDiscount {
|
||||
Quantity int64 `json:"quantity"`
|
||||
Discount int64 `json:"discount"`
|
||||
Discount float64 `json:"discount"`
|
||||
}
|
||||
Subscribe {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show bool `json:"show"`
|
||||
Sell bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
AllowDeduction bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset bool `json:"renewal_reset"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
NodeCount int64 `json:"node_count"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show bool `json:"show"`
|
||||
Sell bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
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"`
|
||||
}
|
||||
SubscribeGroup {
|
||||
Id int64 `json:"id"`
|
||||
@@ -447,6 +460,28 @@ type (
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
RedemptionCode {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
UsedCount int64 `json:"used_count"`
|
||||
SubscribePlan int64 `json:"subscribe_plan"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
Status int64 `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
RedemptionRecord {
|
||||
Id int64 `json:"id"`
|
||||
RedemptionCodeId int64 `json:"redemption_code_id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
RedeemedAt int64 `json:"redeemed_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
Announcement {
|
||||
Id int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -472,6 +507,7 @@ 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"`
|
||||
}
|
||||
@@ -505,6 +541,31 @@ type (
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
FamilySummary {
|
||||
FamilyId int64 `json:"family_id"`
|
||||
OwnerUserId int64 `json:"owner_user_id"`
|
||||
OwnerIdentifier string `json:"owner_identifier"`
|
||||
Status string `json:"status"`
|
||||
ActiveMemberCount int64 `json:"active_member_count"`
|
||||
MaxMembers int64 `json:"max_members"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
FamilyMemberItem {
|
||||
UserId int64 `json:"user_id"`
|
||||
Identifier string `json:"identifier"`
|
||||
Role uint8 `json:"role"`
|
||||
RoleName string `json:"role_name"`
|
||||
Status uint8 `json:"status"`
|
||||
StatusName string `json:"status_name"`
|
||||
JoinSource string `json:"join_source"`
|
||||
JoinedAt int64 `json:"joined_at"`
|
||||
LeftAt int64 `json:"left_at,omitempty"`
|
||||
}
|
||||
FamilyDetail {
|
||||
Summary FamilySummary `json:"summary"`
|
||||
Members []FamilyMemberItem `json:"members"`
|
||||
}
|
||||
UserAuthMethod {
|
||||
AuthType string `json:"auth_type"`
|
||||
AuthIdentifier string `json:"auth_identifier"`
|
||||
@@ -580,7 +641,7 @@ type (
|
||||
//public order
|
||||
PurchaseOrderRequest {
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Quantity int64 `json:"quantity" validate:"required,gt=0"`
|
||||
Quantity int64 `json:"quantity" validate:"required,gt=0,lte=1000"`
|
||||
Payment int64 `json:"payment,omitempty"`
|
||||
Coupon string `json:"coupon,omitempty"`
|
||||
}
|
||||
@@ -598,7 +659,7 @@ type (
|
||||
}
|
||||
RenewalOrderRequest {
|
||||
UserSubscribeID int64 `json:"user_subscribe_id"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
Quantity int64 `json:"quantity" validate:"lte=1000"`
|
||||
Payment int64 `json:"payment"`
|
||||
Coupon string `json:"coupon,omitempty"`
|
||||
}
|
||||
@@ -613,7 +674,7 @@ type (
|
||||
OrderNo string `json:"order_no"`
|
||||
}
|
||||
RechargeOrderRequest {
|
||||
Amount int64 `json:"amount"`
|
||||
Amount int64 `json:"amount" validate:"required,gt=0,lte=2000000000"`
|
||||
Payment int64 `json:"payment"`
|
||||
}
|
||||
RechargeOrderResponse {
|
||||
@@ -634,10 +695,8 @@ type (
|
||||
PublishableKey string `json:"publishable_key"`
|
||||
}
|
||||
QueryOrderListRequest {
|
||||
Page int `form:"page" validate:"required"`
|
||||
Size int `form:"size" validate:"required"`
|
||||
Status uint8 `form:"status,omitempty"`
|
||||
Search string `form:"search,omitempty"`
|
||||
Page int `form:"page" validate:"required"`
|
||||
Size int `form:"size" validate:"required"`
|
||||
}
|
||||
QueryOrderListResponse {
|
||||
Total int64 `json:"total"`
|
||||
@@ -675,6 +734,7 @@ type (
|
||||
List []SubscribeGroup `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
GetUserSubscribeTrafficLogsRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
@@ -847,5 +907,8 @@ 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"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
SET CGO_ENABLED=0
|
||||
SET GOOS=linux
|
||||
SET GOARCH=amd64
|
||||
go build -o ppanel .\ppanel.go
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 63 MiB After Width: | Height: | Size: 61 MiB |
@@ -23,7 +23,6 @@ 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"
|
||||
@@ -50,7 +49,6 @@ 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)
|
||||
@@ -91,9 +89,6 @@ 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()
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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,73 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/updater"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
checkOnly bool
|
||||
)
|
||||
|
||||
var updateCmd = &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Check for updates and update PPanel to the latest version",
|
||||
Long: `Check for available updates from GitHub releases and automatically
|
||||
update the PPanel binary to the latest version.
|
||||
|
||||
Examples:
|
||||
# Check for updates only
|
||||
ppanel-server update --check
|
||||
|
||||
# Update to the latest version
|
||||
ppanel-server update`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
u := updater.NewUpdater()
|
||||
|
||||
if checkOnly {
|
||||
checkForUpdates(u)
|
||||
return
|
||||
}
|
||||
|
||||
performUpdate(u)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
updateCmd.Flags().BoolVarP(&checkOnly, "check", "c", false, "Check for updates without applying them")
|
||||
}
|
||||
|
||||
func checkForUpdates(u *updater.Updater) {
|
||||
fmt.Println("Checking for updates...")
|
||||
|
||||
release, hasUpdate, err := u.CheckForUpdates()
|
||||
if err != nil {
|
||||
fmt.Printf("Error checking for updates: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasUpdate {
|
||||
fmt.Println("You are already running the latest version!")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\nNew version available!\n")
|
||||
fmt.Printf("Current version: %s\n", u.CurrentVersion)
|
||||
fmt.Printf("Latest version: %s\n", release.TagName)
|
||||
fmt.Printf("\nRelease notes:\n%s\n", release.Body)
|
||||
fmt.Printf("\nTo update, run: ppanel-server update\n")
|
||||
}
|
||||
|
||||
func performUpdate(u *updater.Updater) {
|
||||
fmt.Println("Starting update process...")
|
||||
|
||||
if err := u.Update(); err != nil {
|
||||
fmt.Printf("Update failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nUpdate completed successfully!")
|
||||
fmt.Println("Please restart the application to use the new version.")
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# API 版本分流接入指南(`api-header`)
|
||||
|
||||
## 目标
|
||||
- 通过请求头 `api-header` 动态选择不同 Handler(如 `001Handler` / `002Handler`)。
|
||||
- 让旧 App 无需升级仍可走旧逻辑,新 App 通过版本头走新逻辑。
|
||||
|
||||
## 当前规则
|
||||
- 仅识别请求头:`api-header`
|
||||
- 严格版本格式:`x.y.z` 或 `vx.y.z`
|
||||
- 仅当 `api-header > 1.0.0` 时走新逻辑(V2)
|
||||
- 其余情况(缺失/非法/`<=1.0.0`)走旧逻辑(V1)
|
||||
|
||||
相关代码:
|
||||
- 版本解析:`pkg/apiversion/version.go`
|
||||
- 版本注入中间件:`internal/middleware/apiVersionMiddleware.go`
|
||||
- 通用分流器:`internal/middleware/apiVersionSwitchHandler.go`
|
||||
|
||||
## 新接口接入步骤(推荐)
|
||||
|
||||
### 1) 实现两个 Handler
|
||||
```go
|
||||
func FooV1Handler(svcCtx *svc.ServiceContext) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 旧逻辑(001)
|
||||
}
|
||||
}
|
||||
|
||||
func FooV2Handler(svcCtx *svc.ServiceContext) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 新逻辑(002)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2) 在路由中挂分流器
|
||||
```go
|
||||
group.POST("/foo", middleware.ApiVersionSwitchHandler(
|
||||
foo.FooV1Handler(serverCtx),
|
||||
foo.FooV2Handler(serverCtx),
|
||||
))
|
||||
```
|
||||
|
||||
完成后,无需在业务代码里手写 `api-header` 判断。
|
||||
|
||||
## 客户端调用示例
|
||||
|
||||
### 旧逻辑(V1)
|
||||
```bash
|
||||
curl -X POST 'https://example.com/v1/common/foo' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"x":1}'
|
||||
```
|
||||
|
||||
### 新逻辑(V2)
|
||||
```bash
|
||||
curl -X POST 'https://example.com/v1/common/foo' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'api-header: 1.0.1' \
|
||||
-d '{"x":1}'
|
||||
```
|
||||
|
||||
## 测试建议(最小集)
|
||||
- 无 `api-header`:命中 V1
|
||||
- `api-header: 1.0.0`:命中 V1
|
||||
- `api-header: 1.0.1`:命中 V2
|
||||
- `api-header: abc`:命中 V1
|
||||
|
||||
可参考测试:
|
||||
- `internal/middleware/apiVersionSwitchHandler_test.go`
|
||||
|
||||
## 适用建议
|
||||
- 差异较小:优先在 V2 中复用现有逻辑,减少重复代码。
|
||||
- 差异较大:拆分 V1/V2 各自逻辑,避免分支污染。
|
||||
- 上线顺序:先发后端分流能力,再逐步让客户端加 `api-header`。
|
||||
@@ -1,101 +0,0 @@
|
||||
# 客户端错误上报接口文档
|
||||
|
||||
## 接口概览
|
||||
- 路径:`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 端统一封装上报模块,包含:字段收集、级别过滤、采样策略、离线缓存与重试、速率限制配合。
|
||||
@@ -0,0 +1,58 @@
|
||||
# GORM + Redis 缓存调用规范
|
||||
|
||||
本文档约定了项目内 GORM 与 Redis 缓存的推荐调用方式,目标是避免“有的地方自动清缓存,有的地方手动清缓存”的分散写法。
|
||||
|
||||
## 统一入口
|
||||
|
||||
项目缓存统一由 `pkg/cache/CachedConn` 提供,常用方法:
|
||||
|
||||
- `QueryCtx`:读缓存,未命中回源 DB 并写缓存
|
||||
- `ExecCtx`:写 DB,成功后删除指定缓存 key
|
||||
- `QueryNoCacheCtx`:只查 DB,不读写缓存
|
||||
- `ExecNoCacheCtx`:只写 DB,不自动清缓存
|
||||
- `TransactCtx`:事务执行
|
||||
|
||||
## 推荐使用规则
|
||||
|
||||
### 1) 主键/唯一键查询(有稳定 key)
|
||||
|
||||
优先使用 `QueryCtx`,必须显式构建缓存 key。
|
||||
|
||||
### 2) 列表/复杂筛选查询
|
||||
|
||||
仅当 key 规则稳定时用 `QueryCtx`;否则用 `QueryNoCacheCtx`。
|
||||
|
||||
### 3) 写操作(新增/更新/删除)
|
||||
|
||||
优先使用“统一 helper”封装为一条路径:
|
||||
|
||||
1. 执行 DB 写入
|
||||
2. 按模型计算 key
|
||||
3. 清理关联缓存
|
||||
|
||||
避免在业务逻辑层分散调用 `DelCache`。
|
||||
|
||||
## user_subscribe 规范落地
|
||||
|
||||
`internal/model/user/subscribe.go` 已提供统一 helper:
|
||||
|
||||
- `execSubscribeMutation(...)`
|
||||
|
||||
`InsertSubscribe / UpdateSubscribe / DeleteSubscribe / DeleteSubscribeById` 统一通过该 helper 执行,避免每个方法重复写“ExecNoCacheCtx + defer 清缓存”。
|
||||
|
||||
## key 生成约定
|
||||
|
||||
模型缓存 key 统一在模型侧定义,不在 handler/logic 手写:
|
||||
|
||||
- `internal/model/user/cache.go`
|
||||
- `(*Subscribe).GetCacheKeys()`
|
||||
- `ClearSubscribeCacheByModels(...)`
|
||||
|
||||
> 说明:`user_subscribe` 当前会同时清理普通列表 key 与 `:all` 列表 key,避免删改后列表残留旧缓存。
|
||||
|
||||
## 新增模型时的最小模板
|
||||
|
||||
1. 在 model 中定义 `getCacheKeys(...)` 或 `GetCacheKeys()`
|
||||
2. 查询方法优先 `QueryCtx`
|
||||
3. 写方法统一走 helper(DB 写 + 缓存失效)
|
||||
4. 避免在 handler/logic 直接操作缓存 key
|
||||
@@ -159,43 +159,3 @@ 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 权益状态与到期时间。
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
# 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
@@ -1,113 +0,0 @@
|
||||
# 项目加解密使用说明
|
||||
|
||||
本指南介绍了 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
@@ -1,42 +0,0 @@
|
||||
# 报错日志收集(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,354 @@
|
||||
# PPanel 服务部署 (云端/无源码版)
|
||||
# 使用方法:
|
||||
# 1. 确保已将 docker-compose.cloud.yml, configs/, loki/, grafana/, prometheus/ 目录上传到服务器同一目录
|
||||
# 2. 确保 configs/ 目录下有 ppanel.yaml 配置文件
|
||||
# 3. 确保 logs/ 目录存在 (mkdir logs)
|
||||
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
|
||||
|
||||
services:
|
||||
# ----------------------------------------------------
|
||||
# 1. 业务后端 (PPanel Server)
|
||||
# ----------------------------------------------------
|
||||
ppanel-server:
|
||||
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:-latest}
|
||||
container_name: ppanel-server
|
||||
restart: always
|
||||
volumes:
|
||||
- ./configs:/app/etc
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
# 链路追踪配置 (OTLP)
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
|
||||
- OTEL_SERVICE_NAME=ppanel-server
|
||||
- OTEL_TRACES_EXPORTER=otlp
|
||||
- OTEL_METRICS_EXPORTER=prometheus # 指标由 tempo 抓取,不使用 OTLP
|
||||
network_mode: host
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
depends_on:
|
||||
- mysql
|
||||
- redis
|
||||
- tempo
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 14. Tempo (链路追踪存储 - 替代/增强 Jaeger)
|
||||
# ----------------------------------------------------
|
||||
tempo:
|
||||
image: grafana/tempo:2.4.1
|
||||
container_name: ppanel-tempo
|
||||
user: root
|
||||
restart: always
|
||||
command:
|
||||
- "-config.file=/etc/tempo.yaml"
|
||||
- "-target=all"
|
||||
volumes:
|
||||
- ./tempo/tempo-config.yaml:/etc/tempo.yaml # - tempo_data:/var/tempo
|
||||
- ./tempo_data:/var/tempo # 改为映射到当前目录,确保数据彻底干净
|
||||
|
||||
ports:
|
||||
- "3200:3200"
|
||||
- "4317:4317"
|
||||
- "4318:4318"
|
||||
- "9095:9095"
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 2. MySQL Database
|
||||
# ----------------------------------------------------
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: ppanel-mysql
|
||||
restart: always
|
||||
ports:
|
||||
- "3306:3306" # 临时开放外部访问,用完记得关闭!
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: "jpcV41ppanel" # 请修改为强密码
|
||||
MYSQL_DATABASE: "ppanel"
|
||||
TZ: Asia/Shanghai
|
||||
command:
|
||||
- --default-authentication-plugin=mysql_native_password
|
||||
- --innodb_buffer_pool_size=16G
|
||||
- --innodb_buffer_pool_instances=16
|
||||
- --innodb_log_file_size=2G
|
||||
- --innodb_flush_log_at_trx_commit=2
|
||||
- --innodb_io_capacity=5000
|
||||
- --max_connections=5000
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 3. Redis
|
||||
# ----------------------------------------------------
|
||||
redis:
|
||||
image: redis:8.2.1
|
||||
container_name: ppanel-redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command:
|
||||
- redis-server
|
||||
- --tcp-backlog 65535
|
||||
- --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 4. Loki (日志存储)
|
||||
# ----------------------------------------------------
|
||||
loki:
|
||||
image: grafana/loki:3.0.0
|
||||
container_name: ppanel-loki
|
||||
restart: always
|
||||
volumes:
|
||||
# 必须上传 loki 目录到服务器
|
||||
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
|
||||
- loki_data:/loki
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
ports:
|
||||
- "3100:3100"
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 5. Promtail (日志采集)
|
||||
# ----------------------------------------------------
|
||||
promtail:
|
||||
image: grafana/promtail:3.0.0
|
||||
container_name: ppanel-promtail
|
||||
restart: always
|
||||
volumes:
|
||||
- ./loki/promtail-config.yaml:/etc/promtail/config.yaml
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 采集当前目录下的 logs 文件夹
|
||||
- ./logs:/var/log/ppanel-server:ro
|
||||
# 采集 Nginx 访问日志(用于追踪邀请码来源)
|
||||
- /var/log/nginx:/var/log/nginx:ro
|
||||
command: -config.file=/etc/promtail/config.yaml
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- loki
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 6. Grafana (日志界面)
|
||||
# ----------------------------------------------------
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: ppanel-grafana
|
||||
restart: always
|
||||
ports:
|
||||
- "3333:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_FEATURE_TOGGLES_ENABLE=appObservability #- GF_INSTALL_PLUGINS=redis-datasource
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- loki
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 7. Prometheus (指标采集)
|
||||
# ----------------------------------------------------
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: ppanel-prometheus
|
||||
restart: always
|
||||
ports:
|
||||
- "9090:9090" # 暴露端口便于调试
|
||||
volumes:
|
||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus_data:/prometheus
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.enable-lifecycle'
|
||||
- '--web.enable-remote-write-receiver'
|
||||
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 8. Redis Exporter (Redis指标导出)
|
||||
# ----------------------------------------------------
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:latest
|
||||
container_name: ppanel-redis-exporter
|
||||
restart: always
|
||||
environment:
|
||||
- REDIS_ADDR=redis://redis:6379
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- redis
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 9. Nginx Exporter (监控宿主机 Nginx)
|
||||
# ----------------------------------------------------
|
||||
nginx-exporter:
|
||||
image: nginx/nginx-prometheus-exporter:latest
|
||||
container_name: ppanel-nginx-exporter
|
||||
restart: always
|
||||
# 使用 host.docker.internal 访问宿主机
|
||||
command:
|
||||
- -nginx.scrape-uri=http://host.docker.internal:8090/nginx_status
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 10. MySQL Exporter (MySQL指标导出)
|
||||
# ----------------------------------------------------
|
||||
mysql-exporter:
|
||||
image: prom/mysqld-exporter:latest
|
||||
container_name: ppanel-mysql-exporter
|
||||
restart: always
|
||||
command:
|
||||
- --config.my-cnf=/etc/.my.cnf
|
||||
volumes:
|
||||
- ./mysql/.my.cnf:/etc/.my.cnf:ro
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- mysql
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 12. Node Exporter (宿主机监控)
|
||||
# ----------------------------------------------------
|
||||
node-exporter:
|
||||
image: prom/node-exporter:latest
|
||||
container_name: ppanel-node-exporter
|
||||
restart: always
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.sysfs=/host/sys'
|
||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 13. cAdvisor (容器监控)
|
||||
# ----------------------------------------------------
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:latest
|
||||
container_name: ppanel-cadvisor
|
||||
restart: always
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk/:/dev/disk:ro
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
redis_data:
|
||||
loki_data:
|
||||
grafana_data:
|
||||
prometheus_data:
|
||||
tempo_data:
|
||||
|
||||
|
||||
networks:
|
||||
ppanel_net:
|
||||
name: ppanel_net
|
||||
driver: bridge
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
# 设备移出和邀请码优化 - 验收报告
|
||||
|
||||
## 修复内容回顾
|
||||
|
||||
### 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 错误码和 "无邀请码" 提示。
|
||||
|
||||
---
|
||||
|
||||
## 遗留问题 / 注意事项
|
||||
- 无
|
||||
|
||||
## 结论
|
||||
修复已完成,符合预期。
|
||||
@@ -1,160 +0,0 @@
|
||||
# 设备管理系统 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
|
||||
@@ -1,117 +0,0 @@
|
||||
# 设备移出和邀请码优化 - 共识文档(更新版)
|
||||
|
||||
## 需求概述
|
||||
|
||||
修复两个 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是否被踢下线
|
||||
- 输入无效邀请码 → 检查错误提示
|
||||
@@ -1,96 +0,0 @@
|
||||
# 设备移出和邀请码优化 - 设计文档
|
||||
|
||||
## 整体架构
|
||||
|
||||
本次修复涉及两个独立的 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. **代码风格一致**:遵循项目现有的错误处理模式
|
||||
@@ -1,20 +0,0 @@
|
||||
# 设备移出和邀请码优化 - 项目总结
|
||||
|
||||
## 项目概览
|
||||
本次任务修复了两个影响用户体验的 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` 的内存使用情况,确保大量的踢出操作不会造成锁竞争。
|
||||
@@ -1,91 +0,0 @@
|
||||
# 设备移出和邀请码优化 - 任务清单
|
||||
|
||||
## 任务依赖图
|
||||
|
||||
```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
@@ -1,309 +0,0 @@
|
||||
第一个文件
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
Host: # 服务监听地址
|
||||
Port: 8080 # 服务监听端口, 默认: 8080
|
||||
Debug: true # 是否开启调试模式, 默认: false
|
||||
|
||||
JwtAuth: # JWT认证配置
|
||||
AccessSecret: CHANGE_ME_TO_A_RANDOM_SECRET # 访问令牌密钥, 请修改为随机字符串
|
||||
AccessExpire: 604800 # 访问令牌过期时间,单位秒, 默认: 604800 (7天)
|
||||
|
||||
Logger: # 日志配置
|
||||
FilePath: logs/ppanel.log # 日志文件路径
|
||||
MaxSize: 50 # 日志文件最大大小, 单位MB
|
||||
MaxBackup: 3 # 日志文件最大备份数
|
||||
MaxAge: 30 # 日志文件最大保存时间,单位天
|
||||
Compress: true # 是否压缩日志文件
|
||||
Level: debug # 日志级别: debug, info, warn, error, panic, fatal
|
||||
|
||||
MySQL:
|
||||
Addr: 127.0.0.1:3306 # MySQL地址
|
||||
Username: root # MySQL用户名 (与创建的用户一致)
|
||||
Password: password # MySQL密码 (换成之前生成的随机密码)
|
||||
Dbname: ppanel # MySQL数据库名 (与脚本创建的数据库一致)
|
||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||
MaxIdleConns: 10
|
||||
MaxOpenConns: 100
|
||||
LogMode: debug
|
||||
LogZap: true
|
||||
SlowThreshold: 1000
|
||||
|
||||
Redis:
|
||||
Host: 127.0.0.1:6379 # Redis地址,格式:host:port
|
||||
Pass: # Redis密码,如果没有设置密码可以留空
|
||||
DB: 0 # Redis数据库编号,默认0
|
||||
PoolSize: 100 # 连接池大小(最大连接数),根据应用并发量调整,建议:小流量50-100,中流量100-300,大流量300-500
|
||||
MinIdleConns: 10 # 最小空闲连接数,保持一定数量的空闲连接以减少建立连接的开销,建议为PoolSize的10%
|
||||
MaxRetries: 3 # 最大重试次数,网络抖动时自动重试,建议2-3次
|
||||
PoolTimeout: 4 # 连接池超时时间(秒),从连接池获取连接的最大等待时间,建议3-5秒
|
||||
IdleTimeout: 300 # 空闲连接超时时间(秒),自动回收长时间空闲的连接,建议300-600秒(5-10分钟)
|
||||
MaxConnAge: 0 # 连接最大生命周期(秒),定期重建连接避免长时间使用的问题,0表示不限制,建议7200秒(2小时)或0
|
||||
DialTimeout: 5 # 连接超时时间(秒),建立新连接的超时时间,建议5秒
|
||||
ReadTimeout: 3 # 读操作超时时间(秒),建议2-3秒
|
||||
WriteTimeout: 3 # 写操作超时时间(秒),建议2-3秒
|
||||
|
||||
Administrator:
|
||||
Email: admin@ppanel.dev # 后台登录邮箱,请修改
|
||||
Password: CHANGE_ME_TO_STRONG_PASSWORD # 后台登录密码,请修改为强密码
|
||||
@@ -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.35.0
|
||||
github.com/alicebob/miniredis/v2 v2.34.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.9.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
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.11
|
||||
github.com/klauspost/compress v1.17.7
|
||||
github.com/nyaruka/phonenumbers v1.5.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/redis/go-redis/v9 v9.14.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/redis/go-redis/v9 v9.7.2
|
||||
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.11.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
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.10.0
|
||||
golang.org/x/time v0.6.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.65.0
|
||||
google.golang.org/protobuf v1.36.5
|
||||
gorm.io/driver/sqlite v1.4.4
|
||||
google.golang.org/grpc v1.64.1
|
||||
google.golang.org/protobuf v1.36.3
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -79,11 +79,12 @@ 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.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 // 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
|
||||
@@ -93,7 +94,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.1 // indirect
|
||||
github.com/golang/glog v1.2.0 // 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
|
||||
@@ -112,12 +113,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.3 // indirect
|
||||
github.com/openzipkin/zipkin-go v0.4.2 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 // 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
|
||||
@@ -134,15 +135,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.10.0 // indirect
|
||||
go.uber.org/atomic v1.7.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.35.0 // indirect
|
||||
golang.org/x/net v0.34.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-20240711142825-46eb208f015d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // 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
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
)
|
||||
|
||||
@@ -52,8 +52,10 @@ 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/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
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/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=
|
||||
@@ -76,8 +78,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.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
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/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=
|
||||
@@ -142,8 +144,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.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
|
||||
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
|
||||
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-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=
|
||||
@@ -158,8 +160,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.1 h1:OptwRhECazUx5ix5TTWC3EZhsZEHWcYWY4FQHTIubm4=
|
||||
github.com/golang/glog v1.2.1/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
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/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=
|
||||
@@ -230,8 +232,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.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
|
||||
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
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=
|
||||
@@ -255,7 +257,6 @@ 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=
|
||||
@@ -282,8 +283,12 @@ 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.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
|
||||
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
|
||||
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/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=
|
||||
@@ -293,8 +298,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.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
|
||||
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
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/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=
|
||||
@@ -337,9 +342,8 @@ 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=
|
||||
@@ -383,8 +387,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.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
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=
|
||||
@@ -431,8 +435,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.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
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/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=
|
||||
@@ -481,8 +485,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.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
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/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=
|
||||
@@ -503,16 +507,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-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/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/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.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
|
||||
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
|
||||
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
|
||||
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
|
||||
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=
|
||||
@@ -521,8 +525,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.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||
google.golang.org/protobuf v1.36.3/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=
|
||||
@@ -548,7 +552,6 @@ 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=
|
||||
|
||||
+22
-1
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/perfect-panel/server/internal/report"
|
||||
"github.com/perfect-panel/server/pkg/logger"
|
||||
"gorm.io/driver/mysql"
|
||||
|
||||
@@ -35,10 +36,30 @@ 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: ":8080",
|
||||
Addr: fmt.Sprintf("%s:%d", host, port),
|
||||
Handler: r,
|
||||
}
|
||||
// Load templates
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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,6 +15,7 @@ func StartInitSystemConfig(svc *svc.ServiceContext) {
|
||||
Subscribe(svc)
|
||||
Register(svc)
|
||||
Mobile(svc)
|
||||
Currency(svc)
|
||||
if !svc.Config.Debug {
|
||||
Telegram(svc)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
ALTER TABLE traffic_log ADD INDEX IF NOT EXISTS idx_timestamp (timestamp);
|
||||
CREATE INDEX idx_timestamp ON traffic_log (timestamp);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
-- Add longitude if not exists
|
||||
-- Only add the columns to `servers` when they do not already exist
|
||||
|
||||
-- Add longitude
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
@@ -6,16 +8,18 @@ SET @col_exists := (
|
||||
AND TABLE_NAME = 'servers'
|
||||
AND COLUMN_NAME = 'longitude'
|
||||
);
|
||||
SET @sql := IF(
|
||||
|
||||
SET @query := IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `servers` ADD COLUMN `longitude` VARCHAR(255) DEFAULT '''' COMMENT ''longitude''',
|
||||
'SELECT 1'
|
||||
'ALTER TABLE `servers` ADD COLUMN `longitude` VARCHAR(255) DEFAULT '''' COMMENT ''longitude'';',
|
||||
'SELECT "Column `longitude` already exists"'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Add latitude if not exists
|
||||
-- Add latitude
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
@@ -23,16 +27,18 @@ SET @col_exists := (
|
||||
AND TABLE_NAME = 'servers'
|
||||
AND COLUMN_NAME = 'latitude'
|
||||
);
|
||||
SET @sql := IF(
|
||||
|
||||
SET @query := IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `servers` ADD COLUMN `latitude` VARCHAR(255) DEFAULT '''' COMMENT ''latitude''',
|
||||
'SELECT 1'
|
||||
'ALTER TABLE `servers` ADD COLUMN `latitude` VARCHAR(255) DEFAULT '''' COMMENT ''latitude'';',
|
||||
'SELECT "Column `latitude` already exists"'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Add longitude_center if not exists
|
||||
-- Add longitude_center
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
@@ -40,16 +46,18 @@ SET @col_exists := (
|
||||
AND TABLE_NAME = 'servers'
|
||||
AND COLUMN_NAME = 'longitude_center'
|
||||
);
|
||||
SET @sql := IF(
|
||||
|
||||
SET @query := IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `servers` ADD COLUMN `longitude_center` VARCHAR(255) DEFAULT '''' COMMENT ''longitude center''',
|
||||
'SELECT 1'
|
||||
'ALTER TABLE `servers` ADD COLUMN `longitude_center` VARCHAR(255) DEFAULT '''' COMMENT ''longitude center'';',
|
||||
'SELECT "Column `longitude_center` already exists"'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Add latitude_center if not exists
|
||||
-- Add latitude_center
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
@@ -57,11 +65,14 @@ SET @col_exists := (
|
||||
AND TABLE_NAME = 'servers'
|
||||
AND COLUMN_NAME = 'latitude_center'
|
||||
);
|
||||
SET @sql := IF(
|
||||
|
||||
SET @query := IF(
|
||||
@col_exists = 0,
|
||||
'ALTER TABLE `servers` ADD COLUMN `latitude_center` VARCHAR(255) DEFAULT '''' COMMENT ''latitude center''',
|
||||
'SELECT 1'
|
||||
'ALTER TABLE `servers` ADD COLUMN `latitude_center` VARCHAR(255) DEFAULT '''' COMMENT ''latitude center'';',
|
||||
'SELECT "Column `latitude_center` already exists"'
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
|
||||
PREPARE stmt FROM @query;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `user_subscribe`
|
||||
DROP COLUMN `note`;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `user_subscribe`
|
||||
ADD COLUMN `note` VARCHAR(500) NOT NULL DEFAULT ''
|
||||
COMMENT 'User note for subscription'
|
||||
AFTER `status`;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `user`
|
||||
DROP COLUMN IF EXISTS `rules`;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `user`
|
||||
ADD COLUMN `rules` TEXT NULL
|
||||
COMMENT 'User rules for subscription'
|
||||
AFTER `created_at`;
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP TABLE IF EXISTS `withdrawals`;
|
||||
|
||||
DELETE FROM `system`
|
||||
WHERE `category` = 'invite'
|
||||
AND `key` = 'WithdrawalMethod';
|
||||
@@ -0,0 +1,16 @@
|
||||
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,2 @@
|
||||
ALTER TABLE `subscribe`
|
||||
DROP COLUMN `show_original_price`;
|
||||
@@ -0,0 +1,2 @@
|
||||
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`;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS `server_group`;
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
-- This migration script reverts the inventory values in the 'subscribe' table
|
||||
UPDATE `subscribe`
|
||||
SET `inventory` = 0
|
||||
WHERE `inventory` = -1;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Update the `subscribe` table to set `inventory` to -1 where it is currently 0
|
||||
UPDATE `subscribe`
|
||||
SET `inventory` = -1
|
||||
WHERE `inventory` = 0;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Drop redemption_record table
|
||||
DROP TABLE IF EXISTS `redemption_record`;
|
||||
|
||||
-- Drop redemption_code table
|
||||
DROP TABLE IF EXISTS `redemption_code`;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Create redemption_code table
|
||||
CREATE TABLE IF NOT EXISTS `redemption_code` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Primary Key',
|
||||
`code` VARCHAR(255) NOT NULL COMMENT 'Redemption Code',
|
||||
`total_count` BIGINT NOT NULL DEFAULT 0 COMMENT 'Total Redemption Count',
|
||||
`used_count` BIGINT NOT NULL DEFAULT 0 COMMENT 'Used Redemption Count',
|
||||
`subscribe_plan` BIGINT NOT NULL DEFAULT 0 COMMENT 'Subscribe Plan',
|
||||
`unit_time` VARCHAR(50) NOT NULL DEFAULT 'month' COMMENT 'Unit Time: day, month, quarter, half_year, year',
|
||||
`quantity` BIGINT NOT NULL DEFAULT 1 COMMENT 'Quantity',
|
||||
`created_at` DATETIME NOT NULL COMMENT 'Creation Time',
|
||||
`updated_at` DATETIME NOT NULL COMMENT 'Update Time',
|
||||
`deleted_at` DATETIME DEFAULT NULL COMMENT 'Deletion Time',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_code` (`code`),
|
||||
KEY `idx_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Redemption Code Table';
|
||||
|
||||
-- Create redemption_record table
|
||||
CREATE TABLE IF NOT EXISTS `redemption_record` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Primary Key',
|
||||
`redemption_code_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Redemption Code Id',
|
||||
`user_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'User Id',
|
||||
`subscribe_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Subscribe Id',
|
||||
`unit_time` VARCHAR(50) NOT NULL DEFAULT 'month' COMMENT 'Unit Time',
|
||||
`quantity` BIGINT NOT NULL DEFAULT 1 COMMENT 'Quantity',
|
||||
`redeemed_at` DATETIME NOT NULL COMMENT 'Redeemed Time',
|
||||
`created_at` DATETIME NOT NULL COMMENT 'Creation Time',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_redemption_code_id` (`redemption_code_id`),
|
||||
KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Redemption Record Table';
|
||||
@@ -0,0 +1,17 @@
|
||||
SET @dbname = DATABASE();
|
||||
SET @tablename = 'redemption_code';
|
||||
SET @colname = 'status';
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) > 0,
|
||||
'ALTER TABLE `redemption_code` DROP COLUMN `status`;',
|
||||
'SELECT "Column `status` does not exist";'
|
||||
)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @dbname
|
||||
AND TABLE_NAME = @tablename
|
||||
AND COLUMN_NAME = @colname
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,17 @@
|
||||
SET @dbname = DATABASE();
|
||||
SET @tablename = 'redemption_code';
|
||||
SET @colname = 'status';
|
||||
SET @sql = (
|
||||
SELECT IF(
|
||||
COUNT(*) = 0,
|
||||
'ALTER TABLE `redemption_code` ADD COLUMN `status` TINYINT NOT NULL DEFAULT 1 COMMENT ''Status: 1=enabled, 0=disabled'' AFTER `quantity`;',
|
||||
'SELECT "Column `status` already exists";'
|
||||
)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = @dbname
|
||||
AND TABLE_NAME = @tablename
|
||||
AND COLUMN_NAME = @colname
|
||||
);
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Remove device limit configuration from system table
|
||||
DELETE FROM `system` WHERE `category` = 'register' AND `key` = 'DeviceLimit';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add device limit configuration to system table
|
||||
INSERT IGNORE INTO `system` (`id`, `category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
|
||||
VALUES (43, 'register', 'DeviceLimit', '5', 'int', 'Device binding limit', NOW(), NOW());
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Remove short_code column from user_device table
|
||||
ALTER TABLE `user_device` DROP COLUMN `short_code`;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add short_code column to user_device table
|
||||
ALTER TABLE `user_device` ADD COLUMN `short_code` VARCHAR(255) DEFAULT '' COMMENT 'Short Code' AFTER `identifier`;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Remove index on refer_code column
|
||||
ALTER TABLE `user` DROP INDEX `idx_refer_code`;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add index on refer_code column for faster lookup
|
||||
ALTER TABLE `user` ADD INDEX `idx_refer_code` (`refer_code`);
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
CREATE TABLE `log_message` (
|
||||
CREATE TABLE IF NOT EXISTS `log_message` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`platform` VARCHAR(32) NOT NULL,
|
||||
`app_version` VARCHAR(32) NULL,
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE auth_method MODIFY config TEXT NOT NULL COMMENT 'Auth Configuration';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE auth_method MODIFY config MEDIUMTEXT NOT NULL COMMENT 'Auth Configuration';
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS `user_family_member`;
|
||||
DROP TABLE IF EXISTS `user_family`;
|
||||
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE IF NOT EXISTS `user_family` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`owner_user_id` BIGINT NOT NULL COMMENT 'Owner User ID',
|
||||
`max_members` INT NOT NULL DEFAULT 5 COMMENT 'Max members in family',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT 'Status: 1=active, 0=disabled',
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`deleted_at` DATETIME(3) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_owner_user_id` (`owner_user_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `user_family_member` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`family_id` BIGINT NOT NULL COMMENT 'Family ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT 'Member User ID',
|
||||
`role` TINYINT NOT NULL DEFAULT 2 COMMENT 'Role: 1=owner, 2=member',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT 'Status: 1=active, 2=left, 3=removed',
|
||||
`join_source` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Join source',
|
||||
`joined_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`left_at` DATETIME(3) DEFAULT NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`deleted_at` DATETIME(3) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_user_id` (`user_id`),
|
||||
KEY `idx_family_status` (`family_id`, `status`),
|
||||
KEY `idx_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user