Compare commits

..

137 Commits

Author SHA1 Message Date
shanshanzhong147 e5e60f73c2 Debug: Add logs to trace Telegram initialization 2026-01-13 07:00:11 -08:00
shanshanzhong147 14489b6afd Update: Save current progress 2026-01-13 06:56:26 -08:00
shanshanzhong147 d45f4417ed 15ttl
Build docker and publish / build (20.15.1) (push) Successful in 6m0s
2026-01-09 00:43:52 -08:00
shanshanzhong147 7b33ab6e2a jwt注销问题
Build docker and publish / build (20.15.1) (push) Successful in 5m54s
2026-01-09 00:36:19 -08:00
shanshanzhong147 93c4d7b7d1 back 2026-01-09 00:28:37 -08:00
shanshanzhong147 16b4300354 status
Build docker and publish / build (20.15.1) (push) Successful in 5m53s
2026-01-08 02:03:31 -08:00
shanshanzhong147 c4f327562f feat: 记录 Telegram 配置启用状态并初始化设备中间件响应写入器字段。
Build docker and publish / build (20.15.1) (push) Successful in 5m3s
2026-01-06 22:55:10 -08:00
shanshanzhong147 d0a3b36791 feat(telegram): 添加Telegram启用检查逻辑
Build docker and publish / build (20.15.1) (push) Successful in 5m15s
fix(trace): 移除服务中的trace初始化代码并优化agent日志

feat(docker): 添加docker-compose.yaml配置文件

refactor(trace): 为agent添加超时设置和启动日志

chore: 添加nginx配置文件示例
2026-01-06 22:29:07 -08:00
shanshanzhong147 ef64a876cd feat: 添加请求追踪中间件并支持查询过期订阅
Build docker and publish / build (20.15.1) (push) Successful in 5m10s
添加请求追踪中间件以记录请求和响应内容
在用户订阅查询中新增includeExpired参数支持查询历史记录
完善配置系统以支持float64类型默认值解析
2026-01-06 20:54:15 -08:00
shanshanzhong147 55c778b65b fix(兑换率): 移除硬编码的CNY兑换率并添加固定汇率回退逻辑
Build docker and publish / build (20.15.1) (push) Successful in 5m45s
移除之前硬编码的CNY兑换率检查,改为从系统配置中读取固定汇率
当API调用失败时,使用配置的固定汇率作为回退方案
添加对无AccessKey情况的固定汇率支持
2026-01-05 18:36:24 -08:00
shanshanzhong147 b10d0d22e1 feat(汇率查询): 添加汇率查询日志记录
Build docker and publish / build (20.15.1) (push) Successful in 5m18s
在汇率查询逻辑中添加日志记录,包括目标货币、货币单位和是否有AccessKey的信息。当AccessKey为空时,添加跳过转换的日志提示
2026-01-05 18:11:27 -08:00
shanshanzhong147 657c2930b1 feat(用户管理): 添加最后登录时间和会员状态功能
Build docker and publish / build (20.15.1) (push) Successful in 4m57s
- 新增数据库迁移文件添加last_login_time字段
- 在登录逻辑中更新最后登录时间
- 添加FindActiveSubscribesByUserIds方法查询用户订阅状态
- 在用户列表接口中聚合最后登录时间和会员状态信息
- 更新相关API定义和模型结构
- 修复迁移文件版本号冲突问题
- 移除omitempty标签确保字段始终返回
2026-01-05 01:46:39 -08:00
shanshanzhong147 5598181a48 fix(auth): 修复邮件验证码逻辑,支持多种场景验证
Build docker and publish / build (20.15.1) (push) Successful in 4m42s
修改邮件验证码校验逻辑,使其能够处理注册和安全验证等多种场景。移除不再需要的用户绑定检查,简化代码逻辑。
2025-12-31 02:09:31 -08:00
shanshanzhong147 4ffccd5ad8 feat: 实现邮箱验证码登录功能,支持新用户自动注册并记录登录日志
Build docker and publish / build (20.15.1) (push) Successful in 5m40s
2025-12-31 00:55:11 -08:00
shanshanzhong147 fd185bcfe1 refactor(handler): 使用result包统一处理HTTP响应
Build docker and publish / build (20.15.1) (push) Successful in 5m57s
在deleteAccountHandler中使用result.HttpResult统一处理HTTP响应,提高代码一致性和可维护性
2025-12-22 20:32:12 -08:00
shanshanzhong147 9bf09c4b9a fix(account): 删除账户时清理会话token避免残留
Build docker and publish / build (20.15.1) (push) Successful in 6m0s
feat(order): 添加邀请双方赠送天数功能
refactor(invite): 移除绑定邀请码时的天数赠送逻辑
2025-12-22 20:00:09 -08:00
shanshanzhong147 b3edd7e2a6 feat(contactLogic): 添加通过HTTP API发送Telegram消息的备用方案
Build docker and publish / build (20.15.1) (push) Successful in 6m2s
当TelegramBot未初始化时,改用HTTP API发送消息。这提高了功能的可靠性,确保在没有直接bot实例时仍能发送通知。
2025-12-21 23:46:30 -08:00
shanshanzhong147 e42a5b80bf refactor(telegram): 优化 Telegram 初始化逻辑,优先使用数据库配置
Build docker and publish / build (20.15.1) (push) Successful in 5m53s
重构 Telegram 初始化流程,优先从数据库获取配置并添加回退逻辑
移除未使用的 config.Telegram 依赖
添加 webhook 删除操作以避免长轮询冲突
2025-12-21 23:28:13 -08:00
shanshanzhong147 74f4a12422 feat(contact): 添加联系信息提交功能
Build docker and publish / build (20.15.1) (push) Successful in 5m55s
实现联系信息提交功能,包括:
1. 新增ContactRequest类型定义
2. 添加POST /contact路由
3. 实现联系信息提交处理逻辑
4. 通过Telegram发送联系信息通知
5. 在Telegram配置中添加GroupChatID字段
2025-12-21 19:32:23 -08:00
shanshanzhong147 2fdc9c8127 refactor(iap/apple): 优化商品ID解析逻辑以提高可读性
Build docker and publish / build (20.15.1) (push) Successful in 6m22s
将原有的字符串索引查找方式改为分割字符串后遍历检查,使代码更清晰易读
2025-12-17 19:33:56 -08:00
shanshanzhong147 e98709b511 feat(iap/apple): 增强商品ID解析逻辑以支持多种时间单位
扩展商品ID解析功能,支持从day/month/year格式中提取单位和数量
根据不同的时间单位计算对应的订阅时长
2025-12-17 19:05:55 -08:00
shanshanzhong147 5d7ca4b9bd feat(iap/apple): 从Apple商品ID解析购买数量并匹配订阅折扣
Build docker and publish / build (20.15.1) (push) Successful in 6m8s
添加从Apple商品ID中解析购买数量(天数)的逻辑,并基于订阅列表的折扣配置进行匹配。当商品ID包含"day"时,提取后续数字作为购买数量,然后查找对应数量的订阅折扣配置。
2025-12-17 18:48:57 -08:00
shanshanzhong147 9944ab7b8a feat(iap/apple): 添加 Apple IAP 交易绑定的详细日志记录
Build docker and publish / build (20.15.1) (push) Successful in 6m9s
添加详细的日志记录以跟踪 Apple IAP 交易绑定过程中的关键步骤和错误情况,包括验签结果、幂等检查、商品映射解析、订阅到期时间计算等,便于问题排查和调试
2025-12-17 18:32:26 -08:00
shanshanzhong147 041417a177 fix(orm): 设置MySQL连接的最大空闲时间和生命周期
Build docker and publish / build (20.15.1) (push) Successful in 6m34s
feat(middleware): 区分Redis中session不存在的错误情况

在MySQL连接配置中添加SetConnMaxIdleTime和SetConnMaxLifetime设置
在认证中间件中区分Redis返回的key不存在错误和其他错误
2025-12-17 18:16:55 -08:00
shanshanzhong147 d3541a89ae fix(iap): 修复苹果IAP重复处理交易的问题
Build docker and publish / build (20.15.1) (push) Successful in 6m26s
添加对已存在订阅的检查逻辑,避免重复处理相同的苹果IAP交易
添加测试恢复接口的示例代码
2025-12-17 04:00:02 -08:00
shanshanzhong147 d8f5628bb1 feat(iap/apple): 添加对appAccountToken的支持以关联订单
Build docker and publish / build (20.15.1) (push) Successful in 6m18s
解析JWS中的appAccountToken字段并添加到TransactionPayload结构体
在恢复逻辑中尝试使用appAccountToken关联现有订单
2025-12-16 19:28:03 -08:00
shanshanzhong147 40a45199a5 feat(apple支付): 添加按平台查询支付方式和恢复交易逻辑优化
Build docker and publish / build (20.15.1) (push) Successful in 6m52s
添加FindListByPlatform方法用于按平台查询支付方式
优化apple支付恢复交易逻辑,支持直接使用交易ID查询
添加API配置处理逻辑和错误回退机制
2025-12-16 18:58:55 -08:00
shanshanzhong147 5bc453b09f fix(iap/apple): 添加缺失的IssuerID默认值并更新测试配置
Build docker and publish / build (20.15.1) (push) Successful in 6m40s
当IssuerID缺失或为默认值时,使用硬编码值作为回退方案
更新测试文件中的IssuerID和BundleID为实际值
2025-12-16 01:53:36 -08:00
shanshanzhong147 680951611f feat: 添加测试Apple IAP API连接的主程序
实现一个用于测试Apple应用内购买API连接的主程序,包含JWT token生成和API请求功能
2025-12-16 01:53:24 -08:00
shanshanzhong147 ceb3b16dc5 feat(iap/apple): 添加BundleID支持以增强苹果交易验证
在ServerAPIConfig中添加BundleID字段,用于苹果服务器API验证
当BundleID未配置时,尝试从站点自定义数据中获取
删除过时的测试文件
2025-12-16 01:46:47 -08:00
shanshanzhong147 51765c794a test(iap/apple): 添加苹果交易ID关联逻辑的测试用例
Build docker and publish / build (20.15.1) (push) Has been cancelled
测试苹果IAP支付配置中私钥回退逻辑的正确性
2025-12-16 01:21:04 -08:00
shanshanzhong147 f866270ece fix(iap/apple): 修复PEM格式私钥并添加调试回退逻辑
Build docker and publish / build (20.15.1) (push) Successful in 6m47s
修复从请求中获取的PEM格式私钥缺少换行符的问题,自动添加正确格式
添加开发环境下的硬编码私钥回退逻辑,便于调试
2025-12-16 00:57:57 -08:00
shanshanzhong147 b391c12c1b refactor(apple): 优化base64解码和JWT签名逻辑
Build docker and publish / build (20.15.1) (push) Successful in 6m28s
移除notification.go中冗余的base64解码代码,使用统一的decodeB64URL函数处理
在serverapi.go中改进ES256签名实现,正确处理P-256曲线的R和S值填充
2025-12-15 23:59:46 -08:00
shanshanzhong147 e11ed2338d fix(iap): 修复JWS验证逻辑,支持原始R||S签名格式
Build docker and publish / build (20.15.1) (push) Successful in 6m38s
fix(middleware): 增加设备中间件的日志记录
fix(auth): 优化认证中间件的错误日志记录
feat(iap): 添加苹果交易附加逻辑的详细日志
2025-12-15 23:44:55 -08:00
shanshanzhong147 3c6dd5058b feat(apple): 添加通过transaction_id附加苹果交易功能
Build docker and publish / build (20.15.1) (push) Successful in 6m41s
新增通过transaction_id附加苹果交易的功能,包括:
1. 添加AttachAppleTransactionByIdRequest类型和对应路由
2. 实现AppleIAPConfig配置模型
3. 添加ServerAPI获取交易信息的实现
4. 优化JWS解析逻辑,增加cleanB64函数处理空格
5. 完善苹果通知处理逻辑的日志和注释
2025-12-15 22:35:33 -08:00
shanshanzhong147 15fb9a1da5 fix(iap/apple): 添加订单号回退逻辑以处理订阅信息缺失
Build docker and publish / build (20.15.1) (push) Successful in 6m23s
当请求中缺少duration或subscribeId时,优先从order_no查找订单信息作为回退方案,避免直接返回错误
2025-12-15 18:02:06 -08:00
shanshanzhong147 72400ae054 feat(appleIAP): 实现苹果应用内购买通知处理逻辑
Build docker and publish / build (20.15.1) (push) Successful in 6m36s
添加苹果IAP通知处理功能,包括解析和验证JWS签名、处理交易状态变更
新增订单号字段用于关联订单处理
实现交易记录的创建和更新逻辑
处理订阅状态的变更和过期时间计算
2025-12-15 17:49:16 -08:00
shanshanzhong147 1f5eb2784d feat(用户管理): 添加设备ID筛选用户列表功能
Build docker and publish / build (20.15.1) (push) Successful in 6m38s
在用户列表查询中新增设备ID筛选条件,支持通过设备ID或设备标识符查询关联用户
2025-12-14 19:10:15 -08:00
shanshanzhong147 d7b56f3edc feat(支付): 添加Apple应用内支付支持
Build docker and publish / build (20.15.1) (push) Successful in 6m38s
- 在CheckoutOrderResponse结构中添加product_ids字段用于存储Apple IAP产品ID
- 新增AppleIAP支付平台常量及映射
- 实现Apple IAP支付处理逻辑,生成对应的产品ID
2025-12-14 18:56:56 -08:00
shanshanzhong147 0e493caf16 feat(iap): 移除苹果IAP商品列表接口并添加接入指南文档
Build docker and publish / build (20.15.1) (push) Successful in 6m44s
移除不再使用的苹果IAP商品列表相关代码,包括handler、logic和测试文件
新增详细的iOS内购接入指南文档,包含StoreKit2使用流程和接口规范
2025-12-14 18:47:37 -08:00
shanshanzhong147 62186ca672 feat(iap/apple): 实现苹果IAP非续期订阅功能
Build docker and publish / build (20.15.1) (push) Successful in 6m37s
新增苹果IAP相关接口与逻辑,包括产品列表查询、交易绑定、状态查询和恢复购买功能。移除旧的IAP验证逻辑,重构订阅系统以支持苹果IAP交易记录存储和权益计算。

- 新增/pkg/iap/apple包处理JWS解析和产品映射
- 实现GET /products、POST /attach、POST /restore和GET /status接口
- 新增apple_iap_transactions表存储交易记录
- 更新文档说明配置方式和接口规范
- 移除旧的AppleIAP验证和通知处理逻辑
2025-12-13 20:54:50 -08:00
shanshanzhong147 a80d6af035 refactor(payment): 移除ApplePay支付平台支持并整合到Stripe
Build docker and publish / build (20.15.1) (push) Has been cancelled
将ApplePay支付方式整合到Stripe平台处理,移除独立的ApplePay平台代码
简化支付逻辑,统一通过Stripe处理所有支付方式
2025-12-09 00:58:03 -08:00
shanshanzhong147 d95911d6bd feat(支付): 新增Apple IAP支付支持
Build docker and publish / build (20.15.1) (push) Has been cancelled
实现Apple应用内购支付功能,包括:
1. 新增AppleIAP和ApplePay支付平台枚举
2. 添加IAP验证接口/v1/public/iap/verify处理初购验证
3. 实现Apple服务器通知处理逻辑/v1/iap/notifications
4. 新增JWS验签和JWKS公钥缓存功能
5. 复用现有订单系统处理IAP支付订单

相关文档已更新,包含接入方案和实现细节
2025-12-09 00:53:25 -08:00
shanshanzhong147 4b6fcb338e refactor(用户): 优化设备解绑逻辑并提取错误上报接口文档
Build docker and publish / build (20.15.1) (push) Successful in 7m15s
重构设备解绑逻辑,简化事务处理流程并移除冗余代码。将错误上报接口的详细说明从主文档拆分到单独文件,提高文档可维护性。

- 合并设备查询与认证记录查询操作
- 简化匿名用户创建流程
- 移除冗余的错误日志记录
- 将错误上报接口文档拆分到单独文件
2025-12-03 01:45:06 -08:00
shanshanzhong147 f0439f4f80 feat(日志): 新增客户端错误日志收集功能
Build docker and publish / build (20.15.1) (push) Successful in 6m50s
- 创建 log_message 表用于存储客户端错误日志
- 实现客户端日志上报接口 POST /v1/common/log/message/report
- 添加管理端日志查询接口 GET /v1/admin/log/message/error/list 和 GET /v1/admin/log/message/error/detail
- 实现日志指纹去重和限流机制
- 完善相关模型、逻辑和文档说明
2025-12-02 20:12:33 -08:00
shanshanzhong147 61cdc0ce23 feat(log): 添加客户端错误日志采集功能
Build docker and publish / build (20.15.1) (push) Successful in 7m17s
新增 log_message 表用于存储客户端错误日志,包含平台、设备信息、错误详情等字段
添加客户端上报接口和管理端查询接口
实现日志去重、限流和安全防护机制
2025-12-02 19:34:43 -08:00
shanshanzhong147 d1a620f939 feat(支付): 添加支付金额转换日志并优化代码结构
Build docker and publish / build (20.15.1) (push) Successful in 7m17s
为支付宝、Stripe、Epay和CryptoSaaS支付逻辑添加金额转换的日志记录
将支付请求参数提取为变量并添加日志记录
优化代码结构,提高可读性和可维护性
2025-12-02 18:30:16 -08:00
shanshanzhong147 6fba1d683a fix(支付): 修复金额计算精度问题
Build docker and publish / build (20.15.1) (push) Successful in 6m45s
在epayPayment和CryptoSaaSPayment方法中,对转换后的金额进行四舍五入保留两位小数处理,确保支付金额精度准确
2025-12-02 02:35:05 -08:00
shanshanzhong147 9987bd43fa fix(订单): 修复折扣计算问题并添加四舍五入处理
Build docker and publish / build (20.15.1) (push) Successful in 6m53s
统一处理百分比和系数两种折扣输入方式,增加边界保护
在金额计算中使用math.Round进行四舍五入处理
添加相关单元测试确保计算准确性
2025-12-02 02:22:09 -08:00
shanshanzhong147 fcdd6ac170 fix(admin/user): 修复删除用户设备时未清理缓存和会话的问题
Build docker and publish / build (20.15.1) (push) Successful in 6m47s
添加设备删除前的检查逻辑,确保设备存在后再执行删除操作
在删除设备前先踢下线在线设备并清理相关缓存会话
最后删除数据库中的设备记录,确保数据一致性
2025-12-02 01:45:12 -08:00
shanshanzhong147 d546ea6502 feat(auth): 添加设备绑定和解绑的日志记录和缓存清理
Build docker and publish / build (20.15.1) (push) Has been cancelled
在设备绑定和解绑逻辑中添加详细的日志记录,包括用户ID、设备标识符和限制检查
解绑时增加设备管理器的踢出操作,确保设备状态同步
2025-12-02 01:38:32 -08:00
shanshanzhong147 6b65ffb728 fix(user): 修复解绑设备接口的502错误和安全断言问题
Build docker and publish / build (20.15.1) (push) Successful in 7m4s
修复不安全类型断言可能导致panic的问题,将Redis清理移出事务并添加超时控制
增加代理层超时配置和详细日志,提升接口稳定性
2025-12-01 21:24:11 -08:00
shanshanzhong147 37cec59460 refactor(用户绑定邮箱): 修改设备记录处理逻辑,从删除改为更新归属
Build docker and publish / build (20.15.1) (push) Successful in 7m7s
将原本删除设备记录并创建新记录的逻辑改为直接更新设备记录的归属用户ID
移除不再需要的旧设备信息获取和创建新设备记录的代码
2025-12-01 01:22:55 -08:00
shanshanzhong147 e02e1d1f37 refactor(user): 移除未使用的trial订阅激活逻辑
Build docker and publish / build (20.15.1) (push) Successful in 6m59s
2025-12-01 00:52:53 -08:00
shanshanzhong147 842edad966 refactor(用户逻辑): 移除设备解绑时激活试用订阅的逻辑
Build docker and publish / build (20.15.1) (push) Has been cancelled
2025-12-01 00:52:27 -08:00
shanshanzhong147 77e4be3a71 refactor(user): 重构设备解绑逻辑,优化用户迁移流程
Build docker and publish / build (20.15.1) (push) Successful in 7m22s
- 将原有删除设备逻辑改为迁移设备到新匿名用户
- 添加注册日志记录功能
- 优化事务处理流程,确保数据一致性
- 添加试用订阅激活功能
- 完善错误处理和日志记录
2025-12-01 00:44:33 -08:00
shanshanzhong147 306300244d refactor(用户绑定): 移动设备信息获取代码位置以优化逻辑流程
Build docker and publish / build (20.15.1) (push) Successful in 7m19s
2025-12-01 00:22:36 -08:00
shanshanzhong147 8086785828 fix(用户逻辑): 修复设备记录中丢失IP和UserAgent的问题
Build docker and publish / build (20.15.1) (push) Successful in 7m5s
在删除账户和绑定邮箱逻辑中,保留原设备的IP和UserAgent信息
2025-11-30 21:46:13 -08:00
shanshanzhong147 330b4ee6a1 refactor(解绑设备): 优化解绑设备逻辑,保留设备IP和UserAgent信息
Build docker and publish / build (20.15.1) (push) Successful in 6m50s
将deleteDevice变量提前声明以避免重复声明
在registerUserAndDevice方法中新增ip和userAgent参数,保留原设备信息
2025-11-30 20:37:03 -08:00
shanshanzhong147 5de2f37c20 feat(auth): 在JWT令牌中添加设备ID信息
Build docker and publish / build (20.15.1) (push) Has been cancelled
2025-11-30 20:32:34 -08:00
shanshanzhong147 b6e93d0496 fix(device): 修复设备解绑后重建逻辑,确保设备ID稳定
Build docker and publish / build (20.15.1) (push) Has been cancelled
重构设备解绑逻辑,删除原有设备记录后重新创建用户和设备
移除文档中已废弃的测试用例和修复方案说明
2025-11-30 20:28:59 -08:00
shanshanzhong147 5497a1ffdb feat(用户): 添加删除账户响应中的状态码字段
Build docker and publish / build (20.15.1) (push) Successful in 6m39s
refactor(设备绑定): 重构设备所有权转移逻辑以保持设备ID稳定

docs: 添加设备绑定与踢出后重登录的回归测试文档

fix(设备登录): 修复设备不存在时空指针崩溃问题
2025-11-30 20:03:09 -08:00
shanshanzhong147 41b52992e4 refactor(user): 重构设备解绑逻辑,改为迁移设备到新用户而非删除
Build docker and publish / build (20.15.1) (push) Successful in 6m44s
修改设备解绑逻辑,不再删除设备而是将其迁移到新创建的用户账户下
同时优化了事务处理和错误日志记录
2025-11-30 19:14:09 -08:00
shanshanzhong147 109f708652 refactor(用户绑定): 移除设备绑定数量上限校验逻辑
Build docker and publish / build (20.15.1) (push) Successful in 6m56s
简化邮箱绑定流程,直接绑定邮箱而不检查设备数量限制
2025-11-28 01:03:25 -08:00
shanshanzhong147 471c84eb1c feat(用户验证): 添加测试验证码绕过逻辑
Build docker and publish / build (20.15.1) (push) Successful in 7m10s
2025-11-28 00:49:12 -08:00
shanshanzhong147 55b8fe813d fix(用户): 修复邮箱验证码校验逻辑,支持多场景验证
Build docker and publish / build (20.15.1) (push) Has been cancelled
修改邮箱验证码校验逻辑,使其支持安全场景和注册场景的验证码校验。当任一场景验证通过时即视为验证成功,提升用户体验。
2025-11-28 00:47:38 -08:00
shanshanzhong147 14f4e351f4 fix(auth): 修复设备登录时旧会话未清理的问题
Build docker and publish / build (20.15.1) (push) Successful in 7m9s
在设备登录逻辑中添加对旧会话的清理,防止同一设备多会话冲突
同时修复邮箱绑定时的设备数量限制校验问题
2025-11-28 00:25:19 -08:00
shanshanzhong147 6afd6eb307 feat(auth): 添加设备绑定数量限制检查
Build docker and publish / build (20.15.1) (push) Successful in 7m26s
在设备绑定逻辑中添加对设备绑定数量的限制检查,当超过限制时返回特定错误码
同时在用户注册、登录等流程中处理设备绑定数量超限的错误情况
2025-11-27 23:58:10 -08:00
shanshanzhong147 2442831cd7 feat(设备管理): 添加设备在线记录查询功能并优化设备列表排序
Build docker and publish / build (20.15.1) (push) Successful in 7m28s
添加FindLatestDeviceOnlineRecord接口用于查询设备最新在线记录
实现GetOnlineDeviceLoginTime方法获取设备登录时间
优化设备列表查询按最后活动时间排序
移除未使用的依赖项
2025-11-27 23:24:48 -08:00
shanshanzhong147 236fa6c4e6 feat(auth): 在登录响应中添加会话限制信息
Build docker and publish / build (20.15.1) (push) Successful in 7m32s
在LoginResponse结构体中新增Limit字段,用于返回用户最大会话数限制
修复userLoginLogic.go和telephoneLoginLogic.go中的缩进问题
2025-11-26 22:57:10 -08:00
shanshanzhong147 7e32c571ab fix: 将每个用户的最大会话数默认值从3改为1
Build docker and publish / build (20.15.1) (push) Successful in 7m20s
2025-11-26 18:42:09 -08:00
shanshanzhong147 1d5d361ae8 feat(auth): 实现用户会话数限制功能
Build docker and publish / build (20.15.1) (push) Successful in 7m32s
添加用户会话数限制功能,当超过最大会话数时自动移除最旧的会话
- 在config中添加UserSessionsKeyPrefix常量
- 在JwtAuth配置中新增MaxSessionsPerUser字段
- 在ServiceContext中实现EnforceUserSessionLimit方法
- 在所有登录逻辑中调用会话限制检查
2025-11-26 17:52:12 -08:00
shanshanzhong147 4ad384b01a fix(redis): 修复缓存设置和清除逻辑的问题
Build docker and publish / build (20.15.1) (push) Successful in 7m20s
修复Redis缓存设置未设置TTL的问题,使用节点拉取间隔加60秒作为TTL
修复ClearServerAllCache中重复添加keys的问题
修复ClearServerCache中未使用cursor参数的问题
优化ClearServerAllCache以支持清除多种前缀的缓存
2025-11-23 23:39:30 -08:00
shanshanzhong147 58107ed76f feat(handler): 添加绑定邀请码接口路由和处理逻辑
Build docker and publish / build (20.15.1) (push) Successful in 7m56s
新增/public/user/bindInviteCodeHandler.go处理绑定邀请码请求
在routes.go中添加对应的POST路由/bind_invite_code
2025-11-20 05:56:18 -08:00
shanshanzhong147 9e7aaa4242 ci(docker): 升级 Docker CLI 并优化系统工具安装流程
Build docker and publish / build (20.15.1) (push) Successful in 7m9s
移除旧版 docker.io 并安装官方仓库的 Docker CLI,确保 API 版本 >= 1.44
优化 apt 锁等待逻辑并添加版本检查步骤
2025-11-16 23:17:35 -08:00
shanshanzhong147 70561876d6 fix(auth): 修复设备首次登录时空指针崩溃问题
Build docker and publish / build (20.15.1) (push) Failing after 1m21s
在设备不存在分支注册后立即查询设备信息并赋值,避免后续操作解引用空指针
同时增加设备信息空指针保护并完善错误处理
2025-11-16 23:10:35 -08:00
shanshanzhong147 004acd03d2 fix(用户绑定): 修复邮箱绑定流程中用户ID未正确赋值的问题
Build docker and publish / build (20.15.1) (push) Successful in 7m10s
在邮箱不存在的情况下,确保将当前设备用户的ID正确赋值给emailUserId变量,以保证后续token生成和返回结果正确
2025-11-05 18:40:48 -08:00
shanshanzhong147 15f4e69dc3 feat(server): 添加服务器地理位置信息字段
Build docker and publish / build (20.15.1) (push) Successful in 7m3s
为服务器模型添加经度、纬度及中心点坐标字段,并在相关逻辑中处理这些字段
同时修复服务器用户列表缓存功能
2025-11-03 23:50:23 -08:00
shanshanzhong147 071bb1940d feat(adapter): 添加新的协议字段到代理配置
Build docker and publish / build (20.15.1) (push) Successful in 7m6s
添加 UpMbps、DownMbps 等新字段以支持更多协议配置选项
2025-11-02 05:05:47 -08:00
shanshanzhong147 01eab942fd fix(auth): 修复用户注册和登录后缓存清理问题
Build docker and publish / build (20.15.1) (push) Successful in 6m43s
在用户注册、登录、修改订阅和删除订阅等操作后,添加清理服务器缓存的逻辑
同时修复电话重置密码验证码解析问题
2025-11-01 09:31:38 -07:00
shanshanzhong147 49d3fc1c74 feat(用户订阅): 添加获取订阅状态接口
Build docker and publish / build (20.15.1) (push) Successful in 7m3s
新增获取用户设备及邮箱订阅状态的接口,包括请求和响应数据结构定义、路由注册及处理逻辑实现
同时更新相关依赖版本
2025-10-31 03:31:29 -07:00
shanshanzhong147 e1616765c7 refactor(auth): 移除设备登录逻辑中的错误处理并简化代码
Build docker and publish / build (20.15.1) (push) Successful in 6m51s
refactor(user): 修改删除账号处理器的邮箱验证逻辑
2025-10-31 02:14:28 -07:00
shanshanzhong147 ccdcfd3430 fix(account): 将删除账号接口从GET改为POST方法
Build docker and publish / build (20.15.1) (push) Successful in 6m55s
修复删除账号接口的安全问题,GET方法不应用于敏感操作
同时增加邮箱验证码校验,提高账号安全性
```

```msg
feat(auth): 在设备登录时更新用户代理信息

添加设备登录时更新用户代理(UA)的逻辑
确保设备信息保持最新状态
```

```msg
refactor(handler): 重构删除账号处理器的验证逻辑

将邮箱验证码校验逻辑提取为独立函数
提高代码可维护性和复用性
2025-10-31 01:59:14 -07:00
shanshanzhong147 b1e9382e73 fix(用户): 修正账户注销成功消息文本
Build docker and publish / build (20.15.1) (push) Successful in 6m52s
2025-10-31 00:58:29 -07:00
shanshanzhong147 70c8811406 feat(用户): 添加账号注销功能
Build docker and publish / build (20.15.1) (push) Has been cancelled
实现用户账号注销逻辑,包括删除用户所有关联数据并根据原设备信息创建新账号
2025-10-31 00:58:05 -07:00
shanshanzhong147 c5d59b86b0 feat(用户绑定): 实现邮箱绑定功能并优化设备解绑逻辑
Build docker and publish / build (20.15.1) (push) Successful in 7m20s
添加邮箱绑定错误码和消息
修改解绑设备逻辑,解绑后创建新用户设备记录
重构邮箱绑定逻辑,支持检测已绑定邮箱并处理设备转移
2025-10-31 00:14:22 -07:00
shanshanzhong147 e23809b32e fix(user): 初始化订阅详情结构体避免nil指针
Build docker and publish / build (20.15.1) (push) Successful in 7m15s
修复FindOneUserSubscribe函数中未初始化SubscribeDetails结构体的问题,避免可能的nil指针错误
2025-10-30 01:06:25 -07:00
shanshanzhong147 83cb35add6 fix(portal): 修复折扣计算中浮点数精度问题
Build docker and publish / build (20.15.1) (push) Successful in 7m13s
2025-10-29 20:31:58 -07:00
shanshanzhong147 81086eea52 fix(订单折扣): 将折扣计算从整数百分比改为浮点数
Build docker and publish / build (20.15.1) (push) Failing after 6m30s
修改 SubscribeDiscount 结构体中的 Discount 字段类型为 float64,并调整 getDiscount 函数逻辑以直接使用浮点数计算
2025-10-29 20:18:05 -07:00
shanshanzhong147 1302accaf9 fix(用户): 修复邮箱绑定逻辑中的错误处理
Build docker and publish / build (20.15.1) (push) Successful in 7m17s
重构邮箱绑定逻辑的错误处理流程,正确处理数据库查询错误和记录不存在的情况
优化设备查询方法,移除不必要的缓存键生成
2025-10-29 01:38:49 -07:00
shanshanzhong147 02e76ebcf1 feat(用户): 添加新用户注册时激活试用订阅功能
Build docker and publish / build (20.15.1) (push) Successful in 8m33s
在创建邮箱用户时,检查配置是否启用试用订阅功能。若启用,则自动为用户激活试用订阅
2025-10-28 21:20:57 -07:00
shanshanzhong147 63d90fba72 fix(用户绑定): 修复生成JWT token时的时间戳和过期时间参数错误
Build docker and publish / build (20.15.1) (push) Failing after 26s
使用time.Now().Unix()替代now变量,确保获取当前时间戳
添加LoginType参数以明确登录类型为设备
2025-10-28 21:10:28 -07:00
shanshanzhong147 b70738b434 build(Dockerfile): 将基础镜像从scratch改为alpine并优化时区配置
Build docker and publish / build (20.15.1) (push) Successful in 7m24s
简化时区配置,从仅复制上海时区改为复制全部时区数据
移除不必要的/etc目录复制
2025-10-28 20:59:22 -07:00
shanshanzhong147 0f38b3fcd3 refactor(auth): 优化设备登录逻辑,移除冗余代码并添加设备缓存
Build docker and publish / build (20.15.1) (push) Failing after 6m39s
feat(database): 添加用户算法和盐字段的迁移脚本

fix(subscribe): 修复服务器用户列表缓存问题,临时禁用缓存

style(model): 清理用户模型注释,简化代码结构

chore: 删除无用脚本和测试文件

docs: 添加用户绑定流程文档

perf(login): 优化设备登录性能,添加设备缓存键

fix(unbind): 修复设备解绑时的缓存清理逻辑

refactor(verify): 简化邮箱验证逻辑,移除冗余代码

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

Some files were not shown because too many files have changed in this diff Show More