chore: stop tracking local-only files
This commit is contained in:
parent
4d8516b2e1
commit
d220516183
@ -1,241 +0,0 @@
|
||||
# 功能同步计划:私有版 → 开源版
|
||||
|
||||
源目录:`/Users/Apple/vpn/ppanel-server`(私有版)
|
||||
目标目录:`/Users/Apple/code_vpn/vpn/ppanel-server`(开源版)
|
||||
模块名相同:`github.com/perfect-panel/server`
|
||||
|
||||
---
|
||||
|
||||
## 批次 A:新增独立包(直接复制)
|
||||
|
||||
### A1. pkg/iap(Apple IAP)
|
||||
- 复制 `pkg/iap/` 全目录
|
||||
|
||||
### A2. pkg/kutt(短链接服务)
|
||||
- 复制 `pkg/kutt/kutt.go`
|
||||
|
||||
### A3. pkg/loki(Grafana Loki)
|
||||
- 复制 `pkg/loki/loki.go`
|
||||
|
||||
### A4. pkg/openinstall(渠道统计)
|
||||
- 复制 `pkg/openinstall/openinstall.go`
|
||||
|
||||
### A5. internal/model/iap(IAP 数据模型)
|
||||
- 复制 `internal/model/iap/` 全目录
|
||||
|
||||
### A6. internal/model/logmessage(错误日志模型)
|
||||
- 复制 `internal/model/logmessage/` 全目录
|
||||
|
||||
---
|
||||
|
||||
## 批次 B:数据库迁移
|
||||
|
||||
### B1. 新增迁移文件(仅从私有版复制)
|
||||
- `02120_log_message.up.sql` / `.down.sql`
|
||||
- `02121_apple_iap_transactions.up.sql` / `.down.sql`
|
||||
- `02122_add_user_last_login_time.up.sql` / `.down.sql`
|
||||
- `02123_update_auth_method_config.up.sql` / `.down.sql`(auth_method MEDIUMTEXT)
|
||||
|
||||
### B2. 修改共有迁移文件
|
||||
- `00002_init_basic_data.up.sql`:VerifyCodeExpireTime 默认值 300→900
|
||||
- `02118_traffic_log_idx.up.sql`:添加幂等性检查
|
||||
- `02119_node.up.sql`:@sql 变量名修正(已是私有版逻辑,保留)
|
||||
|
||||
---
|
||||
|
||||
## 批次 C:配置与基础设施
|
||||
|
||||
### C1. internal/config/config.go
|
||||
- 添加:KuttConfig、LokiConfig、OpenInstallConfig、AppleIAPConfig 结构体
|
||||
- 添加:FixedRate 浮点型汇率配置
|
||||
|
||||
### C2. internal/config/cacheKey.go
|
||||
- 添加:`UserSessionsKeyPrefix = "auth:user_sessions:"`
|
||||
|
||||
### C3. pkg/conf/default.go
|
||||
- 添加:reflect.Float64 case 处理
|
||||
|
||||
### C4. internal/svc/serviceContext.go
|
||||
- 添加:Kutt、Loki、OpenInstall、IAPModel 字段
|
||||
|
||||
### C5. internal/svc/devce.go
|
||||
- 修复 SQL Bug:`create_at` → `created_at`
|
||||
|
||||
### C6. pkg/orm/mysql.go
|
||||
- 添加:SetConnMaxIdleTime(5min) + SetConnMaxLifetime(30min)
|
||||
|
||||
### C7. pkg/exchangeRate/exchangeRate.go
|
||||
- 保持私有版的 exchangerate.host 实现
|
||||
|
||||
### C8. initialize/config.go & init.go
|
||||
- 添加 Loki、OpenInstall 初始化逻辑
|
||||
|
||||
---
|
||||
|
||||
## 批次 D:数据模型
|
||||
|
||||
### D1. internal/model/user/user.go
|
||||
- 添加:LastLoginTime 字段
|
||||
|
||||
### D2. internal/model/auth/auth.go
|
||||
- config 字段 MEDIUMTEXT(Go 结构体 tag)
|
||||
- 补全 4 种邮件模板初始化
|
||||
|
||||
### D3. internal/model/node/model.go
|
||||
- 添加:CountNodesByIdsAndTags 方法(移除 fmt.Println 调试语句)
|
||||
- 修复:ClearServerAllCache 同时清理 ServerConfig + ServerUserList
|
||||
- 修复:cursor append bug(OSS版的 append(keys, keys...))
|
||||
|
||||
### D4. internal/model/payment/payment.go
|
||||
- 添加:AppleIAPConfig 结构体
|
||||
|
||||
### D5. internal/model/user/subscribe.go
|
||||
- 添加:includeExpired 参数支持(":all" cache key suffix)
|
||||
|
||||
---
|
||||
|
||||
## 批次 E:认证与登录逻辑
|
||||
|
||||
### E1. internal/logic/auth/userLoginLogic.go
|
||||
- 添加:LastLoginTime 更新
|
||||
- 添加:邮箱登录调试日志清理
|
||||
|
||||
### E2. internal/logic/auth/userRegisterLogic.go
|
||||
- 添加:邮箱小写/trim 处理
|
||||
- 添加:验证码过期时间使用配置值
|
||||
|
||||
### E3. 新增:internal/logic/auth/emailLoginLogic.go
|
||||
- 从私有版复制邮箱直登逻辑
|
||||
|
||||
### E4. 新增:apis/auth/auth.api 邮箱登录路由
|
||||
- 添加 emailLogin 接口定义
|
||||
|
||||
---
|
||||
|
||||
## 批次 F:用户功能逻辑
|
||||
|
||||
### F1. internal/logic/public/user/queryUserInfoLogic.go
|
||||
- 添加:Kutt 短链接生成 + Redis 永久缓存
|
||||
- 添加:share_link 字段返回
|
||||
|
||||
### F2. internal/logic/public/user/queryUserSubscribeLogic.go
|
||||
- 添加:查询 Order 判断 IsGift(amount==0)
|
||||
|
||||
### F3. internal/handler/public/user/queryUserSubscribeHandler.go
|
||||
- 添加:includeExpired query param 注入 context
|
||||
|
||||
### F4. internal/model/user/model.go
|
||||
- 添加:FindActiveSubscribe / FindActiveSubscribesByUserIds
|
||||
- 添加:BatchClearRelatedCache
|
||||
- 添加:DeleteUserAuthMethodByIdentifier
|
||||
|
||||
---
|
||||
|
||||
## 批次 G:订单与支付
|
||||
|
||||
### G1. internal/logic/public/order/preCreateOrderLogic.go
|
||||
- 修复:math.Round 金额计算
|
||||
|
||||
### G2. internal/logic/public/order/purchaseLogic.go
|
||||
- 修复:math.Round
|
||||
|
||||
### G3. internal/logic/public/order/renewalLogic.go
|
||||
- 修复:math.Round
|
||||
|
||||
### G4. internal/logic/public/portal/purchaseCheckoutLogic.go
|
||||
- 添加:Apple IAP checkout case
|
||||
- 添加:currency 从 DB 动态读取 + FixedRate fallback
|
||||
- 修复:math.Round for alipay
|
||||
|
||||
### G5. internal/model/payment/model.go
|
||||
- 添加:AppleIAP payment model 支持
|
||||
|
||||
### G6. pkg/payment/platform.go
|
||||
- 添加:AppleIAP platform entry
|
||||
|
||||
---
|
||||
|
||||
## 批次 H:订阅与节点
|
||||
|
||||
### H1. internal/logic/public/portal/getSubscriptionLogic.go
|
||||
- 添加:CountNodesByIdsAndTags 调用,返回 node_count
|
||||
|
||||
### H2. internal/logic/public/portal/purchaseLogic.go
|
||||
- 添加:CloseOrderTimeMinutes 超时配置
|
||||
|
||||
### H3. internal/logic/subscribe/subscribeLogic.go
|
||||
- 保持:PanDomain 模式返回当前 Host
|
||||
|
||||
---
|
||||
|
||||
## 批次 I:队列任务
|
||||
|
||||
### I1. queue/logic/order/activateOrderLogic.go
|
||||
- 添加:findGiftSubscription 逻辑
|
||||
- 添加:extendGiftSubscription 逻辑
|
||||
- 添加:grantGiftDaysToBothParties(双向赠礼)
|
||||
- 添加:no-retry for invalid status
|
||||
|
||||
### I2. queue/logic/email/sendEmailLogic.go
|
||||
- 修改:主题中文化
|
||||
- 修改:动态过期分钟数
|
||||
- 修改:float64→int 类型转换
|
||||
- 添加:Smart Fallback 模板逻辑(4种类型)
|
||||
|
||||
### I3. queue/logic/task/rateLogic.go
|
||||
- 添加:FixedRate config 支持(跳过 API 调用)
|
||||
|
||||
---
|
||||
|
||||
## 批次 J:路由与类型
|
||||
|
||||
### J1. internal/handler/routes.go
|
||||
- 添加:IAP Apple 路由
|
||||
- 添加:email login 路由
|
||||
- 添加:error log 路由
|
||||
- 添加:contact 路由
|
||||
|
||||
### J2. internal/types/types.go
|
||||
- 添加:Apple IAP 相关 Request/Response 类型
|
||||
- 添加:BindEmailWithVerificationRequest/Response
|
||||
- 添加:BindInviteCodeRequest
|
||||
- 添加:ProductIds in checkout
|
||||
|
||||
### J3. internal/types/subscribe.go
|
||||
- 保持私有版(移除 Type/Params 开源版新增字段——已在批次 A 处理)
|
||||
|
||||
---
|
||||
|
||||
## 批次 K:邮件系统
|
||||
|
||||
### K1. pkg/email/template.go
|
||||
- 添加:Maintenance、TrafficExceed、Verify 3 种邮件模板
|
||||
|
||||
---
|
||||
|
||||
## 批次 L:管理后台
|
||||
|
||||
### L1. internal/logic/admin/user/getUserListLogic.go
|
||||
- 添加:MemberStatus + LastLoginTime 展示
|
||||
- 添加:批量获取 active subscriptions
|
||||
|
||||
### L2. internal/logic/admin/user/updateUserBasicInfoLogic.go
|
||||
- 添加:Remark + MemberStatus 更新
|
||||
- 添加:单事务包裹所有修改
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. A(新包) → B(迁移) → C(配置) → D(模型)
|
||||
2. E(认证) → F(用户) → G(订单) → H(订阅)
|
||||
3. I(队列) → J(路由) → K(邮件) → L(管理后台)
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **排除**:App版本管理、设备绑定相关逻辑(ZSet Session、DeviceId in token)
|
||||
- **调试日志清理**:不带 fmt.Println 调试语句
|
||||
- **模块名相同**:import 路径无需修改
|
||||
- **开源版新功能保留**:软删除、注册 IP 限流、兑换码系统、GeoIP 等开源版独有功能不回退
|
||||
@ -1,168 +0,0 @@
|
||||
# Team Plan: 同步私有版剩余功能到 OSS
|
||||
|
||||
源目录:`/Users/Apple/vpn/ppanel-server`(私有版)
|
||||
目标目录:`/Users/Apple/code_vpn/vpn/ppanel-server`(开源版)
|
||||
方向:私有版功能 → 开源版(保留开源版改进)
|
||||
|
||||
---
|
||||
|
||||
## Layer 1(并行)
|
||||
|
||||
### Builder-1: 路由与服务上下文(高复杂度)
|
||||
|
||||
**文件范围:**
|
||||
- `internal/handler/routes.go`
|
||||
- `internal/svc/serviceContext.go`
|
||||
|
||||
**任务:routes.go**
|
||||
1. 读取私有版 routes.go,识别以下需要添加的路由组:
|
||||
- Apple IAP 路由组:`/v1/public/iap/apple`(status, attach, restore)
|
||||
- Email login 路由:`POST /auth/login/email`
|
||||
- Error log report 路由:`POST /common/log/message/report`
|
||||
- Contact 路由:`POST /common/contact`
|
||||
- Bind email with verification:`POST /public/user/bind_email_verification`
|
||||
- Bind invite code:`POST /public/user/bind_invite_code`
|
||||
- Subscribe status:`GET /public/user/subscribe/status`
|
||||
- Agent stats/downloads/sales 路由
|
||||
- Delete account:`POST /public/user/delete_account`
|
||||
- Admin error log routes:`GET /admin/log/message/error/list`, `/detail`
|
||||
2. 保留 OSS 已有路由(Redemption、WS、heartbeat、reset_token、reset_traffic、toggle_status、IP location、module config 等)
|
||||
3. 不添加 App Version 路由(`/admin/application/version`、`/common/app/version`)
|
||||
4. 不添加 Server migration 路由
|
||||
|
||||
**任务:serviceContext.go**
|
||||
1. 添加 import:`logmessage`、`iapapple` model 包
|
||||
2. 在 ServiceContext struct 中添加字段:
|
||||
- `LogMessageModel logmessage.Model`
|
||||
- `IAPAppleTransactionModel iapapple.Model`
|
||||
3. 在 NewServiceContext 中初始化这些字段
|
||||
4. 保留 OSS 已有字段(GeoIP、RedemptionCodeModel、RedemptionRecordModel、Redis 扩展配置)
|
||||
5. 不添加 SessionLimit() 和 EnforceUserSessionLimit() 方法(设备绑定相关)
|
||||
|
||||
**验收标准:**
|
||||
- `go build ./...` 编译通过
|
||||
- 所有新路由正确注册
|
||||
- 不包含设备绑定和 App 版本管理路由
|
||||
|
||||
---
|
||||
|
||||
### Builder-2: 认证与注册逻辑(中复杂度)
|
||||
|
||||
**文件范围:**
|
||||
- `internal/logic/auth/userRegisterLogic.go`
|
||||
- `internal/logic/common/sendEmailCodeLogic.go`
|
||||
- `internal/middleware/deviceMiddleware.go`
|
||||
|
||||
**任务:userRegisterLogic.go**
|
||||
1. 添加邮箱规范化:`req.Email = strings.ToLower(strings.TrimSpace(req.Email))`(在函数开头)
|
||||
2. 保留 OSS 已有改进:IP 限流检查、已删除用户检查、activeTrial 返回 Subscribe 对象、事务外清缓存
|
||||
3. 不添加 bypass code(`"202511"`)
|
||||
4. 不添加 DeviceBindLimitExceeded 错误处理
|
||||
|
||||
**任务:sendEmailCodeLogic.go**
|
||||
1. 添加邮箱规范化:`req.Email = strings.ToLower(strings.TrimSpace(req.Email))`
|
||||
2. 添加动态验证码过期时间:从 `l.svcCtx.Config.VerifyCode.ExpireTime` 读取,默认 900 秒,转换为分钟
|
||||
3. Redis TTL 使用配置的过期时间而非硬编码
|
||||
4. 保留 OSS 已有的 Security type 手机绑定检查
|
||||
5. 不添加 DeleteAccount 邮件类型
|
||||
|
||||
**任务:deviceMiddleware.go**
|
||||
1. 统一常量名:`constant.LoginType` → `constant.CtxLoginType`
|
||||
2. 移除多余 debug 日志
|
||||
|
||||
**验收标准:**
|
||||
- 邮箱注册/验证码发送时自动 trim 和 lowercase
|
||||
- 验证码过期时间可配置
|
||||
- 编译通过
|
||||
|
||||
---
|
||||
|
||||
### Builder-3: 管理后台逻辑(中复杂度)
|
||||
|
||||
**文件范围:**
|
||||
- `internal/logic/admin/user/getUserListLogic.go`
|
||||
- `internal/logic/admin/user/updateUserBasicInfoLogic.go`
|
||||
- `internal/logic/admin/payment/createPaymentMethodLogic.go`
|
||||
|
||||
**任务:getUserListLogic.go**
|
||||
1. 读取私有版,添加批量获取活跃订阅逻辑(FindActiveSubscribesByUserIds)
|
||||
2. 添加 MemberStatus 计算(基于订阅到期时间判断)
|
||||
3. 添加 LastLoginTime 展示
|
||||
4. 保留 OSS 的 Unscoped 和 ShortCode 查询参数
|
||||
5. 不添加 DeviceId 过滤
|
||||
|
||||
**任务:updateUserBasicInfoLogic.go**
|
||||
1. 读取私有版,添加 Remark 字段更新支持
|
||||
2. 添加 MemberStatus 更新支持(如果有)
|
||||
3. 保留 OSS 的错误处理模式(非 fmt.Sprintf 包装)
|
||||
4. 考虑使用事务包裹所有修改(参考私有版 Transaction 用法)
|
||||
|
||||
**任务:createPaymentMethodLogic.go**
|
||||
1. 对比两版差异,同步私有版中有用的改动
|
||||
|
||||
**验收标准:**
|
||||
- 管理员用户列表显示 MemberStatus 和 LastLoginTime
|
||||
- 编译通过
|
||||
|
||||
---
|
||||
|
||||
### Builder-4: 服务器、Handler 和模型修复(低-中复杂度)
|
||||
|
||||
**文件范围:**
|
||||
- `internal/server.go`
|
||||
- `internal/handler/notify.go`
|
||||
- `internal/handler/subscribe.go`
|
||||
- `internal/logic/server/constant.go`
|
||||
- `internal/logic/subscribe/subscribeLogic.go`
|
||||
- `initialize/config.go`
|
||||
- `initialize/init.go`
|
||||
- `cmd/run.go`
|
||||
- `internal/model/subscribe/subscribe.go`
|
||||
- `internal/model/order/model.go`
|
||||
- `internal/model/announcement/model.go`
|
||||
- `internal/model/log/log.go`
|
||||
- `internal/model/user/model.go`
|
||||
- `internal/types/subscribe.go`
|
||||
- `pkg/constant/version.go`
|
||||
- `internal/svc/devce.go`
|
||||
- `internal/logic/server/serverPushUserTrafficLogic.go`
|
||||
- `queue/logic/traffic/trafficStatisticsLogic.go`
|
||||
- `internal/logic/public/subscribe/queryUserSubscribeNodeListLogic.go`
|
||||
- `internal/logic/public/user/resetUserSubscribeTokenLogic.go`
|
||||
|
||||
**任务说明:**
|
||||
以上大部分文件 OSS 版本已经是正确/更好的版本。Builder 需要:
|
||||
1. 逐一 diff 对比,确认 OSS 版本是否完整
|
||||
2. 对于已经正确的文件,跳过不做修改
|
||||
3. 对于需要微调的文件(如 handler/notify.go 的路由路径斜杠修复),进行小修改
|
||||
4. server.go:确认 OSS 有 gateway mode,保持不变
|
||||
5. initialize/config.go、init.go:确认 OSS 有 gateway mode + Currency 调用,保持不变
|
||||
6. cmd/run.go:确认 trace/init 已移至 server.go,保持不变
|
||||
7. handler/notify.go:修复路由路径 `:platform/:token` → `/:platform/:token`(如果 OSS 已修复则跳过)
|
||||
8. svc/devce.go:确认 `create_at` → `created_at` bug fix 已应用
|
||||
|
||||
**验收标准:**
|
||||
- 所有文件状态正确
|
||||
- 编译通过
|
||||
- 不引入回退(不把 OSS 改进覆盖回去)
|
||||
|
||||
---
|
||||
|
||||
## 排除项(不同步)
|
||||
|
||||
- App 版本管理路由和逻辑
|
||||
- 设备绑定相关:SessionLimit、EnforceUserSessionLimit、DeviceId context、DeviceBindLimitExceeded
|
||||
- adapter/adapter.go、adapter/client.go(保留 OSS 的 Type/Params 改进)
|
||||
- internal/model/user/default.go(保留 OSS 的软删除改进)
|
||||
- pkg/payment/stripe/stripe.go(保留 OSS 的 ConstructEventWithOptions 改进)
|
||||
- getDeviceListLogic.go、unbindDeviceLogic.go(设备绑定)
|
||||
- authMethod.go(设备绑定 DeleteUserAuthMethodByIdentifier)
|
||||
- device.go model(设备绑定增强)
|
||||
- constant/types.go(DeleteAccount 枚举 - 设备绑定)
|
||||
- device/device.go(GetOnlineDeviceLoginTime - 设备绑定)
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
Layer 1 所有 Builder 可并行执行,无互相依赖。
|
||||
27
.github/environments/production.yml
vendored
27
.github/environments/production.yml
vendored
@ -1,27 +0,0 @@
|
||||
# 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
|
||||
23
.github/environments/staging.yml
vendored
23
.github/environments/staging.yml
vendored
@ -1,23 +0,0 @@
|
||||
# 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
|
||||
79
.github/workflows/deploy-linux.yml
vendored
79
.github/workflows/deploy-linux.yml
vendored
@ -1,79 +0,0 @@
|
||||
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: ubuntu-latest
|
||||
|
||||
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
.gitignore
vendored
7
.gitignore
vendored
@ -4,6 +4,8 @@
|
||||
*.local.yaml
|
||||
/test/
|
||||
*.log
|
||||
*.sh
|
||||
script/*.sh
|
||||
.DS_Store
|
||||
*_test_config.go
|
||||
*.log*
|
||||
@ -14,4 +16,7 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
package.json
|
||||
/bin
|
||||
/bin
|
||||
.claude
|
||||
./github
|
||||
./run
|
||||
@ -1,12 +0,0 @@
|
||||
<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,78 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "etc/ppanel.yaml", "config file path")
|
||||
query := flag.String("query", "", "sql query")
|
||||
flag.Parse()
|
||||
|
||||
if *query == "" {
|
||||
fmt.Fprintln(os.Stderr, "query is required")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var cfg config.Config
|
||||
conf.MustLoad(*configPath, &cfg)
|
||||
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", cfg.MySQL.Username, cfg.MySQL.Password, cfg.MySQL.Addr, cfg.MySQL.Dbname, cfg.MySQL.Config)
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "connect db failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rows, err := db.Raw(*query).Rows()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "query failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "read columns failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columns))
|
||||
pointers := make([]interface{}, len(columns))
|
||||
for i := range values {
|
||||
pointers[i] = &values[i]
|
||||
}
|
||||
if err = rows.Scan(pointers...); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "scan row failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rowMap := make(map[string]interface{}, len(columns))
|
||||
for i, col := range columns {
|
||||
v := values[i]
|
||||
if b, ok := v.([]byte); ok {
|
||||
rowMap[col] = string(b)
|
||||
continue
|
||||
}
|
||||
rowMap[col] = v
|
||||
}
|
||||
result = append(result, rowMap)
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
if err = encoder.Encode(result); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "encode result failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,346 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
|
||||
CONFIG_PATH="${CONFIG_PATH:-etc/ppanel.yaml}"
|
||||
EMAIL_A="${EMAIL_A:-family_cleanup_$(date +%s)@example.com}"
|
||||
DEVICE_A="${DEVICE_A:-qa-cleanup-device-a-$(date +%s)}"
|
||||
DEVICE_B="${DEVICE_B:-qa-cleanup-device-b-$(date +%s)}"
|
||||
SEND_CODE_RETRY_WAIT="${SEND_CODE_RETRY_WAIT:-61}"
|
||||
SEND_CODE_MAX_RETRY="${SEND_CODE_MAX_RETRY:-8}"
|
||||
VERIFY_CODE_PROVIDER="${VERIFY_CODE_PROVIDER:-}"
|
||||
VERIFY_CODE_OVERRIDE="${VERIFY_CODE_OVERRIDE:-}"
|
||||
REGISTER_CODE_OVERRIDE="${REGISTER_CODE_OVERRIDE:-}"
|
||||
SECURITY_CODE_OVERRIDE="${SECURITY_CODE_OVERRIDE:-}"
|
||||
DELETE_ACCOUNT_CODE_OVERRIDE="${DELETE_ACCOUNT_CODE_OVERRIDE:-}"
|
||||
ALLOW_INTERACTIVE_INPUT="${ALLOW_INTERACTIVE_INPUT:-auto}"
|
||||
ENABLE_REDIS_CODE_FALLBACK="${ENABLE_REDIS_CODE_FALLBACK:-1}"
|
||||
REDIS_CLI_BIN="${REDIS_CLI_BIN:-redis-cli}"
|
||||
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
|
||||
REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
REDIS_DB="${REDIS_DB:-0}"
|
||||
REDIS_PASSWORD="${REDIS_PASSWORD:-}"
|
||||
BIND_VERIFY_RETRY="${BIND_VERIFY_RETRY:-1}"
|
||||
|
||||
bold() { printf "\033[1m%s\033[0m\n" "$*"; }
|
||||
info() { printf "[INFO] %s\n" "$*" >&2; }
|
||||
warn() { printf "[WARN] %s\n" "$*" >&2; }
|
||||
fail() { printf "[FAIL] %s\n" "$*" >&2; exit 1; }
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "缺少命令: $1"
|
||||
}
|
||||
|
||||
json_code() { jq -r '.code // ""' <<<"$1"; }
|
||||
json_msg() { jq -r '.msg // ""' <<<"$1"; }
|
||||
|
||||
can_prompt_input() {
|
||||
if [[ "$ALLOW_INTERACTIVE_INPUT" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$ALLOW_INTERACTIVE_INPUT" == "0" ]]; then
|
||||
return 1
|
||||
fi
|
||||
[[ -t 0 ]]
|
||||
}
|
||||
|
||||
redis_get_key_raw() {
|
||||
local key="$1"
|
||||
command -v "$REDIS_CLI_BIN" >/dev/null 2>&1 || return 1
|
||||
|
||||
local -a args
|
||||
args=("$REDIS_CLI_BIN" -h "$REDIS_HOST" -p "$REDIS_PORT" -n "$REDIS_DB" --raw)
|
||||
if [[ -n "$REDIS_PASSWORD" ]]; then
|
||||
args+=(-a "$REDIS_PASSWORD")
|
||||
fi
|
||||
"${args[@]}" GET "$key" 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
get_verify_code_from_redis() {
|
||||
local email="$1"
|
||||
local typ="$2"
|
||||
local scene key raw code
|
||||
|
||||
if [[ "$ENABLE_REDIS_CODE_FALLBACK" != "1" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$typ" in
|
||||
1) scene="register" ;;
|
||||
2) scene="security" ;;
|
||||
4) scene="delete_account" ;;
|
||||
*) scene="unknown" ;;
|
||||
esac
|
||||
|
||||
key="auth:verify:email:${scene}:${email}"
|
||||
raw="$(redis_get_key_raw "$key" || true)"
|
||||
[[ -n "$raw" ]] || return 1
|
||||
|
||||
code="$(jq -r '.code // ""' <<<"$raw" 2>/dev/null || true)"
|
||||
[[ -n "$code" && "$code" != "null" ]] || return 1
|
||||
|
||||
printf "%s" "$code"
|
||||
return 0
|
||||
}
|
||||
|
||||
api_request() {
|
||||
local method="$1"
|
||||
local path="$2"
|
||||
local body="${3:-}"
|
||||
local token="${4:-}"
|
||||
|
||||
local -a args
|
||||
args=(-sS -X "$method" "${BASE_URL}${path}" -H "Content-Type: application/json")
|
||||
if [[ -n "$token" ]]; then
|
||||
args+=(-H "Authorization: ${token}")
|
||||
fi
|
||||
if [[ "$method" != "GET" ]]; then
|
||||
args+=(-d "$body")
|
||||
fi
|
||||
curl "${args[@]}"
|
||||
}
|
||||
|
||||
assert_success() {
|
||||
local resp="$1"
|
||||
local step="$2"
|
||||
local code
|
||||
code="$(json_code "$resp")"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
fail "${step} 失败, code=${code}, msg=$(json_msg "$resp"), resp=${resp}"
|
||||
fi
|
||||
}
|
||||
|
||||
token_or_keep() {
|
||||
local resp="$1"
|
||||
local fallback="$2"
|
||||
local token
|
||||
token="$(jq -r '.data.token // ""' <<<"$resp")"
|
||||
if [[ -n "$token" && "$token" != "null" ]]; then
|
||||
printf "%s" "$token"
|
||||
else
|
||||
printf "%s" "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
device_login() {
|
||||
local identifier="$1"
|
||||
local user_agent="$2"
|
||||
local payload resp token
|
||||
|
||||
payload="$(jq -nc --arg identifier "$identifier" --arg user_agent "$user_agent" '{identifier:$identifier,user_agent:$user_agent}')"
|
||||
resp="$(api_request "POST" "/v1/auth/login/device" "$payload")"
|
||||
assert_success "$resp" "设备登录(${identifier})"
|
||||
|
||||
token="$(jq -r '.data.token // ""' <<<"$resp")"
|
||||
[[ -n "$token" && "$token" != "null" ]] || fail "设备登录(${identifier})返回空 token: ${resp}"
|
||||
printf "%s" "$token"
|
||||
}
|
||||
|
||||
send_code_with_retry() {
|
||||
local email="$1"
|
||||
local typ="$2"
|
||||
local payload resp code retry
|
||||
|
||||
payload="$(jq -nc --arg email "$email" --argjson type "$typ" '{email:$email,type:$type}')"
|
||||
retry=0
|
||||
while true; do
|
||||
resp="$(api_request "POST" "/v1/common/send_code" "$payload")"
|
||||
code="$(json_code "$resp")"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
printf "%s" "$resp"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$code" == "401" ]]; then
|
||||
retry=$((retry + 1))
|
||||
if (( retry > SEND_CODE_MAX_RETRY )); then
|
||||
fail "发送验证码达到重试上限, email=${email}, type=${typ}, resp=${resp}"
|
||||
fi
|
||||
warn "验证码发送频率限制,等待 ${SEND_CODE_RETRY_WAIT}s 后重试(${retry}/${SEND_CODE_MAX_RETRY})..."
|
||||
sleep "${SEND_CODE_RETRY_WAIT}"
|
||||
continue
|
||||
fi
|
||||
fail "发送验证码失败, email=${email}, type=${typ}, code=${code}, msg=$(json_msg "$resp"), resp=${resp}"
|
||||
done
|
||||
}
|
||||
|
||||
get_verify_code() {
|
||||
local email="$1"
|
||||
local typ="$2"
|
||||
local send_resp code
|
||||
|
||||
send_resp="$(send_code_with_retry "$email" "$typ")"
|
||||
code="$(jq -r '.data.code // ""' <<<"$send_resp")"
|
||||
if [[ -n "$code" && "$code" != "null" ]]; then
|
||||
info "验证码已从接口返回(type=${typ})"
|
||||
printf "%s" "$code"
|
||||
return 0
|
||||
fi
|
||||
|
||||
code="$(get_verify_code_from_redis "$email" "$typ" || true)"
|
||||
if [[ -n "$code" ]]; then
|
||||
info "验证码已从 Redis 回读(type=${typ})"
|
||||
printf "%s" "$code"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$VERIFY_CODE_PROVIDER" ]]; then
|
||||
code="$("$VERIFY_CODE_PROVIDER" "$email" "$typ" || true)"
|
||||
if [[ -n "$code" ]]; then
|
||||
info "验证码由 VERIFY_CODE_PROVIDER 提供(type=${typ})"
|
||||
printf "%s" "$code"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$typ" == "1" && -n "$REGISTER_CODE_OVERRIDE" ]]; then
|
||||
printf "%s" "$REGISTER_CODE_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$typ" == "2" && -n "$SECURITY_CODE_OVERRIDE" ]]; then
|
||||
printf "%s" "$SECURITY_CODE_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$typ" == "4" && -n "$DELETE_ACCOUNT_CODE_OVERRIDE" ]]; then
|
||||
printf "%s" "$DELETE_ACCOUNT_CODE_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$VERIFY_CODE_OVERRIDE" ]]; then
|
||||
printf "%s" "$VERIFY_CODE_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! can_prompt_input; then
|
||||
fail "验证码不可自动获取(type=${typ}, email=${email})"
|
||||
fi
|
||||
read -r -p "请输入邮箱 ${email} 的验证码(type=${typ}):" code
|
||||
[[ -n "$code" ]] || fail "验证码为空"
|
||||
printf "%s" "$code"
|
||||
}
|
||||
|
||||
bind_email_with_verification() {
|
||||
local token="$1"
|
||||
local email="$2"
|
||||
local code="$3"
|
||||
local payload
|
||||
payload="$(jq -nc --arg email "$email" --arg code "$code" '{email:$email,code:$code}')"
|
||||
api_request "POST" "/v1/public/user/bind_email_with_verification" "$payload" "$token"
|
||||
}
|
||||
|
||||
bind_email_with_verification_auto() {
|
||||
local token="$1"
|
||||
local email="$2"
|
||||
local typ="$3"
|
||||
local max_retry="${4:-$BIND_VERIFY_RETRY}"
|
||||
local attempt=0 code resp api_code
|
||||
|
||||
while true; do
|
||||
code="$(get_verify_code "$email" "$typ")"
|
||||
resp="$(bind_email_with_verification "$token" "$email" "$code")"
|
||||
api_code="$(json_code "$resp")"
|
||||
if [[ "$api_code" == "200" ]]; then
|
||||
printf "%s" "$resp"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$api_code" == "70001" && "$attempt" -lt "$max_retry" ]]; then
|
||||
warn "绑定邮箱验证码校验失败,重新获取验证码重试($((attempt+1))/${max_retry})..."
|
||||
attempt=$((attempt + 1))
|
||||
continue
|
||||
fi
|
||||
fail "绑定邮箱(${email}) 失败, code=${api_code}, msg=$(json_msg "$resp"), resp=${resp}"
|
||||
done
|
||||
}
|
||||
|
||||
delete_account() {
|
||||
local token="$1"
|
||||
local email="$2"
|
||||
local code="$3"
|
||||
local payload resp
|
||||
payload="$(jq -nc --arg email "$email" --arg code "$code" '{email:$email,code:$code}')"
|
||||
resp="$(api_request "POST" "/v1/public/user/delete_account" "$payload" "$token")"
|
||||
assert_success "$resp" "注销账号(${email})"
|
||||
printf "%s" "$resp"
|
||||
}
|
||||
|
||||
db_query_json() {
|
||||
local query="$1"
|
||||
GOCACHE=/tmp/go-build go run ./script/db_query.go --config "$CONFIG_PATH" --query "$query"
|
||||
}
|
||||
|
||||
assert_db_field_equals() {
|
||||
local json="$1"
|
||||
local field="$2"
|
||||
local expected="$3"
|
||||
local actual
|
||||
actual="$(jq -r ".[0].${field} // \"\"" <<<"$json")"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
fail "DB校验失败: 字段 ${field} 期望=${expected}, 实际=${actual}, 数据=${json}"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
require_cmd go
|
||||
|
||||
bold "家庭组注销清理校验脚本"
|
||||
info "BASE_URL=${BASE_URL}"
|
||||
info "CONFIG_PATH=${CONFIG_PATH}"
|
||||
info "EMAIL_A=${EMAIL_A}"
|
||||
info "DEVICE_A=${DEVICE_A}"
|
||||
info "DEVICE_B=${DEVICE_B}"
|
||||
|
||||
local token_a token_b
|
||||
local bind_a_resp bind_b_resp del_b_resp del_a_resp
|
||||
local family_id owner_user_id member_user_id
|
||||
local code_delete
|
||||
local q_member_before q_member_after q_owner_after q_family_after
|
||||
|
||||
bold "步骤1: A设备登录并绑定邮箱A"
|
||||
token_a="$(device_login "$DEVICE_A" "qa-cleanup-device-a")"
|
||||
bind_a_resp="$(bind_email_with_verification_auto "$token_a" "$EMAIL_A" 1)"
|
||||
token_a="$(token_or_keep "$bind_a_resp" "$token_a")"
|
||||
|
||||
bold "步骤2: B设备登录并绑定邮箱A加入家庭"
|
||||
token_b="$(device_login "$DEVICE_B" "qa-cleanup-device-b")"
|
||||
bind_b_resp="$(bind_email_with_verification_auto "$token_b" "$EMAIL_A" 2)"
|
||||
token_b="$(token_or_keep "$bind_b_resp" "$token_b")"
|
||||
|
||||
family_id="$(jq -r '.data.family_id // ""' <<<"$bind_b_resp")"
|
||||
owner_user_id="$(jq -r '.data.owner_user_id // ""' <<<"$bind_b_resp")"
|
||||
member_user_id="$(jq -r '.data.user_id // ""' <<<"$bind_b_resp")"
|
||||
[[ -n "$family_id" && "$family_id" != "null" ]] || fail "无法获取 family_id: ${bind_b_resp}"
|
||||
[[ -n "$owner_user_id" && "$owner_user_id" != "null" ]] || fail "无法获取 owner_user_id: ${bind_b_resp}"
|
||||
[[ -n "$member_user_id" && "$member_user_id" != "null" ]] || fail "无法获取 member_user_id: ${bind_b_resp}"
|
||||
|
||||
q_member_before="$(db_query_json "SELECT status, role, family_id FROM user_family_member WHERE user_id = ${member_user_id} ORDER BY id DESC LIMIT 1")"
|
||||
assert_db_field_equals "$q_member_before" "status" "1"
|
||||
info "B注销前家庭状态校验通过(status=1)"
|
||||
|
||||
bold "步骤3: 注销设备B账号,检查B离组"
|
||||
code_delete="$(get_verify_code "$EMAIL_A" 4)"
|
||||
del_b_resp="$(delete_account "$token_b" "$EMAIL_A" "$code_delete")"
|
||||
info "B注销结果: $(jq -c '.data' <<<"$del_b_resp")"
|
||||
|
||||
q_member_after="$(db_query_json "SELECT status, role, family_id FROM user_family_member WHERE user_id = ${member_user_id} ORDER BY id DESC LIMIT 1")"
|
||||
assert_db_field_equals "$q_member_after" "status" "3"
|
||||
info "B注销后家庭状态校验通过(status=3)"
|
||||
|
||||
bold "步骤4: 注销设备A(家庭主)账号,检查家庭解散"
|
||||
code_delete="$(get_verify_code "$EMAIL_A" 4)"
|
||||
del_a_resp="$(delete_account "$token_a" "$EMAIL_A" "$code_delete")"
|
||||
info "A注销结果: $(jq -c '.data' <<<"$del_a_resp")"
|
||||
|
||||
q_owner_after="$(db_query_json "SELECT status, role, family_id FROM user_family_member WHERE user_id = ${owner_user_id} ORDER BY id DESC LIMIT 1")"
|
||||
assert_db_field_equals "$q_owner_after" "status" "3"
|
||||
info "A注销后主账号家庭状态校验通过(status=3)"
|
||||
|
||||
q_family_after="$(db_query_json "SELECT status FROM user_family WHERE id = ${family_id} ORDER BY id DESC LIMIT 1")"
|
||||
assert_db_field_equals "$q_family_after" "status" "0"
|
||||
info "家庭状态校验通过(status=0)"
|
||||
|
||||
bold "校验通过 ✅ 注销后家庭成员与家庭状态已正确更新"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@ -1,335 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
|
||||
EMAIL_A="${EMAIL_A:-family34_a_$(date +%s)@example.com}"
|
||||
DEVICE_A="${DEVICE_A:-qa34-device-a-$(date +%s)}"
|
||||
DEVICE_B="${DEVICE_B:-qa34-device-b-$(date +%s)}"
|
||||
SEND_CODE_RETRY_WAIT="${SEND_CODE_RETRY_WAIT:-61}"
|
||||
SEND_CODE_MAX_RETRY="${SEND_CODE_MAX_RETRY:-8}"
|
||||
VERIFY_CODE_PROVIDER="${VERIFY_CODE_PROVIDER:-}"
|
||||
VERIFY_CODE_OVERRIDE="${VERIFY_CODE_OVERRIDE:-}"
|
||||
REGISTER_CODE_OVERRIDE="${REGISTER_CODE_OVERRIDE:-}"
|
||||
SECURITY_CODE_OVERRIDE="${SECURITY_CODE_OVERRIDE:-}"
|
||||
ALLOW_INTERACTIVE_INPUT="${ALLOW_INTERACTIVE_INPUT:-auto}"
|
||||
ENABLE_REDIS_CODE_FALLBACK="${ENABLE_REDIS_CODE_FALLBACK:-1}"
|
||||
REDIS_CLI_BIN="${REDIS_CLI_BIN:-redis-cli}"
|
||||
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
|
||||
REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
REDIS_DB="${REDIS_DB:-0}"
|
||||
REDIS_PASSWORD="${REDIS_PASSWORD:-}"
|
||||
BIND_VERIFY_RETRY="${BIND_VERIFY_RETRY:-1}"
|
||||
|
||||
bold() { printf "\033[1m%s\033[0m\n" "$*"; }
|
||||
info() { printf "[INFO] %s\n" "$*" >&2; }
|
||||
warn() { printf "[WARN] %s\n" "$*" >&2; }
|
||||
fail() { printf "[FAIL] %s\n" "$*" >&2; exit 1; }
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "缺少命令: $1"
|
||||
}
|
||||
|
||||
json_code() { jq -r '.code // ""' <<<"$1"; }
|
||||
json_msg() { jq -r '.msg // ""' <<<"$1"; }
|
||||
|
||||
can_prompt_input() {
|
||||
if [[ "$ALLOW_INTERACTIVE_INPUT" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$ALLOW_INTERACTIVE_INPUT" == "0" ]]; then
|
||||
return 1
|
||||
fi
|
||||
[[ -t 0 ]]
|
||||
}
|
||||
|
||||
redis_get_key_raw() {
|
||||
local key="$1"
|
||||
command -v "$REDIS_CLI_BIN" >/dev/null 2>&1 || return 1
|
||||
|
||||
local -a args
|
||||
args=("$REDIS_CLI_BIN" -h "$REDIS_HOST" -p "$REDIS_PORT" -n "$REDIS_DB" --raw)
|
||||
if [[ -n "$REDIS_PASSWORD" ]]; then
|
||||
args+=(-a "$REDIS_PASSWORD")
|
||||
fi
|
||||
"${args[@]}" GET "$key" 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
get_verify_code_from_redis() {
|
||||
local email="$1"
|
||||
local typ="$2"
|
||||
local scene key raw code
|
||||
|
||||
if [[ "$ENABLE_REDIS_CODE_FALLBACK" != "1" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$typ" in
|
||||
1) scene="register" ;;
|
||||
2) scene="security" ;;
|
||||
*) scene="unknown" ;;
|
||||
esac
|
||||
|
||||
key="auth:verify:email:${scene}:${email}"
|
||||
raw="$(redis_get_key_raw "$key" || true)"
|
||||
[[ -n "$raw" ]] || return 1
|
||||
|
||||
code="$(jq -r '.code // ""' <<<"$raw" 2>/dev/null || true)"
|
||||
[[ -n "$code" && "$code" != "null" ]] || return 1
|
||||
printf "%s" "$code"
|
||||
}
|
||||
|
||||
api_request() {
|
||||
local method="$1"
|
||||
local path="$2"
|
||||
local body="${3:-}"
|
||||
local token="${4:-}"
|
||||
|
||||
local -a args
|
||||
args=(-sS -X "$method" "${BASE_URL}${path}" -H "Content-Type: application/json")
|
||||
if [[ -n "$token" ]]; then
|
||||
args+=(-H "Authorization: ${token}")
|
||||
fi
|
||||
if [[ "$method" != "GET" ]]; then
|
||||
args+=(-d "$body")
|
||||
fi
|
||||
curl "${args[@]}"
|
||||
}
|
||||
|
||||
assert_success() {
|
||||
local resp="$1"
|
||||
local step="$2"
|
||||
local code
|
||||
code="$(json_code "$resp")"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
fail "${step} 失败, code=${code}, msg=$(json_msg "$resp"), resp=${resp}"
|
||||
fi
|
||||
}
|
||||
|
||||
token_or_keep() {
|
||||
local resp="$1"
|
||||
local fallback="$2"
|
||||
local token
|
||||
token="$(jq -r '.data.token // ""' <<<"$resp")"
|
||||
if [[ -n "$token" && "$token" != "null" ]]; then
|
||||
printf "%s" "$token"
|
||||
else
|
||||
printf "%s" "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
device_login() {
|
||||
local identifier="$1"
|
||||
local user_agent="$2"
|
||||
local payload resp token
|
||||
|
||||
payload="$(jq -nc --arg identifier "$identifier" --arg user_agent "$user_agent" '{identifier:$identifier,user_agent:$user_agent}')"
|
||||
resp="$(api_request "POST" "/v1/auth/login/device" "$payload")"
|
||||
assert_success "$resp" "设备登录(${identifier})"
|
||||
token="$(jq -r '.data.token // ""' <<<"$resp")"
|
||||
[[ -n "$token" && "$token" != "null" ]] || fail "设备登录(${identifier})返回空 token: ${resp}"
|
||||
printf "%s" "$token"
|
||||
}
|
||||
|
||||
send_code_with_retry() {
|
||||
local email="$1"
|
||||
local typ="$2"
|
||||
local payload resp code retry
|
||||
|
||||
payload="$(jq -nc --arg email "$email" --argjson type "$typ" '{email:$email,type:$type}')"
|
||||
retry=0
|
||||
while true; do
|
||||
resp="$(api_request "POST" "/v1/common/send_code" "$payload")"
|
||||
code="$(json_code "$resp")"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
printf "%s" "$resp"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$code" == "401" ]]; then
|
||||
retry=$((retry + 1))
|
||||
if (( retry > SEND_CODE_MAX_RETRY )); then
|
||||
fail "发送验证码达到重试上限, email=${email}, type=${typ}, resp=${resp}"
|
||||
fi
|
||||
warn "验证码发送频率限制,等待 ${SEND_CODE_RETRY_WAIT}s 后重试(${retry}/${SEND_CODE_MAX_RETRY})..."
|
||||
sleep "${SEND_CODE_RETRY_WAIT}"
|
||||
continue
|
||||
fi
|
||||
fail "发送验证码失败, email=${email}, type=${typ}, code=${code}, msg=$(json_msg "$resp"), resp=${resp}"
|
||||
done
|
||||
}
|
||||
|
||||
get_verify_code() {
|
||||
local email="$1"
|
||||
local typ="$2"
|
||||
local send_resp code
|
||||
|
||||
send_resp="$(send_code_with_retry "$email" "$typ")"
|
||||
code="$(jq -r '.data.code // ""' <<<"$send_resp")"
|
||||
if [[ -n "$code" && "$code" != "null" ]]; then
|
||||
info "验证码已从接口返回(type=${typ})"
|
||||
printf "%s" "$code"
|
||||
return 0
|
||||
fi
|
||||
|
||||
code="$(get_verify_code_from_redis "$email" "$typ" || true)"
|
||||
if [[ -n "$code" ]]; then
|
||||
info "验证码已从 Redis 回读(type=${typ})"
|
||||
printf "%s" "$code"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$VERIFY_CODE_PROVIDER" ]]; then
|
||||
code="$("$VERIFY_CODE_PROVIDER" "$email" "$typ" || true)"
|
||||
if [[ -n "$code" ]]; then
|
||||
info "验证码由 VERIFY_CODE_PROVIDER 提供(type=${typ})"
|
||||
printf "%s" "$code"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$typ" == "1" && -n "$REGISTER_CODE_OVERRIDE" ]]; then
|
||||
printf "%s" "$REGISTER_CODE_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$typ" == "2" && -n "$SECURITY_CODE_OVERRIDE" ]]; then
|
||||
printf "%s" "$SECURITY_CODE_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$VERIFY_CODE_OVERRIDE" ]]; then
|
||||
printf "%s" "$VERIFY_CODE_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! can_prompt_input; then
|
||||
fail "验证码不可自动获取(type=${typ}, email=${email})"
|
||||
fi
|
||||
read -r -p "请输入邮箱 ${email} 的验证码(type=${typ}):" code
|
||||
[[ -n "$code" ]] || fail "验证码为空"
|
||||
printf "%s" "$code"
|
||||
}
|
||||
|
||||
bind_email_with_verification() {
|
||||
local token="$1"
|
||||
local email="$2"
|
||||
local code="$3"
|
||||
local payload
|
||||
payload="$(jq -nc --arg email "$email" --arg code "$code" '{email:$email,code:$code}')"
|
||||
api_request "POST" "/v1/public/user/bind_email_with_verification" "$payload" "$token"
|
||||
}
|
||||
|
||||
bind_email_with_verification_auto() {
|
||||
local token="$1"
|
||||
local email="$2"
|
||||
local typ="$3"
|
||||
local max_retry="${4:-$BIND_VERIFY_RETRY}"
|
||||
local attempt=0 code resp api_code
|
||||
|
||||
while true; do
|
||||
code="$(get_verify_code "$email" "$typ")"
|
||||
resp="$(bind_email_with_verification "$token" "$email" "$code")"
|
||||
api_code="$(json_code "$resp")"
|
||||
if [[ "$api_code" == "200" ]]; then
|
||||
printf "%s" "$resp"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$api_code" == "70001" && "$attempt" -lt "$max_retry" ]]; then
|
||||
warn "绑定邮箱验证码校验失败,重新获取验证码重试($((attempt+1))/${max_retry})..."
|
||||
attempt=$((attempt + 1))
|
||||
continue
|
||||
fi
|
||||
fail "绑定邮箱(${email}) 失败, code=${api_code}, msg=$(json_msg "$resp"), resp=${resp}"
|
||||
done
|
||||
}
|
||||
|
||||
get_devices() {
|
||||
local token="$1"
|
||||
local resp
|
||||
resp="$(api_request "GET" "/v1/public/user/devices" "" "$token")"
|
||||
assert_success "$resp" "获取设备列表"
|
||||
printf "%s" "$resp"
|
||||
}
|
||||
|
||||
find_device_id() {
|
||||
local devices_resp="$1"
|
||||
local identifier="$2"
|
||||
jq -r --arg identifier "$identifier" '.data.list[]? | select(.identifier == $identifier) | .id' <<<"$devices_resp" | head -n1
|
||||
}
|
||||
|
||||
assert_device_exists() {
|
||||
local devices_resp="$1"
|
||||
local identifier="$2"
|
||||
jq -e --arg identifier "$identifier" '.data.list | any(.identifier == $identifier)' <<<"$devices_resp" >/dev/null \
|
||||
|| fail "设备列表中未找到 identifier=${identifier}, resp=${devices_resp}"
|
||||
}
|
||||
|
||||
unbind_device() {
|
||||
local token="$1"
|
||||
local device_id="$2"
|
||||
local payload resp
|
||||
payload="$(jq -nc --arg id "$device_id" '{id:($id|tonumber)}')"
|
||||
resp="$(api_request "PUT" "/v1/public/user/unbind_device" "$payload" "$token")"
|
||||
assert_success "$resp" "解绑设备(id=${device_id})"
|
||||
}
|
||||
|
||||
assert_token_invalid() {
|
||||
local token="$1"
|
||||
local who="$2"
|
||||
local resp code
|
||||
resp="$(api_request "GET" "/v1/public/user/info" "" "$token")"
|
||||
code="$(json_code "$resp")"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
fail "${who} 预期已失效,但 token 仍可访问: ${resp}"
|
||||
fi
|
||||
info "${who} token 已失效,符合预期(code=${code}, msg=$(json_msg "$resp"))"
|
||||
}
|
||||
|
||||
main() {
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
|
||||
bold "家庭组快速回归: 场景3/4"
|
||||
info "BASE_URL=${BASE_URL}"
|
||||
info "EMAIL_A=${EMAIL_A}"
|
||||
info "DEVICE_A=${DEVICE_A}"
|
||||
info "DEVICE_B=${DEVICE_B}"
|
||||
|
||||
local token_a token_b token_a2
|
||||
local bind_resp devices_resp
|
||||
local device_id_a device_id_b
|
||||
|
||||
bold "预置: A设备登录并绑定邮箱A"
|
||||
token_a="$(device_login "$DEVICE_A" "qa34-device-a")"
|
||||
bind_resp="$(bind_email_with_verification_auto "$token_a" "$EMAIL_A" 1)"
|
||||
token_a="$(token_or_keep "$bind_resp" "$token_a")"
|
||||
|
||||
bold "场景3: B绑定邮箱A后踢A"
|
||||
token_b="$(device_login "$DEVICE_B" "qa34-device-b")"
|
||||
bind_resp="$(bind_email_with_verification_auto "$token_b" "$EMAIL_A" 2)"
|
||||
token_b="$(token_or_keep "$bind_resp" "$token_b")"
|
||||
devices_resp="$(get_devices "$token_b")"
|
||||
assert_device_exists "$devices_resp" "$DEVICE_A"
|
||||
assert_device_exists "$devices_resp" "$DEVICE_B"
|
||||
device_id_a="$(find_device_id "$devices_resp" "$DEVICE_A")"
|
||||
[[ -n "$device_id_a" ]] || fail "场景3 无法找到设备A ID"
|
||||
unbind_device "$token_b" "$device_id_a"
|
||||
assert_token_invalid "$token_a" "设备A(被B踢后)"
|
||||
info "场景3通过"
|
||||
|
||||
bold "场景4: A重登绑定邮箱A后踢B"
|
||||
token_a2="$(device_login "$DEVICE_A" "qa34-device-a-relogin")"
|
||||
bind_resp="$(bind_email_with_verification_auto "$token_a2" "$EMAIL_A" 2)"
|
||||
token_a2="$(token_or_keep "$bind_resp" "$token_a2")"
|
||||
devices_resp="$(get_devices "$token_a2")"
|
||||
assert_device_exists "$devices_resp" "$DEVICE_A"
|
||||
assert_device_exists "$devices_resp" "$DEVICE_B"
|
||||
device_id_b="$(find_device_id "$devices_resp" "$DEVICE_B")"
|
||||
[[ -n "$device_id_b" ]] || fail "场景4 无法找到设备B ID"
|
||||
unbind_device "$token_a2" "$device_id_b"
|
||||
assert_token_invalid "$token_b" "设备B(被A踢后)"
|
||||
info "场景4通过"
|
||||
|
||||
bold "场景3/4 快速回归完成 ✅"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@ -1,394 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
|
||||
EMAIL_A="${EMAIL_A:-family_a_$(date +%s)@example.com}"
|
||||
DEVICE_A="${DEVICE_A:-qa-device-a-$(date +%s)}"
|
||||
DEVICE_B="${DEVICE_B:-qa-device-b-$(date +%s)}"
|
||||
DEVICE_C="${DEVICE_C:-qa-device-c-$(date +%s)}"
|
||||
EMAIL_LOGIN_CODE="${EMAIL_LOGIN_CODE:-202511}"
|
||||
SEND_CODE_RETRY_WAIT="${SEND_CODE_RETRY_WAIT:-61}"
|
||||
SEND_CODE_MAX_RETRY="${SEND_CODE_MAX_RETRY:-8}"
|
||||
VERIFY_CODE_PROVIDER="${VERIFY_CODE_PROVIDER:-}"
|
||||
VERIFY_CODE_OVERRIDE="${VERIFY_CODE_OVERRIDE:-}"
|
||||
REGISTER_CODE_OVERRIDE="${REGISTER_CODE_OVERRIDE:-}"
|
||||
SECURITY_CODE_OVERRIDE="${SECURITY_CODE_OVERRIDE:-}"
|
||||
ALLOW_INTERACTIVE_INPUT="${ALLOW_INTERACTIVE_INPUT:-auto}"
|
||||
ENABLE_REDIS_CODE_FALLBACK="${ENABLE_REDIS_CODE_FALLBACK:-1}"
|
||||
REDIS_CLI_BIN="${REDIS_CLI_BIN:-redis-cli}"
|
||||
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
|
||||
REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
REDIS_DB="${REDIS_DB:-0}"
|
||||
REDIS_PASSWORD="${REDIS_PASSWORD:-}"
|
||||
BIND_VERIFY_RETRY="${BIND_VERIFY_RETRY:-1}"
|
||||
|
||||
bold() { printf "\033[1m%s\033[0m\n" "$*"; }
|
||||
info() { printf "[INFO] %s\n" "$*" >&2; }
|
||||
warn() { printf "[WARN] %s\n" "$*" >&2; }
|
||||
fail() { printf "[FAIL] %s\n" "$*" >&2; exit 1; }
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "缺少命令: $1"
|
||||
}
|
||||
|
||||
json_code() { jq -r '.code // ""' <<<"$1"; }
|
||||
json_msg() { jq -r '.msg // ""' <<<"$1"; }
|
||||
|
||||
can_prompt_input() {
|
||||
if [[ "$ALLOW_INTERACTIVE_INPUT" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$ALLOW_INTERACTIVE_INPUT" == "0" ]]; then
|
||||
return 1
|
||||
fi
|
||||
[[ -t 0 ]]
|
||||
}
|
||||
|
||||
redis_get_key_raw() {
|
||||
local key="$1"
|
||||
command -v "$REDIS_CLI_BIN" >/dev/null 2>&1 || return 1
|
||||
|
||||
local -a args
|
||||
args=("$REDIS_CLI_BIN" -h "$REDIS_HOST" -p "$REDIS_PORT" -n "$REDIS_DB" --raw)
|
||||
if [[ -n "$REDIS_PASSWORD" ]]; then
|
||||
args+=(-a "$REDIS_PASSWORD")
|
||||
fi
|
||||
|
||||
"${args[@]}" GET "$key" 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
get_verify_code_from_redis() {
|
||||
local email="$1"
|
||||
local typ="$2"
|
||||
local scene key raw code
|
||||
|
||||
if [[ "$ENABLE_REDIS_CODE_FALLBACK" != "1" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$typ" in
|
||||
1) scene="register" ;;
|
||||
2) scene="security" ;;
|
||||
*) scene="unknown" ;;
|
||||
esac
|
||||
|
||||
key="auth:verify:email:${scene}:${email}"
|
||||
raw="$(redis_get_key_raw "$key" || true)"
|
||||
[[ -n "$raw" ]] || return 1
|
||||
|
||||
code="$(jq -r '.code // ""' <<<"$raw" 2>/dev/null || true)"
|
||||
[[ -n "$code" && "$code" != "null" ]] || return 1
|
||||
|
||||
printf "%s" "$code"
|
||||
return 0
|
||||
}
|
||||
|
||||
api_request() {
|
||||
local method="$1"
|
||||
local path="$2"
|
||||
local body="${3:-}"
|
||||
local token="${4:-}"
|
||||
|
||||
local -a args
|
||||
args=(-sS -X "$method" "${BASE_URL}${path}" -H "Content-Type: application/json")
|
||||
if [[ -n "$token" ]]; then
|
||||
args+=(-H "Authorization: ${token}")
|
||||
fi
|
||||
if [[ "$method" != "GET" ]]; then
|
||||
args+=(-d "$body")
|
||||
fi
|
||||
curl "${args[@]}"
|
||||
}
|
||||
|
||||
assert_success() {
|
||||
local resp="$1"
|
||||
local step="$2"
|
||||
local code
|
||||
code="$(json_code "$resp")"
|
||||
if [[ "$code" != "200" ]]; then
|
||||
fail "${step} 失败, code=${code}, msg=$(json_msg "$resp"), resp=${resp}"
|
||||
fi
|
||||
}
|
||||
|
||||
token_or_keep() {
|
||||
local resp="$1"
|
||||
local fallback="$2"
|
||||
local token
|
||||
token="$(jq -r '.data.token // ""' <<<"$resp")"
|
||||
if [[ -n "$token" && "$token" != "null" ]]; then
|
||||
printf "%s" "$token"
|
||||
else
|
||||
printf "%s" "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
device_login() {
|
||||
local identifier="$1"
|
||||
local user_agent="$2"
|
||||
local payload resp token
|
||||
|
||||
payload="$(jq -nc --arg identifier "$identifier" --arg user_agent "$user_agent" '{identifier:$identifier,user_agent:$user_agent}')"
|
||||
resp="$(api_request "POST" "/v1/auth/login/device" "$payload")"
|
||||
assert_success "$resp" "设备登录(${identifier})"
|
||||
|
||||
token="$(jq -r '.data.token // ""' <<<"$resp")"
|
||||
[[ -n "$token" && "$token" != "null" ]] || fail "设备登录(${identifier})返回空 token: ${resp}"
|
||||
printf "%s" "$token"
|
||||
}
|
||||
|
||||
email_login() {
|
||||
local email="$1"
|
||||
local code="$2"
|
||||
local payload resp token
|
||||
|
||||
payload="$(jq -nc --arg email "$email" --arg code "$code" --arg identifier "web-login-$(date +%s)" --arg user_agent "qa-web" '{email:$email,code:$code,identifier:$identifier,user_agent:$user_agent}')"
|
||||
resp="$(api_request "POST" "/v1/auth/login/email" "$payload")"
|
||||
assert_success "$resp" "邮箱登录(${email})"
|
||||
|
||||
token="$(jq -r '.data.token // ""' <<<"$resp")"
|
||||
[[ -n "$token" && "$token" != "null" ]] || fail "邮箱登录(${email})返回空 token: ${resp}"
|
||||
printf "%s" "$token"
|
||||
}
|
||||
|
||||
send_code_with_retry() {
|
||||
local email="$1"
|
||||
local typ="$2"
|
||||
local payload resp code retry
|
||||
|
||||
payload="$(jq -nc --arg email "$email" --argjson type "$typ" '{email:$email,type:$type}')"
|
||||
retry=0
|
||||
while true; do
|
||||
resp="$(api_request "POST" "/v1/common/send_code" "$payload")"
|
||||
code="$(json_code "$resp")"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
printf "%s" "$resp"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$code" == "401" ]]; then
|
||||
retry=$((retry + 1))
|
||||
if (( retry > SEND_CODE_MAX_RETRY )); then
|
||||
fail "发送验证码达到重试上限, email=${email}, type=${typ}, resp=${resp}"
|
||||
fi
|
||||
warn "验证码发送频率限制,等待 ${SEND_CODE_RETRY_WAIT}s 后重试(${retry}/${SEND_CODE_MAX_RETRY})..."
|
||||
sleep "${SEND_CODE_RETRY_WAIT}"
|
||||
continue
|
||||
fi
|
||||
fail "发送验证码失败, email=${email}, type=${typ}, code=${code}, msg=$(json_msg "$resp"), resp=${resp}"
|
||||
done
|
||||
}
|
||||
|
||||
get_verify_code() {
|
||||
local email="$1"
|
||||
local typ="$2"
|
||||
local send_resp code
|
||||
|
||||
send_resp="$(send_code_with_retry "$email" "$typ")"
|
||||
code="$(jq -r '.data.code // ""' <<<"$send_resp")"
|
||||
if [[ -n "$code" && "$code" != "null" ]]; then
|
||||
info "验证码已从接口返回(type=${typ})"
|
||||
printf "%s" "$code"
|
||||
return 0
|
||||
fi
|
||||
|
||||
code="$(get_verify_code_from_redis "$email" "$typ" || true)"
|
||||
if [[ -n "$code" ]]; then
|
||||
info "验证码已从 Redis 回读(type=${typ})"
|
||||
printf "%s" "$code"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$VERIFY_CODE_PROVIDER" ]]; then
|
||||
code="$("$VERIFY_CODE_PROVIDER" "$email" "$typ" || true)"
|
||||
if [[ -n "$code" ]]; then
|
||||
info "验证码由 VERIFY_CODE_PROVIDER 提供(type=${typ})"
|
||||
printf "%s" "$code"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$typ" == "1" && -n "$REGISTER_CODE_OVERRIDE" ]]; then
|
||||
info "使用 REGISTER_CODE_OVERRIDE 作为验证码(type=${typ})"
|
||||
printf "%s" "$REGISTER_CODE_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$typ" == "2" && -n "$SECURITY_CODE_OVERRIDE" ]]; then
|
||||
info "使用 SECURITY_CODE_OVERRIDE 作为验证码(type=${typ})"
|
||||
printf "%s" "$SECURITY_CODE_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$VERIFY_CODE_OVERRIDE" ]]; then
|
||||
info "使用 VERIFY_CODE_OVERRIDE 作为验证码(type=${typ})"
|
||||
printf "%s" "$VERIFY_CODE_OVERRIDE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! can_prompt_input; then
|
||||
fail "验证码不可自动获取(type=${typ}, email=${email})。请设置 VERIFY_CODE_PROVIDER 或 REGISTER_CODE_OVERRIDE/SECURITY_CODE_OVERRIDE,或开启交互输入(ALLOW_INTERACTIVE_INPUT=1)"
|
||||
fi
|
||||
|
||||
read -r -p "请输入邮箱 ${email} 的验证码(type=${typ}):" code
|
||||
[[ -n "$code" ]] || fail "验证码为空"
|
||||
printf "%s" "$code"
|
||||
}
|
||||
|
||||
bind_email_with_verification() {
|
||||
local token="$1"
|
||||
local email="$2"
|
||||
local code="$3"
|
||||
local payload resp
|
||||
|
||||
payload="$(jq -nc --arg email "$email" --arg code "$code" '{email:$email,code:$code}')"
|
||||
resp="$(api_request "POST" "/v1/public/user/bind_email_with_verification" "$payload" "$token")"
|
||||
printf "%s" "$resp"
|
||||
}
|
||||
|
||||
bind_email_with_verification_auto() {
|
||||
local token="$1"
|
||||
local email="$2"
|
||||
local typ="$3"
|
||||
local max_retry="${4:-$BIND_VERIFY_RETRY}"
|
||||
local attempt=0 code resp api_code
|
||||
|
||||
while true; do
|
||||
code="$(get_verify_code "$email" "$typ")"
|
||||
resp="$(bind_email_with_verification "$token" "$email" "$code")"
|
||||
api_code="$(json_code "$resp")"
|
||||
if [[ "$api_code" == "200" ]]; then
|
||||
printf "%s" "$resp"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$api_code" == "70001" && "$attempt" -lt "$max_retry" ]]; then
|
||||
warn "绑定邮箱验证码校验失败,重新获取验证码重试($((attempt+1))/${max_retry})..."
|
||||
attempt=$((attempt + 1))
|
||||
continue
|
||||
fi
|
||||
fail "绑定邮箱(${email}) 失败, code=${api_code}, msg=$(json_msg "$resp"), resp=${resp}"
|
||||
done
|
||||
}
|
||||
|
||||
get_devices() {
|
||||
local token="$1"
|
||||
local resp
|
||||
resp="$(api_request "GET" "/v1/public/user/devices" "" "$token")"
|
||||
assert_success "$resp" "获取设备列表"
|
||||
printf "%s" "$resp"
|
||||
}
|
||||
|
||||
find_device_id() {
|
||||
local devices_resp="$1"
|
||||
local identifier="$2"
|
||||
jq -r --arg identifier "$identifier" '.data.list[]? | select(.identifier == $identifier) | .id' <<<"$devices_resp" | head -n1
|
||||
}
|
||||
|
||||
assert_device_exists() {
|
||||
local devices_resp="$1"
|
||||
local identifier="$2"
|
||||
jq -e --arg identifier "$identifier" '.data.list | any(.identifier == $identifier)' <<<"$devices_resp" >/dev/null \
|
||||
|| fail "设备列表中未找到 identifier=${identifier}, resp=${devices_resp}"
|
||||
}
|
||||
|
||||
unbind_device() {
|
||||
local token="$1"
|
||||
local device_id="$2"
|
||||
local payload resp
|
||||
|
||||
payload="$(jq -nc --arg id "$device_id" '{id:($id|tonumber)}')"
|
||||
resp="$(api_request "PUT" "/v1/public/user/unbind_device" "$payload" "$token")"
|
||||
assert_success "$resp" "解绑设备(id=${device_id})"
|
||||
}
|
||||
|
||||
assert_token_invalid() {
|
||||
local token="$1"
|
||||
local who="$2"
|
||||
local resp code
|
||||
|
||||
resp="$(api_request "GET" "/v1/public/user/info" "" "$token")"
|
||||
code="$(json_code "$resp")"
|
||||
if [[ "$code" == "200" ]]; then
|
||||
fail "${who} 预期已下线/失效,但 token 仍可访问: ${resp}"
|
||||
fi
|
||||
info "${who} token 已失效,符合预期(code=${code}, msg=$(json_msg "$resp"))"
|
||||
}
|
||||
|
||||
main() {
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
|
||||
bold "家庭组场景回归脚本"
|
||||
info "BASE_URL=${BASE_URL}"
|
||||
info "EMAIL_A=${EMAIL_A}"
|
||||
info "DEVICE_A=${DEVICE_A}"
|
||||
info "DEVICE_B=${DEVICE_B}"
|
||||
info "DEVICE_C=${DEVICE_C} (本脚本当前未使用)"
|
||||
info "EMAIL_LOGIN_CODE=${EMAIL_LOGIN_CODE}"
|
||||
info "ALLOW_INTERACTIVE_INPUT=${ALLOW_INTERACTIVE_INPUT}"
|
||||
info "ENABLE_REDIS_CODE_FALLBACK=${ENABLE_REDIS_CODE_FALLBACK}"
|
||||
info "BIND_VERIFY_RETRY=${BIND_VERIFY_RETRY}"
|
||||
|
||||
local token_a token_b token_a2 token_b2 token_web
|
||||
local bind_resp devices_resp
|
||||
local device_id_a device_id_b device_id_b2
|
||||
|
||||
bold "场景1: 设备A登录并绑定邮箱A"
|
||||
token_a="$(device_login "$DEVICE_A" "qa-device-a")"
|
||||
bind_resp="$(bind_email_with_verification_auto "$token_a" "$EMAIL_A" 1)"
|
||||
token_a="$(token_or_keep "$bind_resp" "$token_a")"
|
||||
devices_resp="$(get_devices "$token_a")"
|
||||
assert_device_exists "$devices_resp" "$DEVICE_A"
|
||||
device_id_a="$(find_device_id "$devices_resp" "$DEVICE_A")"
|
||||
[[ -n "$device_id_a" ]] || fail "场景1 无法找到设备A ID"
|
||||
info "场景1完成: deviceA.id=${device_id_a}"
|
||||
|
||||
bold "场景2: 邮箱A网页登录"
|
||||
token_web="$(email_login "$EMAIL_A" "$EMAIL_LOGIN_CODE")"
|
||||
[[ -n "$token_web" ]] || fail "场景2 邮箱登录 token 为空"
|
||||
info "场景2完成: 邮箱登录成功"
|
||||
|
||||
bold "场景3: 设备B登录并绑定邮箱A,然后B踢A"
|
||||
token_b="$(device_login "$DEVICE_B" "qa-device-b")"
|
||||
bind_resp="$(bind_email_with_verification_auto "$token_b" "$EMAIL_A" 2)"
|
||||
token_b="$(token_or_keep "$bind_resp" "$token_b")"
|
||||
jq -e '.data.family_joined == true' <<<"$bind_resp" >/dev/null || warn "场景3: family_joined 非 true,resp=${bind_resp}"
|
||||
|
||||
devices_resp="$(get_devices "$token_b")"
|
||||
assert_device_exists "$devices_resp" "$DEVICE_A"
|
||||
assert_device_exists "$devices_resp" "$DEVICE_B"
|
||||
device_id_a="$(find_device_id "$devices_resp" "$DEVICE_A")"
|
||||
[[ -n "$device_id_a" ]] || fail "场景3 无法从B视角找到A设备ID"
|
||||
unbind_device "$token_b" "$device_id_a"
|
||||
assert_token_invalid "$token_a" "设备A(被B踢后)"
|
||||
info "场景3完成: B已踢A"
|
||||
|
||||
bold "场景4: 设备A重新登录并绑定邮箱A,然后A踢B"
|
||||
token_a2="$(device_login "$DEVICE_A" "qa-device-a-relogin")"
|
||||
bind_resp="$(bind_email_with_verification_auto "$token_a2" "$EMAIL_A" 2)"
|
||||
token_a2="$(token_or_keep "$bind_resp" "$token_a2")"
|
||||
|
||||
devices_resp="$(get_devices "$token_a2")"
|
||||
assert_device_exists "$devices_resp" "$DEVICE_A"
|
||||
assert_device_exists "$devices_resp" "$DEVICE_B"
|
||||
device_id_b="$(find_device_id "$devices_resp" "$DEVICE_B")"
|
||||
[[ -n "$device_id_b" ]] || fail "场景4 无法从A视角找到B设备ID"
|
||||
unbind_device "$token_a2" "$device_id_b"
|
||||
assert_token_invalid "$token_b" "设备B(被A踢后)"
|
||||
info "场景4完成: A已踢B"
|
||||
|
||||
bold "场景5: 设备B登录后执行退出(删除当前设备)"
|
||||
token_b2="$(device_login "$DEVICE_B" "qa-device-b-relogin")"
|
||||
bind_resp="$(bind_email_with_verification_auto "$token_b2" "$EMAIL_A" 2)"
|
||||
token_b2="$(token_or_keep "$bind_resp" "$token_b2")"
|
||||
|
||||
devices_resp="$(get_devices "$token_b2")"
|
||||
device_id_b2="$(find_device_id "$devices_resp" "$DEVICE_B")"
|
||||
[[ -n "$device_id_b2" ]] || fail "场景5 无法找到当前设备B ID"
|
||||
unbind_device "$token_b2" "$device_id_b2"
|
||||
assert_token_invalid "$token_b2" "设备B(主动退出后)"
|
||||
info "场景5完成: B退出后 token 失效"
|
||||
|
||||
bold "全部场景执行完成 ✅"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@ -1,53 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
OS_TYPE=$(uname)
|
||||
ARCH_TYPE=$(uname -m)
|
||||
|
||||
if [[ "$OS_TYPE" == "Linux" ]]; then
|
||||
echo "The current operating system is Linux"
|
||||
if [[ "$ARCH_TYPE" == "x86_64" ]]; then
|
||||
echo "Format api file"
|
||||
./generate/gopure-linux-amd64 api format --dir ./apis
|
||||
echo "Architecture: amd64"
|
||||
./generate/gopure-linux-amd64 api go -api *.api -dir . -style goZero
|
||||
elif [[ "$ARCH_TYPE" == "aarch64" ]]; then
|
||||
echo "Format api file"
|
||||
./generate/gopure-linux-arm64 api format --dir ./apis
|
||||
echo "Architecture: arm64"
|
||||
./generate/gopure-linux-arm64 api go -api *.api -dir . -style goZero
|
||||
else
|
||||
echo "Unrecognized architecture: $ARCH_TYPE"
|
||||
fi
|
||||
elif [[ "$OS_TYPE" == "Darwin" ]]; then
|
||||
echo "The current operating system is macOS"
|
||||
if [[ "$ARCH_TYPE" == "x86_64" ]]; then
|
||||
echo "Format api file"
|
||||
./generate/gopure-darwin-amd64 api format --dir ./apis
|
||||
echo "Architecture: amd64"
|
||||
./generate/gopure-darwin-amd64 api go -api *.api -dir . -style goZero
|
||||
elif [[ "$ARCH_TYPE" == "arm64" ]]; then
|
||||
echo "Format api file"
|
||||
./generate/gopure-darwin-arm64 api format --dir ./apis
|
||||
echo "Architecture: arm64"
|
||||
./generate/gopure-darwin-arm64 api go -api *.api -dir . -style goZero
|
||||
else
|
||||
echo "Unrecognized architecture: $ARCH_TYPE"
|
||||
fi
|
||||
elif [[ "$OS_TYPE" == "CYGWIN"* || "$OS_TYPE" == "MINGW"* ]]; then
|
||||
echo "The current operating system is Windows"
|
||||
if [[ "$ARCH_TYPE" == "x86_64" ]]; then
|
||||
echo "Format api file"
|
||||
./generate/gopure-amd64.exe api format --dir ./apis
|
||||
echo "Architecture: amd64"
|
||||
./generate/gopure-amd64.exe api go -api *.api -dir . -style goZero
|
||||
elif [[ "$ARCH_TYPE" == "arm64" ]]; then
|
||||
echo "Format api file"
|
||||
./generate/gopure-arm64.exe api format --dir ./apis
|
||||
echo "Architecture: arm64"
|
||||
./generate/gopure-arm64.exe api go -api *.api -dir . -style goZero
|
||||
else
|
||||
echo "Unrecognized architecture: $ARCH_TYPE"
|
||||
fi
|
||||
else
|
||||
echo "Unrecognized operating system: $OS_TYPE"
|
||||
fi
|
||||
@ -1,96 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 检查是否以 root 用户运行
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "请以 root 用户运行此脚本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 系统检测,确定使用的包管理工具
|
||||
if [ -f /etc/debian_version ]; then
|
||||
# Ubuntu / Debian 系统
|
||||
PKG_MANAGER="apt-get"
|
||||
elif [ -f /etc/redhat-release ]; then
|
||||
# CentOS 系统
|
||||
PKG_MANAGER="yum"
|
||||
else
|
||||
echo "不支持的系统"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 jq 是否已安装,若未安装则自动安装
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "jq 未安装,正在安装 jq ..."
|
||||
if [ "$PKG_MANAGER" == "apt-get" ]; then
|
||||
apt-get update && apt-get install -y jq
|
||||
elif [ "$PKG_MANAGER" == "yum" ]; then
|
||||
yum install -y jq
|
||||
else
|
||||
echo "无法安装 jq,未知的包管理器"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 获取最新的版本号
|
||||
VERSION=$(curl -s https://api.github.com/repos/perfect-panel/ppanel/releases/latest | jq -r .tag_name)
|
||||
|
||||
if [ "$VERSION" == "null" ]; then
|
||||
echo "无法获取最新版本号,请检查网络或 GitHub API 状态"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 安装路径
|
||||
INSTALL_DIR="/opt/ppanel-server"
|
||||
SERVICE_NAME="ppanel"
|
||||
|
||||
# 下载并解压二进制文件
|
||||
echo "开始下载 ppanel 二进制文件,版本:$VERSION ..."
|
||||
wget https://github.com/perfect-panel/ppanel/releases/download/$VERSION/ppanel-server-linux-amd64.tar.gz -O /tmp/ppanel-server-linux-amd64.tar.gz
|
||||
|
||||
# 创建安装目录
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# 解压文件到安装目录
|
||||
echo "解压文件到 $INSTALL_DIR ..."
|
||||
tar -zxvf /tmp/ppanel-server-linux-amd64.tar.gz -C "$INSTALL_DIR" --strip-components=1
|
||||
|
||||
# 给二进制文件赋予执行权限
|
||||
chmod +x "$INSTALL_DIR/ppanel-server"
|
||||
|
||||
# 创建 systemd 服务文件
|
||||
echo "创建 systemd 服务文件 ..."
|
||||
cat > /etc/systemd/system/$SERVICE_NAME.service <<EOF
|
||||
[Unit]
|
||||
Description=PPANEL Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=$INSTALL_DIR/ppanel-server
|
||||
Restart=always
|
||||
User=root
|
||||
WorkingDirectory=$INSTALL_DIR
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# 重新加载 systemd 服务
|
||||
echo "重新加载 systemd 配置 ..."
|
||||
systemctl daemon-reload
|
||||
|
||||
# 启动服务
|
||||
echo "启动 ppanel 服务 ..."
|
||||
systemctl start $SERVICE_NAME
|
||||
|
||||
# 设置开机自启
|
||||
echo "设置服务开机自启 ..."
|
||||
systemctl enable $SERVICE_NAME
|
||||
|
||||
# 输出服务状态
|
||||
echo "服务已启动,状态如下:"
|
||||
systemctl status $SERVICE_NAME
|
||||
|
||||
# 提示配置文件
|
||||
echo "请通过 http://服务器地址:8080/init 初始化系统配置"
|
||||
Loading…
x
Reference in New Issue
Block a user