Compare commits

...

174 Commits

Author SHA1 Message Date
shanshanzhong147 b6d0c89aa1 修复(#105): 消除 goctl 重新生成漂移
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 23:51:25 -07:00
shanshanzhong147 27986044a1 修复(#105): 补齐 goctl API 聚合入口
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 23:28:16 -07:00
shanshanzhong147 197fed7d12 新功能(#102): 新增邀请记录接口并删除旧邀请销售接口
Build docker and publish / build (20.15.1) (push) Failing after 10m56s
Build docker and publish / build (20.15.1) (pull_request) Successful in 8m50s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 22:50:16 -07:00
shanshanzhong147 f452f80100 配置(#98): 扩展文件上传 Content-Type 白名单支持图片
Build docker and publish / build (20.15.1) (push) Failing after 9m55s
Build docker and publish / build (20.15.1) (pull_request) Successful in 8m7s
- 在 etc/ppanel.yaml 与 internal/config/config.go 的 S3.AllowedContentTypes 新增 image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp
- 保留原 zip/gzip/text/json/octet-stream
- validateInitRequest 在拒绝时携带 content_type is not allowed 业务消息
- 新增 internal/logic/public/file/common_test.go,覆盖允许/拒绝及无 Content-Type 嗅探
- doc/tapi-file-upload-zh.md 同步允许类型列表与错误码说明

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 19:50:51 -07:00
shanshanzhong147 1022160ff8 新功能(#89): 增强邀请列表接口 + 新增全局邀请管理接口
Build docker and publish / build (20.15.1) (push) Failing after 9m30s
Build docker and publish / build (20.15.1) (pull_request) Successful in 7m48s
P01: GET /v1/admin/user/invite/list 增加 search/enable/user_id_search
筛选参数,响应新增 order_count/has_purchased/inviter_commission/
inviter_gift_days/invitee_gift_days 权益字段。

P02: 新增 GET /v1/admin/invite/list 全局邀请管理接口,支持按
邀请人/被邀请人筛选和搜索,返回邀请关系及权益聚合数据。

共用 QueryBenefits 批量查询防 N+1,分页上限 100。

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 10:32:28 -07:00
shanshanzhong147 82eff47f38 新功能(#69): 用户级限速覆盖 — 数据层 + 核心逻辑 + 管理接口
Build docker and publish / build (20.15.1) (push) Failing after 8m52s
Build docker and publish / build (20.15.1) (pull_request) Successful in 7m51s
- 新增 migration 02153: user_subscribe 表添加 speed_limit、traffic_limit 列(幂等)
- model 层 Subscribe/SubscribeDetails 新增用户级限速字段(*string 类型支持 nil 回退)
- server 用户列表和管理员详情接口支持用户级限速覆盖优先级计算
- 更新管理员订阅更新接口支持写入/清除用户级限速

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 08:07:16 -07:00
shanshanzhong147 d351b50066 修复(#71): 管理员订阅限速接口拒绝负数限速和非法 traffic_limit
Build docker and publish / build (20.15.1) (push) Failing after 8m50s
Build docker and publish / build (20.15.1) (pull_request) Successful in 7m56s
- SpeedLimit 加 validate:"gte=0" 校验
- TrafficLimit 加 JSON 格式校验
- 新增单测覆盖负数限速和非法 JSON 场景

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 07:13:05 -07:00
shanshanzhong147 4366a9be8b 修复(#79): 促销管理后台API + 审查问题修复
Build docker and publish / build (20.15.1) (push) Failing after 9m10s
Build docker and publish / build (20.15.1) (pull_request) Successful in 8m18s
- 实现促销管理接口:规则CRUD、优惠价配置、使用记录查询
- 修复 UpsertPrices 新增记录时提前 return 的问题
- DeleteRule/DeletePrice 不存在记录返回 code=404
- SetPromoPriceRequest.items 空数组校验
- 分页参数限制 page>0、1<=size<=200
- 下单侧促销价格按数量档总价口径计算(含 #87 修复)

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 06:32:42 -07:00
shanshanzhong147 b5e50d1ee5 修复(#75): 按数量精确匹配套餐列表促销
Build docker and publish / build (20.15.1) (push) Failing after 9m2s
Build docker and publish / build (20.15.1) (pull_request) Successful in 7m58s
- loadSubscribePromoMap 返回 map[subscribeID]map[quantity]*SubscribePromo 二级映射
- 候选查询按 subscribe_id、quantity、priority DESC 排序,一次取出所有 quantity 档位
- 下单路径 QueryEligibleRules 将 quantity 条件移至 JOIN,精确匹配
- 永久订阅(expire_time=0)用 CASE WHEN 排序兜底,不再误判为回归促销
- 新增 promoEligibility、model、subscribe promo 单元测试

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 06:22:49 -07:00
shanshanzhong147 02b41e7a2c 修复(#85): 促销系统 quantity 设计修正补丁(跨任务统一修复)
Build docker and publish / build (20.15.1) (push) Failing after 8m50s
Build docker and publish / build (20.15.1) (pull_request) Successful in 8m19s
- subscribe_promo 表加 quantity 字段,BIGINT NOT NULL DEFAULT 1
- EvaluatePromo 加 quantity 参数,按 subscribeID + quantity 精确匹配
- Promo 从 Subscribe 顶层移到 SubscribeDiscount
- 查询加 quantity,返回 map[subscribeID][quantity] 二级映射
- recordPromoUsage 错误向上传播,不再静默吞掉
- preCreate 和 purchase 的 allowPromo 判定统一为 orderType==1
- 迁移脚本增加幂等处理(guarded DROP/ADD/MODIFY)

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 05:57:21 -07:00
shanshanzhong147 d12c340743 新功能(#77): 套餐列表 API 返回规格级促销信息
Build docker and publish / build (20.15.1) (push) Failing after 8m23s
Build docker and publish / build (20.15.1) (pull_request) Successful in 7m54s
将 promo 字段从 Subscribe 顶层移至 SubscribeDiscount(discount[] 项内),
促销查询按 subscribe_id + quantity 维度写入,每个规格独立命中最高优先级规则。

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 02:19:58 -07:00
shanshanzhong147 c90edac630 修复(#73): 修正促销迁移表结构 — subscribe_promo 加 quantity + 索引优化
Build docker and publish / build (20.15.1) (push) Failing after 8m28s
Build docker and publish / build (20.15.1) (pull_request) Successful in 7m59s
- subscribe_promo 新增 quantity 列,唯一键改为 (subscribe_id, quantity, promo_rule_id)
- promo_rule 索引合并为 idx_enabled_priority_deleted (enabled, deleted_at, priority DESC)

Co-authored-by: multica-agent <github@multica.ai>
2026-05-27 01:10:31 -07:00
shanshanzhong147 b9192db042 feat: 文件上传接口直接返回完整 URL + 提现 content 改为非必填
Build docker and publish / build (20.15.1) (push) Failing after 8m29s
Build docker and publish / build (20.15.1) (pull_request) Successful in 8m3s
- FileUploadResponse / FileUploadCompleteResponse 精简为只返回 url
- S3Store.BuildObjectURL 拼接完整访问地址
- 提现 method=0 时 content 不再参与必填校验
- 新增用户端 API 接口文档
2026-05-27 00:57:21 -07:00
shanshanzhong147 48e507783e 新功能(#77): 套餐列表返回促销信息
Build docker and publish / build (20.15.1) (push) Failing after 8m34s
Build docker and publish / build (20.15.1) (pull_request) Successful in 8m4s
- 新增 SubscribePromo 响应结构,套餐列表每项追加 promo 字段
- 新增 promo.go 促销候选规则查询与资格评估逻辑
- 重构 authMiddleware 提取 authenticateRequest,新增 OptionalAuthMiddleware
- /v1/public/subscribe/list 改为可选鉴权,未登录仅展示 campaign 类型促销
- /node/list、/group/list 保持强制鉴权不变
- 每个规格只返回最高优先级命中的规则,promo_price 为单价,expires_at 为秒级时间戳
- 新增单测覆盖 campaign/new_user 命中、活动窗口判定等场景

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 22:40:58 -07:00
shanshanzhong147 d6efcb8e0b 修复(#83): 修复脚本目录全量测试构建失败
Build docker and publish / build (20.15.1) (push) Has been cancelled
Build docker and publish / build (20.15.1) (pull_request) Successful in 8m27s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 22:34:19 -07:00
shanshanzhong147 d2f9289338 新功能(#76): 促销系统下单流程集成
Build docker and publish / build (20.15.1) (push) Has been cancelled
Build docker and publish / build (20.15.1) (pull_request) Successful in 8m28s
- 新增促销规则/规格促销价/使用记录模型与幂等迁移
- EvaluatePromo 支持 new_user/inactive_user/campaign 规则判定
- purchase/preCreate 接入促销判定:命中促销时跳过百分比折扣
- activate 激活后按 order_no 幂等写入 promo_usage
- 订单模型和响应结构新增 promo_rule_id、promo_discount 字段

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 22:18:44 -07:00
shanshanzhong147 92e303aaa7 修复(#80): 修复wrapf非常量格式串vet阻断
Build docker and publish / build (20.15.1) (push) Has been cancelled
Build docker and publish / build (20.15.1) (pull_request) Successful in 8m39s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 22:18:26 -07:00
shanshanzhong147 54328b197d 新功能(#78): 预算订单 API 返回 promo_discount 字段
Build docker and publish / build (20.15.1) (push) Has been cancelled
Build docker and publish / build (20.15.1) (pull_request) Successful in 7m54s
PreOrderResponse 新增 promo_discount 字段,未接入促销时默认返回 0。
促销命中逻辑待 HIF-76 接入后生效。

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 22:13:40 -07:00
shanshanzhong147 5ebfe0a981 新功能(#65): 添加用户订阅限速数据层字段
Build docker and publish / build (20.15.1) (push) Failing after 8m30s
Build docker and publish / build (20.15.1) (pull_request) Successful in 7m58s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 11:23:57 -07:00
shanshanzhong147 22d9773f1b x
Build docker and publish / build (20.15.1) (pull_request) Has been cancelled
Build docker and publish / build (20.15.1) (push) Failing after 8m56s
2026-05-26 10:02:38 -07:00
shanshanzhong147 d89798f001 merge: pull internal/internal with secrets for TG notifications
Build docker and publish / build (20.15.1) (push) Failing after 8m22s
Resolved conflict in .gitea/workflows/docker.yml by keeping
secrets-based TG_BOT_TOKEN and TG_CHAT_ID instead of hardcoded values.
2026-05-26 09:25:13 -07:00
shanshanzhong147 81c3059892 x 2026-05-26 09:14:59 -07:00
shanshanzhong147 f9fa4756e9 新功能(#41): 提现优化 — 收款方式选择 + 取消提现 + 收款码上传
Build docker and publish / build (20.15.1) (push) Failing after 8m37s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 08:36:25 -07:00
shanshanzhong147 27f1203282 修复(#49): 修复清空备注时数据丢失 — RefererId 改为指针类型
Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 08:35:45 -07:00
shanshanzhong147 f7f890c990 配置(#47,#51): CI/CD 安全加固 + TG 通知限制 + 部署健康检查
Build docker and publish / build (20.15.1) (push) Failing after 8m35s
- TG Bot Token/Chat ID 改用 secrets,移除硬编码
- PR 事件只构建不推镜像、不部署、不发通知
- 部署后增加健康检查,失败自动回滚
- 增加环境标签区分生产/测试/其他

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 08:07:08 -07:00
shanshanzhong147 8b4e7561f4 修复: scripts 编译冲突 + order 统计 is_new 字段修正
- scripts/ 下两个独立脚本移到各自子目录,消除 package main 冲突
- model/order/model.go 统计查询 type→is_new 字段修正(7处)

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 08:06:50 -07:00
shanshanzhong147 a6e9e2bdb8 配置(#53): 固定 Docker Compose 基础设施镜像版本,移除 latest 标签
Build docker and publish / build (20.15.1) (push) Failing after 7m45s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 21:56:01 -07:00
shanshanzhong147 b798f520c8 fix: add zero-value guard for RefererId to prevent clearing inviter on remark update
Build docker and publish / build (20.15.1) (push) Failing after 8m39s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 20:04:22 -07:00
shanshanzhong147 bae234fe15 Merge remote-tracking branch 'remotes/origin/agent/agent/bfdd0bdd' into merge-to-internal
Build docker and publish / build (20.15.1) (push) Failing after 8m14s
2026-05-25 18:17:24 -07:00
shanshanzhong147 79ab4460bc feat: add GET /v1/admin/log/message/detail endpoint
Adds a temporary admin endpoint to query log_message by ID,
returning the full record including context (JSON) and digest
fields that the existing /error_message/detail endpoint omits.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 12:18:27 -07:00
shanshanzhong147 0169a16ada fix: make refund migration compatible with legacy schemas
Build docker and publish / build (20.15.1) (push) Failing after 8m17s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 11:07:06 -07:00
shanshanzhong147 b6b5bccde6 fix: use withdrawals table consistently
Build docker and publish / build (20.15.1) (push) Failing after 8m20s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 10:43:24 -07:00
shanshanzhong147 eba256bdc9 merge: sync internal with main
Build docker and publish / build (20.15.1) (push) Failing after 8m22s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 10:32:06 -07:00
shanshanzhong147 72f2b94263 fix: restore missing migration files
Build docker and publish / build (20.15.1) (push) Failing after 8m16s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 10:20:46 -07:00
shanshanzhong147 bb67ebcb79 chore: rename activation context migration to 02151
Build docker and publish / build (20.15.1) (push) Failing after 8m27s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 02:21:37 -07:00
shanshanzhong147 0bd7560b64 fix: P1 activation path hardening - Bug 4-9
Bug 4: resolveRenewalActivationSubscription - add fallback by user_id+subscribe_id
  with SELECT FOR UPDATE when token lookup fails

Bug 5: appleIAPNotifyLogic - return error on product ID mapping failure instead of
  silently dropping the notification

Bug 6: NewPurchase fallback query - wrap in transaction with SELECT FOR UPDATE to
  prevent concurrent duplicate subscription creation

Bug 7: appleIAPNotifyLogic - fix UserId=0 by reverse-lookup from original purchase
  order; create renewal audit order record for DID_RENEW/SUBSCRIBED notifications

Bug 8: UpdateOrderStatus - pre-delete cache before DB write (double-delete) to
  close TOCTOU window between DB update and cache invalidation

Bug 9: validateNewUserOnlyEligibilityAtActivation - add Redis distributed lock on
  user_id to serialise concurrent new-user-only order activations

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 02:18:19 -07:00
shanshanzhong147 a0f2d8a7b8 fix: persist guest/redemption activation context to DB to survive Redis TTL expiry (Bug 1 + Bug 10)
- Add `activation_context` TEXT column to `order` table (migration 02150)
- purchaseLogic: write TemporaryOrderInfo JSON to order.ActivationContext in the same
  insert transaction; Redis write is now best-effort (non-fatal)
- redeemCodeLogic: write redemption {type, redemption_code_id, unit_time, quantity} JSON
  to order.ActivationContext at order creation; Redis write is now best-effort (non-fatal)
- getTempOrderInfo: on Redis miss, fall back to order.ActivationContext from DB;
  logs CRITICAL and returns error if both are missing (old orders with no DB record)
- RedemptionActivate: on Redis miss, fall back to order.ActivationContext from DB;
  same CRITICAL log path for legacy orders

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 02:18:19 -07:00
shanshanzhong147 30221232c9 fix: 修复订单状态机 claim 机制的三个 Bug 并增加 stuck 订单恢复
- 将 OrderStatusClaimed 从 4 改为 6,消除与 OrderStatusFailed 的值冲突
- finalizeCouponAndOrder 改用直接 DB 更新(WHERE status=6→SET status=5),
  绕过 UpdateOrderStatus 的 status<target 守卫,同时用 model.Update 刷新缓存
- releaseClaim 返回 error,调用处检查并记录日志;releaseClaim 失败由 stuck
  recovery 定时任务兜底
- claimAndGetOrder 对 status=claimed 返回可重试错误而非静默跳过;
  ProcessTask 区分 "stuck in claimed" 与 "非 paid 跳过" 两种场景
- 新增 StuckOrderRecoveryLogic:每 10 分钟扫描超时 claimed 订单,
  重置 status=paid 并重新入队 ForthwithActivateOrder,确保不依赖
  asynq 原始重试(可能已超 maxRetry)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 02:18:19 -07:00
shanshanzhong147 80751eb8ef fix: move withdrawal commission deduction from application to approval
Build docker and publish / build (20.15.1) (push) Failing after 8m28s
- commissionWithdrawLogic: remove upfront commission deduction;
  balance check now includes sum of all pending withdrawals to prevent
  double-spending; transaction only creates the withdrawal record (status=0)
- approveWithdrawal: add FOR UPDATE lock on user row, balance check before
  deducting, atomic commission decrement and commission log inside one
  transaction; clear user cache after commit
- rejectWithdrawal: remove commission refund and log — commission was never
  deducted on application under the new flow
- add migration 02150: refund commission for existing status=0 withdrawals
  that were deducted under the old logic; includes rollback script

Co-authored-by: multica-agent <github@multica.ai>
2026-05-24 20:26:42 -07:00
shanshanzhong147 3bbce5ce84 fix: 修复退款与仪表盘订单统计口径
Build docker and publish / build (20.15.1) (push) Failing after 8m16s
Co-authored-by: multica-agent <github@multica.ai>
2026-05-24 17:50:29 -07:00
shanshanzhong147 1726a584fa feat: add admin order refund flow
Co-authored-by: multica-agent <github@multica.ai>
2026-05-24 17:50:23 -07:00
shanshanzhong147 fd522b6c71 path
Build docker and publish / build (20.15.1) (push) Successful in 8m56s
2026-05-19 19:35:44 -07:00
shanshanzhong147 a1184ef5ed feat: add userinfo bind-email trial use status
Build docker and publish / build (20.15.1) (push) Successful in 9m2s
2026-05-18 03:02:35 -07:00
shanshanzhong147 7c6efe9dfe x
Build docker and publish / build (20.15.1) (push) Failing after 9m23s
2026-05-16 23:58:53 -07:00
shanshanzhong147 c0ece054a0 x
Build docker and publish / build (20.15.1) (push) Failing after 9m44s
2026-05-16 04:13:05 -07:00
shanshanzhong147 0d57450283 x
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-15 09:53:15 -07:00
shanshanzhong147 f2033fd4b9 feat: add rustfs direct upload endpoint
Build docker and publish / build (20.15.1) (push) Failing after 8m36s
2026-05-14 23:33:50 -07:00
shanshanzhong147 3284cb45f0 ci: trigger internal branch workflow
Build docker and publish / build (20.15.1) (push) Failing after 8m36s
2026-05-14 05:58:05 -07:00
shanshanzhong147 6041bc3419 x
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-14 05:50:44 -07:00
shanshanzhong147 4581a6fc17 ci: use ssh key for main deploy
Build docker and publish / build (20.15.1) (push) Failing after 8m20s
2026-05-13 11:43:01 -07:00
shanshanzhong147 2bdce44e12 merge: add direct s3 upload flow
Build docker and publish / build (20.15.1) (push) Failing after 8m49s
2026-05-13 11:24:31 -07:00
shanshanzhong147 4fa9fcd232 feat: add direct s3 upload flow 2026-05-13 11:24:13 -07:00
shanshanzhong147 f6911965dc chore: snapshot current aws standby and backup tooling work 2026-05-13 10:59:03 -07:00
shanshanzhong147 c4b2ebf7e1 x
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-10 10:41:50 -07:00
shanshanzhong147 f946504cb8 x 2026-05-08 06:19:59 -07:00
shanshanzhong147 7d2f98b7c9 fix: 修复管理员更新用户信息时意外覆盖字段的问题
Build docker and publish / build (20.15.1) (push) Has been cancelled
- Enable/IsAdmin/OnlyFirstPurchase 改为 *bool,未传时不更新
- Avatar/Remark/ReferCode/ReferralPercentage 加空值保护
- getDeviceList: 恢复 hifastday@hifast.com 家庭成员受限逻辑

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 02:21:35 -07:00
shanshanzhong147 8bc8e81e95 补单逻辑
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 05:42:48 -07:00
shanshanzhong147 54daa923da 补单逻辑
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 04:59:04 -07:00
shanshanzhong147 463d3e2315 x
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 03:19:04 -07:00
shanshanzhong147 559d59b4f8 000000
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 03:09:15 -07:00
shanshanzhong147 d4f0d559cf 设备1
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 02:53:18 -07:00
shanshanzhong147 cbb451d18c x
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-06 02:34:43 -07:00
shanshanzhong147 595e4c62f9 x
Build docker and publish / build (20.15.1) (push) Successful in 5m25s
2026-05-03 18:34:14 -07:00
shanshanzhong147 9dd5dcb9d2 fix(order): mark first renewal payments as new
Build docker and publish / build (20.15.1) (push) Failing after 5m32s
2026-05-02 16:48:45 -07:00
shanshanzhong147 110c97ada4 feat(auth): add test bypass code 202511 for bind_email_with_verification
Build docker and publish / build (20.15.1) (push) Failing after 5m30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 08:00:39 -07:00
shanshanzhong147 d748a7e75d fix(user): move bind-email subscriptions to owner
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-05-01 05:24:11 -07:00
shanshanzhong147 cf70838142 fix(order): restore expired subscribe for invite gifts
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-30 13:16:45 -07:00
shanshanzhong147 280437be91 fix(order): guard renewal activation owner
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-30 12:54:02 -07:00
shanshanzhong147 59b7056a20 fix family member renewal target
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-30 09:26:52 -07:00
shanshanzhong147 769622f087 x
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-29 23:30:38 -07:00
shanshanzhong147 91935e3109 Revert "test(auth): add HTTP device no-trial check"
Build docker and publish / build (20.15.1) (push) Has been cancelled
This reverts commit 3b3ed7b3c1.
2026-04-29 23:22:31 -07:00
shanshanzhong147 3b3ed7b3c1 test(auth): add HTTP device no-trial check
Build docker and publish / build (20.15.1) (push) Successful in 5m10s
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 23:00:18 -07:00
shanshanzhong147 b52e01eaa2 fix(auth): grant trial only on email bind
Build docker and publish / build (20.15.1) (push) Successful in 5m17s
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-29 22:36:17 -07:00
shanshanzhong147 32e3dc3c73 fix(order): cover invite gifts and inactive renewals
Build docker and publish / build (20.15.1) (push) Successful in 5m36s
2026-04-29 21:52:28 -07:00
shanshanzhong147 6b64e8c461 test(auth): add device trial registration script
Build docker and publish / build (20.15.1) (push) Successful in 5m6s
2026-04-29 21:05:52 -07:00
shanshanzhong147 47696b9e68 fix(order): reconcile subscriptions and grant device trials
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-29 21:00:46 -07:00
shanshanzhong147 79427c9f4c 0430
Build docker and publish / build (20.15.1) (push) Successful in 5m47s
2026-04-29 12:49:45 -07:00
shanshanzhong147 bcefb274ab perf(server): cache speed limit calculations
Build docker and publish / build (20.15.1) (push) Successful in 5m37s
2026-04-29 01:37:59 -07:00
shanshanzhong147 3ae85f68ea 0428
Build docker and publish / build (20.15.1) (push) Successful in 5m25s
2026-04-28 17:44:28 -07:00
shanshanzhong147 ac57272018 x
Build docker and publish / build (20.15.1) (push) Successful in 6m26s
2026-04-28 06:19:10 -07:00
shanshanzhong147 68c7b0a8ec chore(deploy): add replication deployment assets
Build docker and publish / build (20.15.1) (push) Successful in 5m52s
2026-04-28 05:22:48 -07:00
shanshanzhong147 0ec0e2b9d2 fix(order): align invite gift ownership
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-28 05:19:57 -07:00
shanshanzhong147 ab38cd4943 x
Build docker and publish / build (20.15.1) (push) Failing after 4m44s
2026-04-26 21:12:22 -07:00
shanshanzhong147 5b49aa8242 fix(auth): disable trial grants on public email flows
Build docker and publish / build (20.15.1) (push) Successful in 5m29s
2026-04-25 01:11:27 -07:00
shanshanzhong147 9db4762904 fix(order): prevent duplicate subscriptions and repair invite gifts
Build docker and publish / build (20.15.1) (push) Successful in 5m6s
2026-04-24 21:16:21 -07:00
shanshanzhong147 ae62ecc6b3 fix: 加入家庭组时无条件丢弃成员订阅,防止重复订阅
Build docker and publish / build (20.15.1) (push) Failing after 5m17s
加入家庭组前若成员已购买订阅,原逻辑将订阅转移给 owner,
导致 owner 同时持有自身订阅与成员转入订阅,违反单订阅模式。

修改 transferMemberSubscribesToOwner:
- 移除转移逻辑,改为无条件删除成员所有订阅
- 成员加入后通过 owner 的订阅使用服务
- 后续购买以 entitlement.EffectiveUserID(owner)为目标,不受影响
2026-04-22 09:24:00 -07:00
shanshanzhong147 4b73cd4d3c fix: 泛域名邮箱(+别名/Gmail点号)拦截提前,不受白名单开关影响
Build docker and publish / build (20.15.1) (push) Successful in 5m38s
2026-04-21 09:44:00 -07:00
shanshanzhong147 2c9833df58 fix: 有返佣路径首单漏发被邀请用户赠天
Build docker and publish / build (20.15.1) (push) Successful in 5m37s
邀请人有返佣比例时,handleCommission 走佣金路径,
之前完全未调用 grantGiftDays,导致设备首单付费后
被邀请用户拿不到 N 天赠送。

修复:佣金处理完成后,若 IsNew(首单),
额外给被邀请用户调用 grantGiftDays(邀请人不重复赠天,
已通过佣金受益)。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-21 01:57:22 -07:00
shanshanzhong147 23a7a292ef fix: Gmail 泛域名邮箱(含点号/+别名)直接拒绝赠送试用
Build docker and publish / build (20.15.1) (push) Successful in 5m23s
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-21 01:09:34 -07:00
shanshanzhong147 f1bfc78d66 fix: 统一日期统计查询方式,使用 DATE_FORMAT 替代 time.Time 边界
Build docker and publish / build (20.15.1) (push) Successful in 4m58s
QueryDateOrders 和 QueryDateUserCounts 改用 DATE_FORMAT 字符串比较,
与 QueryDailyOrdersList 的 GROUP BY 逻辑一致,避免 go-sql-driver 时区转换导致金额不一致。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-20 22:36:16 -07:00
shanshanzhong147 9912df9ac6 fix: 修复时区问题 - FixedZone 兜底 + Dockerfile 复制完整 zoneinfo
Build docker and publish / build (20.15.1) (push) Successful in 4m56s
1. ppanel.go: LoadLocation 失败时用 FixedZone("CST", +8h) 兜底
2. Dockerfile: 复制完整 /usr/share/zoneinfo 目录,确保 go-sql-driver 也能加载 Asia/Shanghai

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-20 21:53:48 -07:00
shanshanzhong147 bafb13cf06 fix: 修复 scratch 容器中 time.Local 默认 UTC 导致收入统计时间窗口偏移 8 小时
Build docker and publish / build (20.15.1) (push) Has been cancelled
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-20 21:46:55 -07:00
shanshanzhong147 9a8ae8b6fd fix: 修复非单订阅模式下过期用户重复购买产生双订阅的问题
Build docker and publish / build (20.15.1) (push) Successful in 4m45s
1. purchaseLogic: 非单订阅模式下购买前查询已有订阅,路由为续费(type=2)
2. activateOrderLogic: 续费激活时触发节点分组重算,确保过期续费后权限生效

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-20 20:58:01 -07:00
shanshanzhong147 c8258dc93b feat: 设备登录新增 base_payload 字段,前端传入后存储到 user_device 表
Build docker and publish / build (20.15.1) (push) Successful in 4m48s
2026-04-20 20:08:20 -07:00
shanshanzhong147 c0d839deb9 fix: 修复仪表盘时区统计偏移、重复订阅、新增map_apple字段
Build docker and publish / build (20.15.1) (push) Successful in 5m23s
- fix(order/model): QueryDateOrders/QueryDailyOrdersList 使用 time.Date 替代 Truncate 修复 UTC+8 时区偏移
- fix(user/model): QueryResisterUserTotalByDate 同样修复时区截断
- fix(traffic/model): QueryServerTrafficByDay 同样修复时区截断
- fix(activateOrder): 兜底查询防止过期用户重购产生重复订阅
- feat(api): SubscribeDiscount 新增 map_apple 字段
2026-04-20 02:34:23 -07:00
shanshanzhong147 800f9c8460 x
Build docker and publish / build (20.15.1) (push) Failing after 5m7s
2026-04-12 18:44:37 -07:00
shanshanzhong147 954b19c332 feat: 邮箱规范化(NormalizeEmail)与域名白名单检查(IsEmailDomainWhitelisted)
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-04-12 18:43:47 -07:00
shanshanzhong147 62cf68b49b x
Build docker and publish / build (20.15.1) (push) Successful in 6m35s
2026-04-04 11:46:06 -07:00
shanshanzhong147 e818ac8764 x
Build docker and publish / build (20.15.1) (push) Successful in 7m38s
2026-04-04 09:41:41 -07:00
shanshanzhong147 98d8525fa9 refactor: 简化 trial 赠送配置,删除多余的白名单启用开关
Build docker and publish / build (20.15.1) (push) Successful in 7m42s
**简化方案**:
- 删除 `EnableTrialEmailWhitelist` 配置字段(多余)
- 保留 `TrialEmailDomainWhitelist`(逗号分隔的白名单域名)
- 赠送规则:白名单**非空** 且 **邮箱在列表** 才赠送

**新的赠送逻辑**:
```yaml
TrialEmailDomainWhitelist: "qq.com,163.com,gmail.com"
```
- 为空 →  不赠送(关闭)
- 非空 →  赠送给白名单域名的邮箱

**更新的地方**:
1. internal/config/config.go - 删除 EnableTrialEmailWhitelist 字段
2. userRegisterLogic.go - 简化赠送逻辑
3. emailLoginLogic.go - 简化赠送逻辑
4. bindEmailWithVerificationLogic.go - 简化赠送逻辑
5. oAuthLoginGetTokenLogic.go - 简化赠送逻辑

**配置升级说明**:
旧配置:
```yaml
EnableTrial: true
EnableTrialEmailWhitelist: true
TrialEmailDomainWhitelist: "qq.com,163.com"
```

新配置(只保留两项):
```yaml
EnableTrial: true
TrialEmailDomainWhitelist: "qq.com,163.com"
```

关闭赠送:
```yaml
EnableTrial: true
TrialEmailDomainWhitelist: ""
```

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-03 07:20:10 -07:00
shanshanzhong147 19777df2ed fix: bindEmailWithVerificationLogic 邮箱绑定后赠送逻辑错误
**问题**: 邮箱绑定后应该赠送 trial 的逻辑判断错误,导致以下场景无法赠送:
- EnableTrial=true, EnableTrialEmailWhitelist=false → 应该赠送但未赠送

**根本原因**: 第215行条件判断使用 OR 逻辑,要求白名单必须启用才处理
```go
if !rc.EnableTrial || !rc.EnableTrialEmailWhitelist {
    return  //  错误:关闭白名单时也返回,无法赠送
}
```

**修复**: 改为正确的逻辑
```go
if !rc.EnableTrial {
    return  // 关闭赠送时不处理
}
if rc.EnableTrialEmailWhitelist && !IsEmailDomainWhitelisted(...) {
    return  // 白名单启用但域名不匹配时不赠送
}
// 否则赠送
```

**影响场景**:
- 设备登录 → 绑定邮箱 → 应该赠送 trial
  - 当 EnableTrialEmailWhitelist=false 时,应该赠送(修复前未赠送)
  - 当 EnableTrialEmailWhitelist=true 且域名在白名单 → 赠送(修复前未赠送)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-03 07:17:27 -07:00
shanshanzhong147 d586bbeabb fix: OAuth registration missing email domain whitelist check for trial gifting
**Problem**: OAuth registration path (Google, Apple, Telegram) was missing the
email domain whitelist validation, causing trial subscriptions to be granted to
all users regardless of the whitelist configuration.

**Root Cause**: The previous commit (3417da2a) that implemented trial domain
whitelist only updated device/phone/email direct registration paths, but
missed the OAuth registration path in oAuthLoginGetTokenLogic.go.

**Solution**:
- Added email domain whitelist check to OAuth register() method
- Added isEmailDomainWhitelisted() helper function matching the pattern
  used in other auth logic files
- Only activate trial if EnableTrial=true AND
  (whitelist disabled OR email domain matches whitelist)
- Added email logging to trial subscription activation log

Affected flows:
- OAuth Google login with new user
- OAuth Apple login with new user
- OAuth Telegram login with new user

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-03 06:44:33 -07:00
shanshanzhong147 92f278d38b x
Build docker and publish / build (20.15.1) (push) Successful in 6m50s
2026-04-02 22:17:31 -07:00
shanshanzhong147 3417da2a9e feat: trial 赠送改为白名单邮箱域名控制
Build docker and publish / build (20.15.1) (push) Failing after 7m14s
- 新增 EnableTrialEmailWhitelist + TrialEmailDomainWhitelist 配置
- 邮箱注册/登录:加白名单域名判断,域名匹配才赠送 trial
- 设备登录/手机注册:移除 activeTrial,不再自动赠送
- 绑定邮箱(bindEmailWithVerification):绑定成功后检查白名单+防重复赠送
- 新增 IsEmailDomainWhitelisted 导出函数供跨包调用
- 清理 deviceLoginLogic/telephoneUserRegisterLogic 中的 activeTrial 死代码

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-02 05:46:04 -07:00
shanshanzhong147 2362b67634 fix: QueryUserInfo 设备列表改为家庭范围查询,修复 email 用户 user_devices 为空的问题
Build docker and publish / build (20.15.1) (push) Failing after 7m43s
2026-03-31 17:07:09 -07:00
shanshanzhong147 06e4698888 Merge remote-tracking branch 'internal/main' into internal
Build docker and publish / build (20.15.1) (push) Failing after 7m33s
2026-03-31 07:39:02 -07:00
shanshanzhong147 e51dbea7c7 fix: getDiscount 新人取折扣最大档,非新人取折扣最小档
Build docker and publish / build (20.15.1) (push) Failing after 7m31s
- 新人:所有匹配 quantity 的条目中取 discount 值最小的(折扣力度最大)
- 非新人:所有匹配 quantity 的条目中取 discount 值最大的(折扣力度最小,接近原价)
- 不再按 new_user_only 过滤,由 isNewUserOnlyForQuantity 在上层控制准入

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 07:22:12 -07:00
shanshanzhong147 c8f172dc0e fix: getDiscount 新人取折扣最大档,非新人取最贵档
Build docker and publish / build (20.15.1) (push) Has been cancelled
- 新人:遍历所有匹配 quantity 的条目,取 discount 值最小的(折扣力度最大)
- 非新人:仅看 new_user_only=false 的条目,取 discount 值最大的(最接近原价)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 07:18:04 -07:00
shanshanzhong147 52aaaf4de5 fix: getDiscount 新人价取值错误 — 始终优先取 new_user_only=false 的实际折扣档
Build docker and publish / build (20.15.1) (push) Has been cancelled
同一 quantity 有两条记录时:
- new_user_only=true (discount=99.64) 是新人限制标记,非实际折扣
- new_user_only=false (discount=35.36) 是实际折扣价格

原逻辑遍历到第一条即返回,导致新人拿到 99.64 而非 35.36。
修复后优先取 new_user_only=false 的条目,仅在无 false 条目时才降级到 new_user_only=true。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 07:14:37 -07:00
shanshanzhong147 877a471e24 x
Build docker and publish / build (20.15.1) (push) Failing after 7m46s
2026-03-31 06:57:57 -07:00
shanshanzhong147 40cfe46ba8 fix: CountScopedSubscribePurchaseOrders 包含续费单并排除赠送单
Build docker and publish / build (20.15.1) (push) Has been cancelled
- type IN (1, 2):同时统计首购和续费历史,防止用户用续费绕过新人价限制
- amount > 0:排除 admin 赠送的免费订单(amount=0),避免误判新人资格

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 06:56:35 -07:00
shanshanzhong147 5733ebe40d fix: renewal logic 尝鲜价未生效 — 改为走 newUser 资格判断
Build docker and publish / build (20.15.1) (push) Successful in 7m17s
renewalLogic 在计算折扣时硬编码 isNewUser=false,
导致新用户在 24h 窗口内通过 renewal 接口购买时无法享受尝鲜价。
改为调用 resolveNewUserDiscountEligibility,与 purchaseLogic 保持一致。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 06:35:14 -07:00
shanshanzhong147 0b1e6ce3c3 fix: 单订阅模式激活时赠送订阅也用受益者 ID 查找
Build docker and publish / build (20.15.1) (push) Successful in 7m21s
findGiftSubscription 同步改用 singleModeUserId
(家庭组场景为家庭主 ID),与锚点订阅查找保持一致。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 06:10:58 -07:00
shanshanzhong147 3167465865 fix: 单订阅模式成员购买时用家庭主 ID 查锚点订阅
Build docker and publish / build (20.15.1) (push) Has been cancelled
ResolvePurchaseRoute 传入 entitlement.EffectiveUserID
(家庭主 ID)而非 u.Id(成员 ID),确保成员购买时
能找到家庭主已有订阅并续费,而不是新建一条订阅。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 06:08:28 -07:00
shanshanzhong147 8838fc51f8 fix: 单订阅模式家庭组购买时用 SubscriptionUserId 查锚点
Build docker and publish / build (20.15.1) (push) Successful in 7m28s
NewPurchase 中查 FindSingleModeAnchorSubscribe 时,家庭组
场景下应使用实际受益者 SubscriptionUserId(成员/主),
而非付款人 UserId,否则找不到已有订阅,会为成员创建新订阅。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 05:56:09 -07:00
shanshanzhong147 ea34718a0b fix: 设备变更时同步清除用户缓存,修复 user_devices 为空问题
Build docker and publish / build (20.15.1) (push) Successful in 7m23s
Device.GetCacheKeys() 新增 cacheUserIdPrefix+UserId,
确保 InsertDevice/UpdateDevice/DeleteDevice 时同时失效
cache:user:id:{userId},避免下次 FindOne 读到不含
UserDevices 的旧缓存。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 05:46:48 -07:00
shanshanzhong147 98972401c2 fix: 设备变更时同步清除用户缓存,修复 user_devices 为空问题
Build docker and publish / build (20.15.1) (push) Has been cancelled
Device.GetCacheKeys() 新增 cacheUserIdPrefix+UserId,
确保 InsertDevice/UpdateDevice/DeleteDevice 时同时失效
cache:user:id:{userId},避免下次 FindOne 读到不含
UserDevices 的旧缓存。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 05:28:05 -07:00
shanshanzhong147 abc9864d46 fix: 单订阅模式下避免重复创建订阅
Build docker and publish / build (20.15.1) (push) Successful in 7m31s
1. FindSingleModeAnchorSubscribe 补充 status=4(流量耗完)到查询范围,
   避免流量耗尽的订阅被忽略导致续费时新建订阅

2. activateOrderLogic NewPurchase 路径去掉套餐ID匹配检查,
   单订阅模式下无论购买何种套餐都更新现有订阅而非新建

3. singleModeHelper fallback 查询同步补充 status=4

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 05:13:00 -07:00
shanshanzhong147 fd48afd0ab x
Build docker and publish / build (20.15.1) (push) Successful in 7m10s
2026-03-31 03:44:41 -07:00
shanshanzhong147 e3cd0643e1 fix: IAP续费双重叠加订阅天数
Build docker and publish / build (20.15.1) (push) Successful in 7m43s
早返回路径(existTx!=nil)中的 existSub/orderLinkedSub/singleModeAnchorSub
分支同时调用 extendSubscribeForIAP 更新 expire_time,又通过
syncOrderStatusAndEnqueue(iapExpireAt=0) 入队,导致激活队列再次叠加天数。

修复:移除早返回路径中的 extendSubscribeForIAP 直接写入,改为传入
exp.Unix() 给 syncOrderStatusAndEnqueue,由激活队列的
updateSubscriptionWithIAPExpire 统一处理,前端返回 calcIAPRenewalExpire
估算值,与事务路径行为一致。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 02:54:52 -07:00
shanshanzhong147 6724e341b8 Merge branch 'internal'
Build docker and publish / build (20.15.1) (push) Successful in 7m24s
# Conflicts:
#	internal/logic/public/order/getDiscount.go
#	queue/logic/order/activateOrderLogic.go
2026-03-31 00:48:05 -07:00
shanshanzhong147 202c1c18d7 fix: 修复同 quantity 存在新用户和普通折扣档时老用户无法下单的问题
Build docker and publish / build (20.15.1) (push) Successful in 7m34s
isNewUserOnlyForQuantity 原逻辑取第一条匹配记录即返回,
当同一 quantity 同时配置了 new_user_only=true 和 false 两档时,
结果依赖数组顺序,导致老用户/超24h用户触发 SubscribeNewUserOnly 错误。

修复:扫描所有同 quantity 档位,仅当存在 true 且无 false 兜底时才返回 true;
有 false 兜底则返回 false,老用户可正常购买并命中普通折扣档。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-30 22:30:12 -07:00
shanshanzhong147 e0b2be2058 x
Build docker and publish / build (20.15.1) (push) Successful in 7m36s
2026-03-30 21:05:21 -07:00
shanshanzhong147 d351d9e436 x
Build docker and publish / build (20.15.1) (push) Successful in 7m23s
2026-03-30 07:41:21 -07:00
shanshanzhong147 d072217e85 x 2026-03-30 07:40:42 -07:00
shanshanzhong147 2a9c01ff8b x
Build docker and publish / build (20.15.1) (push) Successful in 7m28s
2026-03-30 04:49:55 -07:00
shanshanzhong147 df7303738a bug: 无订阅情况 出现下多笔订单 支付状态乱
Build docker and publish / build (20.15.1) (push) Successful in 7m18s
2026-03-30 00:32:41 -07:00
shanshanzhong147 a9205cc3fc feat: 用户订阅显示节点分组名及限速起止时间
Build docker and publish / build (20.15.1) (push) Successful in 7m28s
- UserSubscribe/UserSubscribeDetail 新增 node_group_id/node_group_name 字段
- 管理员查询用户订阅列表批量填充分组名
- 管理员查询单个订阅详情填充分组名
- ThrottleResult/UserSubscribeDetail 新增 throttle_start/throttle_end 字段
- 限速时返回限速窗口起止时间(秒级 Unix 时间戳)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-29 10:07:17 -07:00
shanshanzhong147 1dbc3a81e9 fix: 老版本客户端去掉每个套餐 discount 最后一个,而非整个套餐 2026-03-29 03:19:15 -07:00
shanshanzhong147 3b4e882965 feat: 根据 X-App-Id 判断是否裁剪套餐列表,老版客户端去掉最后一个
Build docker and publish / build (20.15.1) (push) Successful in 7m37s
2026-03-28 23:21:00 -07:00
shanshanzhong147 26c9ff2f0f x
Build docker and publish / build (20.15.1) (push) Successful in 8m31s
2026-03-28 22:57:19 -07:00
shanshanzhong147 aaacb47dad x
Build docker and publish / build (20.15.1) (push) Failing after 7m51s
2026-03-28 22:44:22 -07:00
shanshanzhong147 eb414749c3 fix: apiversion.UseLatest 阈值判断改为 >= 避免误判新版本为老版本
Build docker and publish / build (20.15.1) (push) Successful in 8m26s
原 compare > 0 要求严格大于阈值,导致 header=1.0.0 被判为老版本,
去掉最后一个套餐后列表为空。改为 >= 0,有 header 且版本 >= 阈值均视为新版本。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-28 20:42:57 -07:00
shanshanzhong147 1a08949c80 fix: 流量触发限速后主动清除节点用户缓存
节点用户列表缓存(server:user:{id})永不过期,用户流量超限触发按量限速后
缓存中仍是旧的速度值,节点不会感知限速状态。

修复:每次写入 traffic_log 后,检查该订阅是否触发按量限速规则,
若 IsThrottled=true 则立即删除对应节点的用户列表缓存,
节点下次拉取时重新计算并应用降速后的 speed_limit。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-28 18:36:13 -07:00
shanshanzhong147 0f28f4995f fix: 修复在线设备列表无数据问题
user_device 表的 subscribe_id 字段从未在插入时写入(始终为 NULL),
导致 QueryDevicePageList 的 WHERE 条件 `subscribe_id = ?` 永远匹配不到记录。
移除 subscribe_id 过滤,改为只按 user_id 查询。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-28 18:23:18 -07:00
shanshanzhong147 8398865bd3 fix: SingleModel 下首购 isNew 判断错误导致佣金不发
SingleModel 模式下,用户首次购买会被路由成 orderType=2(续费),
导致 isNew 判断逻辑跳过,始终为 false,激活时 shouldProcessCommission
误判为非首购,佣金不发给邀请人。

修复:去除 isNew 查询对 orderType==1 的依赖,始终通过
IsUserEligibleForNewOrder 判断用户是否有历史完成订单。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-28 11:28:02 -07:00
shanshanzhong147 6f6943f295 api-header
Build docker and publish / build (20.15.1) (push) Successful in 8m23s
2026-03-28 10:05:32 -07:00
shanshanzhong147 507ee16a30 x
Build docker and publish / build (20.15.1) (push) Successful in 8m20s
2026-03-28 09:06:24 -07:00
shanshanzhong147 2db2bc0860 feat: 限速状态可视化 - 后台订阅详情展示实际限速与降速状态
Build docker and publish / build (20.15.1) (push) Successful in 8m7s
- 新增 pkg/speedlimit/calculator.go 公共限速计算函数
- UserSubscribeDetail 响应新增 effective_speed/is_throttled/throttle_rule 字段
- GetUserSubscribeById 接口查询时实时计算并返回限速状态
- 重构 getServerUserListLogic 的 calculateEffectiveSpeedLimit,改用共享函数

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-28 08:14:07 -07:00
shanshanzhong147 2bdda7558c fix: JSON_CONTAINS node_group_ids 参数需用引号包裹
Build docker and publish / build (20.15.1) (push) Successful in 8m25s
JSON_CONTAINS 第二参数必须是 JSON 字符串,裸数字会报 Error 3146。
修复:%d → '%d'

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 07:22:39 -07:00
shanshanzhong147 89e0c03fec fix: 统一限速单位为 Mbps,与 ppanel-node 节点端对齐
Build docker and publish / build (20.15.1) (push) Successful in 8m1s
ppanel-node limiter.go 将 speed_limit 视为 Mbps(× 1000000 / 8 → Bytes/s),
但后端之前存储的是 Bytes(前端 mbToBits 转换),导致节点收到天文数字等于不限速。

- 回滚 traffic_limit KB→Bytes 转换,统一用 Mbps 直接比较
- 部署后需执行数据迁移:UPDATE subscribe SET speed_limit = speed_limit / 1048576 WHERE speed_limit > 0

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 02:46:25 -07:00
shanshanzhong147 315c59e5b7 fix: traffic_limit 限速值 KB→Bytes 转换,修复与基础限速单位不一致
Build docker and publish / build (20.15.1) (push) Successful in 7m56s
traffic_limit 的 speed_limit 前端输入单位是 KB,
订阅基础 speed_limit 存储单位是 Bytes,
比较和返回前需要 ×1024 转换为 Bytes,否则限速值异常偏低。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 02:30:06 -07:00
shanshanzhong147 cee97ae630 fix: /v1/common/log/report 跳过签名验证
Build docker and publish / build (20.15.1) (push) Successful in 8m7s
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 00:05:43 -07:00
shanshanzhong147 e5c8e965af fix: 设备组详情补充 auth_type/device_type 字段,统一用户名显示
Build docker and publish / build (20.15.1) (push) Successful in 8m15s
- FamilyMemberItem 新增 auth_type、device_type 字段
- FamilySummary 新增 owner_auth_type 字段
- findUserIdentifiers 返回 identifierInfo(含 auth_type)
- 新增 parseDeviceType() 从 UserAgent 解析设备类型

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-26 21:30:51 -07:00
shanshanzhong147 c431393266 fix: 折扣匹配改为精确匹配 quantity,不再取全局最小值
Build docker and publish / build (20.15.1) (push) Successful in 7m44s
将 getDiscount 和 isNewUserOnlyForQuantity 的匹配逻辑从
inputMonths >= discount.Quantity 改为 inputMonths == discount.Quantity,
买多少天就用多少天对应的折扣,避免错误配置导致折扣泄漏。
同时增加 discount 值合法性校验(>0 且 <100)。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-26 04:18:45 -07:00
shanshanzhong147 4a2501a3d1 fix: 折扣匹配改为精确匹配 quantity,不再取全局最小值
Build docker and publish / build (20.15.1) (push) Successful in 7m55s
将 getDiscount 和 isNewUserOnlyForQuantity 的匹配逻辑从
inputMonths >= discount.Quantity 改为 inputMonths == discount.Quantity,
买多少天就用多少天对应的折扣,避免错误配置导致折扣泄漏。
同时增加 discount 值合法性校验(>0 且 <100)。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-26 01:28:50 -07:00
shanshanzhong147 774fce6147 fix: 预览节点只显示用户实际所在分组的节点,不再包含公共节点
Build docker and publish / build (20.15.1) (push) Successful in 7m48s
2026-03-25 23:15:48 -07:00
shanshanzhong147 d63b943964 fix: 修复订阅更新 group_locked 不能为 null 问题 + Kutt 短链3次重试
Build docker and publish / build (20.15.1) (push) Successful in 7m46s
- updateUserSubscribeLogic: 更新订阅时补充 GroupLocked 字段,避免写入 NULL 触发数据库约束错误
- inviteLinkResolver: generateShortLinkWithTimeout 增加最多3次重试,防止网络波动导致短链生成失败

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-25 22:36:34 -07:00
shanshanzhong147 b43dcd8dab merge: 同步 origin/master 新功能到定制版本
Build docker and publish / build (20.15.1) (push) Successful in 8m8s
2026-03-24 01:03:56 -07:00
shanshanzhong147 95830eae8a fix: 流量分组查询条件改为仅判断 max_traffic_gb > 0 2026-03-24 01:03:29 -07:00
shanshanzhong147 62b8c67261 Merge fix/api-sync-handlers into internal
Build docker and publish / build (20.15.1) (push) Successful in 8m46s
- JSON_CONTAINS 参数类型修复(node_group_ids IS NOT NULL 兼容)
- API sync 改动
2026-03-23 06:58:36 -07:00
shanshanzhong147 f111b36389 fix: JSON_CONTAINS 参数类型修复 + API sync
- 修复 JSON_CONTAINS(node_group_ids, int64) 类型错误,改为传 JSON 字符串
- 添加 node_group_ids IS NOT NULL 兼容判断,防止 NULL 列报错
- 同步 apis/ 及 routes.go、compat_types.go 改动

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-23 06:58:28 -07:00
EUForest 7fe7243c24 feat: Add slider verification code 2026-03-23 02:42:12 +08:00
shanshanzhong147 f703b5089a 分组概念
Build docker and publish / build (20.15.1) (push) Successful in 7m58s
2026-03-19 03:21:47 -07:00
shanshanzhong147 e5e9f93f68 Merge remote-tracking branch 'origin/master' into internal 2026-03-19 01:55:01 -07:00
EUForest bc721b0ba6 update: Adding interference to CAPTCHA 2026-03-18 12:45:09 +08:00
EUForest 06a2425474 feat(subscribe): add traffic limit rules and user traffic stats
- Add subscribe traffic_limit schema and migration\n- Support traffic_limit in admin create/update and list/details\n- Apply traffic_limit when building server user list speed limits\n- Add public user traffic stats API
2026-03-14 12:41:52 +08:00
EUForest 17163486f6 fix(subscribe): fix user subscription node retrieval logic to support directly assigned nodes 2026-03-10 18:29:19 +08:00
EUForest 884310d951 feat(routes): register admin authentication and captcha endpoints
- Register /v1/auth/captcha/generate route for user captcha
- Register /v1/auth/admin/login route for admin login
- Register /v1/auth/admin/reset-password route for admin password reset
- Register /v1/auth/admin/captcha/generate route for admin captcha
- Add admin authentication route group
2026-03-09 22:56:20 +08:00
EUForest 3ca471f58c refactor(auth): move captcha verification from handler to logic layer
- Remove duplicate captcha verification from user login handler
- Remove duplicate captcha verification from user register handler
- Remove duplicate captcha verification from password reset handler
- Remove duplicate captcha verification from phone login handler
- Remove duplicate captcha verification from phone register handler
- Update phone reset password handler structure
- Improve separation of concerns between handler and logic layers
- Handlers now only handle HTTP request/response, logic handles business rules
2026-03-09 22:56:07 +08:00
EUForest fae77a8954 feat(auth): add captcha verification to phone authentication
- Add verifyCaptcha method to phone login logic
- Add verifyCaptcha method to phone registration logic
- Support both local and Turnstile captcha verification
- Check EnableUserLoginCaptcha for phone login
- Check EnableUserRegisterCaptcha for phone registration
- Validate captcha before processing phone authentication
2026-03-09 22:55:23 +08:00
EUForest cea3e31f3a feat(auth): add captcha verification to user email authentication
- Add verifyCaptcha method to user login logic
- Add verifyCaptcha method to user registration logic
- Add verifyCaptcha method to password reset logic
- Support both local and Turnstile captcha verification
- Check respective configuration flags before verification
- Validate captcha code and ID for local captcha
- Validate Turnstile token for Turnstile mode
2026-03-09 22:55:08 +08:00
EUForest 9aaffec61d feat(auth): add admin authentication with permission checks
- Add admin login handler and logic with IsAdmin verification
- Add admin password reset handler and logic
- Add admin captcha generation handler and logic
- Implement device binding for admin login
- Add login logging for admin authentication
- Check EnableAdminLoginCaptcha configuration
- Separate admin authentication from user authentication
- Verify admin permission before allowing access
2026-03-09 22:54:59 +08:00
EUForest 2afb86f973 feat(auth): add user captcha generation endpoint
- Add handler for /v1/auth/captcha/generate endpoint
- Implement captcha generation logic based on configuration
- Support local image captcha generation with Redis storage
- Return Turnstile site key for Turnstile mode
- Check EnableUserLoginCaptcha configuration
2026-03-09 22:54:47 +08:00
EUForest 5727708bbd feat(config): add captcha configuration to global config response
- Add CaptchaType field to verify config
- Add EnableUserLoginCaptcha field
- Add EnableUserRegisterCaptcha field
- Add EnableAdminLoginCaptcha field
- Add EnableUserResetPasswordCaptcha field
- Expose captcha configuration to frontend
2026-03-09 22:54:33 +08:00
EUForest f224d09d09 feat(types): update request types with captcha fields
- Add CaptchaId and CaptchaCode to UserLoginRequest
- Add CaptchaId and CaptchaCode to UserRegisterRequest
- Add CaptchaId and CaptchaCode to ResetPasswordRequest
- Add CaptchaId and CaptchaCode to TelephoneLoginRequest
- Add CaptchaId and CaptchaCode to TelephoneUserRegisterRequest
- Add CaptchaId and CaptchaCode to TelephoneResetPasswordRequest
- Add GenerateCaptchaResponse type
- Add AdminLoginRequest and AdminResetPasswordRequest types
2026-03-09 22:54:21 +08:00
EUForest eb327b26b9 feat(api): add captcha fields and admin authentication endpoints
- Add CaptchaId and CaptchaCode fields to login/register/reset requests
- Add /v1/auth/captcha/generate endpoint for user captcha generation
- Add /v1/auth/admin/login endpoint for admin authentication
- Add /v1/auth/admin/reset-password endpoint for admin password reset
- Add /v1/auth/admin/captcha/generate endpoint for admin captcha generation
- Update GlobalConfigResponse with new verify configuration fields
- Add GenerateCaptchaResponse type for captcha generation
2026-03-09 22:54:08 +08:00
EUForest 0f6fddc36d feat(error): add PermissionDenied error code
- Add error code 40008 for permission denied scenarios
- Add corresponding error message for admin permission checks
2026-03-09 22:53:59 +08:00
EUForest 2fd22c97e0 feat(migration): add captcha configuration migration
- Add CaptchaType field for selecting captcha implementation
- Add EnableUserLoginCaptcha for user login verification
- Add EnableUserRegisterCaptcha for user registration verification
- Add EnableAdminLoginCaptcha for admin login verification
- Add EnableUserResetPasswordCaptcha for password reset verification
- Remove deprecated EnableLoginVerify, EnableRegisterVerify, EnableResetPasswordVerify fields
- Support rollback with down migration
2026-03-09 22:53:45 +08:00
EUForest 36119b842c build(deps): add base64Captcha library for local captcha generation
- Add github.com/mojocn/base64Captcha v1.3.6
- Add github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 (indirect)
- Add golang.org/x/image v0.23.0 (indirect)
2026-03-09 22:53:34 +08:00
EUForest 0dbcff85f1 feat(captcha): add captcha service interface and implementations
- Add captcha service interface with Generate and Verify methods
- Implement local image captcha using base64Captcha library
- Implement Cloudflare Turnstile verification wrapper
- Support Redis-based captcha storage with 5-minute expiration
- Add factory method for creating captcha service instances
2026-03-09 22:53:13 +08:00
EUForest fae8787ff4 Merge branch 'master' of https://github.com/OmnTeam/ppanel-server 2026-03-08 23:24:05 +08:00
EUForest 39310d5b9a Features:
- Node group CRUD operations with traffic-based filtering
  - Three grouping modes: average distribution, subscription-based, and traffic-based
  - Automatic and manual group recalculation with history tracking
  - Group assignment preview before applying changes
  - User subscription group locking to prevent automatic reassignment
  - Subscribe-to-group mapping configuration
  - Group calculation history and detailed reports
  - System configuration for group management (enabled/mode/auto_create)

  Database:
  - Add node_group table for group definitions
  - Add group_history and group_history_detail tables for tracking
  - Add node_group_ids (JSON) to nodes and subscribe tables
  - Add node_group_id and group_locked fields to user_subscribe table
  - Add migration files for schema changes
2026-03-08 23:22:38 +08:00
EUForest e215ffcae9 fix(subscribe): invalidate user subscription cache when plan is updated
When administrators update subscription plan configurations (traffic limits,
nodes, speed limits, etc.), existing subscribers were not seeing the updated
settings immediately. This was caused by stale cache entries that were not
being invalidated.

The issue occurred because:
- User subscription queries cache the entire result including preloaded plan details
- Plan update/delete operations only cleared the plan's own cache keys
- User subscription cache keys (cache:user:subscribe:user:{userId}) remained stale

This fix ensures that when a subscription plan is updated or deleted, all
associated user subscription caches are properly invalidated by:
- Querying all active users subscribed to the plan
- Building cache keys for each affected user
- Clearing both plan and user subscription caches atomically

Users will now immediately see updated plan configurations without waiting
for cache expiration.
2026-03-06 13:26:31 +08:00
414 changed files with 38076 additions and 13062 deletions
-38
View File
@@ -1,38 +0,0 @@
# .agents Directory
This directory contains agent configuration and skills for OpenAI Codex CLI.
## Structure
```
.agents/
config.toml # Main configuration file
skills/ # Skill definitions
skill-name/
SKILL.md # Skill instructions
scripts/ # Optional scripts
docs/ # Optional documentation
README.md # This file
```
## Configuration
The `config.toml` file controls:
- Model selection
- Approval policies
- Sandbox modes
- MCP server connections
- Skills configuration
## Skills
Skills are invoked using `$skill-name` syntax. Each skill has:
- YAML frontmatter with metadata
- Trigger and skip conditions
- Commands and examples
## Documentation
- Main instructions: `AGENTS.md` (project root)
- Local overrides: `.codex/AGENTS.override.md` (gitignored)
- Claude Flow: https://github.com/ruvnet/claude-flow
-298
View File
@@ -1,298 +0,0 @@
# =============================================================================
# Claude Flow V3 - Codex Configuration
# =============================================================================
# Generated by: @claude-flow/codex
# Documentation: https://github.com/ruvnet/claude-flow
#
# This file configures the Codex CLI for Claude Flow integration.
# Place in .agents/config.toml (project) or .codex/config.toml (user).
# =============================================================================
# =============================================================================
# Core Settings
# =============================================================================
# Model selection - the AI model to use for code generation
# Options: gpt-5.3-codex, gpt-4o, claude-sonnet, claude-opus
model = "gpt-5.3-codex"
# Approval policy determines when human approval is required
# - untrusted: Always require approval
# - on-failure: Require approval only after failures
# - on-request: Require approval for significant changes
# - never: Auto-approve all actions (use with caution)
approval_policy = "on-request"
# Sandbox mode controls file system access
# - read-only: Can only read files, no modifications
# - workspace-write: Can write within workspace directory
# - danger-full-access: Full file system access (dangerous)
sandbox_mode = "workspace-write"
# Web search enables internet access for research
# - disabled: No web access
# - cached: Use cached results when available
# - live: Always fetch fresh results
web_search = "cached"
# =============================================================================
# Project Documentation
# =============================================================================
# Maximum bytes to read from AGENTS.md files
project_doc_max_bytes = 65536
# Fallback filenames if AGENTS.md not found
project_doc_fallback_filenames = [
"AGENTS.md",
"TEAM_GUIDE.md",
".agents.md"
]
# =============================================================================
# Features
# =============================================================================
[features]
# Enable child AGENTS.md guidance
child_agents_md = true
# Cache shell environment for faster repeated commands
shell_snapshot = true
# Smart approvals based on request context
request_rule = true
# Enable remote compaction for large histories
remote_compaction = true
# =============================================================================
# MCP Servers
# =============================================================================
[mcp_servers.claude-flow]
command = "npx"
args = ["-y", "@claude-flow/cli@latest"]
enabled = true
tool_timeout_sec = 120
# =============================================================================
# Skills Configuration
# =============================================================================
[[skills.config]]
path = ".agents/skills/swarm-orchestration"
enabled = true
[[skills.config]]
path = ".agents/skills/memory-management"
enabled = true
[[skills.config]]
path = ".agents/skills/sparc-methodology"
enabled = true
[[skills.config]]
path = ".agents/skills/security-audit"
enabled = true
# =============================================================================
# Profiles
# =============================================================================
# Development profile - more permissive for local work
[profiles.dev]
approval_policy = "never"
sandbox_mode = "danger-full-access"
web_search = "live"
# Safe profile - maximum restrictions
[profiles.safe]
approval_policy = "untrusted"
sandbox_mode = "read-only"
web_search = "disabled"
# CI profile - for automated pipelines
[profiles.ci]
approval_policy = "never"
sandbox_mode = "workspace-write"
web_search = "cached"
# =============================================================================
# History
# =============================================================================
[history]
# Save all session transcripts
persistence = "save-all"
# =============================================================================
# Shell Environment
# =============================================================================
[shell_environment_policy]
# Inherit environment variables
inherit = "core"
# Exclude sensitive variables
exclude = ["*_KEY", "*_SECRET", "*_TOKEN", "*_PASSWORD"]
# =============================================================================
# Sandbox Workspace Write Settings
# =============================================================================
[sandbox_workspace_write]
# Additional writable paths beyond workspace
writable_roots = []
# Allow network access
network_access = true
# Exclude temp directories
exclude_slash_tmp = false
# =============================================================================
# Security Settings
# =============================================================================
[security]
# Enable input validation for all user inputs
input_validation = true
# Prevent directory traversal attacks
path_traversal_prevention = true
# Scan for hardcoded secrets
secret_scanning = true
# Scan dependencies for known CVEs
cve_scanning = true
# Maximum file size for operations (bytes)
max_file_size = 10485760
# Allowed file extensions (empty = allow all)
allowed_extensions = []
# Blocked file patterns (regex)
blocked_patterns = ["\\.env$", "credentials\\.json$", "\\.pem$", "\\.key$"]
# =============================================================================
# Performance Settings
# =============================================================================
[performance]
# Maximum concurrent agents
max_agents = 8
# Task timeout in seconds
task_timeout = 300
# Memory limit per agent
memory_limit = "512MB"
# Enable response caching
cache_enabled = true
# Cache TTL in seconds
cache_ttl = 3600
# Enable parallel task execution
parallel_execution = true
# =============================================================================
# Logging Settings
# =============================================================================
[logging]
# Log level: debug, info, warn, error
level = "info"
# Log format: json, text, pretty
format = "pretty"
# Log destination: stdout, file, both
destination = "stdout"
# =============================================================================
# Neural Intelligence Settings
# =============================================================================
[neural]
# Enable SONA (Self-Optimizing Neural Architecture)
sona_enabled = true
# Enable HNSW vector search
hnsw_enabled = true
# HNSW index parameters
hnsw_m = 16
hnsw_ef_construction = 200
hnsw_ef_search = 100
# Enable pattern learning
pattern_learning = true
# Learning rate for neural adaptation
learning_rate = 0.01
# =============================================================================
# Swarm Orchestration Settings
# =============================================================================
[swarm]
# Default topology: hierarchical, mesh, ring, star
default_topology = "hierarchical"
# Default strategy: balanced, specialized, adaptive
default_strategy = "specialized"
# Consensus algorithm: raft, byzantine, gossip
consensus = "raft"
# Enable anti-drift measures
anti_drift = true
# Checkpoint interval (tasks)
checkpoint_interval = 10
# =============================================================================
# Hooks Configuration
# =============================================================================
[hooks]
# Enable lifecycle hooks
enabled = true
# Pre-task hook
pre_task = true
# Post-task hook (for learning)
post_task = true
# Enable neural training on post-edit
train_on_edit = true
# =============================================================================
# Background Workers
# =============================================================================
[workers]
# Enable background workers
enabled = true
# Worker configuration
[workers.audit]
enabled = true
priority = "critical"
interval = 300
[workers.optimize]
enabled = true
priority = "high"
interval = 600
[workers.consolidate]
enabled = true
priority = "low"
interval = 1800
-126
View File
@@ -1,126 +0,0 @@
---
name: memory-management
description: >
AgentDB memory system with HNSW vector search. Provides 150x-12,500x faster pattern retrieval, persistent storage, and semantic search capabilities for learning and knowledge management.
Use when: need to store successful patterns, searching for similar solutions, semantic lookup of past work, learning from previous tasks, sharing knowledge between agents, building knowledge base.
Skip when: no learning needed, ephemeral one-off tasks, external data sources available, read-only exploration.
---
# Memory Management Skill
## Purpose
AgentDB memory system with HNSW vector search. Provides 150x-12,500x faster pattern retrieval, persistent storage, and semantic search capabilities for learning and knowledge management.
## When to Trigger
- need to store successful patterns
- searching for similar solutions
- semantic lookup of past work
- learning from previous tasks
- sharing knowledge between agents
- building knowledge base
## When to Skip
- no learning needed
- ephemeral one-off tasks
- external data sources available
- read-only exploration
## Commands
### Store Pattern
Store a pattern or knowledge item in memory
```bash
npx @claude-flow/cli memory store --key "[key]" --value "[value]" --namespace patterns
```
**Example:**
```bash
npx @claude-flow/cli memory store --key "auth-jwt-pattern" --value "JWT validation with refresh tokens" --namespace patterns
```
### Semantic Search
Search memory using semantic similarity
```bash
npx @claude-flow/cli memory search --query "[search terms]" --limit 10
```
**Example:**
```bash
npx @claude-flow/cli memory search --query "authentication best practices" --limit 5
```
### Retrieve Entry
Retrieve a specific memory entry by key
```bash
npx @claude-flow/cli memory get --key "[key]" --namespace [namespace]
```
**Example:**
```bash
npx @claude-flow/cli memory get --key "auth-jwt-pattern" --namespace patterns
```
### List Entries
List all entries in a namespace
```bash
npx @claude-flow/cli memory list --namespace [namespace]
```
**Example:**
```bash
npx @claude-flow/cli memory list --namespace patterns --limit 20
```
### Delete Entry
Delete a memory entry
```bash
npx @claude-flow/cli memory delete --key "[key]" --namespace [namespace]
```
### Initialize HNSW Index
Initialize HNSW vector search index
```bash
npx @claude-flow/cli memory init --enable-hnsw
```
### Memory Stats
Show memory usage statistics
```bash
npx @claude-flow/cli memory stats
```
### Export Memory
Export memory to JSON
```bash
npx @claude-flow/cli memory export --output memory-backup.json
```
## Scripts
| Script | Path | Description |
|--------|------|-------------|
| `memory-backup` | `.agents/scripts/memory-backup.sh` | Backup memory to external storage |
| `memory-consolidate` | `.agents/scripts/memory-consolidate.sh` | Consolidate and optimize memory |
## References
| Document | Path | Description |
|----------|------|-------------|
| `HNSW Guide` | `docs/hnsw.md` | HNSW vector search configuration |
| `Memory Schema` | `docs/memory-schema.md` | Memory namespace and schema reference |
## Best Practices
1. Check memory for existing patterns before starting
2. Use hierarchical topology for coordination
3. Store successful patterns after completion
4. Document any new learnings
-135
View File
@@ -1,135 +0,0 @@
---
name: security-audit
description: >
Comprehensive security scanning and vulnerability detection. Includes input validation, path traversal prevention, CVE detection, and secure coding pattern enforcement.
Use when: authentication implementation, authorization logic, payment processing, user data handling, API endpoint creation, file upload handling, database queries, external API integration.
Skip when: read-only operations on public data, internal development tooling, static documentation, styling changes.
---
# Security Audit Skill
## Purpose
Comprehensive security scanning and vulnerability detection. Includes input validation, path traversal prevention, CVE detection, and secure coding pattern enforcement.
## When to Trigger
- authentication implementation
- authorization logic
- payment processing
- user data handling
- API endpoint creation
- file upload handling
- database queries
- external API integration
## When to Skip
- read-only operations on public data
- internal development tooling
- static documentation
- styling changes
## Commands
### Full Security Scan
Run comprehensive security analysis on the codebase
```bash
npx @claude-flow/cli security scan --depth full
```
**Example:**
```bash
npx @claude-flow/cli security scan --depth full --output security-report.json
```
### Input Validation Check
Check for input validation issues
```bash
npx @claude-flow/cli security scan --check input-validation
```
**Example:**
```bash
npx @claude-flow/cli security scan --check input-validation --path ./src/api
```
### Path Traversal Check
Check for path traversal vulnerabilities
```bash
npx @claude-flow/cli security scan --check path-traversal
```
### SQL Injection Check
Check for SQL injection vulnerabilities
```bash
npx @claude-flow/cli security scan --check sql-injection
```
### XSS Check
Check for cross-site scripting vulnerabilities
```bash
npx @claude-flow/cli security scan --check xss
```
### CVE Scan
Scan dependencies for known CVEs
```bash
npx @claude-flow/cli security cve --scan
```
**Example:**
```bash
npx @claude-flow/cli security cve --scan --severity high
```
### Security Audit Report
Generate full security audit report
```bash
npx @claude-flow/cli security audit --report
```
**Example:**
```bash
npx @claude-flow/cli security audit --report --format markdown --output SECURITY.md
```
### Threat Modeling
Run threat modeling analysis
```bash
npx @claude-flow/cli security threats --analyze
```
### Validate Secrets
Check for hardcoded secrets
```bash
npx @claude-flow/cli security validate --check secrets
```
## Scripts
| Script | Path | Description |
|--------|------|-------------|
| `security-scan` | `.agents/scripts/security-scan.sh` | Run full security scan pipeline |
| `cve-remediate` | `.agents/scripts/cve-remediate.sh` | Auto-remediate known CVEs |
## References
| Document | Path | Description |
|----------|------|-------------|
| `Security Checklist` | `docs/security-checklist.md` | Security review checklist |
| `OWASP Guide` | `docs/owasp-top10.md` | OWASP Top 10 mitigation guide |
## Best Practices
1. Check memory for existing patterns before starting
2. Use hierarchical topology for coordination
3. Store successful patterns after completion
4. Document any new learnings
-118
View File
@@ -1,118 +0,0 @@
---
name: sparc-methodology
description: >
SPARC development workflow: Specification, Pseudocode, Architecture, Refinement, Completion. A structured approach for complex implementations that ensures thorough planning before coding.
Use when: new feature implementation, complex implementations, architectural changes, system redesign, integration work, unclear requirements.
Skip when: simple bug fixes, documentation updates, configuration changes, well-defined small tasks, routine maintenance.
---
# Sparc Methodology Skill
## Purpose
SPARC development workflow: Specification, Pseudocode, Architecture, Refinement, Completion. A structured approach for complex implementations that ensures thorough planning before coding.
## When to Trigger
- new feature implementation
- complex implementations
- architectural changes
- system redesign
- integration work
- unclear requirements
## When to Skip
- simple bug fixes
- documentation updates
- configuration changes
- well-defined small tasks
- routine maintenance
## Commands
### Specification Phase
Define requirements, acceptance criteria, and constraints
```bash
npx @claude-flow/cli hooks route --task "specification: [requirements]"
```
**Example:**
```bash
npx @claude-flow/cli hooks route --task "specification: user authentication with OAuth2, MFA, and session management"
```
### Pseudocode Phase
Write high-level pseudocode for the implementation
```bash
npx @claude-flow/cli hooks route --task "pseudocode: [feature]"
```
**Example:**
```bash
npx @claude-flow/cli hooks route --task "pseudocode: OAuth2 login flow with token refresh"
```
### Architecture Phase
Design system structure, interfaces, and dependencies
```bash
npx @claude-flow/cli hooks route --task "architecture: [design]"
```
**Example:**
```bash
npx @claude-flow/cli hooks route --task "architecture: auth module with service layer, repository, and API endpoints"
```
### Refinement Phase
Iterate on the design based on feedback
```bash
npx @claude-flow/cli hooks route --task "refinement: [feedback]"
```
**Example:**
```bash
npx @claude-flow/cli hooks route --task "refinement: add rate limiting and brute force protection"
```
### Completion Phase
Finalize implementation with tests and documentation
```bash
npx @claude-flow/cli hooks route --task "completion: [final checks]"
```
**Example:**
```bash
npx @claude-flow/cli hooks route --task "completion: verify all tests pass, update API docs, security review"
```
### SPARC Coordinator
Spawn SPARC coordinator agent
```bash
npx @claude-flow/cli agent spawn --type sparc-coord --name sparc-lead
```
## Scripts
| Script | Path | Description |
|--------|------|-------------|
| `sparc-init` | `.agents/scripts/sparc-init.sh` | Initialize SPARC workflow for a new feature |
| `sparc-review` | `.agents/scripts/sparc-review.sh` | Run SPARC phase review checklist |
## References
| Document | Path | Description |
|----------|------|-------------|
| `SPARC Overview` | `docs/sparc.md` | Complete SPARC methodology guide |
| `Phase Templates` | `docs/sparc-templates.md` | Templates for each SPARC phase |
## Best Practices
1. Check memory for existing patterns before starting
2. Use hierarchical topology for coordination
3. Store successful patterns after completion
4. Document any new learnings
-114
View File
@@ -1,114 +0,0 @@
---
name: swarm-orchestration
description: >
Multi-agent swarm coordination for complex tasks. Uses hierarchical topology with specialized agents to break down and execute complex work across multiple files and modules.
Use when: 3+ files need changes, new feature implementation, cross-module refactoring, API changes with tests, security-related changes, performance optimization across codebase, database schema changes.
Skip when: single file edits, simple bug fixes (1-2 lines), documentation updates, configuration changes, quick exploration.
---
# Swarm Orchestration Skill
## Purpose
Multi-agent swarm coordination for complex tasks. Uses hierarchical topology with specialized agents to break down and execute complex work across multiple files and modules.
## When to Trigger
- 3+ files need changes
- new feature implementation
- cross-module refactoring
- API changes with tests
- security-related changes
- performance optimization across codebase
- database schema changes
## When to Skip
- single file edits
- simple bug fixes (1-2 lines)
- documentation updates
- configuration changes
- quick exploration
## Commands
### Initialize Swarm
Start a new swarm with hierarchical topology (anti-drift)
```bash
npx @claude-flow/cli swarm init --topology hierarchical --max-agents 8 --strategy specialized
```
**Example:**
```bash
npx @claude-flow/cli swarm init --topology hierarchical --max-agents 6 --strategy specialized
```
### Route Task
Route a task to the appropriate agents based on task type
```bash
npx @claude-flow/cli hooks route --task "[task description]"
```
**Example:**
```bash
npx @claude-flow/cli hooks route --task "implement OAuth2 authentication flow"
```
### Spawn Agent
Spawn a specific agent type
```bash
npx @claude-flow/cli agent spawn --type [type] --name [name]
```
**Example:**
```bash
npx @claude-flow/cli agent spawn --type coder --name impl-auth
```
### Monitor Status
Check the current swarm status
```bash
npx @claude-flow/cli swarm status --verbose
```
### Orchestrate Task
Orchestrate a task across multiple agents
```bash
npx @claude-flow/cli task orchestrate --task "[task]" --strategy adaptive
```
**Example:**
```bash
npx @claude-flow/cli task orchestrate --task "refactor auth module" --strategy parallel --max-agents 4
```
### List Agents
List all active agents
```bash
npx @claude-flow/cli agent list --filter active
```
## Scripts
| Script | Path | Description |
|--------|------|-------------|
| `swarm-start` | `.agents/scripts/swarm-start.sh` | Initialize swarm with default settings |
| `swarm-monitor` | `.agents/scripts/swarm-monitor.sh` | Real-time swarm monitoring dashboard |
## References
| Document | Path | Description |
|----------|------|-------------|
| `Agent Types` | `docs/agents.md` | Complete list of agent types and capabilities |
| `Topology Guide` | `docs/topology.md` | Swarm topology configuration guide |
## Best Practices
1. Check memory for existing patterns before starting
2. Use hierarchical topology for coordination
3. Store successful patterns after completion
4. Document any new learnings
@@ -0,0 +1,25 @@
package main
import (
"fmt"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/conf"
)
func main() {
var c config.Config
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
ctx := svc.NewServiceContext(c)
const key = "cache:auth:method:device"
before, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result()
fmt.Printf("cache before=%q\n", before)
m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
fmt.Printf("model err=%v enabled_nil=%v", err, m == nil || m.Enabled == nil)
if m != nil && m.Enabled != nil { fmt.Printf(" enabled=%v", *m.Enabled) }
if m != nil { fmt.Printf(" config=%s", m.Config) }
fmt.Println()
after, _ := ctx.Redis.Get(ctx.DB.Statement.Context, key).Result()
fmt.Printf("cache after=%q\n", after)
}
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"fmt"
initpkg "github.com/perfect-panel/server/initialize"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/conf"
)
func main() {
var c config.Config
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
ctx := svc.NewServiceContext(c)
method, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
if err != nil {
panic(err)
}
fmt.Printf("db auth_method.enabled=%v config=%s\n", *method.Enabled, method.Config)
initpkg.Device(ctx)
fmt.Printf("ctx.Config.Device.Enable=%v SecuritySecret=%q EnableSecurity=%v\n", ctx.Config.Device.Enable, ctx.Config.Device.SecuritySecret, ctx.Config.Device.EnableSecurity)
}
+36
View File
@@ -0,0 +1,36 @@
package main
import (
"fmt"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/conf"
)
type row struct {
ID int64
Method string
Enabled int
Config string
}
func main() {
var c config.Config
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
ctx := svc.NewServiceContext(c)
var rows []row
if err := ctx.DB.Raw("SELECT id, method, enabled, config FROM auth_method WHERE method = ?", "device").Scan(&rows).Error; err != nil {
panic(err)
}
fmt.Printf("raw rows: %+v\n", rows)
m, err := ctx.AuthModel.FindOneByMethod(ctx.DB.Statement.Context, "device")
fmt.Printf("model err=%v\n", err)
if err == nil && m != nil && m.Enabled != nil {
fmt.Printf("model row: id=%d method=%s enabled=%v config=%s\n", m.Id, m.Method, *m.Enabled, m.Config)
} else {
fmt.Printf("model row nil or no enabled ptr: %#v\n", m)
}
}
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"fmt"
"github.com/perfect-panel/server/internal/config"
authmodel "github.com/perfect-panel/server/internal/model/auth"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/conf"
)
func main() {
var c config.Config
conf.MustLoad("/private/tmp/ppanel-local-upload.yaml", &c)
ctx := svc.NewServiceContext(c)
var a1 authmodel.Auth
err1 := ctx.DB.Model(&authmodel.Auth{}).Where("method = ?", "device").First(&a1).Error
fmt.Printf("gorm direct err=%v enabled_nil=%v", err1, a1.Enabled == nil)
if a1.Enabled != nil { fmt.Printf(" enabled=%v", *a1.Enabled) }
fmt.Printf(" config=%s\n", a1.Config)
var a2 authmodel.Auth
err2 := ctx.DB.Table("auth_method").Where("method = ?", "device").First(&a2).Error
fmt.Printf("gorm table err=%v enabled_nil=%v", err2, a2.Enabled == nil)
if a2.Enabled != nil { fmt.Printf(" enabled=%v", *a2.Enabled) }
fmt.Printf(" config=%s\n", a2.Config)
}
-3
View File
@@ -1,3 +0,0 @@
{
"extends": ["@commitlint/config-conventional"]
}
+9 -2
View File
@@ -7,5 +7,12 @@ MYSQL_ROOT_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
# Grafana 管理员密码 # Grafana 管理员密码
GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
# PPanel Server 镜像标签(留空使用 latest # PPanel Server 镜像标签(由 CI/CD 传入不可变 tag,如 git SHA
PPANEL_SERVER_TAG=latest PPANEL_SERVER_TAG=CHANGE_ME_TO_GIT_SHA
# AWS 区域(香港)
AWS_REGION=ap-east-1
# Grafana 公开域名(如需反代)
GRAFANA_DOMAIN=logs-new.hifast.biz
GRAFANA_ROOT_URL=https://logs-new.hifast.biz
+159 -58
View File
@@ -14,14 +14,15 @@ on:
env: env:
# Docker镜像仓库 # Docker镜像仓库
REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }} REPO: ${{ vars.REPO || 'registry.kxsw.us/vpn-server' }}
# SSH连接信息 (根据分支自动选择) # SSH连接信息 (根据分支自动选择服务器和用户)
SSH_HOST: ${{ github.ref_name == 'main' && vars.SSH_HOST || vars.DEV_SSH_HOST }} SSH_HOST: ${{ github.ref_name == 'main' && vars.SSH_HOST || vars.DEV_SSH_HOST }}
SSH_PORT: ${{ vars.SSH_PORT }} SSH_PORT: ${{ vars.SSH_PORT }}
SSH_USER: ${{ vars.SSH_USER }} SSH_USER: ${{ github.ref_name == 'main' && 'ubuntu' || 'root' }}
SSH_PASSWORD: ${{ github.ref_name == 'main' && vars.SSH_PASSWORD || vars.DEV_SSH_PASSWORD }} # SSH私钥(Gitea Secret 名称:AWS
SSH_KEY: ${{ secrets.AWS }}
# TG通知 # TG通知
TG_BOT_TOKEN: 8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0 TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
TG_CHAT_ID: "-4940243803" TG_CHAT_ID: ${{ secrets.TG_CHAT_ID }}
# Go构建变量 # Go构建变量
SERVICE: vpn SERVICE: vpn
SERVICE_STYLE: vpn SERVICE_STYLE: vpn
@@ -42,27 +43,30 @@ jobs:
# 步骤1: 下载代码 # 步骤1: 下载代码
- name: 📥 下载代码 - name: 📥 下载代码
uses: actions/checkout@v4 uses: actions/checkout@v4
# 步骤2: 设置动态环境变量 # 步骤2: 设置动态环境变量
- name: ⚙️ 设置动态环境变量 - name: ⚙️ 设置动态环境变量
run: | run: |
if [ "${{ github.ref_name }}" = "main" ]; then if [ "${{ github.ref_name }}" = "main" ]; then
echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV echo "DOCKER_TAG_SUFFIX=latest" >> $GITHUB_ENV
echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV
echo "DEPLOY_PATH=/root/bindbox" >> $GITHUB_ENV echo "DEPLOY_PATH=/opt/ppanel" >> $GITHUB_ENV
echo "DEPLOY_ENV_LABEL=🚀 服务已成功部署到生产环境" >> $GITHUB_ENV
echo "为 main 分支设置生产环境变量" echo "为 main 分支设置生产环境变量"
elif [ "${{ github.ref_name }}" = "internal" ]; then elif [ "${{ github.ref_name }}" = "internal" ]; then
echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV
echo "CONTAINER_NAME=ppanel-server-internal" >> $GITHUB_ENV echo "CONTAINER_NAME=ppanel-server-internal" >> $GITHUB_ENV
echo "DEPLOY_PATH=/root/bindbox" >> $GITHUB_ENV echo "DEPLOY_PATH=/root/bindbox" >> $GITHUB_ENV
echo "DEPLOY_ENV_LABEL=🧪 服务已成功部署到测试环境" >> $GITHUB_ENV
echo "为 internal 分支设置开发环境变量" echo "为 internal 分支设置开发环境变量"
else else
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
echo "CONTAINER_NAME=ppanel-server-${{ 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 "DEPLOY_PATH=/root/vpn_server_other" >> $GITHUB_ENV
echo "DEPLOY_ENV_LABEL=🔧 服务已成功部署到其他环境" >> $GITHUB_ENV
echo "为其他分支 (${{ github.ref_name }}) 设置环境变量" echo "为其他分支 (${{ github.ref_name }}) 设置环境变量"
fi fi
# 步骤3: 安装系统工具 (curl, jq) 并升级 Docker CLI 到 1.44+ # 步骤3: 安装系统工具 (curl, jq) 并升级 Docker CLI 到 1.44+
- name: 🔧 安装系统工具并升级 Docker CLI - name: 🔧 安装系统工具并升级 Docker CLI
run: | run: |
@@ -110,127 +114,224 @@ jobs:
docker --version || true docker --version || true
docker version || true docker version || true
echo "客户端 API 版本:" $(docker version --format '{{.Client.APIVersion}}') echo "客户端 API 版本:" $(docker version --format '{{.Client.APIVersion}}')
# 步骤4: 构建并发布到镜像仓库 # 步骤4: 构建镜像
- name: 📤 构建并发布到镜像仓库 - name: 🏗️ 构建镜像
run: | run: |
echo "开始构建并推送镜像..." echo "开始构建镜像..."
echo "仓库: ${{ env.REPO }}" echo "仓库: ${{ env.REPO }}"
echo "版本标签: ${{ env.VERSION }}" echo "版本标签: ${{ env.VERSION }}"
echo "分支标签: ${{ env.DOCKER_TAG_SUFFIX }}" echo "分支标签: ${{ env.DOCKER_TAG_SUFFIX }}"
# 构建镜像,同时打上版本和分支两个标签 BUILD_TAG_ARGS="-t ${{ env.REPO }}:${{ env.VERSION }}"
if [ "${{ github.event_name }}" = "push" ]; then
BUILD_TAG_ARGS="$BUILD_TAG_ARGS -t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
else
echo "PR事件仅构建版本标签,不推送镜像、不部署"
fi
docker build -f Dockerfile \ docker build -f Dockerfile \
--platform linux/amd64 \ --platform linux/amd64 \
--build-arg TARGETARCH=amd64 \ --build-arg TARGETARCH=amd64 \
--build-arg VERSION=${{ env.VERSION }} \ --build-arg VERSION=${{ env.VERSION }} \
--build-arg BUILDTIME=${{ env.BUILDTIME }} \ --build-arg BUILDTIME=${{ env.BUILDTIME }} \
-t ${{ env.REPO }}:${{ env.VERSION }} \ $BUILD_TAG_ARGS \
-t ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }} \
. .
echo "镜像构建完成"
# 步骤5: 发布到镜像仓库
- name: 📤 发布到镜像仓库
if: github.event_name == 'push'
run: |
echo "开始推送镜像..."
echo "推送版本标签镜像: ${{ env.REPO }}:${{ env.VERSION }}" echo "推送版本标签镜像: ${{ env.REPO }}:${{ env.VERSION }}"
docker push ${{ env.REPO }}:${{ env.VERSION }} docker push ${{ env.REPO }}:${{ env.VERSION }}
echo "推送分支标签镜像: ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}" echo "推送分支标签镜像: ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}"
docker push ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }} docker push ${{ env.REPO }}:${{ env.DOCKER_TAG_SUFFIX }}
echo "镜像推送完成" echo "镜像推送完成"
# 调试: 打印 SSH 连接信息 # 步骤6: 调试 - 打印部署目标(不输出敏感信息
- name: 🔍 调试 - 打印 SSH 连接信息 - name: 🔍 调试 - 打印部署目标
if: github.event_name == 'push'
run: | run: |
echo "========== SSH 连接信息调试 ==========" echo "========== 部署目标调试 =========="
echo "当前分支: ${{ github.ref_name }}" echo "当前分支: ${{ github.ref_name }}"
echo "SSH_HOST: ${{ env.SSH_HOST }}" echo "SSH_HOST: ${{ env.SSH_HOST }}"
echo "SSH_PORT: ${{ env.SSH_PORT }}" echo "SSH_PORT: ${{ env.SSH_PORT }}"
echo "SSH_USER: ${{ env.SSH_USER }}" echo "SSH_USER: ${{ env.SSH_USER }}"
echo "SSH_PASSWORD 长度: ${#SSH_PASSWORD}" echo "SSH认证方式: 私钥 (AWS)"
echo "SSH_PASSWORD 前3位: $(echo "$SSH_PASSWORD" | cut -c1-3)***"
echo "SSH_PASSWORD 完整值: ${{ env.SSH_PASSWORD }}"
echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}" echo "DEPLOY_PATH: ${{ env.DEPLOY_PATH }}"
echo "=====================================" echo "====================================="
# 步骤5: 传输配置文件 # 步骤7: 传输配置文件
- name: 📂 传输配置文件 - name: 📂 传输配置文件
if: github.event_name == 'push'
uses: appleboy/scp-action@v0.1.7 uses: appleboy/scp-action@v0.1.7
with: with:
host: ${{ env.SSH_HOST }} host: ${{ env.SSH_HOST }}
username: ${{ env.SSH_USER }} username: ${{ env.SSH_USER }}
password: ${{ env.SSH_PASSWORD }} key: ${{ env.SSH_KEY }}
port: ${{ env.SSH_PORT }} port: ${{ env.SSH_PORT }}
source: "docker-compose.cloud.yml" source: "docker-compose.cloud.yml"
target: "${{ env.DEPLOY_PATH }}/" target: "/tmp/ppanel-deploy/"
# 步骤6: 连接服务器更新并启动 # 步骤8: 连接服务器更新、健康检查并按需回滚
- name: 🚀 连接服务器更新并启动 - name: 🚀 连接服务器更新并启动
if: github.event_name == 'push'
uses: appleboy/ssh-action@v1.0.3 uses: appleboy/ssh-action@v1.0.3
with: with:
host: ${{ env.SSH_HOST }} host: ${{ env.SSH_HOST }}
username: ${{ env.SSH_USER }} username: ${{ env.SSH_USER }}
password: ${{ env.SSH_PASSWORD }} key: ${{ env.SSH_KEY }}
port: ${{ env.SSH_PORT }} port: ${{ env.SSH_PORT }}
timeout: 300s timeout: 300s
command_timeout: 600s command_timeout: 600s
script: | script: |
set -e
echo "连接服务器成功,开始部署..." echo "连接服务器成功,开始部署..."
echo "部署目录: ${{ env.DEPLOY_PATH }}" echo "部署目录: ${{ env.DEPLOY_PATH }}"
echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}" echo "部署标签: ${{ env.DOCKER_TAG_SUFFIX }}"
echo "登录用户: ${{ env.SSH_USER }}"
HEALTHCHECK_URL="http://127.0.0.1:8080/v1/common/heartbeat"
NEW_TAG="${{ env.DOCKER_TAG_SUFFIX }}"
ROLLBACK_TAG="rollback-${{ env.VERSION }}"
SUDO=""
if [ "${{ github.ref_name }}" = "main" ]; then
SUDO="sudo"
fi
docker_cmd() {
if [ -n "$SUDO" ]; then
sudo docker "$@"
else
docker "$@"
fi
}
compose_with_tag() {
tag="$1"
shift
if [ -n "$SUDO" ]; then
sudo env PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
else
PPANEL_SERVER_TAG="$tag" docker-compose -f docker-compose.cloud.yml "$@"
fi
}
write_previous_tag() {
if [ -n "$SUDO" ]; then
printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" | sudo tee .previous-ppanel-image-tag >/dev/null
else
printf '%s\n' "${PREVIOUS_IMAGE_TAG:-}" > .previous-ppanel-image-tag
fi
}
health_check() {
attempt=1
while [ "$attempt" -le 3 ]; do
if curl -sf "$HEALTHCHECK_URL"; then
echo
return 0
fi
echo "健康检查第 ${attempt}/3 次失败,10s 后重试..."
attempt=$((attempt + 1))
sleep 10
done
return 1
}
if [ "${{ github.ref_name }}" = "main" ]; then
sudo mkdir -p ${{ env.DEPLOY_PATH }}
sudo cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
else
mkdir -p ${{ env.DEPLOY_PATH }}
cp /tmp/ppanel-deploy/docker-compose.cloud.yml ${{ env.DEPLOY_PATH }}/docker-compose.cloud.yml
fi
# 进入部署目录
cd ${{ env.DEPLOY_PATH }} cd ${{ env.DEPLOY_PATH }}
# 创建/更新环境变量文件 PREVIOUS_IMAGE_TAG="$(docker_cmd inspect --format '{{.Config.Image}}' ppanel-server 2>/dev/null || true)"
# echo "PPANEL_SERVER_TAG=${{ env.DOCKER_TAG_SUFFIX }}" > .env PREVIOUS_IMAGE_ID="$(docker_cmd inspect --format '{{.Image}}' ppanel-server 2>/dev/null || true)"
echo "上一版本镜像tag: ${PREVIOUS_IMAGE_TAG:-未发现}"
# 拉取最新镜像 echo "上一版本镜像ID: ${PREVIOUS_IMAGE_ID:-未发现}"
echo "📥 拉取镜像..." write_previous_tag
docker-compose -f docker-compose.cloud.yml pull ppanel-server
echo "📥 拉取镜像: ${{ env.REPO }}:${NEW_TAG}"
# 启动服务 compose_with_tag "$NEW_TAG" pull ppanel-server
echo "🚀 启动服务..." echo "🚀 启动服务..."
docker-compose -f docker-compose.cloud.yml up -d ppanel-server compose_with_tag "$NEW_TAG" up -d ppanel-server
# 清理未使用的镜像 echo "🩺 部署后健康检查: ${HEALTHCHECK_URL}"
docker image prune -f || true if health_check; then
docker_cmd image prune -f || true
echo "✅ 部署命令执行完成" echo "✅ 部署后健康检查通过"
echo "✅ 部署命令执行完成"
# 步骤6: TG通知 (成功) exit 0
fi
echo "❌ 部署后健康检查连续 3 次失败,开始回滚..."
if [ -n "$PREVIOUS_IMAGE_ID" ]; then
docker_cmd tag "$PREVIOUS_IMAGE_ID" "${{ env.REPO }}:${ROLLBACK_TAG}"
echo "回滚镜像tag: ${{ env.REPO }}:${ROLLBACK_TAG}"
compose_with_tag "$ROLLBACK_TAG" up -d ppanel-server
echo "🩺 回滚后健康检查: ${HEALTHCHECK_URL}"
if health_check; then
echo "✅ 回滚后健康检查通过"
else
echo "❌ 回滚后健康检查仍失败"
fi
else
echo "未找到上一版本镜像ID,无法自动回滚"
fi
docker_cmd image prune -f || true
exit 1
# 步骤9: TG通知 (成功)
- name: 📱 发送成功通知到Telegram - name: 📱 发送成功通知到Telegram
if: success() if: success() && github.event_name == 'push'
uses: appleboy/telegram-action@master uses: appleboy/telegram-action@master
with: with:
token: ${{ env.TG_BOT_TOKEN }} token: ${{ env.TG_BOT_TOKEN }}
to: ${{ env.TG_CHAT_ID }} to: ${{ env.TG_CHAT_ID }}
message: | message: |
✅ 部署成功! ✅ 部署成功!
📦 项目: ${{ github.repository }} 📦 项目: ${{ github.repository }}
🌿 分支: ${{ github.ref_name }} 🌿 分支: ${{ github.ref_name }}
📝 提交: ${{ github.sha }} 📝 提交: ${{ github.sha }}
👤 提交者: ${{ github.actor }} 👤 提交者: ${{ github.actor }}
🕐 时间: ${{ github.event.head_commit.timestamp }} 🕐 时间: ${{ github.event.head_commit.timestamp }}
🚀 服务已成功部署到生产环境 ${{ env.DEPLOY_ENV_LABEL }}
🩺 健康检查: 通过 (http://127.0.0.1:8080/v1/common/heartbeat)
parse_mode: Markdown parse_mode: Markdown
# 步骤5: TG通知 (失败) # 步骤10: TG通知 (失败)
- name: 📱 发送失败通知到Telegram - name: 📱 发送失败通知到Telegram
if: failure() if: failure() && github.event_name == 'push'
uses: appleboy/telegram-action@master uses: appleboy/telegram-action@master
with: with:
token: ${{ env.TG_BOT_TOKEN }} token: ${{ env.TG_BOT_TOKEN }}
to: ${{ env.TG_CHAT_ID }} to: ${{ env.TG_CHAT_ID }}
message: | message: |
❌ 部署失败! ❌ 部署失败!
📦 项目: ${{ github.repository }} 📦 项目: ${{ github.repository }}
🌿 分支: ${{ github.ref_name }} 🌿 分支: ${{ github.ref_name }}
📝 提交: ${{ github.sha }} 📝 提交: ${{ github.sha }}
👤 提交者: ${{ github.actor }} 👤 提交者: ${{ github.actor }}
🕐 时间: ${{ github.event.head_commit.timestamp }} 🕐 时间: ${{ github.event.head_commit.timestamp }}
🩺 健康检查: 失败或未完成;若新版本健康检查连续 3 次失败,已自动尝试回滚并重新检查
⚠️ 请检查构建日志获取详细信息 ⚠️ 请检查构建日志获取详细信息
parse_mode: Markdown parse_mode: Markdown
@@ -1,12 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="go build github.com/perfect-panel/server" type="GoApplicationRunConfiguration" factoryName="Go Application" nameIsGenerated="true">
<module name="server" />
<working_directory value="$PROJECT_DIR$" />
<parameters value="run --config etc/ppanel-dev.yaml" />
<kind value="PACKAGE" />
<package value="github.com/perfect-panel/server" />
<directory value="$PROJECT_DIR$" />
<filePath value="$PROJECT_DIR$/ppanel.go" />
<method v="2" />
</configuration>
</component>
-145
View File
@@ -1,145 +0,0 @@
# ppanel-server
> Multi-agent orchestration framework for agentic coding
## Project Overview
A Claude Flow powered project
**Tech Stack**: TypeScript, Node.js
**Architecture**: Domain-Driven Design with bounded contexts
## Quick Start
### Installation
```bash
npm install
```
### Build
```bash
npm run build
```
### Test
```bash
npm test
```
### Development
```bash
npm run dev
```
## Agent Coordination
### Swarm Configuration
This project uses hierarchical swarm coordination for complex tasks:
| Setting | Value | Purpose |
|---------|-------|---------|
| Topology | `hierarchical` | Queen-led coordination (anti-drift) |
| Max Agents | 8 | Optimal team size |
| Strategy | `specialized` | Clear role boundaries |
| Consensus | `raft` | Leader-based consistency |
### When to Use Swarms
**Invoke swarm for:**
- Multi-file changes (3+ files)
- New feature implementation
- Cross-module refactoring
- API changes with tests
- Security-related changes
- Performance optimization
**Skip swarm for:**
- Single file edits
- Simple bug fixes (1-2 lines)
- Documentation updates
- Configuration changes
### Available Skills
Use `$skill-name` syntax to invoke:
| Skill | Use Case |
|-------|----------|
| `$swarm-orchestration` | Multi-agent task coordination |
| `$memory-management` | Pattern storage and retrieval |
| `$sparc-methodology` | Structured development workflow |
| `$security-audit` | Security scanning and CVE detection |
### Agent Types
| Type | Role | Use Case |
|------|------|----------|
| `researcher` | Requirements analysis | Understanding scope |
| `architect` | System design | Planning structure |
| `coder` | Implementation | Writing code |
| `tester` | Test creation | Quality assurance |
| `reviewer` | Code review | Security and quality |
## Code Standards
### File Organization
- **NEVER** save to root folder
- `/src` - Source code files
- `/tests` - Test files
- `/docs` - Documentation
- `/config` - Configuration files
### Quality Rules
- Files under 500 lines
- No hardcoded secrets
- Input validation at boundaries
- Typed interfaces for public APIs
- TDD London School (mock-first) preferred
### Commit Messages
```
<type>(<scope>): <description>
[optional body]
Co-Authored-By: claude-flow <ruv@ruv.net>
```
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`
## Security
### Critical Rules
- NEVER commit secrets, credentials, or .env files
- NEVER hardcode API keys
- Always validate user input
- Use parameterized queries for SQL
- Sanitize output to prevent XSS
### Path Security
- Validate all file paths
- Prevent directory traversal (../)
- Use absolute paths internally
## Memory System
### Storing Patterns
```bash
npx @claude-flow/cli memory store \
--key "pattern-name" \
--value "pattern description" \
--namespace patterns
```
### Searching Memory
```bash
npx @claude-flow/cli memory search \
--query "search terms" \
--namespace patterns
```
## Links
- Documentation: https://github.com/ruvnet/claude-flow
- Issues: https://github.com/ruvnet/claude-flow/issues
+1 -1
View File
@@ -28,7 +28,7 @@ FROM scratch
# Copy CA certificates and timezone data # Copy CA certificates and timezone data
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 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 ENV TZ=Asia/Shanghai
+215
View File
@@ -0,0 +1,215 @@
syntax = "v1"
info (
title: "Group API"
desc: "API for user group and node group management"
author: "Tension"
email: "tension@ppanel.com"
version: "0.0.1"
)
import (
"../types.api"
"./server.api"
)
type (
// ===== 节点组管理 =====
// GetNodeGroupListRequest
GetNodeGroupListRequest {
Page int `form:"page"`
Size int `form:"size"`
GroupId string `form:"group_id,omitempty"`
}
// GetNodeGroupListResponse
GetNodeGroupListResponse {
Total int64 `json:"total"`
List []NodeGroup `json:"list"`
}
// CreateNodeGroupRequest
CreateNodeGroupRequest {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
Sort int `json:"sort"`
ForCalculation *bool `json:"for_calculation"`
IsExpiredGroup *bool `json:"is_expired_group"`
ExpiredDaysLimit *int `json:"expired_days_limit"`
MaxTrafficGBExpired *int64 `json:"max_traffic_gb_expired,omitempty"`
SpeedLimit *int `json:"speed_limit"`
MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"`
MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"`
}
// UpdateNodeGroupRequest
UpdateNodeGroupRequest {
Id int64 `json:"id" validate:"required"`
Name string `json:"name"`
Description string `json:"description"`
Sort int `json:"sort"`
ForCalculation *bool `json:"for_calculation"`
IsExpiredGroup *bool `json:"is_expired_group"`
ExpiredDaysLimit *int `json:"expired_days_limit"`
MaxTrafficGBExpired *int64 `json:"max_traffic_gb_expired,omitempty"`
SpeedLimit *int `json:"speed_limit"`
MinTrafficGB *int64 `json:"min_traffic_gb,omitempty"`
MaxTrafficGB *int64 `json:"max_traffic_gb,omitempty"`
}
// DeleteNodeGroupRequest
DeleteNodeGroupRequest {
Id int64 `json:"id" validate:"required"`
}
// ===== 分组配置管理 =====
// GetGroupConfigRequest
GetGroupConfigRequest {
Keys []string `form:"keys,omitempty"`
}
// GetGroupConfigResponse
GetGroupConfigResponse {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
Config map[string]interface{} `json:"config"`
State RecalculationState `json:"state"`
}
// UpdateGroupConfigRequest
UpdateGroupConfigRequest {
Enabled bool `json:"enabled"`
Mode string `json:"mode"`
Config map[string]interface{} `json:"config"`
}
// RecalculationState
RecalculationState {
State string `json:"state"`
Progress int `json:"progress"`
Total int `json:"total"`
}
// ===== 分组操作 =====
// RecalculateGroupRequest
RecalculateGroupRequest {
Mode string `json:"mode" validate:"required"`
TriggerType string `json:"trigger_type"` // "manual" or "scheduled"
}
// GetGroupHistoryRequest
GetGroupHistoryRequest {
Page int `form:"page"`
Size int `form:"size"`
GroupMode string `form:"group_mode,omitempty"`
TriggerType string `form:"trigger_type,omitempty"`
}
// GetGroupHistoryResponse
GetGroupHistoryResponse {
Total int64 `json:"total"`
List []GroupHistory `json:"list"`
}
// GetGroupHistoryDetailRequest
GetGroupHistoryDetailRequest {
Id int64 `form:"id" validate:"required"`
}
// GetGroupHistoryDetailResponse
GetGroupHistoryDetailResponse {
GroupHistoryDetail
}
// PreviewUserNodesRequest
PreviewUserNodesRequest {
UserId int64 `form:"user_id" validate:"required"`
}
// PreviewUserNodesResponse
PreviewUserNodesResponse {
UserId int64 `json:"user_id"`
NodeGroups []NodeGroupItem `json:"node_groups"`
}
// NodeGroupItem
NodeGroupItem {
Id int64 `json:"id"`
Name string `json:"name"`
Nodes []Node `json:"nodes"`
}
// ExportGroupResultRequest
ExportGroupResultRequest {
HistoryId *int64 `form:"history_id,omitempty"`
}
// ===== Reset Groups =====
// ResetGroupsRequest
ResetGroupsRequest {
Confirm bool `json:"confirm" validate:"required"`
}
// ===== 套餐分组映射 =====
// SubscribeGroupMappingItem
SubscribeGroupMappingItem {
SubscribeName string `json:"subscribe_name"`
NodeGroupName string `json:"node_group_name"`
}
// GetSubscribeGroupMappingRequest
GetSubscribeGroupMappingRequest {}
// GetSubscribeGroupMappingResponse
GetSubscribeGroupMappingResponse {
List []SubscribeGroupMappingItem `json:"list"`
}
)
@server (
prefix: v1/admin/group
group: admin/group
jwt: JwtAuth
middleware: AuthMiddleware
)
service ppanel {
// ===== 节点组管理 =====
@doc "Get node group list"
@handler GetNodeGroupList
get /node/list (GetNodeGroupListRequest) returns (GetNodeGroupListResponse)
@doc "Create node group"
@handler CreateNodeGroup
post /node (CreateNodeGroupRequest)
@doc "Update node group"
@handler UpdateNodeGroup
put /node (UpdateNodeGroupRequest)
@doc "Delete node group"
@handler DeleteNodeGroup
delete /node (DeleteNodeGroupRequest)
// ===== 分组配置管理 =====
@doc "Get group config"
@handler GetGroupConfig
get /config (GetGroupConfigRequest) returns (GetGroupConfigResponse)
@doc "Update group config"
@handler UpdateGroupConfig
put /config (UpdateGroupConfigRequest)
// ===== 分组操作 =====
@doc "Recalculate group"
@handler RecalculateGroup
post /recalculate (RecalculateGroupRequest)
@doc "Get recalculation status"
@handler GetRecalculationStatus
get /recalculation/status returns (RecalculationState)
@doc "Get group history"
@handler GetGroupHistory
get /history (GetGroupHistoryRequest) returns (GetGroupHistoryResponse)
@doc "Export group result"
@handler ExportGroupResult
get /export (ExportGroupResultRequest)
// Routes with query parameters
@doc "Get group history detail"
@handler GetGroupHistoryDetail
get /history/detail (GetGroupHistoryDetailRequest) returns (GetGroupHistoryDetailResponse)
@doc "Preview user nodes"
@handler PreviewUserNodes
get /preview (PreviewUserNodesRequest) returns (PreviewUserNodesResponse)
@doc "Reset all groups"
@handler ResetGroups
post /reset (ResetGroupsRequest)
@doc "Get subscribe group mapping"
@handler GetSubscribeGroupMapping
get /subscribe/mapping (GetSubscribeGroupMappingRequest) returns (GetSubscribeGroupMappingResponse)
}
+48
View File
@@ -0,0 +1,48 @@
syntax = "v1"
info (
title: "invite admin API"
desc: "API for ppanel"
author: "Tension"
email: "tension@ppanel.com"
version: "0.0.1"
)
type (
GetInviteManageListRequest {
Page int `form:"page"`
Size int `form:"size"`
Search string `form:"search"`
InviterId int64 `form:"inviter_id"`
InviteeId int64 `form:"invitee_id"`
}
InviteManageRecord {
InviterId int64 `json:"inviter_id"`
InviterIdentifier string `json:"inviter_identifier"`
InviteeId int64 `json:"invitee_id"`
InviteeIdentifier string `json:"invitee_identifier"`
InviteeAvatar string `json:"invitee_avatar"`
InviteeEnable bool `json:"invitee_enable"`
InvitedAt int64 `json:"invited_at"`
OrderCount int64 `json:"order_count"`
HasPurchased bool `json:"has_purchased"`
InviterCommission int64 `json:"inviter_commission"`
InviterGiftDays int64 `json:"inviter_gift_days"`
InviteeGiftDays int64 `json:"invitee_gift_days"`
}
GetInviteManageListResponse {
Total int64 `json:"total"`
List []InviteManageRecord `json:"list"`
}
)
@server (
prefix: v1/admin/invite
group: admin/invite
middleware: AuthMiddleware
)
service ppanel {
@doc "Get invite manage list"
@handler GetInviteManageList
get /list (GetInviteManageListRequest) returns (GetInviteManageListResponse)
}
+61
View File
@@ -149,6 +149,35 @@ type (
Total int64 `json:"total"` Total int64 `json:"total"`
List []CommissionLog `json:"list"` List []CommissionLog `json:"list"`
} }
OrderRefundLog {
OrderId int64 `json:"order_id"`
OrderNo string `json:"order_no"`
OperatorUserId int64 `json:"operator_user_id"`
OperatorAuthIdentifier string `json:"operator_auth_identifier,omitempty"`
TargetUserId int64 `json:"target_user_id"`
UserSubscribeId int64 `json:"user_subscribe_id"`
RefererUserId int64 `json:"referer_user_id,omitempty"`
CommissionAmount int64 `json:"commission_amount"`
Reason string `json:"reason,omitempty"`
OrderStatusBefore uint8 `json:"order_status_before"`
OrderStatusAfter uint8 `json:"order_status_after"`
SubscribeStatusBefore uint8 `json:"subscribe_status_before"`
SubscribeStatusAfter uint8 `json:"subscribe_status_after"`
SubscribeExpireBefore int64 `json:"subscribe_expire_before"`
SubscribeExpireAfter int64 `json:"subscribe_expire_after"`
CommissionBefore int64 `json:"commission_before"`
CommissionAfter int64 `json:"commission_after"`
Timestamp int64 `json:"timestamp"`
}
FilterOrderRefundLogRequest {
FilterLogParams
OrderId int64 `form:"order_id,optional"`
UserId int64 `form:"user_id,optional"`
}
FilterOrderRefundLogResponse {
Total int64 `json:"total"`
List []OrderRefundLog `json:"list"`
}
GiftLog { GiftLog {
Type uint16 `json:"type"` Type uint16 `json:"type"`
userId int64 `json:"user_id"` userId int64 `json:"user_id"`
@@ -239,6 +268,30 @@ type (
OccurredAt int64 `json:"occurred_at"` OccurredAt int64 `json:"occurred_at"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
GetLogMessageRawRequest {
Id int64 `form:"id" validate:"required"`
}
GetLogMessageRawResponse {
Id int64 `json:"id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
OsName string `json:"os_name"`
OsVersion string `json:"os_version"`
DeviceId string `json:"device_id"`
UserId *int64 `json:"user_id"`
SessionId string `json:"session_id"`
Level uint8 `json:"level"`
ErrorCode string `json:"error_code"`
Message string `json:"message"`
Stack string `json:"stack"`
Context interface{} `json:"context"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"`
Locale string `json:"locale"`
Digest string `json:"digest"`
OccurredAt int64 `json:"occurred_at"`
CreatedAt int64 `json:"created_at"`
}
) )
@server ( @server (
@@ -291,6 +344,10 @@ service ppanel {
@handler FilterCommissionLog @handler FilterCommissionLog
get /commission/list (FilterCommissionLogRequest) returns (FilterCommissionLogResponse) get /commission/list (FilterCommissionLogRequest) returns (FilterCommissionLogResponse)
@doc "Filter order refund log"
@handler FilterOrderRefundLog
get /order/refund/list (FilterOrderRefundLogRequest) returns (FilterOrderRefundLogResponse)
@doc "Filter gift log" @doc "Filter gift log"
@handler FilterGiftLog @handler FilterGiftLog
get /gift/list (FilterGiftLogRequest) returns (FilterGiftLogResponse) get /gift/list (FilterGiftLogRequest) returns (FilterGiftLogResponse)
@@ -314,5 +371,9 @@ service ppanel {
@doc "Get error log message detail" @doc "Get error log message detail"
@handler GetErrorLogMessageDetail @handler GetErrorLogMessageDetail
get /error_message/detail returns (GetErrorLogMessageDetailResponse) get /error_message/detail returns (GetErrorLogMessageDetailResponse)
@doc "Get log message raw detail (temporary)"
@handler GetLogMessageRaw
get /message/detail (GetLogMessageRawRequest) returns (GetLogMessageRawResponse)
} }
+4
View File
@@ -163,5 +163,9 @@ service ppanel {
@doc "Query quota task list" @doc "Query quota task list"
@handler QueryQuotaTaskList @handler QueryQuotaTaskList
get /quota/list (QueryQuotaTaskListRequest) returns (QueryQuotaTaskListResponse) get /quota/list (QueryQuotaTaskListRequest) returns (QueryQuotaTaskListResponse)
@doc "Query quota task status"
@handler QueryQuotaTaskStatus
post /quota/status (QueryQuotaTaskStatusRequest) returns (QueryQuotaTaskStatusResponse)
} }
+8
View File
@@ -33,6 +33,10 @@ type (
PaymentId int64 `json:"payment_id,omitempty"` PaymentId int64 `json:"payment_id,omitempty"`
TradeNo string `json:"trade_no,omitempty"` TradeNo string `json:"trade_no,omitempty"`
} }
RefundOrderRequest {
Id int64 `json:"id" validate:"required"`
Reason string `json:"reason,omitempty" validate:"omitempty,max=500"`
}
ActivateOrderRequest { ActivateOrderRequest {
OrderNo string `json:"order_no" validate:"required"` OrderNo string `json:"order_no" validate:"required"`
} }
@@ -68,6 +72,10 @@ service ppanel {
@handler UpdateOrderStatus @handler UpdateOrderStatus
put /status (UpdateOrderStatusRequest) put /status (UpdateOrderStatusRequest)
@doc "Refund order"
@handler RefundOrder
post /refund (RefundOrderRequest)
@doc "Manually activate order" @doc "Manually activate order"
@handler ActivateOrder @handler ActivateOrder
post /activate (ActivateOrderRequest) post /activate (ActivateOrderRequest)
+121
View File
@@ -0,0 +1,121 @@
syntax = "v1"
info (
title: "promo admin API"
desc: "API for ppanel"
author: "Tension"
email: "tension@ppanel.com"
version: "0.0.1"
)
import "../types.api"
type (
CreatePromoRuleRequest {
Name string `json:"name" validate:"required,max=100"`
Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"`
Params map[string]interface{} `json:"params"`
Priority int64 `json:"priority" validate:"gte=0"`
Enabled *bool `json:"enabled"`
StartTime *int64 `json:"start_time"`
EndTime *int64 `json:"end_time"`
}
UpdatePromoRuleRequest {
Id int64 `uri:"id" validate:"required,gt=0"`
Name string `json:"name" validate:"required,max=100"`
Type string `json:"type" validate:"required,oneof=new_user inactive_user campaign"`
Params map[string]interface{} `json:"params"`
Priority int64 `json:"priority" validate:"gte=0"`
Enabled *bool `json:"enabled"`
StartTime *int64 `json:"start_time"`
EndTime *int64 `json:"end_time"`
}
GetPromoRuleDetailRequest {
Id int64 `uri:"id" validate:"required,gt=0"`
}
DeletePromoRuleRequest {
Id int64 `uri:"id" validate:"required,gt=0"`
}
GetPromoRuleListRequest {
Page int64 `form:"page" validate:"required,gt=0"`
Size int64 `form:"size" validate:"required,gt=0,lte=200"`
Type string `form:"type" validate:"omitempty,oneof=new_user inactive_user campaign"`
Enabled *bool `form:"enabled"`
Search string `form:"search,omitempty"`
}
GetPromoRuleListResponse {
Total int64 `json:"total"`
List []PromoRule `json:"list"`
}
SetPromoPriceRequest {
PromoRuleId int64 `json:"promo_rule_id" validate:"required,gt=0"`
Items []PromoPriceItem `json:"items" validate:"required,min=1,dive"`
}
GetPromoPriceListRequest {
PromoRuleId int64 `form:"promo_rule_id" validate:"required,gt=0"`
Page int64 `form:"page" validate:"required,gt=0"`
Size int64 `form:"size" validate:"required,gt=0,lte=200"`
}
GetPromoPriceListResponse {
Total int64 `json:"total"`
List []PromoPrice `json:"list"`
}
DeletePromoPriceRequest {
Id int64 `uri:"id" validate:"required,gt=0"`
}
GetPromoUsageListRequest {
Page int64 `form:"page" validate:"required,gt=0"`
Size int64 `form:"size" validate:"required,gt=0,lte=200"`
RuleId int64 `form:"rule_id,omitempty"`
UserId int64 `form:"user_id,omitempty"`
SubscribeId int64 `form:"subscribe_id,omitempty"`
OrderNo string `form:"order_no,omitempty"`
}
GetPromoUsageListResponse {
Total int64 `json:"total"`
List []PromoUsage `json:"list"`
}
)
@server (
prefix: v1/admin/promo
group: admin/promo
middleware: AuthMiddleware
)
service ppanel {
@doc "Create promo rule"
@handler CreateRule
post /rule (CreatePromoRuleRequest) returns (PromoRule)
@doc "Get promo rule list"
@handler GetRuleList
get /rule/list (GetPromoRuleListRequest) returns (GetPromoRuleListResponse)
@doc "Get promo rule detail"
@handler GetRuleDetail
get /rule/:id (GetPromoRuleDetailRequest) returns (PromoRule)
@doc "Update promo rule"
@handler UpdateRule
put /rule/:id (UpdatePromoRuleRequest) returns (PromoRule)
@doc "Delete promo rule"
@handler DeleteRule
delete /rule/:id (DeletePromoRuleRequest)
@doc "Set promo prices"
@handler SetPrice
post /price (SetPromoPriceRequest)
@doc "Get promo price list"
@handler GetPriceList
get /price/list (GetPromoPriceListRequest) returns (GetPromoPriceListResponse)
@doc "Delete promo price"
@handler DeletePrice
delete /price/:id (DeletePromoPriceRequest)
@doc "Get promo usage list"
@handler GetUsageList
get /usage/list (GetPromoUsageListRequest) returns (GetPromoUsageListResponse)
}
+34 -29
View File
@@ -80,36 +80,40 @@ type (
Protocols []Protocol `json:"protocols"` Protocols []Protocol `json:"protocols"`
} }
Node { Node {
Id int64 `json:"id"` Id int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Port uint16 `json:"port"` Port uint16 `json:"port"`
Address string `json:"address"` Address string `json:"address"`
ServerId int64 `json:"server_id"` ServerId int64 `json:"server_id"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
Sort int `json:"sort,omitempty"` Sort int `json:"sort,omitempty"`
CreatedAt int64 `json:"created_at"` NodeGroupId int64 `json:"node_group_id,omitempty"`
UpdatedAt int64 `json:"updated_at"` NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
} }
CreateNodeRequest { CreateNodeRequest {
Name string `json:"name"` Name string `json:"name"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Port uint16 `json:"port"` Port uint16 `json:"port"`
Address string `json:"address"` Address string `json:"address"`
ServerId int64 `json:"server_id"` ServerId int64 `json:"server_id"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
} }
UpdateNodeRequest { UpdateNodeRequest {
Id int64 `json:"id"` Id int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Port uint16 `json:"port"` Port uint16 `json:"port"`
Address string `json:"address"` Address string `json:"address"`
ServerId int64 `json:"server_id"` ServerId int64 `json:"server_id"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
} }
ToggleNodeStatusRequest { ToggleNodeStatusRequest {
Id int64 `json:"id"` Id int64 `json:"id"`
@@ -119,9 +123,10 @@ type (
Id int64 `json:"id"` Id int64 `json:"id"`
} }
FilterNodeListRequest { FilterNodeListRequest {
Page int `form:"page"` Page int `form:"page"`
Size int `form:"size"` Size int `form:"size"`
Search string `form:"search,omitempty"` Search string `form:"search,omitempty"`
NodeGroupId *int64 `form:"node_group_id,omitempty"`
} }
FilterNodeListResponse { FilterNodeListResponse {
Total int64 `json:"total"` Total int64 `json:"total"`
+14 -5
View File
@@ -46,8 +46,12 @@ type (
SpeedLimit int64 `json:"speed_limit"` SpeedLimit int64 `json:"speed_limit"`
DeviceLimit int64 `json:"device_limit"` DeviceLimit int64 `json:"device_limit"`
Quota int64 `json:"quota"` Quota int64 `json:"quota"`
NewUserOnly *bool `json:"new_user_only"`
Nodes []int64 `json:"nodes"` Nodes []int64 `json:"nodes"`
NodeTags []string `json:"node_tags"` NodeTags []string `json:"node_tags"`
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
NodeGroupId int64 `json:"node_group_id"`
TrafficLimit []TrafficLimit `json:"traffic_limit"`
Show *bool `json:"show"` Show *bool `json:"show"`
Sell *bool `json:"sell"` Sell *bool `json:"sell"`
DeductionRatio int64 `json:"deduction_ratio"` DeductionRatio int64 `json:"deduction_ratio"`
@@ -55,6 +59,7 @@ type (
ResetCycle int64 `json:"reset_cycle"` ResetCycle int64 `json:"reset_cycle"`
RenewalReset *bool `json:"renewal_reset"` RenewalReset *bool `json:"renewal_reset"`
ShowOriginalPrice bool `json:"show_original_price"` ShowOriginalPrice bool `json:"show_original_price"`
AutoCreateGroup bool `json:"auto_create_group"`
} }
UpdateSubscribeRequest { UpdateSubscribeRequest {
Id int64 `json:"id" validate:"required"` Id int64 `json:"id" validate:"required"`
@@ -70,8 +75,12 @@ type (
SpeedLimit int64 `json:"speed_limit"` SpeedLimit int64 `json:"speed_limit"`
DeviceLimit int64 `json:"device_limit"` DeviceLimit int64 `json:"device_limit"`
Quota int64 `json:"quota"` Quota int64 `json:"quota"`
NewUserOnly *bool `json:"new_user_only"`
Nodes []int64 `json:"nodes"` Nodes []int64 `json:"nodes"`
NodeTags []string `json:"node_tags"` NodeTags []string `json:"node_tags"`
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
NodeGroupId int64 `json:"node_group_id"`
TrafficLimit []TrafficLimit `json:"traffic_limit"`
Show *bool `json:"show"` Show *bool `json:"show"`
Sell *bool `json:"sell"` Sell *bool `json:"sell"`
Sort int64 `json:"sort"` Sort int64 `json:"sort"`
@@ -85,10 +94,11 @@ type (
Sort []SortItem `json:"sort"` Sort []SortItem `json:"sort"`
} }
GetSubscribeListRequest { GetSubscribeListRequest {
Page int64 `form:"page" validate:"required"` Page int64 `form:"page" validate:"required"`
Size int64 `form:"size" validate:"required"` Size int64 `form:"size" validate:"required"`
Language string `form:"language,omitempty"` Language string `form:"language,omitempty"`
Search string `form:"search,omitempty"` Search string `form:"search,omitempty"`
NodeGroupId int64 `form:"node_group_id,omitempty"`
} }
SubscribeItem { SubscribeItem {
Subscribe Subscribe
@@ -167,4 +177,3 @@ service ppanel {
@handler ResetAllSubscribeToken @handler ResetAllSubscribeToken
post /reset_all_token returns (ResetAllSubscribeTokenResponse) post /reset_all_token returns (ResetAllSubscribeTokenResponse)
} }
+102 -43
View File
@@ -23,10 +23,12 @@ type (
SubscribeId *int64 `form:"subscribe_id,omitempty"` SubscribeId *int64 `form:"subscribe_id,omitempty"`
UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"` UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"`
ShortCode string `form:"short_code,omitempty"` ShortCode string `form:"short_code,omitempty"`
DeviceId *int64 `form:"device_id,omitempty"`
FamilyJoined *bool `form:"family_joined,omitempty"` FamilyJoined *bool `form:"family_joined,omitempty"`
FamilyStatus string `form:"family_status,omitempty"` FamilyStatus string `form:"family_status,omitempty"`
FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"` FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"`
FamilyId *int64 `form:"family_id,omitempty"` FamilyId *int64 `form:"family_id,omitempty"`
SortOrder string `form:"sort_order,omitempty"`
} }
// GetUserListResponse // GetUserListResponse
GetUserListResponse { GetUserListResponse {
@@ -38,20 +40,20 @@ type (
Id int64 `form:"id" validate:"required"` Id int64 `form:"id" validate:"required"`
} }
UpdateUserBasiceInfoRequest { UpdateUserBasiceInfoRequest {
UserId int64 `json:"user_id" validate:"required"` UserId int64 `json:"user_id" validate:"required"`
Password string `json:"password"` Password string `json:"password"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Balance int64 `json:"balance"` Balance *int64 `json:"balance"`
Commission int64 `json:"commission"` Commission *int64 `json:"commission"`
ReferralPercentage uint8 `json:"referral_percentage"` ReferralPercentage uint8 `json:"referral_percentage"`
OnlyFirstPurchase bool `json:"only_first_purchase"` OnlyFirstPurchase *bool `json:"only_first_purchase"`
GiftAmount int64 `json:"gift_amount"` GiftAmount *int64 `json:"gift_amount"`
Telegram int64 `json:"telegram"` Telegram int64 `json:"telegram"`
ReferCode string `json:"refer_code"` ReferCode string `json:"refer_code"`
RefererId int64 `json:"referer_id"` RefererId *int64 `json:"referer_id"`
Enable bool `json:"enable"` Enable *bool `json:"enable"`
IsAdmin bool `json:"is_admin"` IsAdmin *bool `json:"is_admin"`
Remark string `json:"remark"` Remark *string `json:"remark"`
} }
UpdateUserNotifySettingRequest { UpdateUserNotifySettingRequest {
UserId int64 `json:"user_id" validate:"required"` UserId int64 `json:"user_id" validate:"required"`
@@ -76,24 +78,6 @@ type (
GiftAmount int64 `json:"gift_amount"` GiftAmount int64 `json:"gift_amount"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
} }
UserSubscribeDetail {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
User User `json:"user"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
Token string `json:"token"`
Status uint8 `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
BatchDeleteUserRequest { BatchDeleteUserRequest {
Ids []int64 `json:"ids" validate:"required"` Ids []int64 `json:"ids" validate:"required"`
} }
@@ -152,19 +136,57 @@ type (
List []UserDevice `json:"list"` List []UserDevice `json:"list"`
Total int64 `json:"total"` Total int64 `json:"total"`
} }
GetAdminUserInviteStatsRequest {
UserId int64 `form:"user_id" validate:"required"`
}
GetAdminUserInviteStatsResponse {
InviteCount int64 `json:"invite_count"`
TotalCommission int64 `json:"total_commission"`
CurrentCommission int64 `json:"current_commission"`
ReferralPercentage uint8 `json:"referral_percentage"`
OnlyFirstPurchase bool `json:"only_first_purchase"`
}
GetAdminUserInviteListRequest {
Page int `form:"page"`
Size int `form:"size"`
UserId int64 `form:"user_id" validate:"required"`
Search string `form:"search,omitempty"`
Enable *bool `form:"enable,omitempty"`
UserIdSearch int64 `form:"user_id_search,omitempty"`
}
AdminInvitedUser {
Id int64 `json:"id"`
Avatar string `json:"avatar"`
Identifier string `json:"identifier"`
Enable bool `json:"enable"`
CreatedAt int64 `json:"created_at"`
OrderCount int64 `json:"order_count"`
HasPurchased bool `json:"has_purchased"`
InviterCommission int64 `json:"inviter_commission"`
InviterGiftDays int64 `json:"inviter_gift_days"`
InviteeGiftDays int64 `json:"invitee_gift_days"`
}
GetAdminUserInviteListResponse {
Total int64 `json:"total"`
List []AdminInvitedUser `json:"list"`
}
CreateUserSubscribeRequest { CreateUserSubscribeRequest {
UserId int64 `json:"user_id"` UserId int64 `json:"user_id"`
ExpiredAt int64 `json:"expired_at"` ExpiredAt int64 `json:"expired_at"`
Traffic int64 `json:"traffic"` Traffic int64 `json:"traffic"`
SubscribeId int64 `json:"subscribe_id"` SubscribeId int64 `json:"subscribe_id"`
SpeedLimit int64 `json:"speed_limit,optional"`
TrafficLimit string `json:"traffic_limit,optional"`
} }
UpdateUserSubscribeRequest { UpdateUserSubscribeRequest {
UserSubscribeId int64 `json:"user_subscribe_id"` UserSubscribeId int64 `json:"user_subscribe_id"`
SubscribeId int64 `json:"subscribe_id"` SubscribeId int64 `json:"subscribe_id"`
Traffic int64 `json:"traffic"` Traffic int64 `json:"traffic"`
ExpiredAt int64 `json:"expired_at"` ExpiredAt int64 `json:"expired_at"`
Upload int64 `json:"upload"` Upload int64 `json:"upload"`
Download int64 `json:"download"` Download int64 `json:"download"`
SpeedLimit *int64 `json:"speed_limit,omitempty" validate:"omitempty,gte=0"`
TrafficLimit *string `json:"traffic_limit,omitempty"`
} }
GetUserLoginLogsRequest { GetUserLoginLogsRequest {
Page int `form:"page"` Page int `form:"page"`
@@ -225,6 +247,24 @@ type (
FamilyId int64 `json:"family_id" validate:"required,gt=0"` FamilyId int64 `json:"family_id" validate:"required,gt=0"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
} }
GetWithdrawalListRequest {
Page int `form:"page"`
Size int `form:"size"`
UserId *int64 `form:"user_id,omitempty"`
Status *uint8 `form:"status,omitempty"`
Method *uint8 `form:"method,omitempty"`
}
GetWithdrawalListResponse {
List []WithdrawalLog `json:"list"`
Total int64 `json:"total"`
}
ApproveWithdrawalRequest {
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
}
RejectWithdrawalRequest {
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
Reason string `json:"reason" validate:"required,max=500"`
}
) )
@server ( @server (
@@ -314,6 +354,14 @@ service ppanel {
@handler GetUserSubscribeTrafficLogs @handler GetUserSubscribeTrafficLogs
get /subscribe/traffic_logs (GetUserSubscribeTrafficLogsRequest) returns (GetUserSubscribeTrafficLogsResponse) get /subscribe/traffic_logs (GetUserSubscribeTrafficLogsRequest) returns (GetUserSubscribeTrafficLogsResponse)
@doc "Get admin user invite stats"
@handler GetAdminUserInviteStats
get /invite/stats (GetAdminUserInviteStatsRequest) returns (GetAdminUserInviteStatsResponse)
@doc "Get admin user invite list"
@handler GetAdminUserInviteList
get /invite/list (GetAdminUserInviteListRequest) returns (GetAdminUserInviteListResponse)
@doc "Get user subcribe devices" @doc "Get user subcribe devices"
@handler GetUserSubscribeDevices @handler GetUserSubscribeDevices
get /subscribe/device (GetUserSubscribeDevicesRequest) returns (GetUserSubscribeDevicesResponse) get /subscribe/device (GetUserSubscribeDevicesRequest) returns (GetUserSubscribeDevicesResponse)
@@ -365,5 +413,16 @@ service ppanel {
@doc "Dissolve family" @doc "Dissolve family"
@handler DissolveFamily @handler DissolveFamily
put /family/dissolve (DissolveFamilyRequest) put /family/dissolve (DissolveFamilyRequest)
}
@doc "Get withdrawal list"
@handler GetWithdrawalList
get /withdrawal/list (GetWithdrawalListRequest) returns (GetWithdrawalListResponse)
@doc "Approve withdrawal"
@handler ApproveWithdrawal
post /withdrawal/approve (ApproveWithdrawalRequest)
@doc "Reject withdrawal"
@handler RejectWithdrawal
post /withdrawal/reject (RejectWithdrawalRequest)
}
+102 -38
View File
@@ -11,13 +11,16 @@ info (
type ( type (
// User login request // User login request
UserLoginRequest { UserLoginRequest {
Identifier string `json:"identifier"` Identifier string `json:"identifier"`
Email string `json:"email" validate:"required"` Email string `json:"email" validate:"required"`
Password string `json:"password" validate:"required"` Password string `json:"password" validate:"required"`
IP string `header:"X-Original-Forwarded-For"` IP string `header:"X-Original-Forwarded-For"`
UserAgent string `header:"User-Agent"` UserAgent string `header:"User-Agent"`
LoginType string `header:"Login-Type"` LoginType string `header:"Login-Type"`
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
// Check user is exist request // Check user is exist request
CheckUserRequest { CheckUserRequest {
@@ -29,37 +32,43 @@ type (
} }
// User login response // User login response
UserRegisterRequest { UserRegisterRequest {
Identifier string `json:"identifier"` Identifier string `json:"identifier"`
Email string `json:"email" validate:"required"` Email string `json:"email" validate:"required"`
Password string `json:"password" validate:"required"` Password string `json:"password" validate:"required"`
Invite string `json:"invite,optional"` Invite string `json:"invite,optional"`
Code string `json:"code,optional"` Code string `json:"code,optional"`
IP string `header:"X-Original-Forwarded-For"` IP string `header:"X-Original-Forwarded-For"`
UserAgent string `header:"User-Agent"` UserAgent string `header:"User-Agent"`
LoginType string `header:"Login-Type"` LoginType string `header:"Login-Type"`
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
// User login response // User reset password request
ResetPasswordRequest { ResetPasswordRequest {
Identifier string `json:"identifier"` Identifier string `json:"identifier"`
Email string `json:"email" validate:"required"` Email string `json:"email" validate:"required"`
Password string `json:"password" validate:"required"` Password string `json:"password" validate:"required"`
Code string `json:"code,optional"` Code string `json:"code,optional"`
IP string `header:"X-Original-Forwarded-For"` IP string `header:"X-Original-Forwarded-For"`
UserAgent string `header:"User-Agent"` UserAgent string `header:"User-Agent"`
LoginType string `header:"Login-Type"` LoginType string `header:"Login-Type"`
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
// Email login request // Email login request
EmailLoginRequest { EmailLoginRequest {
Identifier string `json:"identifier"` Identifier string `json:"identifier" form:"identifier"`
Email string `json:"email" validate:"required"` Email string `json:"email" form:"email" validate:"required,email"`
Code string `json:"code" validate:"required"` Code string `json:"code" form:"code" validate:"required"`
Invite string `json:"invite,optional"` Invite string `json:"invite,optional" form:"invite"`
IP string `header:"X-Original-Forwarded-For"` IP string `header:"X-Original-Forwarded-For"`
UserAgent string `header:"User-Agent"` UserAgent string `header:"User-Agent"`
LoginType string `header:"Login-Type"` LoginType string `header:"Login-Type"`
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional" form:"cf_token"`
} }
LoginResponse { LoginResponse {
Token string `json:"token"` Token string `json:"token"`
@@ -86,6 +95,9 @@ type (
UserAgent string `header:"User-Agent"` UserAgent string `header:"User-Agent"`
LoginType string `header:"Login-Type"` LoginType string `header:"Login-Type"`
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
// Check user is exist request // Check user is exist request
TelephoneCheckUserRequest { TelephoneCheckUserRequest {
@@ -108,6 +120,9 @@ type (
UserAgent string `header:"User-Agent"` UserAgent string `header:"User-Agent"`
LoginType string `header:"Login-Type,optional"` LoginType string `header:"Login-Type,optional"`
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
// User login response // User login response
TelephoneResetPasswordRequest { TelephoneResetPasswordRequest {
@@ -120,6 +135,9 @@ type (
UserAgent string `header:"User-Agent"` UserAgent string `header:"User-Agent"`
LoginType string `header:"Login-Type,optional"` LoginType string `header:"Login-Type,optional"`
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
CaptchaId string `json:"captcha_id,optional"`
CaptchaCode string `json:"captcha_code,optional"`
SliderToken string `json:"slider_token,optional"`
} }
AppleLoginCallbackRequest { AppleLoginCallbackRequest {
Code string `form:"code"` Code string `form:"code"`
@@ -131,11 +149,27 @@ type (
State string `form:"state"` State string `form:"state"`
} }
DeviceLoginRequest { DeviceLoginRequest {
Identifier string `json:"identifier" validate:"required"` Identifier string `json:"identifier" validate:"required"`
IP string `header:"X-Original-Forwarded-For"` IP string `header:"X-Original-Forwarded-For"`
UserAgent string `json:"user_agent" validate:"required"` UserAgent string `json:"user_agent" validate:"required"`
CfToken string `json:"cf_token,optional"` CfToken string `json:"cf_token,optional"`
ShortCode string `json:"short_code,optional"` ShortCode string `json:"short_code,optional"`
BasePayload string `json:"base_payload,optional"`
}
GenerateCaptchaResponse {
Id string `json:"id"`
Image string `json:"image"`
Type string `json:"type"`
BlockImage string `json:"block_image,omitempty"`
}
SliderVerifyCaptchaRequest {
Id string `json:"id" validate:"required"`
X int `json:"x" validate:"required"`
Y int `json:"y" validate:"required"`
Trail string `json:"trail"`
}
SliderVerifyCaptchaResponse {
Token string `json:"token"`
} }
) )
@@ -174,16 +208,47 @@ service ppanel {
get /check/telephone (TelephoneCheckUserRequest) returns (TelephoneCheckUserResponse) get /check/telephone (TelephoneCheckUserRequest) returns (TelephoneCheckUserResponse)
@doc "User Telephone register" @doc "User Telephone register"
@handler TelephoneUserRegister @handler TelephoneRegister
post /register/telephone (TelephoneRegisterRequest) returns (LoginResponse) post /register/telephone (TelephoneRegisterRequest) returns (LoginResponse)
@doc "Reset password" @doc "Reset password by telephone"
@handler TelephoneResetPassword @handler TelephoneResetPassword
post /reset/telephone (TelephoneResetPasswordRequest) returns (LoginResponse) post /reset/telephone (TelephoneResetPasswordRequest) returns (LoginResponse)
@doc "Device Login" @doc "Device Login"
@handler DeviceLogin @handler DeviceLogin
post /login/device (DeviceLoginRequest) returns (LoginResponse) post /login/device (DeviceLoginRequest) returns (LoginResponse)
@doc "Generate captcha"
@handler GenerateCaptcha
post /captcha/generate returns (GenerateCaptchaResponse)
@doc "Verify slider captcha"
@handler SliderVerifyCaptcha
post /captcha/slider/verify (SliderVerifyCaptchaRequest) returns (SliderVerifyCaptchaResponse)
}
@server (
prefix: v1/auth/admin
group: auth/admin
middleware: DeviceMiddleware
)
service ppanel {
@doc "Admin login"
@handler AdminLogin
post /login (UserLoginRequest) returns (LoginResponse)
@doc "Admin reset password"
@handler AdminResetPassword
post /reset (ResetPasswordRequest) returns (LoginResponse)
@doc "Generate captcha"
@handler AdminGenerateCaptcha
post /captcha/generate returns (GenerateCaptchaResponse)
@doc "Verify slider captcha"
@handler AdminSliderVerifyCaptcha
post /captcha/slider/verify (SliderVerifyCaptchaRequest) returns (SliderVerifyCaptchaResponse)
} }
@server ( @server (
@@ -203,4 +268,3 @@ service ppanel {
@handler AppleLoginCallback @handler AppleLoginCallback
post /callback/apple (AppleLoginCallbackRequest) post /callback/apple (AppleLoginCallbackRequest)
} }
+68 -4
View File
@@ -12,10 +12,12 @@ import "./types.api"
type ( type (
VeifyConfig { VeifyConfig {
TurnstileSiteKey string `json:"turnstile_site_key"` CaptchaType string `json:"captcha_type"`
EnableLoginVerify bool `json:"enable_login_verify"` TurnstileSiteKey string `json:"turnstile_site_key"`
EnableRegisterVerify bool `json:"enable_register_verify"` EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"`
EnableResetPasswordVerify bool `json:"enable_reset_password_verify"` EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"`
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"`
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"`
} }
GetGlobalConfigResponse { GetGlobalConfigResponse {
Site SiteConfig `json:"site"` Site SiteConfig `json:"site"`
@@ -94,6 +96,48 @@ type (
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"` Timestamp int64 `json:"timestamp,omitempty"`
} }
GetDownloadLinkRequest {
InviteCode string `form:"invite_code,optional"`
Platform string `form:"platform" validate:"required,oneof=windows mac ios android"`
}
GetDownloadLinkResponse {
Url string `json:"url"`
}
ContactRequest {
Name string `json:"name" validate:"required,max=100"`
Email string `json:"email" validate:"required,email"`
OtherContact string `json:"other_contact" validate:"max=200"`
Notes string `json:"notes" validate:"max=2000"`
}
ReportLogMessageRequest {
Platform string `json:"platform" validate:"required,max=32"`
AppVersion string `json:"app_version" validate:"required,max=64"`
OsName string `json:"os_name" validate:"max=64"`
OsVersion string `json:"os_version" validate:"max=64"`
DeviceId string `json:"device_id" validate:"required,max=255"`
UserId int64 `json:"user_id"`
SessionId string `json:"session_id" validate:"max=255"`
Level uint8 `json:"level"`
ErrorCode string `json:"error_code" validate:"max=128"`
Message string `json:"message" validate:"required,max=65535"`
Stack string `json:"stack" validate:"max=1048576"`
Context interface{} `json:"context"`
OccurredAt int64 `json:"occurred_at"`
}
ReportLogMessageResponse {
Id int64 `json:"id"`
}
LegacyCheckVerificationCodeRequest {
Method string `json:"method" form:"method" validate:"omitempty,oneof=email mobile"`
Account string `json:"account" form:"account"`
Email string `json:"email" form:"email"`
Code string `json:"code" form:"code" validate:"required"`
Type uint8 `json:"type" form:"type" validate:"required"`
}
LegacyCheckVerificationCodeResponse {
Status bool `json:"status"`
Exist bool `json:"exist"`
}
) )
@server ( @server (
@@ -141,5 +185,25 @@ service ppanel {
@doc "Heartbeat" @doc "Heartbeat"
@handler Heartbeat @handler Heartbeat
get /heartbeat returns (HeartbeatResponse) get /heartbeat returns (HeartbeatResponse)
@doc "Get Download Link"
@handler GetDownloadLink
get /client/download (GetDownloadLinkRequest) returns (GetDownloadLinkResponse)
@doc "Submit Contact"
@handler SubmitContact
post /contact (ContactRequest)
@doc "Report log message"
@handler ReportLogMessage
post /log/report (ReportLogMessageRequest) returns (ReportLogMessageResponse)
@doc "Check verification code (legacy v1)"
@handler CheckCodeLegacy
post /check_code (LegacyCheckVerificationCodeRequest) returns (LegacyCheckVerificationCodeResponse)
@doc "Check verification code (legacy v2, consume code)"
@handler CheckCodeLegacyV2
post /check_code/v2 (LegacyCheckVerificationCodeRequest) returns (LegacyCheckVerificationCodeResponse)
} }
+2 -3
View File
@@ -111,7 +111,7 @@ type (
@server ( @server (
prefix: v1/server prefix: v1/server
group: server group: node/server
middleware: ServerMiddleware middleware: ServerMiddleware
) )
service ppanel { service ppanel {
@@ -138,11 +138,10 @@ service ppanel {
@server ( @server (
prefix: v2/server prefix: v2/server
group: server group: node/server
) )
service ppanel { service ppanel {
@doc "Get Server Protocol Config" @doc "Get Server Protocol Config"
@handler QueryServerProtocolConfig @handler QueryServerProtocolConfig
get /:server_id (QueryServerConfigRequest) returns (QueryServerConfigResponse) get /:server_id (QueryServerConfigRequest) returns (QueryServerConfigResponse)
} }
+48
View File
@@ -0,0 +1,48 @@
syntax = "v1"
info (
title: "ppanel API"
desc: "API for ppanel"
author: "Tension"
email: "tension@ppanel.com"
version: "0.0.1"
)
import (
"common.api"
"node/node.api"
"auth/auth.api"
"admin/system.api"
"admin/user.api"
"admin/auth.api"
"admin/server.api"
"admin/subscribe.api"
"admin/payment.api"
"admin/coupon.api"
"admin/redemption.api"
"admin/order.api"
"admin/ticket.api"
"admin/announcement.api"
"admin/document.api"
"admin/tool.api"
"admin/console.api"
"admin/log.api"
"admin/ads.api"
"admin/marketing.api"
"admin/application.api"
"admin/promo.api"
"admin/invite.api"
"admin/group.api"
"public/user.api"
"public/subscribe.api"
"public/redemption.api"
"public/recovery.api"
"public/order.api"
"public/announcement.api"
"public/ticket.api"
"public/payment.api"
"public/document.api"
"public/file.api"
"public/portal.api"
"public/iap.api"
)
+65
View File
@@ -0,0 +1,65 @@
syntax = "v1"
info (
title: "File API"
desc: "API for ppanel file upload"
author: "Codex"
email: "codex@openai.com"
version: "0.0.1"
)
import "../types.api"
type (
FileUploadRequest {
BizType string `form:"biz_type" validate:"required"`
}
FileUploadResponse {
Url string `json:"url"`
}
FileUploadInitRequest {
BizType string `json:"biz_type" validate:"required"`
FileName string `json:"file_name" validate:"required"`
ContentType string `json:"content_type" validate:"required"`
Size int64 `json:"size" validate:"required"`
Sha256 string `json:"sha256"`
}
FileUploadInitResponse {
FileId string `json:"file_id"`
ObjectKey string `json:"object_key"`
UploadURL string `json:"upload_url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
ExpiredAt int64 `json:"expired_at"`
}
FileUploadCompleteRequest {
FileId string `json:"file_id" validate:"required"`
}
FileUploadCompleteResponse {
Url string `json:"url"`
}
)
@server (
prefix: v1/public/file
group: public/file
middleware: AuthMiddleware,DeviceMiddleware
)
service ppanel {
@doc "Upload file to RustFS"
@handler FileUpload
post /upload (FileUploadRequest) returns (FileUploadResponse)
@doc "Init file upload"
@handler FileUploadInit
post /upload/init (FileUploadInitRequest) returns (FileUploadInitResponse)
@doc "Complete file upload"
@handler FileUploadComplete
post /upload/complete (FileUploadCompleteRequest) returns (FileUploadCompleteResponse)
}
+41
View File
@@ -0,0 +1,41 @@
syntax = "v1"
info (
title: "recovery API"
desc: "API for order recovery"
author: "Tension"
email: "tension@ppanel.com"
version: "0.0.1"
)
import "../types.api"
type (
RecoverySendCodeRequest {
Email string `json:"email" validate:"required,email"`
}
RecoverOrderRequest {
OrderNo string `json:"order_no" validate:"required"`
Email string `json:"email" validate:"required,email"`
Code string `json:"code" validate:"required"`
}
RecoverOrderResponse {
Success bool `json:"success"`
Message string `json:"message"`
}
)
@server (
prefix: v1/public/recovery
group: public/recovery
middleware: DeviceMiddleware
)
service ppanel {
@doc "Send recovery verification code"
@handler SendCode
post /send_code (RecoverySendCodeRequest) returns (SendCodeResponse)
@doc "Recover order subscription"
@handler RecoverOrder
post /order (RecoverOrderRequest) returns (RecoverOrderResponse)
}
+4
View File
@@ -71,5 +71,9 @@ service ppanel {
@doc "Get user subscribe node info" @doc "Get user subscribe node info"
@handler QueryUserSubscribeNodeList @handler QueryUserSubscribeNodeList
get /node/list returns (QueryUserSubscribeNodeListResponse) get /node/list returns (QueryUserSubscribeNodeListResponse)
@doc "Get subscribe group list"
@handler QuerySubscribeGroupList
get /group/list returns (QuerySubscribeGroupListResponse)
} }
+49 -16
View File
@@ -109,8 +109,11 @@ type (
Rules []string `json:"rules" validate:"required"` Rules []string `json:"rules" validate:"required"`
} }
CommissionWithdrawRequest { CommissionWithdrawRequest {
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
Content string `json:"content"` Content string `json:"content"`
Method uint8 `json:"method" validate:"oneof=0 1 2 3"`
Account string `json:"account,omitempty"`
QrCodeUrl string `json:"qr_code_url,omitempty"`
} }
WithdrawalLog { WithdrawalLog {
Id int64 `json:"id"` Id int64 `json:"id"`
@@ -119,9 +122,15 @@ type (
Content string `json:"content"` Content string `json:"content"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Method uint8 `json:"method"`
Account string `json:"account"`
QrCodeUrl string `json:"qr_code_url"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
CancelWithdrawalRequest {
WithdrawalId int64 `json:"withdrawal_id" validate:"required,gt=0"`
}
QueryWithdrawalLogListRequest { QueryWithdrawalLogListRequest {
Page int `form:"page"` Page int `form:"page"`
Size int `form:"size"` Size int `form:"size"`
@@ -192,21 +201,22 @@ type (
GrowthRate string `json:"growth_rate"` GrowthRate string `json:"growth_rate"`
PaidGrowthRate string `json:"paid_growth_rate"` PaidGrowthRate string `json:"paid_growth_rate"`
} }
GetInviteSalesRequest { GetInviteRecordsRequest {
Page int `form:"page"` Page int `form:"page"`
Size int `form:"size"` Size int `form:"size"`
StartTime int64 `form:"start_time"` StartTime int64 `form:"start_time"`
EndTime int64 `form:"end_time"` EndTime int64 `form:"end_time"`
} }
InvitedUserSale { InviteRecord {
Amount float64 `json:"amount"` Role string `json:"role"`
UpdatedAt int64 `json:"updated_at"` PeerHash string `json:"peer_hash"`
UserHash string `json:"user_hash"` GiftDays int64 `json:"gift_days"`
ProductName string `json:"product_name"` OrderNo string `json:"order_no"`
CreatedAt int64 `json:"created_at"`
} }
GetInviteSalesResponse { GetInviteRecordsResponse {
Total int64 `json:"total"` Total int64 `json:"total"`
List []InvitedUserSale `json:"list"` List []InviteRecord `json:"list"`
} }
GetSubscribeStatusRequest { GetSubscribeStatusRequest {
Email string `form:"email" json:"email" validate:"omitempty,email"` Email string `form:"email" json:"email" validate:"omitempty,email"`
@@ -220,6 +230,22 @@ type (
FriendlyCount int64 `json:"friendly_count"` FriendlyCount int64 `json:"friendly_count"`
HistoryCount int64 `json:"history_count"` HistoryCount int64 `json:"history_count"`
} }
GetUserTrafficStatsRequest {
UserSubscribeId string `form:"user_subscribe_id" validate:"required"`
Days int `form:"days" validate:"required,oneof=7 30"`
}
DailyTrafficStats {
Date string `json:"date"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
Total int64 `json:"total"`
}
GetUserTrafficStatsResponse {
List []DailyTrafficStats `json:"list"`
TotalUpload int64 `json:"total_upload"`
TotalDownload int64 `json:"total_download"`
TotalTraffic int64 `json:"total_traffic"`
}
) )
@server ( @server (
@@ -336,6 +362,10 @@ service ppanel {
@handler CommissionWithdraw @handler CommissionWithdraw
post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog) post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog)
@doc "Cancel pending withdrawal"
@handler CancelWithdrawal
post /withdrawal_cancel (CancelWithdrawalRequest) returns (WithdrawalLog)
@doc "Query Withdrawal Log" @doc "Query Withdrawal Log"
@handler QueryWithdrawalLog @handler QueryWithdrawalLog
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse) get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
@@ -368,17 +398,21 @@ service ppanel {
@handler GetAgentRealtime @handler GetAgentRealtime
get /agent_realtime (GetAgentRealtimeRequest) returns (GetAgentRealtimeResponse) get /agent_realtime (GetAgentRealtimeRequest) returns (GetAgentRealtimeResponse)
@doc "Get Invite Sales" @doc "Get Invite Records"
@handler GetInviteSales @handler GetInviteRecords
get /invite_sales (GetInviteSalesRequest) returns (GetInviteSalesResponse) get /invite_records (GetInviteRecordsRequest) returns (GetInviteRecordsResponse)
@doc "Get Subscribe Status" @doc "Get Subscribe Status"
@handler GetSubscribeStatus @handler GetSubscribeStatus
get /subscribe_status (GetSubscribeStatusRequest) returns (GetSubscribeStatusResponse) post /subscribe_status (GetSubscribeStatusRequest) returns (GetSubscribeStatusResponse)
@doc "Get User Invite Stats" @doc "Get User Invite Stats"
@handler GetUserInviteStats @handler GetUserInviteStats
get /invite_stats (GetUserInviteStatsRequest) returns (GetUserInviteStatsResponse) get /invite_stats (GetUserInviteStatsRequest) returns (GetUserInviteStatsResponse)
@doc "Get User Traffic Statistics"
@handler GetUserTrafficStats
get /traffic_stats (GetUserTrafficStatsRequest) returns (GetUserTrafficStatsResponse)
} }
@server ( @server (
@@ -391,4 +425,3 @@ service ppanel {
@handler DeviceWsConnect @handler DeviceWsConnect
get /device_ws_connect get /device_ws_connect
} }
+157 -19
View File
@@ -27,6 +27,7 @@ type (
EnableLoginNotify bool `json:"enable_login_notify"` EnableLoginNotify bool `json:"enable_login_notify"`
EnableSubscribeNotify bool `json:"enable_subscribe_notify"` EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
EnableTradeNotify bool `json:"enable_trade_notify"` EnableTradeNotify bool `json:"enable_trade_notify"`
UseStatus bool `json:"use_status"` // Whether to show the "bind email to get free trial" prompt
AuthMethods []UserAuthMethod `json:"auth_methods"` AuthMethods []UserAuthMethod `json:"auth_methods"`
UserDevices []UserDevice `json:"user_devices"` UserDevices []UserDevice `json:"user_devices"`
Rules []string `json:"rules"` Rules []string `json:"rules"`
@@ -159,22 +160,26 @@ type (
OnlyRealDevice bool `json:"only_real_device"` OnlyRealDevice bool `json:"only_real_device"`
} }
RegisterConfig { RegisterConfig {
StopRegister bool `json:"stop_register"` StopRegister bool `json:"stop_register"`
EnableTrial bool `json:"enable_trial"` EnableTrial bool `json:"enable_trial"`
TrialSubscribe int64 `json:"trial_subscribe"` EnableTrialEmailWhitelist bool `json:"enable_trial_email_whitelist"`
TrialTime int64 `json:"trial_time"` TrialSubscribe int64 `json:"trial_subscribe"`
TrialTimeUnit string `json:"trial_time_unit"` TrialTime int64 `json:"trial_time"`
EnableIpRegisterLimit bool `json:"enable_ip_register_limit"` TrialTimeUnit string `json:"trial_time_unit"`
IpRegisterLimit int64 `json:"ip_register_limit"` TrialEmailDomainWhitelist string `json:"trial_email_domain_whitelist"`
IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"` EnableIpRegisterLimit bool `json:"enable_ip_register_limit"`
DeviceLimit int64 `json:"device_limit"` IpRegisterLimit int64 `json:"ip_register_limit"`
IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"`
DeviceLimit int64 `json:"device_limit"`
} }
VerifyConfig { VerifyConfig {
TurnstileSiteKey string `json:"turnstile_site_key"` CaptchaType string `json:"captcha_type"` // local or turnstile
TurnstileSecret string `json:"turnstile_secret"` TurnstileSiteKey string `json:"turnstile_site_key"`
EnableLoginVerify bool `json:"enable_login_verify"` TurnstileSecret string `json:"turnstile_secret"`
EnableRegisterVerify bool `json:"enable_register_verify"` EnableUserLoginCaptcha bool `json:"enable_user_login_captcha"` // User login captcha
EnableResetPasswordVerify bool `json:"enable_reset_password_verify"` EnableUserRegisterCaptcha bool `json:"enable_user_register_captcha"` // User register captcha
EnableAdminLoginCaptcha bool `json:"enable_admin_login_captcha"` // Admin login captcha
EnableUserResetPasswordCaptcha bool `json:"enable_user_reset_password_captcha"` // User reset password captcha
} }
NodeConfig { NodeConfig {
NodeSecret string `json:"node_secret"` NodeSecret string `json:"node_secret"`
@@ -223,8 +228,58 @@ type (
CurrencySymbol string `json:"currency_symbol"` CurrencySymbol string `json:"currency_symbol"`
} }
SubscribeDiscount { SubscribeDiscount {
Quantity int64 `json:"quantity"` Quantity int64 `json:"quantity"`
Discount float64 `json:"discount"` Discount float64 `json:"discount"`
NewUserOnly bool `json:"new_user_only"`
MapApple string `json:"map_apple"`
Promo *SubscribePromo `json:"promo"`
}
PromoPrice {
Id int64 `json:"id"`
SubscribeId int64 `json:"subscribe_id"`
PromoRuleId int64 `json:"promo_rule_id"`
Quantity int64 `json:"quantity"`
PromoPrice int64 `json:"promo_price"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
PromoPriceItem {
SubscribeId int64 `json:"subscribe_id" validate:"required,gt=0"`
Quantity int64 `json:"quantity" validate:"required,gt=0,lte=1000"`
PromoPrice int64 `json:"promo_price" validate:"required,gt=0"`
}
PromoRule {
Id int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Params map[string]interface{} `json:"params"`
Priority int64 `json:"priority"`
Enabled bool `json:"enabled"`
StartTime *int64 `json:"start_time"`
EndTime *int64 `json:"end_time"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
PromoUsage {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
PromoRuleId int64 `json:"promo_rule_id"`
SubscribeId int64 `json:"subscribe_id"`
OrderNo string `json:"order_no"`
PromoPrice int64 `json:"promo_price"`
CreatedAt int64 `json:"created_at"`
}
SubscribePromo {
RuleName string `json:"rule_name"`
RuleType string `json:"rule_type"`
PromoPrice int64 `json:"promo_price"`
ExpiresAt int64 `json:"expires_at"`
}
TrafficLimit {
StatType string `json:"stat_type"`
StatValue int64 `json:"stat_value"`
TrafficUsage int64 `json:"traffic_usage"`
SpeedLimit int64 `json:"speed_limit"`
} }
Subscribe { Subscribe {
Id int64 `json:"id"` Id int64 `json:"id"`
@@ -241,8 +296,12 @@ type (
SpeedLimit int64 `json:"speed_limit"` SpeedLimit int64 `json:"speed_limit"`
DeviceLimit int64 `json:"device_limit"` DeviceLimit int64 `json:"device_limit"`
Quota int64 `json:"quota"` Quota int64 `json:"quota"`
NewUserOnly bool `json:"new_user_only"`
Nodes []int64 `json:"nodes"` Nodes []int64 `json:"nodes"`
NodeTags []string `json:"node_tags"` NodeTags []string `json:"node_tags"`
NodeGroupIds []int64 `json:"node_group_ids,omitempty"`
NodeGroupId int64 `json:"node_group_id"`
TrafficLimit []TrafficLimit `json:"traffic_limit"`
Show bool `json:"show"` Show bool `json:"show"`
Sell bool `json:"sell"` Sell bool `json:"sell"`
Sort int64 `json:"sort"` Sort int64 `json:"sort"`
@@ -407,6 +466,8 @@ type (
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
GiftAmount int64 `json:"gift_amount"` GiftAmount int64 `json:"gift_amount"`
Discount int64 `json:"discount"` Discount int64 `json:"discount"`
PromoRuleId int64 `json:"promo_rule_id"`
PromoDiscount int64 `json:"promo_discount"`
Coupon string `json:"coupon"` Coupon string `json:"coupon"`
CouponDiscount int64 `json:"coupon_discount"` CouponDiscount int64 `json:"coupon_discount"`
Commission int64 `json:"commission,omitempty"` Commission int64 `json:"commission,omitempty"`
@@ -414,6 +475,7 @@ type (
FeeAmount int64 `json:"fee_amount"` FeeAmount int64 `json:"fee_amount"`
TradeNo string `json:"trade_no"` TradeNo string `json:"trade_no"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
StatusName string `json:"status_name,omitempty"`
SubscribeId int64 `json:"subscribe_id"` SubscribeId int64 `json:"subscribe_id"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
@@ -428,6 +490,8 @@ type (
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
GiftAmount int64 `json:"gift_amount"` GiftAmount int64 `json:"gift_amount"`
Discount int64 `json:"discount"` Discount int64 `json:"discount"`
PromoRuleId int64 `json:"promo_rule_id"`
PromoDiscount int64 `json:"promo_discount"`
Coupon string `json:"coupon"` Coupon string `json:"coupon"`
CouponDiscount int64 `json:"coupon_discount"` CouponDiscount int64 `json:"coupon_discount"`
Commission int64 `json:"commission,omitempty"` Commission int64 `json:"commission,omitempty"`
@@ -436,6 +500,7 @@ type (
FeeAmount int64 `json:"fee_amount"` FeeAmount int64 `json:"fee_amount"`
TradeNo string `json:"trade_no"` TradeNo string `json:"trade_no"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
StatusName string `json:"status_name,omitempty"`
SubscribeId int64 `json:"subscribe_id"` SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"` Subscribe Subscribe `json:"subscribe"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
@@ -500,10 +565,13 @@ type (
} }
UserSubscribe { UserSubscribe {
Id int64 `json:"id"` Id int64 `json:"id"`
IdStr string `json:"id_str"`
UserId int64 `json:"user_id"` UserId int64 `json:"user_id"`
OrderId int64 `json:"order_id"` OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"` SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"` Subscribe Subscribe `json:"subscribe"`
NodeGroupId int64 `json:"node_group_id"`
NodeGroupName string `json:"node_group_name"`
StartTime int64 `json:"start_time"` StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"` ExpireTime int64 `json:"expire_time"`
FinishedAt int64 `json:"finished_at"` FinishedAt int64 `json:"finished_at"`
@@ -521,6 +589,35 @@ type (
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
UserSubscribeDetail {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
User User `json:"user"`
OrderId int64 `json:"order_id"`
SubscribeId int64 `json:"subscribe_id"`
Subscribe Subscribe `json:"subscribe"`
NodeGroupId int64 `json:"node_group_id"`
NodeGroupName string `json:"node_group_name"`
GroupLocked bool `json:"group_locked"`
StartTime int64 `json:"start_time"`
ExpireTime int64 `json:"expire_time"`
ResetTime int64 `json:"reset_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
SpeedLimit int64 `json:"speed_limit"`
TrafficLimit []TrafficLimit `json:"user_traffic_limit"`
PlanSpeedLimit int64 `json:"plan_speed_limit"`
Token string `json:"token"`
Status uint8 `json:"status"`
EffectiveSpeed int64 `json:"effective_speed"`
IsThrottled bool `json:"is_throttled"`
ThrottleRule string `json:"throttle_rule,omitempty"`
ThrottleStart int64 `json:"throttle_start,omitempty"`
ThrottleEnd int64 `json:"throttle_end,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
UserAffiliate { UserAffiliate {
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Identifier string `json:"identifier"` Identifier string `json:"identifier"`
@@ -556,6 +653,7 @@ type (
FamilyId int64 `json:"family_id"` FamilyId int64 `json:"family_id"`
OwnerUserId int64 `json:"owner_user_id"` OwnerUserId int64 `json:"owner_user_id"`
OwnerIdentifier string `json:"owner_identifier"` OwnerIdentifier string `json:"owner_identifier"`
OwnerAuthType string `json:"owner_auth_type"`
Status string `json:"status"` Status string `json:"status"`
ActiveMemberCount int64 `json:"active_member_count"` ActiveMemberCount int64 `json:"active_member_count"`
MaxMembers int64 `json:"max_members"` MaxMembers int64 `json:"max_members"`
@@ -565,7 +663,9 @@ type (
FamilyMemberItem { FamilyMemberItem {
UserId int64 `json:"user_id"` UserId int64 `json:"user_id"`
Identifier string `json:"identifier"` Identifier string `json:"identifier"`
AuthType string `json:"auth_type"`
DeviceNo string `json:"device_no"` DeviceNo string `json:"device_no"`
DeviceType string `json:"device_type"`
Role uint8 `json:"role"` Role uint8 `json:"role"`
RoleName string `json:"role_name"` RoleName string `json:"role_name"`
Status uint8 `json:"status"` Status uint8 `json:"status"`
@@ -661,6 +761,7 @@ type (
Price int64 `json:"price"` Price int64 `json:"price"`
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
Discount int64 `json:"discount"` Discount int64 `json:"discount"`
PromoDiscount int64 `json:"promo_discount"`
GiftAmount int64 `json:"gift_amount"` GiftAmount int64 `json:"gift_amount"`
Coupon string `json:"coupon"` Coupon string `json:"coupon"`
CouponDiscount int64 `json:"coupon_discount"` CouponDiscount int64 `json:"coupon_discount"`
@@ -816,8 +917,9 @@ type (
Sandbox *bool `json:"sandbox,omitempty"` Sandbox *bool `json:"sandbox,omitempty"`
} }
AttachAppleTransactionResponse { AttachAppleTransactionResponse {
ExpiresAt int64 `json:"expires_at"` ExpiresAt int64 `json:"expires_at"`
Tier string `json:"tier"` Tier string `json:"tier"`
ExistingOrderNo string `json:"existing_order_no,omitempty"`
} }
RestoreAppleTransactionsRequest { RestoreAppleTransactionsRequest {
Transactions []string `json:"transactions" validate:"required"` Transactions []string `json:"transactions" validate:"required"`
@@ -951,5 +1053,41 @@ type (
ResetUserSubscribeTokenRequest { ResetUserSubscribeTokenRequest {
UserSubscribeId int64 `json:"user_subscribe_id"` UserSubscribeId int64 `json:"user_subscribe_id"`
} }
// ===== 分组功能类型定义 =====
// NodeGroup 节点组
NodeGroup {
Id int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Sort int `json:"sort"`
ForCalculation bool `json:"for_calculation"`
IsExpiredGroup bool `json:"is_expired_group"`
ExpiredDaysLimit int `json:"expired_days_limit"`
MaxTrafficGBExpired int64 `json:"max_traffic_gb_expired,omitempty"`
SpeedLimit int `json:"speed_limit"`
MinTrafficGB int64 `json:"min_traffic_gb,omitempty"`
MaxTrafficGB int64 `json:"max_traffic_gb,omitempty"`
NodeCount int64 `json:"node_count,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// GroupHistory 分组历史记录
GroupHistory {
Id int64 `json:"id"`
GroupMode string `json:"group_mode"`
TriggerType string `json:"trigger_type"`
TotalUsers int `json:"total_users"`
SuccessCount int `json:"success_count"`
FailedCount int `json:"failed_count"`
StartTime *int64 `json:"start_time,omitempty"`
EndTime *int64 `json:"end_time,omitempty"`
Operator string `json:"operator,omitempty"`
ErrorLog string `json:"error_log,omitempty"`
CreatedAt int64 `json:"created_at"`
}
// GroupHistoryDetail 分组历史详情
GroupHistoryDetail {
GroupHistory
ConfigSnapshot map[string]interface{} `json:"config_snapshot,omitempty"`
}
) )
+796
View File
@@ -0,0 +1,796 @@
package cmd
import (
"bufio"
"context"
"fmt"
"io"
"math/rand"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/perfect-panel/server/internal/config"
logmodel "github.com/perfect-panel/server/internal/model/log"
ordermodel "github.com/perfect-panel/server/internal/model/order"
usermodel "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/conf"
"github.com/perfect-panel/server/pkg/orm"
"github.com/redis/go-redis/v9"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
func init() {
retroactiveReferralCmd.Flags().StringVar(&retroAgentIdStr, "agent-id", "", "目标代理用户 ID,或 * 表示所有 referral_percentage>0 的代理(必填)")
retroactiveReferralCmd.Flags().StringVar(&retroPoolStart, "pool-start", "2025-05-04", "自然流量订单起始时间,格式 YYYY-MM-DD 或 'YYYY-MM-DD HH:MM:SS'(默认 2025-05-04")
retroactiveReferralCmd.Flags().StringVar(&retroPoolEnd, "pool-end", "", "自然流量订单截止时间,格式 YYYY-MM-DD 或 'YYYY-MM-DD HH:MM:SS'(默认今天)")
retroactiveReferralCmd.Flags().IntVar(&retroPercentage, "percentage", 120, "补偿百分比,例如 120 表示 120%(默认 120")
retroactiveReferralCmd.Flags().IntVar(&retroForceCommissionPct, "force-commission-pct", 50, "强制指定发佣比例(0=使用数据库/配置,非0时覆盖代理设置,默认 50%)")
retroactiveReferralCmd.Flags().StringVar(&retroOutput, "output", "retro_result.txt", "结果输出到指定 txt 文件(默认 retro_result.txt")
retroactiveReferralCmd.Flags().StringVar(&retroConfigPath, "config", "etc/ppanel.yaml", "配置文件路径")
retroactiveReferralCmd.Flags().StringVar(&retroAgentCreatedAfter, "agent-created-after", "", "仅处理在此日期之后注册的代理,格式 YYYY-MM-DD(留空=不限)")
retroactiveReferralCmd.Flags().StringVar(&retroLossStart, "loss-start", "2026-05-06", "数据丢失起始时间,丢失时长=现在-此时间(默认 2026-05-06")
retroactiveReferralCmd.Flags().StringVar(&retroOrderStart, "order-start", "2026-05-01", "池内用户至少有一笔 updated_at >= 此时间的订单才入池(默认 2026-05-01")
retroactiveReferralCmd.Flags().BoolVar(&retroDryRun, "dry-run", false, "仅预览,不执行写入")
rootCmd.AddCommand(retroactiveReferralCmd)
}
var (
retroAgentIdStr string
retroAgentCreatedAfter string
retroLossStart string
retroOrderStart string
retroPoolStart string
retroPoolEnd string
retroPercentage int
retroForceCommissionPct int
retroConfigPath string
retroDryRun bool
retroOutput string
)
var retroactiveReferralCmd = &cobra.Command{
Use: "retro-referral",
Short: "补单:按代理历史日均佣金补偿指定比例的用户",
Long: `统计代理从首次邀请到 pool-end 的日均佣金,
按指定百分比计算目标补偿金额,
从 pool-start 到 pool-end 的自然流量用户中随机抽取匹配的用户数量挂载到该代理。
--agent-id 支持单个 ID 或 *(处理所有 referral_percentage>0 的代理)。`,
RunE: func(cmd *cobra.Command, args []string) error {
if retroAgentIdStr == "" {
return fmt.Errorf("--agent-id 必填(单个 ID 或 *")
}
return runRetroactiveReferral()
},
}
// commissionRule holds resolved commission settings for an agent.
type commissionRule struct {
Percentage uint8
OnlyFirstPurchase bool
}
// candidateUser holds a pool user plus their pre-calculated qualifying orders.
type candidateUser struct {
Id int64
CreatedAt time.Time
Identifier string
Orders []ordermodel.Order
CommissionTotal int64
}
// agentPlan holds one agent's computed allocation plan (preview phase output).
type agentPlan struct {
Agent *usermodel.User
Rule commissionRule
Selected []candidateUser
TargetAmt float64 // in cents
PreviewCommission int64
}
func runRetroactiveReferral() error {
// ── 0. 初始化输出(终端 + 可选文件)─────────────────────────
var w io.Writer = os.Stdout
if retroOutput != "" {
f, err := os.Create(retroOutput)
if err != nil {
return fmt.Errorf("创建输出文件失败: %w", err)
}
defer f.Close()
w = io.MultiWriter(os.Stdout, f)
fmt.Printf("结果将同步写入: %s\n\n", retroOutput)
}
// ── 1. 加载配置 ──────────────────────────────────────────────
var c config.Config
conf.MustLoad(retroConfigPath, &c)
// ── 2. 初始化 DB + Redis ──────────────────────────────────────
db, err := orm.ConnectMysql(orm.Mysql{Config: c.MySQL})
if err != nil {
return fmt.Errorf("连接数据库失败: %w", err)
}
rds := redis.NewClient(&redis.Options{
Addr: c.Redis.Host,
Password: c.Redis.Pass,
DB: c.Redis.DB,
})
ctx := context.Background()
if err = rds.Ping(ctx).Err(); err != nil {
return fmt.Errorf("连接 Redis 失败: %w", err)
}
um := usermodel.NewModel(db, rds)
// ── 3. 解析时间参数 ───────────────────────────────────────────
poolStart, err := parseFlexibleTime(retroPoolStart)
if err != nil {
return fmt.Errorf("--pool-start 格式错误: %w", err)
}
poolEnd := time.Now()
if retroPoolEnd != "" {
poolEnd, err = parseFlexibleTime(retroPoolEnd)
if err != nil {
return fmt.Errorf("--pool-end 格式错误: %w", err)
}
}
if !poolEnd.After(poolStart) {
return fmt.Errorf("--pool-end 必须晚于 --pool-start")
}
// ── 4. 确定代理列表 ───────────────────────────────────────────
var agents []*usermodel.User
if retroAgentIdStr == "*" {
agents, err = queryAllActiveAgents(ctx, db, retroAgentCreatedAfter)
if err != nil {
return fmt.Errorf("查询代理列表失败: %w", err)
}
if len(agents) == 0 {
return fmt.Errorf("没有找到任何 referral_percentage>0 的代理用户")
}
fmt.Fprintf(w, "模式:全量代理,共找到 %d 个代理(referral_percentage>0\n\n", len(agents))
} else {
agentID, parseErr := strconv.ParseInt(retroAgentIdStr, 10, 64)
if parseErr != nil || agentID <= 0 {
return fmt.Errorf("--agent-id 必须是正整数或 *")
}
agent, findErr := um.FindOne(ctx, agentID)
if findErr != nil {
return fmt.Errorf("查询代理用户失败: %w", findErr)
}
if agent.DeletedAt.Valid {
return fmt.Errorf("代理用户 %d 已被删除", agentID)
}
agents = []*usermodel.User{agent}
}
if retroForceCommissionPct > 0 {
fmt.Fprintf(w, "⚠️ 强制覆盖所有代理佣金比例为 %d%%\n\n", retroForceCommissionPct)
}
// ── 5. 查询自然流量用户池(所有代理共用同一个池)────────────
// 先用 50% 规则(或强制值)预加载池,以便预览;执行时每个代理用自身规则
var orderStart time.Time
if retroOrderStart != "" {
orderStart, err = parseFlexibleTime(retroOrderStart)
if err != nil {
return fmt.Errorf("--order-start 格式错误: %w", err)
}
}
previewRule := commissionRule{Percentage: uint8(retroForceCommissionPct), OnlyFirstPurchase: false}
pool, err := queryNaturalTrafficPool(ctx, db, poolStart, poolEnd, orderStart, previewRule)
if err != nil {
return fmt.Errorf("查询自然流量用户池失败: %w", err)
}
orderStartDesc := ""
if !orderStart.IsZero() {
orderStartDesc = fmt.Sprintf(",订单 updated_at >= %s", orderStart.Format("2006-01-02"))
}
fmt.Fprintf(w, "自然流量用户池(%s ~ %sreferer_id=0,有已支付订单%s):共 %d 人\n\n",
poolStart.Format("2006-01-02 15:04"), poolEnd.Format("2006-01-02 15:04"), orderStartDesc, len(pool))
if len(pool) == 0 {
return fmt.Errorf("自然流量用户池为空,无法补充")
}
// ── 6. 逐代理生成分配计划(预览阶段)────────────────────────
// 池按顺序分配:每个代理从剩余池中取用户,避免重复分配
remainingPool := make([]candidateUser, len(pool))
copy(remainingPool, pool)
plans := make([]agentPlan, 0, len(agents))
for _, agent := range agents {
plan, planErr := buildAgentPlan(w, ctx, db, c, agent, remainingPool, poolEnd)
if planErr != nil {
fmt.Fprintf(w, "⚠️ 代理 %d 跳过: %v\n\n", agent.Id, planErr)
continue
}
// 从剩余池中移除已分配给该代理的用户
assignedSet := make(map[int64]struct{}, len(plan.Selected))
for _, u := range plan.Selected {
assignedSet[u.Id] = struct{}{}
}
newRemaining := remainingPool[:0]
for _, u := range remainingPool {
if _, used := assignedSet[u.Id]; !used {
newRemaining = append(newRemaining, u)
}
}
remainingPool = newRemaining
plans = append(plans, plan)
}
if len(plans) == 0 {
return fmt.Errorf("所有代理均无法生成分配计划")
}
// ── 7. 汇总预览 ───────────────────────────────────────────────
var grandTotalUsers int
var grandTotalCommission int64
var grandTargetAmt float64
for _, p := range plans {
grandTotalUsers += len(p.Selected)
grandTotalCommission += p.PreviewCommission
grandTargetAmt += p.TargetAmt
}
fmt.Fprintln(w, strings.Repeat("═", 75))
fmt.Fprintf(w, "汇总:共 %d 个代理,补充 %d 个用户\n", len(plans), grandTotalUsers)
fmt.Fprintf(w, " 预计追溯佣金总额: $%.2f(目标补偿金额: $%.2f\n",
float64(grandTotalCommission)/100, grandTargetAmt/100)
fmt.Fprintln(w, strings.Repeat("═", 75))
fmt.Fprintln(w)
if retroDryRun {
fmt.Fprintln(w, "[dry-run] 预览完成,未执行任何写入。")
return nil
}
// ── 8. 执行前汇总打印 ─────────────────────────────────────────
fmt.Fprintf(w, "\n┌─────────────────────────────────────────────┐\n")
fmt.Fprintf(w, "│ 即将写入数据库 │\n")
fmt.Fprintf(w, "│ 代理数量 : %-4d 个 │\n", len(plans))
fmt.Fprintf(w, "│ 补充用户 : %-4d 人 │\n", grandTotalUsers)
fmt.Fprintf(w, "│ 赠送金额 : $%-10.2f │\n", float64(grandTotalCommission)/100)
fmt.Fprintf(w, "└─────────────────────────────────────────────┘\n\n")
fmt.Printf("确认执行?(yes/no): ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "yes" && answer != "y" {
fmt.Fprintln(w, "已取消。")
return nil
}
// ── 9. 逐代理执行 ─────────────────────────────────────────────
var totalSuccess, totalFailed int
var totalCreditedOrder, totalCreditedAmt int64
for _, plan := range plans {
fmt.Fprintf(w, "\n── 执行代理 %d ──────────────────────────────────────────────\n", plan.Agent.Id)
var sc, fc int
var co, ca int64
for _, eu := range plan.Selected {
if eu.Id == plan.Agent.Id {
fmt.Fprintf(w, "[SKIP] 用户 %d 与代理相同,跳过\n", eu.Id)
fc++
continue
}
credited, amount, execErr := processOneUser(ctx, db, um, plan.Agent.Id, eu.Id, plan.Rule, orderStart)
if execErr != nil {
fmt.Fprintf(w, "[FAIL] 用户 %d: %v\n", eu.Id, execErr)
fc++
continue
}
fmt.Fprintf(w, "[OK] 用户 %d → 代理 %d,发佣 %d 单,金额 $%.2f\n",
eu.Id, plan.Agent.Id, credited, float64(amount)/100)
sc++
co += credited
ca += amount
}
// 直接删除代理的缓存 key,下次请求时从 DB 重新加载(避免 FindOne 读到旧缓存再写回)
if sc > 0 {
cacheKey := fmt.Sprintf("cache:user:id:%d", plan.Agent.Id)
_ = rds.Del(ctx, cacheKey).Err()
}
fmt.Fprintf(w, " 代理 %d 小计:成功 %d 人,失败 %d 人,佣金 $%.2f\n", plan.Agent.Id, sc, fc, float64(ca)/100)
totalSuccess += sc
totalFailed += fc
totalCreditedOrder += co
totalCreditedAmt += ca
}
// ── 10. 全局汇总 ──────────────────────────────────────────────
fmt.Fprintf(w, "\n══════════════════════════════════════════════\n")
fmt.Fprintf(w, " 成功挂载 : %d 人\n", totalSuccess)
fmt.Fprintf(w, " 失败/跳过 : %d 人\n", totalFailed)
fmt.Fprintf(w, " 追溯佣金 : %d 单,总额 $%.2f\n", totalCreditedOrder, float64(totalCreditedAmt)/100)
fmt.Fprintf(w, "══════════════════════════════════════════════\n")
return nil
}
// buildAgentPlan 计算一个代理的补单预览,同时打印预览内容,返回分配计划。
func buildAgentPlan(w io.Writer, ctx context.Context, db *gorm.DB, c config.Config,
agent *usermodel.User, pool []candidateUser, poolEnd time.Time) (agentPlan, error) {
rule := resolveCommissionRule(agent, c)
if retroForceCommissionPct > 0 {
rule.Percentage = uint8(retroForceCommissionPct)
}
// 补单场景:不区分新购/续费,所有已支付订单均参与佣金计算
rule.OnlyFirstPurchase = false
firstReferralTime, lastReferralTime, agentCreatedAt, totalReferred, totalOrders, totalCommission, err :=
queryAgentStats(ctx, db, agent.Id, poolEnd)
if err != nil {
return agentPlan{}, fmt.Errorf("查询历史数据失败: %w", err)
}
if totalReferred == 0 {
return agentPlan{}, fmt.Errorf("在 %s 之前没有任何邀请记录", poolEnd.Format("2006-01-02"))
}
lossStartTime, err := parseFlexibleTime(retroLossStart)
if err != nil {
return agentPlan{}, fmt.Errorf("--loss-start 格式错误: %w", err)
}
lossHours := time.Now().Sub(lossStartTime).Hours()
statsDays := lastReferralTime.Sub(firstReferralTime).Hours() / 24
if statsDays < 1 {
statsDays = 1
}
dailyAvgOrders := float64(totalOrders) / statsDays
avgOrdersPerUser := float64(totalOrders) / float64(totalReferred)
if avgOrdersPerUser < 1 {
avgOrdersPerUser = 1
}
dailyAvgCommission := float64(totalCommission) / statsDays
// 用用户池自身的平均佣金估算人数(避免历史费率与当前50%费率不匹配导致超发)
var poolAvgCommissionPerUser float64
if len(pool) > 0 {
var poolCommTotal int64
for _, u := range pool {
poolCommTotal += u.CommissionTotal
}
poolAvgCommissionPerUser = float64(poolCommTotal) / float64(len(pool))
}
if poolAvgCommissionPerUser < 1 {
poolAvgCommissionPerUser = 1
}
estimatedLostCommission := dailyAvgCommission * (lossHours / 24)
targetCommission := estimatedLostCommission * (float64(retroPercentage) / 100)
extraCount := int(targetCommission/poolAvgCommissionPerUser + 0.5)
if extraCount < 1 {
extraCount = 1
}
fmt.Fprintf(w, "\n═══════════════════════════════════════════════════════════\n")
fmt.Fprintf(w, " 代理 ID : %d(注册于 %s\n", agent.Id, agentCreatedAt.Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, " 统计起点 : %s(首次邀请时间)\n", firstReferralTime.Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, " 统计截止 : %s(最后邀请时间)\n", lastReferralTime.Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, " 统计天数 : %.2f 天\n", statsDays)
fmt.Fprintf(w, " 历史邀请总人数 : %d 人\n", totalReferred)
fmt.Fprintf(w, " 下线总订单数 : %d 单(所有下线,不限时间)\n", totalOrders)
fmt.Fprintf(w, " 日均订单 : %.4f 单/天\n", dailyAvgOrders)
fmt.Fprintf(w, " 每用户平均订单 : %.4f 单\n", avgOrdersPerUser)
fmt.Fprintf(w, " 历史佣金总额 : $%.2f\n", float64(totalCommission)/100)
fmt.Fprintf(w, " 日均佣金 : $%.4f\n", dailyAvgCommission/100)
fmt.Fprintf(w, " 池内用户均佣金 : $%.4f\n", poolAvgCommissionPerUser/100)
fmt.Fprintf(w, " 佣金规则 : %d%% 仅首单=%v\n", rule.Percentage, rule.OnlyFirstPurchase)
fmt.Fprintf(w, "───────────────────────────────────────────────────────────\n")
fmt.Fprintf(w, " 丢失时长 : %.2f 小时(%s → 现在)\n",
lossHours, lossStartTime.Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, " 预估丢失佣金 : $%.4f$%.4f × %.2f/24\n",
estimatedLostCommission/100, dailyAvgCommission/100, lossHours)
fmt.Fprintf(w, " 目标补偿金额 : $%.4f(× %d%%\n",
targetCommission/100, retroPercentage)
fmt.Fprintf(w, " 需补充人数 : %d 人($%.4f ÷ $%.4f\n",
extraCount, targetCommission/100, poolAvgCommissionPerUser/100)
fmt.Fprintf(w, "═══════════════════════════════════════════════════════════\n\n")
// 重新按当前代理规则计算池内用户佣金(pool 由调用方传入,已是当前规则计算好的)
if len(pool) == 0 {
return agentPlan{}, fmt.Errorf("剩余用户池为空")
}
if len(pool) < extraCount {
fmt.Fprintf(w, "⚠️ 剩余用户池只有 %d 人,少于需要的 %d 人,将全部分配\n\n", len(pool), extraCount)
extraCount = len(pool)
}
selected := randomSampleCandidates(pool, extraCount)
// 兜底追加:确保佣金合计 >= 目标
{
selectedSet := make(map[int64]struct{}, len(selected))
var selectedCommTotal int64
for _, u := range selected {
selectedSet[u.Id] = struct{}{}
selectedCommTotal += u.CommissionTotal
}
if selectedCommTotal < int64(targetCommission) {
remaining := make([]candidateUser, 0, len(pool)-len(selected))
for _, u := range pool {
if _, used := selectedSet[u.Id]; !used {
remaining = append(remaining, u)
}
}
// 按佣金从小到大排序,追加时精准补足,减少超发
sort.Slice(remaining, func(i, j int) bool {
return remaining[i].CommissionTotal < remaining[j].CommissionTotal
})
for _, u := range remaining {
if selectedCommTotal >= int64(targetCommission) {
break
}
selected = append(selected, u)
selectedCommTotal += u.CommissionTotal
}
if selectedCommTotal < int64(targetCommission) {
fmt.Fprintf(w, "⚠️ 用户池佣金不足,已抽取全部可用用户(实际 $%.2f < 目标 $%.2f\n\n",
float64(selectedCommTotal)/100, targetCommission/100)
}
}
}
// 打印选中用户明细
var previewTotalCommission int64
fmt.Fprintf(w, "随机抽取 %d 个用户(含待追溯佣金订单):\n", len(selected))
fmt.Fprintln(w, strings.Repeat("═", 75))
for i, u := range selected {
fmt.Fprintf(w, "[%d] 用户 %-10d 注册: %s %s\n",
i+1, u.Id, u.CreatedAt.Format("2006-01-02 15:04:05"), u.Identifier)
if len(u.Orders) == 0 {
fmt.Fprintln(w, " (无符合条件的订单)")
} else {
fmt.Fprintf(w, " %-38s %10s %8s %10s %s\n", "订单号", "金额", "手续费", "佣金", "类型")
fmt.Fprintf(w, " %s\n", strings.Repeat("-", 72))
for _, od := range u.Orders {
commAmt := calcCommissionAmount(od.Amount, od.FeeAmount, rule.Percentage)
orderType := "首购"
if od.Type == 2 {
orderType = "续费"
}
fmt.Fprintf(w, " %-38s $%8.2f $%6.2f $%8.2f %s\n",
od.OrderNo,
float64(od.Amount)/100,
float64(od.FeeAmount)/100,
float64(commAmt)/100,
orderType)
}
fmt.Fprintf(w, " 本用户追溯佣金合计: $%.2f\n", float64(u.CommissionTotal)/100)
}
previewTotalCommission += u.CommissionTotal
fmt.Fprintln(w)
}
fmt.Fprintln(w, strings.Repeat("═", 75))
fmt.Fprintf(w, "预计追溯佣金总额: $%.2f(目标补偿金额: $%.2f\n\n",
float64(previewTotalCommission)/100, targetCommission/100)
return agentPlan{
Agent: agent,
Rule: rule,
Selected: selected,
TargetAmt: targetCommission,
PreviewCommission: previewTotalCommission,
}, nil
}
// queryAllActiveAgents returns all agents with referral_percentage > 0, optionally filtered by created_after.
func queryAllActiveAgents(ctx context.Context, db *gorm.DB, createdAfter string) ([]*usermodel.User, error) {
q := db.WithContext(ctx).Model(&usermodel.User{}).
Where("referral_percentage > 0 AND deleted_at IS NULL")
if createdAfter != "" {
t, err := parseFlexibleTime(createdAfter)
if err != nil {
return nil, fmt.Errorf("--agent-created-after 格式错误: %w", err)
}
q = q.Where("created_at >= ?", t)
}
var agents []*usermodel.User
if err := q.Order("id ASC").Find(&agents).Error; err != nil {
return nil, err
}
return agents, nil
}
// parseFlexibleTime parses "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD".
func parseFlexibleTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil {
return t, nil
}
return time.ParseInLocation("2006-01-02", s, time.Local)
}
// queryAgentStats returns (firstReferralTime, lastReferralTime, agentCreatedAt, totalReferred, totalOrders, totalCommission, error).
func queryAgentStats(ctx context.Context, db *gorm.DB, agentID int64, endTime time.Time) (time.Time, time.Time, time.Time, int64, int64, int64, error) {
var agent usermodel.User
if err := db.WithContext(ctx).Model(&usermodel.User{}).
Where("id = ?", agentID).
First(&agent).Error; err != nil {
return time.Time{}, time.Time{}, time.Time{}, 0, 0, 0, err
}
agentCreatedAt := agent.CreatedAt
var firstUser usermodel.User
if err := db.WithContext(ctx).Model(&usermodel.User{}).
Where("referer_id = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL",
agentID, agentCreatedAt, endTime).
Order("created_at ASC").
First(&firstUser).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, nil
}
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err
}
var lastUser usermodel.User
if err := db.WithContext(ctx).Model(&usermodel.User{}).
Where("referer_id = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL",
agentID, agentCreatedAt, endTime).
Order("created_at DESC").
First(&lastUser).Error; err != nil {
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err
}
var totalReferred int64
if err := db.WithContext(ctx).Model(&usermodel.User{}).
Where("referer_id = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL",
agentID, agentCreatedAt, endTime).
Count(&totalReferred).Error; err != nil {
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err
}
var totalOrders int64
if err := db.WithContext(ctx).Model(&ordermodel.Order{}).
Joins("JOIN user u ON u.id = `order`.user_id").
Where("u.referer_id = ?", agentID).
Where("`order`.status IN (2, 5)").
Count(&totalOrders).Error; err != nil {
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err
}
type commResult struct{ Total int64 }
var result commResult
err := db.WithContext(ctx).Raw(`
SELECT COALESCE(SUM(
CAST(JSON_UNQUOTE(JSON_EXTRACT(content, '$.amount')) AS SIGNED)
), 0) AS total
FROM system_logs
WHERE type = 33
AND object_id = ?
AND created_at <= ?
AND JSON_UNQUOTE(JSON_EXTRACT(content, '$.type')) IN ('331', '332')
`, agentID, endTime).Scan(&result).Error
if err != nil {
return time.Time{}, time.Time{}, agentCreatedAt, 0, 0, 0, err
}
return firstUser.CreatedAt, lastUser.CreatedAt, agentCreatedAt, totalReferred, totalOrders, result.Total, nil
}
// queryNaturalTrafficPool returns pool candidates with pre-loaded qualifying orders.
// orderStart (可为零值):若非零,则只收录至少有一笔 updated_at >= orderStart 订单的用户,
// 且只加载/统计 updated_at >= orderStart 的订单(确保分配后代理能在对应月份的销售报表中看到记录)。
func queryNaturalTrafficPool(ctx context.Context, db *gorm.DB, start, end time.Time, orderStart time.Time, rule commissionRule) ([]candidateUser, error) {
type userRow struct {
Id int64
CreatedAt time.Time
AuthIdentifier string
}
var userRows []userRow
q := db.WithContext(ctx).
Table("user u").
Select("u.id, u.created_at, COALESCE(am.auth_identifier, '') AS auth_identifier").
Joins("JOIN `order` o ON o.user_id = u.id AND o.status IN (2, 5)").
Joins("LEFT JOIN user_auth_methods am ON am.user_id = u.id AND am.auth_type = 'email'").
Where("u.created_at >= ? AND u.created_at <= ?", start, end).
Where("u.referer_id = 0").
Where("u.deleted_at IS NULL")
if !orderStart.IsZero() {
// 只入池那些在 orderStart 之后有过订单的用户(保证代理销售报表里能看到)
q = q.Where("o.updated_at >= ?", orderStart)
}
if err := q.Group("u.id, u.created_at, am.auth_identifier").
Order("u.id ASC").
Scan(&userRows).Error; err != nil {
return nil, err
}
if len(userRows) == 0 {
return nil, nil
}
userIDs := make([]int64, len(userRows))
for i, r := range userRows {
userIDs[i] = r.Id
}
orderQuery := db.WithContext(ctx).Model(&ordermodel.Order{}).
Where("user_id IN ? AND status IN (2, 5)", userIDs)
if !orderStart.IsZero() {
// 只加载 orderStart 之后的订单:保证佣金统计和销售记录对齐
orderQuery = orderQuery.Where("updated_at >= ?", orderStart)
}
var allOrders []ordermodel.Order
if err := orderQuery.Order("user_id ASC, created_at ASC").Find(&allOrders).Error; err != nil {
return nil, err
}
ordersByUser := make(map[int64][]ordermodel.Order, len(userRows))
for _, od := range allOrders {
ordersByUser[od.UserId] = append(ordersByUser[od.UserId], od)
}
candidates := make([]candidateUser, 0, len(userRows))
for _, r := range userRows {
orders := ordersByUser[r.Id]
var commTotal int64
for i := range orders {
if canCreditOrder(rule, &orders[i]) {
commTotal += calcCommissionAmount(orders[i].Amount, orders[i].FeeAmount, rule.Percentage)
}
}
candidates = append(candidates, candidateUser{
Id: r.Id,
CreatedAt: r.CreatedAt,
Identifier: r.AuthIdentifier,
Orders: orders,
CommissionTotal: commTotal,
})
}
return candidates, nil
}
// randomSampleCandidates picks n random elements from pool without replacement.
func randomSampleCandidates(pool []candidateUser, n int) []candidateUser {
if n >= len(pool) {
return append([]candidateUser{}, pool...)
}
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
indices := rng.Perm(len(pool))[:n]
result := make([]candidateUser, n)
for i, idx := range indices {
result[i] = pool[idx]
}
return result
}
// processOneUser assigns agentId as referer and retroactively credits commission for qualifying orders.
func processOneUser(
ctx context.Context,
db *gorm.DB,
um usermodel.Model,
agentID, userID int64,
rule commissionRule,
orderStart time.Time,
) (int64, int64, error) {
var creditedOrders, creditedAmount int64
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var target usermodel.User
if e := tx.Model(&usermodel.User{}).
Where("id = ? AND referer_id = 0 AND deleted_at IS NULL", userID).
First(&target).Error; e != nil {
if e == gorm.ErrRecordNotFound {
return fmt.Errorf("用户不存在或已有代理或已删除")
}
return e
}
var orders []ordermodel.Order
oq := tx.Model(&ordermodel.Order{}).
Where("user_id = ? AND status IN (2, 5)", userID)
if !orderStart.IsZero() {
oq = oq.Where("updated_at >= ?", orderStart)
}
if e := oq.Order("created_at ASC, id ASC").Find(&orders).Error; e != nil {
return e
}
if len(orders) == 0 {
return fmt.Errorf("无合格订单")
}
if e := tx.Model(&usermodel.User{}).
Where("id = ? AND referer_id = 0 AND deleted_at IS NULL", userID).
Updates(map[string]interface{}{
"referer_id": agentID,
"updated_at": time.Now(),
}).Error; e != nil {
return e
}
for i := range orders {
od := &orders[i]
if !canCreditOrder(rule, od) {
continue
}
amount := calcCommissionAmount(od.Amount, od.FeeAmount, rule.Percentage)
if amount <= 0 {
continue
}
var existCount int64
if e := tx.Model(&logmodel.SystemLog{}).
Where("type = ? AND object_id = ? AND content LIKE ?",
logmodel.TypeCommission.Uint8(), agentID,
fmt.Sprintf("%%\"%s\"%%", od.OrderNo),
).Count(&existCount).Error; e != nil {
return e
}
if existCount > 0 {
continue
}
if e := tx.Model(&usermodel.User{}).
Where("id = ? AND deleted_at IS NULL", agentID).
UpdateColumn("commission", gorm.Expr("commission + ?", amount)).Error; e != nil {
return e
}
commType := logmodel.CommissionTypePurchase
if od.Type == 2 {
commType = logmodel.CommissionTypeRenewal
}
payload := &logmodel.Commission{
Type: commType,
Amount: amount,
OrderNo: od.OrderNo,
Timestamp: od.CreatedAt.UnixMilli(),
}
content, _ := payload.Marshal()
if e := tx.Create(&logmodel.SystemLog{
Type: logmodel.TypeCommission.Uint8(),
Date: od.CreatedAt.Format("2006-01-02"),
ObjectID: agentID,
Content: string(content),
CreatedAt: od.CreatedAt,
}).Error; e != nil {
return e
}
creditedOrders++
creditedAmount += amount
}
return nil
})
if err != nil {
return 0, 0, err
}
if updated, e := um.FindOne(ctx, userID); e == nil {
_ = um.UpdateUserCache(ctx, updated)
}
return creditedOrders, creditedAmount, nil
}
func resolveCommissionRule(agent *usermodel.User, c config.Config) commissionRule {
if agent.ReferralPercentage > 0 {
onlyFirst := true
if agent.OnlyFirstPurchase != nil {
onlyFirst = *agent.OnlyFirstPurchase
}
return commissionRule{Percentage: agent.ReferralPercentage, OnlyFirstPurchase: onlyFirst}
}
return commissionRule{
Percentage: uint8(c.Invite.ReferralPercentage),
OnlyFirstPurchase: c.Invite.OnlyFirstPurchase,
}
}
func canCreditOrder(rule commissionRule, od *ordermodel.Order) bool {
if rule.Percentage == 0 {
return false
}
if rule.OnlyFirstPurchase && !od.IsNew {
return false
}
return od.Status == 2 || od.Status == 5
}
func calcCommissionAmount(amount, feeAmount int64, percentage uint8) int64 {
base := amount - feeAmount
if base <= 0 || percentage == 0 {
return 0
}
return int64(float64(base) * float64(percentage) / 100)
}
+3
View File
@@ -4316,6 +4316,9 @@
"discount": { "discount": {
"type": "number", "type": "number",
"format": "double" "format": "double"
},
"promo": {
"$ref": "#/definitions/SubscribePromo"
} }
}, },
"title": "SubscribeDiscount", "title": "SubscribeDiscount",
+152
View File
@@ -0,0 +1,152 @@
# MySQL 8.0 master/replica compose for two separate servers.
#
# Master server:
# COMPOSE_PROFILES=master docker compose -f config/docker-compose.mysql-replication.yml up -d
#
# Replica server:
# MASTER_HOST=<master_public_or_private_ip> COMPOSE_PROFILES=replica docker compose -f config/docker-compose.mysql-replication.yml up -d
#
# Required env on both servers:
# MYSQL_ROOT_PASSWORD=<strong-root-password>
# MYSQL_REPLICATION_PASSWORD=<strong-replication-password>
#
# Optional env:
# MYSQL_DATABASE=ppanel
# MYSQL_REPLICATION_USER=repl
# MYSQL_MASTER_PORT=3306
# MYSQL_REPLICA_PORT=3306
# MYSQL_SERVER_ID=1 # master default
# MYSQL_REPLICA_ID=2 # replica default
#
# If the master already has data, import a GTID-aware dump into the replica
# before starting replication. Fresh empty deployments can start master first,
# then replica, then point the application at the master.
services:
mysql-master:
image: mysql:8.0
container_name: ppanel-mysql-master
profiles:
- master
restart: always
ports:
- "${MYSQL_MASTER_PORT:-3306}:3306"
environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?please set MYSQL_ROOT_PASSWORD}"
MYSQL_DATABASE: "${MYSQL_DATABASE:-ppanel}"
MYSQL_REPLICATION_USER: "${MYSQL_REPLICATION_USER:-repl}"
MYSQL_REPLICATION_PASSWORD: "${MYSQL_REPLICATION_PASSWORD:?please set MYSQL_REPLICATION_PASSWORD}"
TZ: Asia/Shanghai
command:
- --default-authentication-plugin=mysql_native_password
- --server-id=${MYSQL_SERVER_ID:-1}
- --log-bin=mysql-bin
- --binlog-format=ROW
- --gtid-mode=ON
- --enforce-gtid-consistency=ON
- --log-replica-updates=ON
- --binlog-expire-logs-seconds=604800
- --max_connections=1000
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- mysql_master_data:/var/lib/mysql
configs:
- source: mysql_master_init
target: /docker-entrypoint-initdb.d/01-create-replication-user.sh
mode: 0755
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
mysql-replica:
image: mysql:8.0
container_name: ppanel-mysql-replica
profiles:
- replica
restart: always
ports:
- "${MYSQL_REPLICA_PORT:-3306}:3306"
environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?please set MYSQL_ROOT_PASSWORD}"
MYSQL_DATABASE: "${MYSQL_DATABASE:-ppanel}"
TZ: Asia/Shanghai
command:
- --default-authentication-plugin=mysql_native_password
- --server-id=${MYSQL_REPLICA_ID:-2}
- --relay-log=mysql-relay-bin
- --read-only=ON
- --super-read-only=ON
- --gtid-mode=ON
- --enforce-gtid-consistency=ON
- --log-replica-updates=ON
- --binlog-format=ROW
- --max_connections=1000
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- mysql_replica_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
mysql-replica-init:
image: mysql:8.0
container_name: ppanel-mysql-replica-init
profiles:
- replica
restart: "no"
depends_on:
mysql-replica:
condition: service_healthy
environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?please set MYSQL_ROOT_PASSWORD}"
MYSQL_REPLICATION_USER: "${MYSQL_REPLICATION_USER:-repl}"
MYSQL_REPLICATION_PASSWORD: "${MYSQL_REPLICATION_PASSWORD:?please set MYSQL_REPLICATION_PASSWORD}"
MASTER_HOST: "${MASTER_HOST:?please set MASTER_HOST to the master server ip or hostname}"
MASTER_PORT: "${MASTER_PORT:-3306}"
entrypoint:
- /bin/sh
- -ec
- |
mysql -hmysql-replica -uroot -p"$${MYSQL_ROOT_PASSWORD}" <<SQL
STOP REPLICA;
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='$${MASTER_HOST}',
SOURCE_PORT=$${MASTER_PORT},
SOURCE_USER='$${MYSQL_REPLICATION_USER}',
SOURCE_PASSWORD='$${MYSQL_REPLICATION_PASSWORD}',
SOURCE_AUTO_POSITION=1,
GET_SOURCE_PUBLIC_KEY=1;
START REPLICA;
SQL
configs:
mysql_master_init:
content: |
#!/bin/sh
set -eu
mysql -uroot -p"$${MYSQL_ROOT_PASSWORD}" <<SQL
CREATE USER IF NOT EXISTS '$${MYSQL_REPLICATION_USER}'@'%' IDENTIFIED WITH mysql_native_password BY '$${MYSQL_REPLICATION_PASSWORD}';
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO '$${MYSQL_REPLICATION_USER}'@'%';
FLUSH PRIVILEGES;
SQL
volumes:
mysql_master_data:
mysql_replica_data:
+20
View File
@@ -0,0 +1,20 @@
EC2 SSH 连接资料
服务器名称: hifast-hk-app-01
公网 IP: 43.198.248.161
登录用户: ubuntu
私钥文件:
- hifast-hk-app-01-reset
公钥文件:
- hifast-hk-app-01-reset.pub
连接命令:
ssh -i hifast-hk-app-01-reset ubuntu@43.198.248.161
如果在 Mac / Linux 上使用,先执行:
chmod 600 hifast-hk-app-01-reset
如果要给别人使用,只需要把私钥文件 hifast-hk-app-01-reset 发给对方即可。
出于安全考虑,建议通过安全渠道传输,并在后续需要时重新轮换密钥。
@@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCgAAAKjy5KDa8uSg
2gAAAAtzc2gtZWQyNTUxOQAAACDb6msDqmSHLv0mWgCP4vfjQ3A552qv95uQdsH94RnoCg
AAAEDwSb0b/0S6Tw8Od5hAtIKqt1JvomqQQS44Ty3xL+FjPtvqawOqZIcu/SZaAI/i9+ND
cDnnaq/3m5B2wf3hGegKAAAAIWhpZmFzdC1oay1hcHAtMDEtcmVzZXQtMjAyNi0wNS0xMA
ECAwQ=
-----END OPENSSH PRIVATE KEY-----
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINvqawOqZIcu/SZaAI/i9+NDcDnnaq/3m5B2wf3hGegK hifast-hk-app-01-reset-2026-05-10
+363
View File
@@ -0,0 +1,363 @@
# PPanel 香港区新 AWS 账号部署说明
本目录用于在 **新 AWS 账号** 中按 **香港区 `ap-east-1`** 重建一套全新空环境。
目标架构:
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL`
## 1. 资源清单
按下面顺序创建资源:
1. VPC
2. 2 个公有子网 + 2 个私有子网
3. Internet Gateway
4. 公有 / 私有路由表
5. 安全组
6. RDS MySQL
7. EC2 本机 Redis Docker
8. EC2
9. ACM 证书
10. ALB + Target Group
11. WAF Web ACL
12. 平行环境域名
建议命名:
- VPC: `ppanel-hk-prod`
- EC2: `ppanel-app-hk-01`
- RDS: `ppanel-mysql-hk`
- Redis container: `hifast-redis`
- ALB: `ppanel-alb-hk`
- WAF: `ppanel-waf-hk`
## 2. 默认规格
### EC2
- Region: `ap-east-1`
- OS: Ubuntu 24.04 LTS
- Instance type: `t4g.large` 起步
- Disk: `gp3 80GB`
- Public subnet: 是
- IAM Role: 允许读取 CloudWatch / SSM(如使用)
- 如果要在 AWS EC2 本机执行 S3 备份:额外允许写入专用备份桶
### RDS MySQL
- Engine: MySQL 8.0
- Class: `db.r7g.xlarge`
- Storage: `gp3 100GB`
- DB name: `hifast`
- Username: `admin`
- Public access: `No`
- Charset: `utf8mb4`
- Backup: `7-14 days`
### Redis
- 部署位置:业务 EC2 本机
- 部署方式:Docker
- 版本:`redis:8.2.1`
- 监听:`0.0.0.0:6379`
- 应用连接:`127.0.0.1:6379`
- 安全组:仅对白名单备用节点或同机应用开放
## 3. 网络与安全组
### 子网布局
- `public-a`, `public-b`: ALB / EC2
- `private-a`, `private-b`: RDS
### 安全组建议
#### `sg-alb`
- Inbound
- `80/tcp` from `0.0.0.0/0`
- `443/tcp` from `0.0.0.0/0`
- Outbound
- `80/tcp` to `sg-ec2`
#### `sg-ec2`
- Inbound
- `80/tcp` from `sg-alb`
- `22/tcp` from `你的固定运维 IP`
- Outbound
- all
说明:
- 应用容器监听 `127.0.0.1:8080`
- EC2 对外只让 Nginx 监听 `80`
- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1`
#### `sg-rds`
- Inbound
- `3306/tcp` from `sg-ec2`
#### `sg-ec2` 额外说明
- 如果需要外部备用节点复制 Redis,再额外放行:
- `6379/tcp` from `104.238.220.230/32`
## 4. ALB / Target Group / 健康检查
### Target Group
- Type: `Instance`
- Protocol: `HTTP`
- Port: `80`
- Health check path: `/v1/common/heartbeat`
- Success code: `200`
这个路径已由项目现有接口提供,无需额外改代码。
### ALB 监听器
- `80` -> redirect to `443`
- `443` -> forward 到 target group
### ACM
-`ap-east-1` 申请证书
- 先给平行环境域名,例如:
- `api-new.hifast.biz`
- `logs-new.hifast.biz`
## 5. WAF 规则
首版至少启用:
1. `AWSManagedRulesCommonRuleSet`
2. `AWSManagedRulesKnownBadInputsRuleSet`
3. `AWSManagedRulesAmazonIpReputationList`
4. 全站 rate-based rule
5. 针对高风险路径的 rate-based rule
建议的第一版限流:
- 全站:每 IP `2000 / 5 分钟`
- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟`
- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟`
节点上报接口建议后续补:
- `/v1/server/status`
- `/v1/server/online`
- `/v1/server/traffic`
优先用节点出口 IP 白名单;没有固定出口 IP 的节点暂时保留 `secret_key`,但不要把它当成唯一防线。
## 6. EC2 文件落地
在 EC2 上建议使用:
- 应用目录:`/opt/ppanel`
- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf`
需要上传这些文件 / 目录:
- `docker-compose.cloud.yml`
- `deploy/aws/ap-east-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml`
- `deploy/aws/ap-east-1/nginx/ppanel-api.conf`
- `grafana/`
- `loki/`
- `prometheus/`
- `tempo/`
- `.env.example` -> 重命名为 `.env`
目标目录示例:
```text
/opt/ppanel/
docker-compose.cloud.yml
.env
configs/ppanel.yaml
grafana/
loki/
prometheus/
tempo/
logs/
cache/
tempo_data/
```
## 7. 应用配置
基线模板见:
- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example)
- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf)
关键值必须替换:
- `MySQL.Addr`
- `MySQL.Password`
- `Redis.Host`
- `Redis.Pass`
- `JwtAuth.AccessSecret`
- `Administrator.Email`
- `Administrator.Password`
- `AppSignature.AppSecrets.*`
- `device.security_secret`
- `Site.Host`
- `Site.SiteName`
Redis 约定保持不变:
- 业务缓存:DB `0`
- AsynqDB `5`(代码内部已固定使用)
## 8. 部署步骤
### 8.1 初始化 EC2
把脚本上传到 EC2 后执行:
## 9. 104 灾备节点常用运维脚本
如果你要在 `104.238.220.230` 上执行数据迁移、主从重拉、主库提升,可以直接复用仓库里的这几份脚本:
- 数据导出 / 导入交互工具:
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
- MySQL 主从运维工具:
- [`deploy/scripts/mysql_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/mysql_replica_ops.sh)
- Redis 主从运维工具:
- [`deploy/scripts/redis_replica_ops.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/redis_replica_ops.sh)
- 统一总入口:
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
- 主从运维环境模板:
- [`deploy/aws/ap-east-1/configs/replica-ops.env.example`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/aws/ap-east-1/configs/replica-ops.env.example)
### 9.1 数据迁移工具
支持:
- 备份 MySQL 到 S3
- 备份 Redis 到 S3
- 从正式库导出 MySQL `sql.gz`
-`sql.gz` 导入 AWS RDS
- 从正式 Redis 导出 `RDB`
-`RDB` 导入 Docker Redis 或宿主机 Redis
- 查看 MySQL / Redis 当前主从状态
- 强制重拉 MySQL / Redis 主从
- 把 MySQL / Redis 从库提升为可写主库
示例:
```bash
bash deploy/scripts/hifast_data_sync_tool.sh /root/replica-ops.env
```
```bash
chmod +x deploy/scripts/bootstrap_aws_ec2.sh
sudo APP_DIR=/opt/ppanel deploy/scripts/bootstrap_aws_ec2.sh
```
### 8.2 安装 Nginx 配置
```bash
sudo cp deploy/aws/ap-east-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf
sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf
sudo nginx -t
sudo systemctl reload nginx
```
### 8.3 启动容器
```bash
cd /opt/ppanel
docker compose -f docker-compose.cloud.yml up -d
```
### 8.4 预检
```bash
chmod +x deploy/scripts/preflight_aws_hk.sh
APP_DIR=/opt/ppanel \
RDS_HOST=<new-rds-endpoint> \
REDIS_HOST=127.0.0.1 \
deploy/scripts/preflight_aws_hk.sh
```
## 9. 平行环境验证
先验证 `api-new.hifast.biz`,不要直接切正式域名。
必测项:
1. `ALB target` 为 healthy
2. `GET /v1/common/heartbeat` 返回 200
3. 管理员登录
4. 用户注册 / 登录
5. 订阅查询
6. 节点上报 `/v1/server/status`
7. 本机 Redis 可写缓存
8. Asynq 可入队并消费
## 10. 正式切换
切换前检查:
1. ALB 5xx 为 0
2. EC2 CPU / Memory 正常
3. RDS CPU / Connections 正常
4. 本机 Redis CPU / Connections / Memory 正常
5. WAF 已挂到 ALB
6. EC2 安全组没有对公网放 `8080/3333/9090/4317`
切换方式:
1. 保持新环境先跑平行域名
2. 正式域名切到新 ALB
3. 观察至少 1 小时
4. 确认无误后再处理旧环境
## 11. 监控建议
至少建这些 CloudWatch / Grafana 观测项:
- ALB `RequestCount`, `HTTPCode_ELB_5XX_Count`, `TargetResponseTime`
- EC2 `CPUUtilization`, `NetworkIn`, `NetworkOut`, `StatusCheckFailed`
- RDS `CPUUtilization`, `DatabaseConnections`, `ReadLatency`, `WriteLatency`
- Redis 容器 CPU / Memory / restart count
## 12. 这次方案的边界
本目录交付的是:
- 香港区新账号的部署模板
- 新空环境启动与验证流程
- ALB / WAF / EC2 / RDS / 本机 Redis 的落地约定
不包含:
- 旧数据迁移
- Terraform / CloudFormation 自动建资源
- Redis 托管版改造
- 多活 / 自动扩缩容
## 13. S3 备份补强
当前已落地的 S3 备份桶:
- `hifast-prod-backups-200810848252-ap-east-1`
建议与现网结合方式:
1. `RDS automated backup` 继续保留,作为第一层恢复能力
2. `104` 外部 MySQL 从库执行逻辑备份并上传到 S3,作为第二层可下载备份
3. `104` 外部 Redis 从库按需导出 `RDB` 到 S3,补齐缓存类灾备材料
仓库中已补充:
- 环境变量模板:[`configs/backup-to-s3.env.example`](./configs/backup-to-s3.env.example)
- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh)
- Redis 备份脚本:[`../../scripts/redis_rdb_backup_to_s3.sh`](../../scripts/redis_rdb_backup_to_s3.sh)
建议把 MySQL 备份脚本优先部署到 `104`,因为它直接连接本地只读从库,对 AWS 主库扰动最小。
@@ -0,0 +1,18 @@
AWS_REGION=ap-east-1
S3_BUCKET=hifast-prod-backups-200810848252-ap-east-1
S3_PREFIX=mysql
BACKUP_DIR=/var/backups/hifast
HOST_TAG=104-standby
KEEP_LOCAL_DAYS=3
CHECK_REPLICA=1
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=backup_reader
MYSQL_PASSWORD=CHANGE_ME
MYSQL_SOCKET=
MYSQL_DATABASE=hifast
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
@@ -0,0 +1,20 @@
PRIMARY_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
PRIMARY_PORT=3306
PRIMARY_USER=admin
PRIMARY_PASSWORD=CHANGE_ME
PRIMARY_DB=hifast
PRIMARY_REPL_USER=repl
PRIMARY_REPL_PASSWORD=CHANGE_ME
PRIMARY_REPL_HOST=104.238.220.230
PRIMARY_BINLOG_RETENTION_HOURS=24
REPLICA_HOST=127.0.0.1
REPLICA_PORT=3306
REPLICA_USER=root
REPLICA_PASSWORD=
REPLICA_SOCKET=/var/run/mysqld/mysqld.sock
REPLICA_DB=hifast
REPLICA_SOURCE_SSL=1
DUMP_FILE=
@@ -0,0 +1,111 @@
Host: 0.0.0.0
Port: 8080
Debug: false
JwtAuth:
AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET
AccessExpire: 604800
Logger:
ServiceName: PPanel
Mode: console
Encoding: plain
TimeFormat: "2006-01-02 15:04:05.000"
Path: logs
Level: info
MaxContentLength: 0
Compress: false
Stat: true
KeepDays: 7
StackCooldownMillis: 100
MaxBackups: 7
MaxSize: 100
Rotation: daily
FileTimeFormat: "2006-01-02T15:04:05.000Z07:00"
MySQL:
Addr: YOUR_RDS_ENDPOINT:3306
Dbname: hifast
Username: admin
Password: CHANGE_ME_TO_RDS_PASSWORD
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
MaxIdleConns: 10
MaxOpenConns: 100
SlowThreshold: 1000
Redis:
Host: 127.0.0.1:6379
Pass: CHANGE_ME_TO_REDIS_PASSWORD
DB: 0
PoolSize: 100
MinIdleConns: 10
MaxRetries: 3
PoolTimeout: 4
IdleTimeout: 300
MaxConnAge: 0
DialTimeout: 5
ReadTimeout: 3
WriteTimeout: 3
Trace:
Name: ppanel-server
Endpoint: 127.0.0.1:4317
Sampler: 0.1
Batcher: otlpgrpc
Site:
Host: api-new.hifast.biz
SiteName: HiFastVPN
Administrator:
Email: admin@example.com
Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD
Telegram:
Enable: false
BotID: 0
BotName: ""
BotToken: ""
GroupChatID: ""
EnableNotify: false
WebHookDomain: ""
Kutt:
Enable: false
ApiURL: ""
ApiKey: ""
TargetURL: ""
Domain: ""
OpenInstall:
Enable: false
AppKey: ""
ApiKey: ""
Loki:
Enable: true
URL: "http://localhost:3100"
AppSignature:
AppSecrets:
android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET
ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET
web-client: CHANGE_ME_WEB_SIGNATURE_SECRET
ValidWindowSeconds: 300
SkipPrefixes:
- /v1/notify/
- /v1/iap/notifications
- /v1/telegram/webhook
- /v1/subscribe/config
Signature:
EnableSignature: false
device:
enable: true
security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET
Register:
EnableTrial: true
EnableTrialEmailWhitelist: true
TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com"
@@ -0,0 +1,23 @@
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=CHANGE_ME
MYSQL_SOCKET=
REPL_SOURCE_HOST=hifast-mysql-prod-v2.cd6aey40m6ag.ap-east-1.rds.amazonaws.com
REPL_SOURCE_PORT=3306
REPL_SOURCE_USER=repl
REPL_SOURCE_PASSWORD=CHANGE_ME
REPL_SOURCE_SSL=1
REPL_SOURCE_LOG_FILE=
REPL_SOURCE_LOG_POS=
REPL_SOURCE_AUTO_POSITION=1
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
REDIS_SOURCE_HOST=18.163.33.75
REDIS_SOURCE_PORT=6379
REDIS_SOURCE_USER=
REDIS_SOURCE_PASSWORD=CHANGE_ME
@@ -0,0 +1,33 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
client_max_body_size 20m;
access_log /var/log/nginx/ppanel-access.log;
error_log /var/log/nginx/ppanel-error.log warn;
location / {
proxy_http_version 1.1;
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_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location = /nginx_status {
stub_status;
access_log off;
allow 127.0.0.1;
deny all;
}
}
+358
View File
@@ -0,0 +1,358 @@
# PPanel 日本东京区 AWS 部署说明
本目录用于在 **AWS 日本东京区 `ap-northeast-1`** 重建一套全新生产环境,并承接当前香港区 `ap-east-1` 的正式迁移。
如果你要看“当前已经真实跑起来的东京架构”,优先看:
- [`ops/hifast-current-architecture-zh.md`](/Users/Apple/code_vpn/vpn/ppanel-server/ops/hifast-current-architecture-zh.md)
- [`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md)
这份 README 更偏向:
- 目标架构
- 资源规划
- 部署方法
- 后续待完成项
目标架构:
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + Redis + observability) -> RDS MySQL`
灾备链路:
`RDS MySQL / EC2 Redis -> 104.238.220.230 外部灾备`
当前仓库内已补充:
- 东京基础设施参数模板:[`configs/aws-jp-infra.env.example`](./configs/aws-jp-infra.env.example)
- 东京真实实施状态登记:[`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md)
- 东京底座资源创建脚本:[`../../scripts/aws_jp_create_base_infra.sh`](../../scripts/aws_jp_create_base_infra.sh)
- 东京资源状态检查脚本:[`../../scripts/aws_jp_describe_state.sh`](../../scripts/aws_jp_describe_state.sh)
- MySQL 备份脚本:[`../../scripts/mysql_backup_to_s3.sh`](../../scripts/mysql_backup_to_s3.sh)
- 10 分钟 MySQL 备份定时器安装脚本:[`../../scripts/install_mysql_backup_timer.sh`](../../scripts/install_mysql_backup_timer.sh)
## 1. 资源清单
按下面顺序创建资源:
1. VPC
2. 2 个公有子网 + 2 个私有子网
3. Internet Gateway
4. 公有 / 私有路由表
5. 安全组
6. RDS MySQL
7. EC2 本机 Redis Docker
8. EC2
9. ACM 证书
10. ALB + Target Group
11. WAF Web ACL
12. 东京平行环境域名
13. 东京 S3 备份桶
建议命名:
- VPC: `ppanel-jp-prod`
- EC2: `ppanel-app-jp-01`
- RDS: `ppanel-mysql-jp`
- Redis container: `hifast-redis`
- ALB: `ppanel-alb-jp`
- WAF: `ppanel-waf-jp`
- S3: `hifast-prod-backups-200810848252-ap-northeast-1`
## 2. 默认规格
### EC2
- Region: `ap-northeast-1`
- OS: Ubuntu 24.04 LTS
- Instance type: `t4g.large`
- Disk: `gp3 80GB`
- Public subnet: 是
- IAM Role:
- 允许读取 CloudWatch / SSM(如使用)
- 如果要在东京 EC2 上执行 S3 备份:额外允许写入东京备份桶
### RDS MySQL
- Engine: `MySQL 8.4`
- Class: `db.r7g.xlarge`
- Storage: `gp3 100GB`
- DB name: `hifast`
- Username: `admin`
- Public access: `Yes`
- Charset: `utf8mb4`
- Backup retention: `7-14 days`
- Deletion protection: `On`
- Multi-AZ: `Yes`(当前按 2 实例 Multi-AZ 创建)
说明:
- 当前东京 RDS 需要允许 `104.238.220.230` 从公网直连 `3306`,用于外部 MySQL 从库复制
- 因此本阶段 RDS 使用 `public subnet group + Publicly accessible = Yes`
- 访问面只通过 `sg-rds` 严格限制到业务 EC2 安全组和 `104.238.220.230/32`
### Redis
- 部署位置:业务 EC2 本机
- 部署方式:Docker
- 版本:`redis:8.2.1`
- 监听:`0.0.0.0:6379`
- 应用连接:`127.0.0.1:6379`
- 安全组:仅对白名单备用节点 `104.238.220.230/32` 或同机应用开放
## 3. 网络与安全组
### 子网布局
- `public-a`, `public-c`: ALB / EC2
- `private-a`, `private-c`: RDS
说明:
- 东京优先使用 `ap-northeast-1a``ap-northeast-1c`
- 如果账户映射不同,也可以用任意 2 个可用区,但公私网必须各 2 个子网
### 安全组建议
#### `sg-alb`
- Inbound
- `80/tcp` from `0.0.0.0/0`
- `443/tcp` from `0.0.0.0/0`
- Outbound
- `80/tcp` to `sg-ec2`
#### `sg-ec2`
- Inbound
- `80/tcp` from `sg-alb`
- `22/tcp` from `你的固定运维 IP`
- `6379/tcp` from `104.238.220.230/32`
- Outbound
- all
说明:
- 应用容器监听 `127.0.0.1:8080`
- EC2 对外只让 Nginx 监听 `80`
- Grafana / Prometheus / Tempo 仅监听 `127.0.0.1`
#### `sg-rds`
- Inbound
- `3306/tcp` from `sg-ec2`
- `3306/tcp` from `104.238.220.230/32`
## 4. ALB / Target Group / 健康检查
### Target Group
- Type: `Instance`
- Protocol: `HTTP`
- Port: `80`
- Health check path: `/v1/common/heartbeat`
- Success code: `200`
### ALB 监听器
- `80` -> redirect to `443`
- `443` -> forward 到 target group
### ACM
-`ap-northeast-1` 重新申请证书
- 先给平行环境域名,例如:
- `api-jp.hifast.biz`
- `logs-jp.hifast.biz`
## 5. WAF 规则
首版至少启用:
1. `AWSManagedRulesCommonRuleSet`
2. `AWSManagedRulesKnownBadInputsRuleSet`
3. `AWSManagedRulesAmazonIpReputationList`
4. 全站 rate-based rule
5. 针对高风险路径的 rate-based rule
建议的第一版限流:
- 全站:每 IP `2000 / 5 分钟`
- `/v1/public/user/subscribe`:每 IP `300 / 5 分钟`
- 登录 / 注册 / 验证码接口:每 IP `100 / 5 分钟`
节点上报接口建议后续补:
- `/v1/server/status`
- `/v1/server/online`
- `/v1/server/traffic`
## 6. EC2 文件落地
在 EC2 上建议使用:
- 应用目录:`/opt/ppanel`
- Nginx 配置:`/etc/nginx/sites-available/ppanel-api.conf`
需要上传这些文件 / 目录:
- `docker-compose.cloud.yml`
- `deploy/aws/ap-northeast-1/configs/ppanel.yaml.example` -> 重命名为 `configs/ppanel.yaml`
- `deploy/aws/ap-northeast-1/nginx/ppanel-api.conf`
- `grafana/`
- `loki/`
- `prometheus/`
- `tempo/`
- `.env.example` -> 重命名为 `.env`
目标目录示例:
```text
/opt/ppanel/
docker-compose.cloud.yml
.env
configs/ppanel.yaml
grafana/
loki/
prometheus/
tempo/
logs/
cache/
tempo_data/
```
## 7. 应用配置
基线模板见:
- [`configs/ppanel.yaml.example`](./configs/ppanel.yaml.example)
- [`nginx/ppanel-api.conf`](./nginx/ppanel-api.conf)
关键值必须替换:
- `MySQL.Addr`
- `MySQL.Password`
- `Redis.Host`
- `Redis.Pass`
- `JwtAuth.AccessSecret`
- `Administrator.Email`
- `Administrator.Password`
- `AppSignature.AppSecrets.*`
- `device.security_secret`
- `Site.Host`
- `Site.SiteName`
Redis 约定保持不变:
- 业务缓存:DB `0`
- AsynqDB `5`
## 8. 部署步骤
### 8.0 创建东京基础设施
如果本机或跳板机已经配置好 AWS CLI 凭据,可以先直接执行:
```bash
cp deploy/aws/ap-northeast-1/configs/aws-jp-infra.env.example /root/aws-jp-infra.env
chmod 600 /root/aws-jp-infra.env
vim /root/aws-jp-infra.env
chmod +x deploy/scripts/aws_jp_create_base_infra.sh
bash deploy/scripts/aws_jp_create_base_infra.sh /root/aws-jp-infra.env
```
执行后可用下面命令随时核对东京底座状态:
```bash
chmod +x deploy/scripts/aws_jp_describe_state.sh
bash deploy/scripts/aws_jp_describe_state.sh /root/aws-jp-infra.env
```
### 8.1 初始化 EC2
把脚本上传到东京 EC2 后执行:
```bash
chmod +x deploy/scripts/bootstrap_aws_ec2.sh
sudo APP_DIR=/opt/ppanel APP_USER=ubuntu deploy/scripts/bootstrap_aws_ec2.sh
```
### 8.2 安装 Nginx 配置
```bash
sudo cp deploy/aws/ap-northeast-1/nginx/ppanel-api.conf /etc/nginx/sites-available/ppanel-api.conf
sudo ln -sf /etc/nginx/sites-available/ppanel-api.conf /etc/nginx/sites-enabled/ppanel-api.conf
sudo nginx -t
sudo systemctl reload nginx
```
### 8.3 启动容器
```bash
cd /opt/ppanel
docker compose -f docker-compose.cloud.yml up -d
```
### 8.4 预检
```bash
chmod +x deploy/scripts/preflight_aws_jp.sh
APP_DIR=/opt/ppanel \
RDS_HOST=<TOKYO_RDS_ENDPOINT> \
REDIS_HOST=127.0.0.1 \
deploy/scripts/preflight_aws_jp.sh
```
## 9. 数据迁移与切换
正式迁移请按:
- [`ops/hifast-aws-jp-migration-runbook-zh.md`](/Users/Apple/code_vpn/vpn/ppanel-server/ops/hifast-aws-jp-migration-runbook-zh.md)
执行。
核心原则:
- 先搭平行环境
- 停机后再导出香港主数据
- 东京验收通过后再切正式域名
- 切换后再重挂 `104` 灾备
## 10. 104 灾备节点常用模板
如果迁移完成后要把 `104.238.220.230` 重挂为东京主站从库,可复用:
- [`deploy/scripts/hifast_mysql_seed_primary_and_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_mysql_seed_primary_and_replica.sh)
- [`deploy/scripts/hifast_mysql_attach_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_mysql_attach_replica.sh)
- [`deploy/scripts/hifast_redis_attach_replica.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_redis_attach_replica.sh)
- [`deploy/scripts/hifast_data_sync_tool.sh`](/Users/Apple/code_vpn/vpn/ppanel-server/deploy/scripts/hifast_data_sync_tool.sh)
- [`configs/replica-ops.env.example`](./configs/replica-ops.env.example)
## 11. 东京资源创建前置检查
在 AWS 控制台里至少先确认:
- 东京区已启用
- `ap-northeast-1` 可创建 `t4g.large`
- `ap-northeast-1` RDS 可创建 `db.r7g.xlarge`
- ACM / ALB / WAF / S3 服务在东京区可正常使用
- Tokyo 对应配额满足:
- On-Demand Standard vCPU
- ALB 数量
- Elastic IP(如需)
- RDS 实例数
## 12. 当前已知真实进度
截至 `2026-05-20`,已知状态如下:
- 东京 VPC `ppanel-jp-prod` 已创建
- VPC ID: `vpc-0846b23b4a7d64eac`
- VPC CIDR: `10.20.0.0/16`
- 4 个子网在 AWS 控制台里曾填写完成,但提交时控制台 session 失效
- 因此:
- 子网是否真正创建成功,需要重新核实
- IGW / 路由表 / 安全组 / RDS / EC2 / ALB / WAF 都应按“未完成”处理,重新复核
实时状态请以后续更新的 [`configs/resource-inventory.current.md`](./configs/resource-inventory.current.md) 为准。
@@ -0,0 +1,55 @@
AWS_REGION=ap-northeast-1
AWS_ACCOUNT_ID=200810848252
VPC_NAME=ppanel-jp-prod
VPC_ID=
VPC_CIDR=10.20.0.0/16
PUBLIC_SUBNET_A_NAME=ppanel-jp-public-a
PUBLIC_SUBNET_A_AZ=ap-northeast-1a
PUBLIC_SUBNET_A_CIDR=10.20.0.0/24
PUBLIC_SUBNET_C_NAME=ppanel-jp-public-c
PUBLIC_SUBNET_C_AZ=ap-northeast-1c
PUBLIC_SUBNET_C_CIDR=10.20.1.0/24
PRIVATE_SUBNET_A_NAME=ppanel-jp-private-a
PRIVATE_SUBNET_A_AZ=ap-northeast-1a
PRIVATE_SUBNET_A_CIDR=10.20.10.0/24
PRIVATE_SUBNET_C_NAME=ppanel-jp-private-c
PRIVATE_SUBNET_C_AZ=ap-northeast-1c
PRIVATE_SUBNET_C_CIDR=10.20.11.0/24
IGW_NAME=ppanel-jp-igw
PUBLIC_ROUTE_TABLE_NAME=ppanel-jp-public-rt
PRIVATE_ROUTE_TABLE_NAME=ppanel-jp-private-rt
SG_ALB_NAME=ppanel-jp-sg-alb
SG_EC2_NAME=ppanel-jp-sg-ec2
SG_RDS_NAME=ppanel-jp-sg-rds
OPS_SSH_CIDR=CHANGE_ME_TO_YOUR_FIXED_PUBLIC_IP_OR_CIDR
DR_REPLICA_IP=104.238.220.230/32
EC2_NAME=ppanel-app-jp-01
EC2_AMI_FAMILY=ubuntu-24.04
EC2_INSTANCE_TYPE=t4g.large
EC2_DISK_GB=80
EC2_KEY_PAIR=CHANGE_ME
RDS_IDENTIFIER=ppanel-mysql-jp
RDS_DB_NAME=hifast
RDS_ADMIN_USER=admin
RDS_INSTANCE_CLASS=db.r7g.xlarge
RDS_STORAGE_GB=100
ALB_NAME=ppanel-alb-jp
TARGET_GROUP_NAME=ppanel-tg-jp
WAF_NAME=ppanel-waf-jp
PARALLEL_API_DOMAIN=api-jp.hifast.biz
PARALLEL_LOGS_DOMAIN=logs-jp.hifast.biz
PRODUCTION_API_DOMAIN=CHANGE_ME
S3_BACKUP_BUCKET=hifast-prod-backups-200810848252-ap-northeast-1
@@ -0,0 +1,19 @@
AWS_REGION=ap-northeast-1
S3_BUCKET=hifast-prod-backups-200810848252-ap-northeast-1
S3_PREFIX=mysql
BACKUP_DIR=/var/backups/hifast
HOST_TAG=104-standby-for-jp
KEEP_LOCAL_DAYS=3
CHECK_REPLICA=1
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=backup_reader
MYSQL_PASSWORD=CHANGE_ME
MYSQL_SOCKET=
MYSQL_DATABASE=hifast
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
@@ -0,0 +1,21 @@
PRIMARY_HOST=ppanel-mysql-jp.<CHANGE_ME>.ap-northeast-1.rds.amazonaws.com
PRIMARY_PORT=3306
PRIMARY_USER=admin
PRIMARY_PASSWORD=CHANGE_ME
PRIMARY_DB=hifast
PRIMARY_REPL_USER=repl
PRIMARY_REPL_PASSWORD=CHANGE_ME
PRIMARY_REPL_HOST=104.238.220.230
PRIMARY_BINLOG_RETENTION_HOURS=24
REPLICA_HOST=127.0.0.1
REPLICA_PORT=3306
REPLICA_USER=root
REPLICA_PASSWORD=
REPLICA_SOCKET=/var/run/mysqld/mysqld.sock
REPLICA_DB=hifast
REPLICA_SOURCE_SSL=1
DUMP_FILE=
@@ -0,0 +1,112 @@
Host: 0.0.0.0
Port: 8080
Debug: false
JwtAuth:
AccessSecret: CHANGE_ME_TO_A_LONG_RANDOM_SECRET
AccessExpire: 604800
Logger:
ServiceName: PPanel
Mode: console
Encoding: plain
TimeFormat: "2006-01-02 15:04:05.000"
Path: logs
Level: info
MaxContentLength: 0
Compress: false
Stat: true
KeepDays: 7
StackCooldownMillis: 100
MaxBackups: 7
MaxSize: 100
Rotation: daily
FileTimeFormat: "2006-01-02T15:04:05.000Z07:00"
MySQL:
Addr: YOUR_TOKYO_RDS_ENDPOINT:3306
Dbname: hifast
Username: admin
Password: CHANGE_ME_TO_TOKYO_RDS_PASSWORD
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FTokyo
MaxIdleConns: 10
MaxOpenConns: 100
SlowThreshold: 1000
Redis:
Host: 127.0.0.1:6379
Pass: CHANGE_ME_TO_TOKYO_REDIS_PASSWORD
DB: 0
PoolSize: 100
MinIdleConns: 10
MaxRetries: 3
PoolTimeout: 4
IdleTimeout: 300
MaxConnAge: 0
DialTimeout: 5
ReadTimeout: 3
WriteTimeout: 3
Trace:
Name: ppanel-server
Endpoint: 127.0.0.1:4317
Sampler: 0.1
Batcher: otlpgrpc
Site:
Host: api-jp.hifast.biz
SiteName: HiFastVPN
Administrator:
Email: admin@example.com
Password: CHANGE_ME_TO_STRONG_ADMIN_PASSWORD
Telegram:
Enable: false
BotID: 0
BotName: ""
BotToken: ""
GroupChatID: ""
EnableNotify: false
WebHookDomain: ""
Kutt:
Enable: false
ApiURL: ""
ApiKey: ""
TargetURL: ""
Domain: ""
OpenInstall:
Enable: false
AppKey: ""
ApiKey: ""
Loki:
Enable: true
URL: "http://localhost:3100"
AppSignature:
AppSecrets:
android-client: CHANGE_ME_ANDROID_SIGNATURE_SECRET
ios-client: CHANGE_ME_IOS_SIGNATURE_SECRET
web-client: CHANGE_ME_WEB_SIGNATURE_SECRET
ValidWindowSeconds: 300
SkipPrefixes:
- /v1/notify/
- /v1/iap/notifications
- /v1/telegram/webhook
- /v1/subscribe/config
Signature:
EnableSignature: false
device:
enable: true
security_secret: CHANGE_ME_DEVICE_SECURITY_SECRET
Register:
EnableTrial: true
EnableTrialEmailWhitelist: true
TrialEmailDomainWhitelist: "gmail.com,outlook.com,icloud.com,qq.com,163.com"
@@ -0,0 +1,23 @@
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=CHANGE_ME
MYSQL_SOCKET=
REPL_SOURCE_HOST=ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com
REPL_SOURCE_PORT=3306
REPL_SOURCE_USER=repl
REPL_SOURCE_PASSWORD=XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer
REPL_SOURCE_SSL=1
REPL_SOURCE_LOG_FILE=mysql-bin-changelog.000189
REPL_SOURCE_LOG_POS=185053
REPL_SOURCE_AUTO_POSITION=1
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
REDIS_SOURCE_HOST=3.114.29.208
REDIS_SOURCE_PORT=6379
REDIS_SOURCE_USER=
REDIS_SOURCE_PASSWORD=hifast67yj
@@ -0,0 +1,187 @@
# Tokyo Resource Inventory
最后更新:`2026-05-21`
这个文件记录当前东京迁移的真实实施状态,不是示例。
## Region
- AWS account: `hifastvpn (200810848252)`
- Region: `ap-northeast-1`
## Current Status
- 东京迁移方案已在仓库内落地为执行资产
- 东京 VPC 已创建
- 东京子网、IGW、路由表已在 AWS 控制台创建并复核
- 东京三层安全组已在 AWS 控制台创建并复核
- 东京 VPC DNS 开关已开启,可支持公网可访问 RDS
- 东京 S3 备份桶已在 AWS 控制台创建并复核
- 东京 ACM 证书请求已创建,等待 DNS 验证
- 东京 RDS MySQL 已创建完成并可用
- 东京业务 EC2 已创建完成并绑定固定 EIP
- 因当前本机没有可用 AWS CLI 凭据,云上资源状态仍需在 AWS 控制台或已登录环境中复查
## Networking
- VPC
- Name: `ppanel-jp-prod`
- VPC ID: `vpc-0846b23b4a7d64eac`
- CIDR: `10.20.0.0/16`
- Status: `created`
- Public subnet A
- Name: `ppanel-jp-public-a`
- AZ: `ap-northeast-1a`
- CIDR: `10.20.0.0/24`
- Subnet ID: `subnet-091232bdb53e71490`
- Status: `created`
- Public subnet C
- Name: `ppanel-jp-public-c`
- AZ: `ap-northeast-1c`
- CIDR: `10.20.1.0/24`
- Subnet ID: `subnet-01ba0975c525ce8cf`
- Status: `created`
- Private subnet A
- Name: `ppanel-jp-private-a`
- AZ: `ap-northeast-1a`
- CIDR: `10.20.10.0/24`
- Subnet ID: `subnet-0bd13111c02f0edbe`
- Status: `created`
- Private subnet C
- Name: `ppanel-jp-private-c`
- AZ: `ap-northeast-1c`
- CIDR: `10.20.11.0/24`
- Subnet ID: `subnet-0d86c5c756dbc84b2`
- Status: `created`
- Internet Gateway
- Name: `ppanel-jp-igw`
- IGW ID: `igw-028041bcbf63b672c`
- Status: `created`
- Public route table
- Name: `ppanel-jp-public-rt`
- Route Table ID: `rtb-061b101080e4800e5`
- Default route: `0.0.0.0/0 -> igw-028041bcbf63b672c`
- Status: `created`
- Private route table
- Name: `ppanel-jp-private-rt`
- Route Table ID: `rtb-0d7a191a515031c45`
- Status: `created`
## Security
- `sg-alb`
- Name: `ppanel-jp-sg-alb`
- Security Group ID: `sg-0b3a23c31041a5a5a`
- Inbound:
- `80/tcp <- 0.0.0.0/0`
- `443/tcp <- 0.0.0.0/0`
- Status: `created`
- `sg-ec2`
- Name: `ppanel-jp-sg-ec2`
- Security Group ID: `sg-01f2a5a81e7505c91`
- Inbound:
- `80/tcp <- sg-0b3a23c31041a5a5a`
- `22/tcp <- 64.118.144.142/32`
- `6379/tcp <- 104.238.220.230/32`
- Status: `created`
- `sg-rds`
- Name: `ppanel-jp-sg-rds`
- Security Group ID: `sg-0b71db1e2c18b57c0`
- Inbound:
- `3306/tcp <- sg-01f2a5a81e7505c91`
- `3306/tcp <- 104.238.220.230/32`
- Status: `created`
## Compute / Database / Edge
- EC2 `ppanel-app-jp-01`:
- Instance ID: `i-07839130074cd7ed9`
- Type: `c7i.xlarge`
- Platform: `Ubuntu 26.04 / Linux`
- AZ: `ap-northeast-1c`
- VPC: `ppanel-jp-prod (vpc-0846b23b4a7d64eac)`
- Subnet: `ppanel-jp-public-c (subnet-01ba0975c525ce8cf)`
- Private IP: `10.20.1.168`
- Public IP / Elastic IP: `3.114.29.208`
- Public DNS: `ec2-3-114-29-208.ap-northeast-1.compute.amazonaws.com`
- Security group: `ppanel-jp-sg-ec2 (sg-01f2a5a81e7505c91)`
- Key pair: `ppanel-jp-key-20260521`
- Root volume: `gp3 100GiB`
- ENI: `eni-08accb427a470c9f7`
- EIP allocation ID: `eipalloc-038b32d5c0119accf`
- EIP association ID: `eipassoc-09184aa9161b6a4d9`
- Status: `running`
- SSH recovery private key: `deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery`
- SSH recovery public key: `deploy/aws/ap-northeast-1/keys/ppanel-jp-recovery.pub`
- RDS subnet group:
- Name: `ppanel-jp-rds-subnet-group`
- VPC: `vpc-0846b23b4a7d64eac`
- Subnets:
- `subnet-0bd13111c02f0edbe` / `ppanel-jp-private-a`
- `subnet-0d86c5c756dbc84b2` / `ppanel-jp-private-c`
- Status: `created`
- RDS public subnet group:
- Name: `ppanel-jp-rds-public-subnet-group`
- VPC: `vpc-0846b23b4a7d64eac`
- Subnets:
- `subnet-091232bdb53e71490` / `ppanel-jp-public-a`
- `subnet-01ba0975c525ce8cf` / `ppanel-jp-public-c`
- Status: `created`
- RDS `ppanel-mysql-jp`:
- Engine: `MySQL Community 8.4.8`
- Class: `db.r7g.xlarge`
- Storage: `gp3 100GiB`
- Deployment: `Single instance (current actual state)`
- VPC: `ppanel-jp-prod (vpc-0846b23b4a7d64eac)`
- Subnet group: `ppanel-jp-rds-public-subnet-group`
- Security group: `ppanel-jp-sg-rds (sg-0b71db1e2c18b57c0)`
- Master username: `admin`
- Credential management: `self-managed`
- Secrets Manager managed password: `disabled`
- Current master password visibility: `not retrievable from AWS console; reset only`
- Public access: `enabled (set at creation time for external replication)`
- Status: `available`
- Endpoint: `ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com`
- Public IP (resolved via public DNS): `52.196.204.186`
- Connection test from Tokyo EC2:
- `mysql -h ppanel-mysql-jp.cpo0keikgh80.ap-northeast-1.rds.amazonaws.com -u admin -e "select 1"`
- Result: `ERROR 1045 (28000): Access denied for user 'admin'@'ip-10-20-1-168.ap-northeast-1.compute.internal' (using password: NO)`
- Meaning: `network path and security group are working; only the password is missing`
- Current admin password: `TkyRds20260521!N9mQ8sKe2vLp7Xa`
- External replica prep for `104.238.220.230`:
- binlog retention hours: `24`
- replication user: `repl@104.238.220.230`
- replication password: `XwWrQGVWtxmXJ3etHmkFvnRSD54MKYer`
- current binlog file: `mysql-bin-changelog.000189`
- current binlog position: `185053`
- ALB `ppanel-alb-jp`: `not created`
- WAF `ppanel-waf-jp`: `not created`
- ACM certificate in `ap-northeast-1`:
- Certificate ID: `29d0b9b6-ab37-44d9-ad9e-18fa7e9aae3a`
- Domains:
- `api-jp.hifast.biz`
- `logs-jp.hifast.biz`
- Status: `pending_validation`
- Route 53 hosted zone in current AWS account: `not found`
- S3 backup bucket `hifast-prod-backups-200810848252-ap-northeast-1`: `created`
## Domains
- Parallel API domain: `api-jp.hifast.biz`
- Parallel logs domain: `logs-jp.hifast.biz`
- Production API domain: `pending user final confirmation`
- ACM DNS validation records pending external DNS add:
- `api-jp.hifast.biz`
- Name: `_0de5970dfbadaf46759447b2ea627a10.api-jp.hifast.biz.`
- Type: `CNAME`
- Value: `_6a632a5b85c5b3f304cc492090b741b3.jkddzztszm.acm-validations.aws.`
- `logs-jp.hifast.biz`
- Name: `_349a2b2bc4678d76c3ab341ccf73db61.logs-jp.hifast.biz.`
- Type: `CNAME`
- Value: `_74ced20dc96aa39070188605cf0ced18.jkddzztszm.acm-validations.aws.`
## DR
- DR host: `104.238.220.230`
- Planned MySQL upstream after cutover: `Tokyo RDS`
- Planned Redis upstream after cutover: `Tokyo EC2 public IP`
@@ -0,0 +1,69 @@
# Tokyo Resource Inventory Example
Use this file as the single source of truth while building the Tokyo environment.
## Region
- AWS account: `hifastvpn (200810848252)`
- Region: `ap-northeast-1`
## DNS
- Production API domain: `CHANGE_ME`
- Parallel API domain: `api-jp.hifast.biz`
- Parallel logs domain: `logs-jp.hifast.biz`
## Networking
- VPC name: `ppanel-jp-prod`
- VPC CIDR: `10.20.0.0/16`
- Public subnet A: `10.20.0.0/24`
- Public subnet C: `10.20.1.0/24`
- Private subnet A: `10.20.10.0/24`
- Private subnet C: `10.20.11.0/24`
- Ops CIDR for SSH: `CHANGE_ME`
## Compute
- EC2 name: `ppanel-app-jp-01`
- EC2 type: `t4g.large`
- EC2 disk: `gp3 80GB`
- SSH key pair: `CHANGE_ME`
## Database
- RDS identifier: `ppanel-mysql-jp`
- RDS engine: `MySQL 8.4`
- RDS class: `db.r7g.xlarge`
- RDS storage: `gp3 100GB`
- DB name: `hifast`
- DB admin user: `admin`
## Cache
- Redis container: `hifast-redis`
- Redis port: `6379`
- Redis password: `CHANGE_ME`
## Security / Secrets
- JWT secret: `CHANGE_ME`
- Admin email: `CHANGE_ME`
- Admin password: `CHANGE_ME`
- Android app signature secret: `CHANGE_ME`
- iOS app signature secret: `CHANGE_ME`
- Web app signature secret: `CHANGE_ME`
- Device security secret: `CHANGE_ME`
## Backup
- S3 backup bucket: `hifast-prod-backups-200810848252-ap-northeast-1`
- Versioning: `Enabled`
## DR
- DR host: `104.238.220.230`
- MySQL repl user: `repl`
- MySQL repl password: `CHANGE_ME`
- Redis source password: `CHANGE_ME`
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1YwAAAKBaQXT3WkF0
9wAAAAtzc2gtZWQyNTUxOQAAACBsJxZDjKvaIdVjer3QMHRYw43wkVnzHurA2TQqHlr1Yw
AAAEArWQWWwPoJp+za5JhSnrE0Pw1/TtqWRrdY5e8hULqYDGwnFkOMq9oh1WN6vdAwdFjD
jfCRWfMe6sDZNCoeWvVjAAAAF0FwcGxlQE1hY0Jvb2stUHJvLmxvY2FsAQIDBAUG
-----END OPENSSH PRIVATE KEY-----
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGwnFkOMq9oh1WN6vdAwdFjDjfCRWfMe6sDZNCoeWvVj Apple@MacBook-Pro.local
@@ -0,0 +1,34 @@
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
client_max_body_size 20m;
access_log /var/log/nginx/ppanel-access.log;
error_log /var/log/nginx/ppanel-error.log warn;
location / {
proxy_http_version 1.1;
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_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location = /nginx_status {
stub_status;
access_log off;
allow 127.0.0.1;
deny all;
}
}
@@ -0,0 +1,17 @@
[Unit]
Description=Hifast MySQL backup to S3
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=root
Group=root
EnvironmentFile=/root/backup-to-s3.env
ExecStart=/usr/bin/env bash -lc 'exec /opt/ppanel/deploy/scripts/mysql_backup_to_s3.sh'
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
[Install]
WantedBy=multi-user.target
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Run Hifast MySQL backup to S3 every 10 minutes
[Timer]
OnCalendar=*:0/10
Persistent=true
RandomizedDelaySec=30
Unit=hifast-mysql-backup.service
[Install]
WantedBy=timers.target
+560
View File
@@ -0,0 +1,560 @@
# 提现 & 文件上传 & 日志上报 — 用户端 API 接口文档
> 基于 ppanel-server 源码整理,所有时间戳均为**秒级 Unix**。
---
## 目录
- [一、提现接口](#一提现接口)
- [1.1 申请提现](#11-申请提现)
- [1.2 取消提现](#12-取消提现)
- [1.3 查询提现记录](#13-查询提现记录)
- [二、枚举值与状态流转](#二枚举值与状态流转)
- [三、文件上传接口](#三文件上传接口)
- [3.1 直传文件(小文件)](#31-直传文件小文件)
- [3.2 初始化上传(大文件 — 预签名)](#32-初始化上传大文件--预签名)
- [3.3 确认上传完成](#33-确认上传完成)
- [四、日志查询接口 (Admin)](#四日志查询接口-admin)
- [4.1 错误日志列表](#41-错误日志列表)
- [4.2 错误日志详情](#42-错误日志详情)
- [4.3 日志消息原始详情](#43-日志消息原始详情)
---
## 一、提现接口
> 认证方式: JWT(用户登录态)
>
> 路由前缀: `/v1/public/user`
### 1.1 申请提现
提交佣金提现申请,创建一条待审核的提现记录。
```
POST /v1/public/user/commission_withdraw
```
**Request Body**
| 字段 | 类型 | 必填 | 校验 | 说明 |
|------|------|------|------|------|
| `amount` | int64 | 是 | — | 提现金额(分) |
| `method` | uint8 | 是 | `oneof=0 1 2 3` | 收款方式(见枚举表) |
| `content` | string | 否 | — | 提现备注 |
| `account` | string | 条件必填 | — | 收款账号 |
| `qr_code_url` | string | 条件必填 | — | 收款码图片 URL |
**各收款方式的必填字段**
| method | 收款方式 | 必填字段 |
|--------|---------|---------|
| `1` 支付宝 | `qr_code_url` | 收款码图片 |
| `2` 微信 | `qr_code_url` | 收款码图片 |
| `3` 银行卡 | `account` | 收款账号 |
| `0` 其他 | `account` 必填 |
**Request 示例**
```json
{
"amount": 5000,
"content": "提现到支付宝",
"method": 1,
"account": "user@example.com",
"qr_code_url": "https://cdn.example.com/qrcode/alipay.png"
}
```
**Response**: [`WithdrawalLog`](#withdrawallog-对象)
---
### 1.2 取消提现
用户取消自己的待审核提现申请,佣金退回账户。
```
POST /v1/public/user/withdrawal_cancel
```
**Request Body**
| 字段 | 类型 | 必填 | 校验 | 说明 |
|------|------|------|------|------|
| `withdrawal_id` | int64 | 是 | `required,gt=0` | 提现记录 ID |
**Request 示例**
```json
{
"withdrawal_id": 123
}
```
**Response**: [`WithdrawalLog`](#withdrawallog-对象)(状态已变为 `3=已取消`
---
### 1.3 查询提现记录
分页查询当前用户的提现记录(自动按 JWT 中的 userId 过滤)。
```
GET /v1/public/user/withdrawal_log
```
**Query 参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `page` | int | 否 | 页码,默认 1 |
| `size` | int | 否 | 每页条数,默认 10 |
**Request 示例**
```
GET /v1/public/user/withdrawal_log?page=1&size=10
```
**Response**
```json
{
"list": [WithdrawalLog, ...],
"total": 25
}
```
---
## 二、枚举值与状态流转
### 提现状态 (`status`)
| 值 | 说明 |
|----|------|
| 0 | 待审核 |
| 1 | 已通过 |
| 2 | 已拒绝 |
| 3 | 已取消 |
### 收款方式 (`method`)
| 值 | 说明 |
|----|------|
| 0 | 其他 |
| 1 | 支付宝 |
| 2 | 微信 |
| 3 | 银行卡 |
### 状态流转
```
┌── 管理员通过 ──▶ 已通过 (1)
待审核 (0) ──────┼── 管理员拒绝 ──▶ 已拒绝 (2)
└── 用户取消 ───▶ 已取消 (3)
```
### WithdrawalLog 对象
所有提现接口共用的响应结构:
```json
{
"id": 1,
"user_id": 100,
"amount": 5000,
"content": "提现备注",
"status": 0,
"reason": "",
"method": 1,
"account": "user@example.com",
"qr_code_url": "https://cdn.example.com/qrcode/alipay.png",
"created_at": 1716700000,
"updated_at": 1716700000
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int64 | 提现记录 ID |
| `user_id` | int64 | 用户 ID |
| `amount` | int64 | 提现金额(分) |
| `content` | string | 提现备注 |
| `status` | uint8 | 状态(见枚举表) |
| `reason` | string | 拒绝原因(仅 status=2 时有值,其余 omitempty |
| `method` | uint8 | 收款方式(见枚举表) |
| `account` | string | 收款账号 |
| `qr_code_url` | string | 收款码图片 URL |
| `created_at` | int64 | 创建时间(秒级 Unix |
| `updated_at` | int64 | 更新时间(秒级 Unix |
---
## 三、文件上传接口
> 认证方式: JWT + DeviceMiddleware(用户登录态 + 设备认证)
>
> 路由前缀: `/v1/public/file`
>
> 存储后端: S3 兼容(RustFS
提供两种上传方式:
| 方式 | 适用场景 | 流程 |
|------|---------|------|
| **直传** | 小文件(收款码等) | 1 次请求,`multipart/form-data` 直接上传 |
| **预签名** | 大文件 / 客户端直传 S3 | init → 客户端 PUT 到预签名 URL → complete 确认 |
---
### 3.1 直传文件(小文件)
通过 `multipart/form-data` 直接上传文件到服务端,服务端转存至 S3。
```
POST /v1/public/file/upload
Content-Type: multipart/form-data
```
**Form 参数**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `biz_type` | string | 是 | 业务类型(如 `withdrawal_qrcode``avatar` 等) |
| `file` | file | 是 | 上传的文件(multipart |
**cURL 示例**
```bash
curl -X POST /v1/public/file/upload \
-H "Authorization: Bearer <token>" \
-F "biz_type=withdrawal_qrcode" \
-F "file=@/path/to/alipay_qr.png"
```
**Response**
```json
{
"file_id": "a1b2c3d4e5f678901234",
"file_name": "alipay_qr.png",
"object_key": "app-upload/2026/05/27/100/alipay_qr.png__a1b2c3d4e5f678901234",
"size": 52480,
"content_type": "image/png",
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"status": "completed"
}
```
**FileUploadResponse 字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| `file_id` | string | 文件唯一 ID24 字符 hex |
| `file_name` | string | 原始文件名 |
| `object_key` | string | S3 对象路径 |
| `size` | int64 | 文件大小(字节) |
| `content_type` | string | MIME 类型 |
| `etag` | string | S3 ETag |
| `status` | string | 状态,直传成功即 `completed` |
---
### 3.2 初始化上传(大文件 — 预签名)
获取 S3 预签名 URL,客户端直接 PUT 到 S3,避免文件经过服务端。
```
POST /v1/public/file/upload/init
```
**Request Body**
| 字段 | 类型 | 必填 | 校验 | 说明 |
|------|------|------|------|------|
| `biz_type` | string | 是 | `required` | 业务类型 |
| `file_name` | string | 是 | `required` | 文件名 |
| `content_type` | string | 是 | `required` | MIME 类型(如 `image/png` |
| `size` | int64 | 是 | `required` | 文件大小(字节) |
| `sha256` | string | 否 | — | 文件 SHA256(可选校验) |
**Request 示例**
```json
{
"biz_type": "withdrawal_qrcode",
"file_name": "wechat_qr.png",
"content_type": "image/png",
"size": 102400,
"sha256": "e3b0c44298fc1c149afbf4c8996fb924..."
}
```
**Response**
```json
{
"file_id": "b2c3d4e5f6789012345a",
"object_key": "app-upload/2026/05/27/100/wechat_qr.png__b2c3d4e5f6789012345a",
"upload_url": "https://s3.example.com/bucket/app-upload/...?X-Amz-Signature=...",
"method": "PUT",
"headers": {
"Content-Type": "image/png"
},
"expired_at": 1716700300
}
```
**FileUploadInitResponse 字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| `file_id` | string | 文件唯一 ID |
| `object_key` | string | S3 对象路径 |
| `upload_url` | string | 预签名上传 URL |
| `method` | string | HTTP 方法(`PUT` |
| `headers` | map | 上传时需携带的请求头 |
| `expired_at` | int64 | 预签名过期时间(秒级 Unix,默认 300 秒) |
**客户端上传流程**
```
1. 调用 /upload/init 获取 upload_url
2. 用返回的 method + headers 直接上传文件到 upload_url
3. 上传成功后调用 /upload/complete 确认
```
---
### 3.3 确认上传完成
客户端通过预签名 URL 上传完成后,调用此接口确认文件状态。
```
POST /v1/public/file/upload/complete
```
**Request Body**
| 字段 | 类型 | 必填 | 校验 | 说明 |
|------|------|------|------|------|
| `file_id` | string | 是 | `required` | init 返回的 file_id |
**Request 示例**
```json
{
"file_id": "b2c3d4e5f6789012345a"
}
```
**Response**
```json
{
"file_id": "b2c3d4e5f6789012345a",
"object_key": "app-upload/2026/05/27/100/wechat_qr.png__b2c3d4e5f6789012345a",
"size": 102400,
"content_type": "image/png",
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
"status": "completed"
}
```
**FileUploadCompleteResponse 字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| `file_id` | string | 文件唯一 ID |
| `object_key` | string | S3 对象路径 |
| `size` | int64 | 实际文件大小(S3 HeadObject 获取) |
| `content_type` | string | MIME 类型 |
| `etag` | string | S3 ETag |
| `status` | string | `completed` |
**校验规则**
- 文件大小不能超过配置的 `S3.MaxUploadSize`
- Content-Type 必须在配置的 `S3.AllowedContentTypes` 白名单内(若配置了)
- complete 时会校验 S3 上的实际文件大小是否与 init 声明的一致
- 只能确认自己发起的上传(userId 校验)
---
## 四、日志查询接口 (Admin)
> 认证方式: AuthMiddleware(管理员权限)
>
> 路由前缀: `/v1/admin/log`
>
> 数据来源: `log_message` 表(客户端上报的错误/崩溃日志)
---
### 4.1 错误日志列表
分页查询客户端上报的错误日志,支持多维度筛选。
```
GET /v1/admin/log/error_message/list
```
**Query 参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `page` | int | 是 | 页码 |
| `size` | int | 是 | 每页条数 |
| `platform` | string | 否 | 平台筛选(ios / android / windows / mac / harmony |
| `level` | uint8 | 否 | 日志级别 |
| `user_id` | int64 | 否 | 用户 ID |
| `device_id` | string | 否 | 设备 ID |
| `error_code` | string | 否 | 错误码 |
| `keyword` | string | 否 | 关键字搜索(匹配 message) |
| `start` | int64 | 否 | 开始时间(秒级 Unix) |
| `end` | int64 | 否 | 结束时间(秒级 Unix) |
**Request 示例**
```
GET /v1/admin/log/error_message/list?page=1&size=20&platform=ios&start=1716600000&end=1716700000
```
**Response**
```json
{
"total": 50,
"list": [
{
"id": 1,
"platform": "ios",
"app_version": "2.1.0",
"os_name": "iOS",
"os_version": "17.5",
"device_id": "A1B2C3D4",
"user_id": 100,
"session_id": "sess_xxx",
"level": 3,
"error_code": "VPN_CONNECT_FAIL",
"message": "Failed to establish VPN tunnel",
"created_at": 1716700000
}
]
}
```
**ErrorLogMessage 字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int64 | 日志 ID |
| `platform` | string | 平台 |
| `app_version` | string | 客户端版本 |
| `os_name` | string | 操作系统名称 |
| `os_version` | string | 操作系统版本 |
| `device_id` | string | 设备 ID |
| `user_id` | int64 | 用户 ID |
| `session_id` | string | 会话 ID |
| `level` | uint8 | 日志级别 |
| `error_code` | string | 错误码 |
| `message` | string | 错误消息 |
| `created_at` | int64 | 创建时间(秒级 Unix |
---
### 4.2 错误日志详情
获取单条错误日志的完整详情(列表字段 + 堆栈/IP/UA 等扩展信息)。
```
GET /v1/admin/log/error_message/detail
```
**Response**
```json
{
"id": 1,
"platform": "ios",
"app_version": "2.1.0",
"os_name": "iOS",
"os_version": "17.5",
"device_id": "A1B2C3D4",
"user_id": 100,
"session_id": "sess_xxx",
"level": 3,
"error_code": "VPN_CONNECT_FAIL",
"message": "Failed to establish VPN tunnel",
"stack": "at VPNManager.connect() line 42\nat ...",
"client_ip": "1.2.3.4",
"user_agent": "PPanel/2.1.0 iOS/17.5",
"locale": "zh-CN",
"occurred_at": 1716700000,
"created_at": 1716700000
}
```
**相比列表额外返回的字段**
| 字段 | 类型 | 说明 |
|------|------|------|
| `stack` | string | 堆栈信息 |
| `client_ip` | string | 客户端 IP |
| `user_agent` | string | User-Agent |
| `locale` | string | 客户端语言/地区 |
| `occurred_at` | int64 | 错误发生时间(秒级 Unix) |
---
### 4.3 日志消息原始详情
获取单条 `log_message` 的完整原始数据(含 context、digest 等全量字段)。
```
GET /v1/admin/log/message/detail
```
**Query 参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | int64 | 是 | 日志消息 ID |
**Response**
```json
{
"id": 1,
"platform": "ios",
"app_version": "2.1.0",
"os_name": "iOS",
"os_version": "17.5",
"device_id": "A1B2C3D4",
"user_id": 100,
"session_id": "sess_xxx",
"level": 3,
"error_code": "VPN_CONNECT_FAIL",
"message": "Failed to establish VPN tunnel",
"stack": "at VPNManager.connect() line 42\nat ...",
"context": { "server_id": 5, "protocol": "vmess" },
"client_ip": "1.2.3.4",
"user_agent": "PPanel/2.1.0 iOS/17.5",
"locale": "zh-CN",
"digest": "sha256_abc123...",
"occurred_at": 1716700000,
"created_at": 1716700000
}
```
**相比详情额外返回的字段**
| 字段 | 类型 | 说明 |
|------|------|------|
| `context` | any | 附加上下文(原始 JSON |
| `digest` | string | 内容摘要(用于去重) |
+862
View File
@@ -0,0 +1,862 @@
# 邀请赠送与购买订阅逻辑说明
本文档说明当前代码中的购买订阅、订单激活、邀请佣金、邀请赠送时间、家庭组归属逻辑。重点覆盖每个主要分支,方便排查“重复订单/重复订阅/邀请未赠时/赠时落点错误”等问题。
涉及核心文件:
- `internal/logic/public/order/purchaseLogic.go`
- `queue/logic/order/activateOrderLogic.go`
- `internal/logic/common/familyEntitlement.go`
- `internal/model/user/model.go`
## 1. 核心概念
### 1.1 订单状态
| 状态 | 含义 |
| --- | --- |
| `1` | pending,已创建未支付 |
| `2` | paid,已支付待激活 |
| `3` | close,已关闭 |
| `4` | failed/claimed,代码里同时用于失败和 worker 临时领取 |
| `5` | finished,激活完成 |
### 1.2 订单类型
| 类型 | 含义 |
| --- | --- |
| `1` | 新购套餐 |
| `2` | 续费/换套餐 |
| `3` | 重置流量 |
| `4` | 余额充值 |
| `5` | 兑换码激活 |
### 1.3 用户 ID 与订阅归属
订单有两个重要用户字段:
| 字段 | 含义 |
| --- | --- |
| `user_id` | 发起订单/付款的用户 |
| `subscription_user_id` | 订阅权益归属用户;`0` 表示同 `user_id` |
家庭组规则:
- 普通用户下单:`subscription_user_id = user_id`
- 家庭组成员下单:`subscription_user_id = 家主用户 ID`
- 家庭组主账号下单:`subscription_user_id = 家主用户 ID`
当前代码使用 `ResolveEntitlementUser` 判断家庭归属:
- 只有有效家庭组、有效成员关系、角色为 member 时,权益归家主。
- 家主本人不会被改写到别人名下。
## 2. 购买订阅下单逻辑
入口:`Purchase(req *types.PurchaseOrderRequest)`
这里只是创建订单和安排关闭任务,不直接发放订阅。真正发放订阅在订单支付后由队列激活处理。
### 2.1 登录用户检查
分支:
- 上下文没有当前用户:返回 `InvalidAccess`
- 当前用户存在:继续。
### 2.2 解析订阅权益归属
调用 `ResolveEntitlementUser`
| 场景 | `effective_user_id` | 结果 |
| --- | --- | --- |
| 普通用户 | 本人 ID | 订阅归本人 |
| 家庭组主账号 | 本人 ID | 订阅归本人 |
| 家庭组成员 | 家主 ID | 订阅归家主 |
后续查询已有订阅、quota、创建订单里的 `subscription_user_id` 都会使用这个归属结果。
### 2.3 数量校验
分支:
- `quantity <= 0`:自动改成 `1`
- `quantity > MaxQuantity`:返回参数错误。
- 合法:继续。
### 2.4 单订阅模式路由
先默认:
```text
order_type = 1
target_subscribe_id = req.subscribe_id
parent_order_id = 0
subscribe_token = ""
```
如果开启 `Subscribe.SingleModel`
| 查询结果 | 行为 |
| --- | --- |
| 找到已有 anchor 订阅 | 下单路由为续费:`order_type=2`,保留新请求套餐 ID,设置 parent/order token |
| 没找到已有订阅 | 保持新购:`order_type=1` |
| 查询异常 | 返回数据库错误 |
说明:即使是换套餐,只要单订阅模式已有订阅,也走续费语义,后续激活会更新套餐 ID 和流量配置。
### 2.5 非 SingleModel 的全局单订阅兜底
如果未开启 `SingleModel`,且当前还是新购 `order_type=1`
- 查询 `effective_user_id` 名下已有付费订阅:
```sql
user_id = effective_user_id
AND token != ''
AND (order_id > 0 OR token LIKE 'iap:%')
```
| 查询结果 | 行为 |
| --- | --- |
| 找到已有订阅 | 路由为续费:`order_type=2`,用已有订阅 token |
| 没找到 | 仍然新购 |
目的:避免同一个权益归属用户购买不同套餐后出现多条订阅权益。
### 2.6 pending 订单处理
当前只有一个分支会主动关闭旧 pending 单:
```text
SingleModel = true
AND order_type = 1
AND 存在同 user_id + subscribe_id + status=1 的订单
```
行为:
- 关闭旧 pending 订单。
- 继续创建新订单。
注意:
- 如果订单已被路由为 `order_type=2`,这里不会关闭旧 pending。
-`SingleModel` 下也不会走这段 pending 关闭逻辑。
### 2.7 套餐校验
分支:
| 条件 | 行为 |
| --- | --- |
| 套餐不存在 | 返回数据库错误 |
| `sell=false` | 返回套餐不可售 |
| 新购且库存为 `0` | 返回库存不足 |
| 续费/换套餐 | 不检查库存为 0 的拦截分支 |
### 2.8 新用户优惠与新用户限定
调用 `resolveNewUserDiscountEligibility`
| 分支 | 行为 |
| --- | --- |
| 解析失败 | 返回错误 |
| 有折扣且符合条件 | 按折扣计算金额 |
| 有折扣但不符合条件 | 按原价 |
| 新用户限定且不是新用户窗口 | 新购事务内再次校验,不通过则失败 |
### 2.9 优惠券逻辑
如果 `req.coupon` 为空:跳过。
如果不为空:
| 校验 | 不通过行为 |
| --- | --- |
| 优惠券存在 | 返回 `CouponNotExist` |
| 总使用次数未超限 | 返回使用次数不足 |
| 用户使用次数未超限 | 返回用户次数不足 |
| 套餐适用 | 返回不适用 |
通过后计算 `coupon_discount`,从订单金额中扣除。
### 2.10 支付手续费与礼品余额抵扣
流程:
1. 找支付方式。
2. 如果金额大于 0,计算手续费并加到订单金额。
3. 如果用户 `gift_amount > 0`,继续抵扣订单金额。
4. 抵扣金额记录到订单 `gift_amount`
事务内如果有礼品余额抵扣:
- 扣减用户 `gift_amount`
-`system_logs` 的 gift reduce 日志。
### 2.11 `is_new` 首单标记
创建订单前调用:
```sql
SELECT COUNT(*)
FROM `order`
WHERE user_id =
AND status IN (2, 5)
```
| 结果 | `is_new` |
| --- | --- |
| count = 0 | `true` |
| count > 0 | `false` |
注意:
- 判断口径是付款用户 `user_id`,不是 `subscription_user_id`
- pending/closed 订单不影响 `is_new`
- 续费订单也可能是 `is_new=true`,例如单订阅模式下首次购买被路由为续费。
### 2.12 事务内创建订单
事务内执行:
1. 新购且套餐有 quota 时,再查一次 `effective_user_id` 名下订阅数量防并发。
2. 新购且新用户限定时,再查一次新用户资格。
3. 如有礼品余额抵扣,扣减余额并写日志。
4. 新购且库存不是 `-1` 时扣库存。
5. 插入订单。
订单核心字段:
| 字段 | 值 |
| --- | --- |
| `user_id` | 当前付款用户 |
| `subscription_user_id` | 权益归属用户 |
| `type` | 新购 `1` 或续费 `2` |
| `subscribe_id` | 本次购买的套餐 ID |
| `subscribe_token` | 续费时已有订阅 token |
| `is_new` | 首单标记 |
| `status` | `1` pending |
### 2.13 延迟关单任务
订单创建成功后,发送 `DeferCloseOrder` 任务:
- 延迟时间:15 分钟。
- 作用:未支付订单自动关闭。
## 3. 订单支付后的激活逻辑
入口:`ActivateOrderLogic.ProcessTask`
### 3.1 任务解析和订单领取
分支:
| 分支 | 行为 |
| --- | --- |
| payload 解析失败 | 记录错误,不重试 |
| 订单不存在 | 返回错误,允许重试 |
| 订单已 finished | 幂等跳过 |
| 订单不是 paid | 跳过 |
| paid 订单 | 原子更新为 claimed 状态后处理 |
### 3.2 按订单类型分发
| 订单类型 | 处理函数 |
| --- | --- |
| 新购 `1` | `NewPurchase` |
| 续费 `2` | `Renewal` |
| 重置流量 `3` | `ResetTraffic` |
| 充值 `4` | `Recharge` |
| 兑换码 `5` | `RedemptionActivate` |
处理成功后:
1. 执行订阅合并兜底 `reconcilePostOrderSubscriptions`
2. 更新优惠券使用次数。
3. 更新订单为 `finished`
如果处理失败:
- 把订单从 claimed 释放回 paid。
- 返回错误给队列重试。
## 4. 新购订单激活与订阅发放
入口:`NewPurchase`
### 4.1 获取用户
分支:
| 订单 user_id | 行为 |
| --- | --- |
| 不为 0 | 查询已有用户 |
| 为 0 | 从 Redis 临时订单创建游客用户 |
游客订单创建用户时:
- 创建用户和 auth method。
- 生成 refer code。
- 把订单 `user_id` 更新为新用户 ID。
- 如果临时订单有邀请码,绑定 `referer_id`
### 4.2 新用户限定激活时复查
如果套餐是新用户限定,激活时再次校验:
- 不符合则激活失败,订单回到 paid 等待重试/处理。
- 符合继续。
### 4.3 SingleModel 下复用 anchor 订阅
如果 `Subscribe.SingleModel=true`
| 分支 | 行为 |
| --- | --- |
| 找到 anchor 订阅 | 更新订单 parent_id;用续费逻辑延长/换套餐 |
| 找不到 | 继续后续分支 |
| 查询异常 | 记录错误,继续后续分支 |
家庭组场景下查 anchor 的用户 ID:
- 优先 `subscription_user_id`
- 没有则用 `user_id`
### 4.4 复用赠送订阅
如果还没有可复用订阅:
- 查找权益归属用户名下 `order_id=0` 的赠送订阅。
- 找到后将它升级为付费订阅:
- `order_id` 改为当前订单 ID。
- 延长到期时间。
- 状态改为 active。
- 如果套餐变更,更新套餐 ID、流量额度,并清空已用流量。
### 4.5 兜底复用已有订阅
如果仍未复用到订阅:
- 候选用户 ID
- `order.user_id`
- 如果 `subscription_user_id` 存在且不同,也加入候选。
- 查找这些用户名下 `token != ''` 的订阅。
找到后:
- 如果订阅 owner 不是当前 `subscription_user_id`,先把 `user_id` 修正为权益归属用户。
- 用续费逻辑延长/换套餐。
目的:家庭组绑定前后 owner 变化时,也尽量复用旧记录,避免创建重复订阅。
### 4.6 创建新订阅
如果以上都没有复用成功,才创建新 `user_subscribe`
| 字段 | 值 |
| --- | --- |
| `user_id` | `subscription_user_id`,没有则 `order.user_id` |
| `order_id` | 当前订单 ID |
| `subscribe_id` | 当前订单套餐 ID |
| `start_time` | 当前时间 |
| `expire_time` | 按套餐时间单位和数量计算 |
| `traffic` | 套餐流量 |
| `token` | 基于订单号生成 |
| `uuid` | 新 UUID |
| `status` | `1` active |
创建前如果套餐有 quota,会再按订阅 owner 统计数量。
### 4.7 新购激活后的异步逻辑
订阅发放后:
1. 后台触发用户分组重算。
2. 后台异步处理邀请佣金和赠送时间。
3. 清套餐缓存。
注意:邀请逻辑在 goroutine 中执行,不阻塞订单激活。
## 5. 续费/换套餐激活逻辑
入口:`Renewal`
### 5.1 获取用户和订阅
- 查询订单 `user_id` 对应用户。
- 通过 `subscribe_token` 查订阅。
- 查询订单 `subscribe_id` 对应套餐。
### 5.2 Apple IAP 与普通续费分支
| 分支 | 行为 |
| --- | --- |
| `iap_expire_at > 0` | 使用 IAP 到期时间兜底,但仍按累计加时语义 |
| 普通续费 | `updateSubscriptionForRenewal` |
### 5.3 普通续费/换套餐规则
`updateSubscriptionForRenewal`
- 如果当前订阅已过期,先把基准时间改为现在。
- 如果套餐 ID 变化:
- 更新订阅套餐 ID。
- 更新流量额度。
- 清空已用流量。
- 如果套餐没变:
- 如果套餐设置 renewal reset,或今天是重置日,则清空已用流量。
- 清理 `finished_at`
- `order_id` 改为当前订单 ID。
- 按套餐时间单位和数量延长到期时间。
- 状态改为 active。
- 清空过期流量字段。
### 5.4 续费后的邀请逻辑
续费成功后也会调用 `handleCommission`
- 是否发佣金由邀请配置和 `order.is_new` 决定。
- 是否赠时同样由邀请配置和 `order.is_new` 决定。
注意:如果 `OnlyFirstPurchase=true`,非首单续费通常不会发佣金,也不会赠首单时间。
## 6. 邀请关系绑定逻辑
### 6.1 注册/登录时的邀请码
新用户注册、游客订单创建用户时,如果带邀请码:
- 根据邀请码查邀请人。
- 设置新用户 `referer_id = 邀请人 ID`
### 6.2 用户后绑邀请码
入口:`BindInviteCode`
分支:
| 分支 | 行为 |
| --- | --- |
| 当前用户不存在 | 返回无权限 |
| 当前用户已有 `referer_id` | 返回已绑定 |
| 邀请码不存在 | 返回邀请码错误 |
| 邀请码属于自己 | 返回不允许绑定自己 |
| 通过 | 更新当前用户 `referer_id` |
注意:
- `referer_id` 始终记录实际邀请码所有者。
- 邀请人是家庭成员时,`referer_id` 仍然是该成员 ID,不自动改为家主 ID。
## 7. 邀请佣金与赠送时间逻辑
入口:`handleCommission(userInfo, orderInfo)`
这里的 `userInfo` 是订单付款用户,也就是被邀请人。
### 7.1 总入口分支
先调用 `shouldProcessCommission(userInfo, orderInfo.IsNew)`
| 结果 | 行为 |
| --- | --- |
| `false` | 不发佣金;如果 `is_new=true`,走双方赠时 |
| `true` | 发佣金;如果 `is_new=true`,被邀请人赠时 |
### 7.2 什么时候发佣金
`shouldProcessCommission` 规则:
| 条件 | 结果 |
| --- | --- |
| 被邀请人为空 | 不发 |
| 被邀请人 `referer_id=0` | 不发 |
| 查不到邀请人 | 不发 |
| 邀请人自定义 `referral_percentage > 0`,且只首购但不是首单 | 不发 |
| 邀请人自定义 `referral_percentage > 0`,且通过首购限制 | 发佣金 |
| 邀请人无自定义比例,系统 `ReferralPercentage=0` | 不发 |
| 系统 `OnlyFirstPurchase=true` 且不是首单 | 不发 |
| 系统有比例且通过首购限制 | 发佣金 |
### 7.3 发佣金路径
如果 `shouldProcessCommission=true`
1. 查询邀请人,也就是 `userInfo.referer_id` 对应用户。
2. 佣金比例:
- 邀请人自定义比例优先。
- 否则用系统配置 `Invite.ReferralPercentage`
3. 佣金金额:
```text
(order.amount - order.fee_amount) * referral_percentage / 100
```
4. 事务内幂等检查:
- 如果已有同订单佣金日志,则跳过。
- 否则增加邀请人的 `commission`
-`system_logs type=33` 佣金日志。
5. 更新邀请人缓存。
6. 如果 `order.is_new=true`
- 给被邀请人赠送订阅时间。
当前保持不变的行为:
- 邀请人是家庭成员时,佣金仍然给实际邀请人成员本人。
- 佣金不归并到家主。
- 有佣金路径下,邀请人不额外赠送订阅时间。
### 7.4 不发佣金路径
如果 `shouldProcessCommission=false`
| `order.is_new` | 行为 |
| --- | --- |
| `true` | 被邀请人和邀请人双方赠送订阅时间 |
| `false` | 不赠送时间 |
双方赠时具体为:
1. 被邀请人赠时:
- 如果被邀请人是家庭成员,加到被邀请人家主套餐。
- 否则加到被邀请人本人套餐。
2. 邀请人赠时:
- 如果邀请人是家庭成员,加到邀请人家主套餐。
- 否则加到邀请人本人套餐。
## 8. 赠送时间目标解析
入口:`resolveGiftTargetUser(source, forcedOwnerID)`
### 8.1 强制 owner 分支
如果 `forcedOwnerID > 0`
- 赠送目标直接使用 `forcedOwnerID`
- 典型场景:订单里已有 `subscription_user_id`
- 这保证了家庭成员购买时,被邀请人的赠时落到家主。
### 8.2 自动家庭组解析分支
如果没有强制 owner
- 调用 `ResolveEntitlementUser(source.Id)`
- 如果 source 是有效家庭成员,目标改为家主。
- 否则目标为本人。
典型场景:
- 无佣金路径下,邀请人也赠时。
- 邀请人如果是家庭成员,赠时会加到邀请人家主套餐。
### 8.3 目标用户查询失败
如果解析出来的目标用户查不到:
- 记录错误日志。
- 回退为 source 本人。
## 9. 赠送时间落库逻辑
入口:`grantGiftDays(u, days, orderNo, remark)`
### 9.1 空值和配置分支
| 条件 | 行为 |
| --- | --- |
| 目标用户为空 | 直接返回,不写日志 |
| `days <= 0` | 直接返回,不写日志 |
### 9.2 幂等检查
按下面条件查 gift 日志:
```sql
type = 34
AND object_id = ID
AND content LIKE '%订单号%'
```
| 结果 | 行为 |
| --- | --- |
| 已存在 | 跳过,不重复赠时 |
| 不存在 | 继续 |
### 9.3 查目标用户活跃订阅
调用 `FindActiveSubscribe`
当前活跃口径:
```sql
user_id = ID
AND status IN (0, 1)
AND (
expire_time > NOW()
OR expire_time = FROM_UNIXTIME(0)
)
```
说明:
- `expire_time > NOW()` 是普通未过期订阅。
- `expire_time = FROM_UNIXTIME(0)` 是永久/不限时订阅。
### 9.4 没有活跃订阅
如果查不到活跃订阅:
- 不创建新订阅。
- 写一条 `system_logs type=34` 日志。
- 日志 remark 为:
```text
邀请赠送 skipped: no active subscription
```
这表示邀请赠时触发过,但目标用户当时没有可加时的套餐。
### 9.5 找到普通活跃订阅
如果目标订阅不是永久订阅:
- `expire_time += days * 24h`
- 更新订阅。
-`system_logs type=34` gift increase 日志。
### 9.6 找到永久订阅
如果目标订阅 `expire_time = FROM_UNIXTIME(0)`
- 不改变 `expire_time`,因为永久订阅没有可延长的到期时间。
- 仍写 `system_logs type=34` gift increase 日志,表示赠送逻辑已识别并处理。
### 9.7 赠时失败日志
发佣金路径和无佣金路径都会检查 `grantGiftDays` 返回错误。
如果出错,会写应用日志:
```text
Grant invite gift days failed
```
附带字段:
- `stage`
- `target_user_id`
- `order_no`
- `error`
## 10. 家庭组下的完整分支示例
### 10.1 被邀请人是普通用户,邀请人普通用户,有佣金
条件:
- 被邀请人 `referer_id != 0`
- 系统或邀请人佣金比例大于 0
- `order.is_new=true`
结果:
- 佣金给邀请人本人。
- 被邀请人本人套餐加赠送时间。
- 邀请人不加赠送时间。
### 10.2 被邀请人是家庭成员,邀请人普通用户,有佣金
结果:
- 佣金给邀请人本人。
- 被邀请人的赠送时间加到被邀请人家主套餐。
- 被邀请人成员本人不单独加订阅时间。
### 10.3 被邀请人普通用户,邀请人是家庭成员,有佣金
结果:
- 佣金给邀请人成员本人。
- 被邀请人本人套餐加赠送时间。
- 邀请人不加赠送时间。
- 邀请人家主不拿佣金,也不因该佣金路径加赠时。
### 10.4 被邀请人是家庭成员,邀请人也是家庭成员,有佣金
结果:
- 佣金给邀请人成员本人。
- 被邀请人的赠送时间加到被邀请人家主套餐。
- 邀请人不加赠送时间。
- 邀请人家主不拿佣金。
### 10.5 无佣金路径,被邀请人普通用户,邀请人普通用户
触发条件示例:
- `ReferralPercentage=0`
- 或因首购限制导致不发佣金
-`order.is_new=true`
结果:
- 被邀请人本人套餐加赠送时间。
- 邀请人本人套餐加赠送时间。
### 10.6 无佣金路径,被邀请人是家庭成员
结果:
- 被邀请人的赠送时间加到被邀请人家主套餐。
- 邀请人的赠时按邀请人自己的家庭归属解析。
### 10.7 无佣金路径,邀请人是家庭成员
结果:
- 被邀请人的赠时按被邀请人的家庭归属解析。
- 邀请人的赠送时间加到邀请人家主套餐。
- 邀请人成员本人不单独加订阅时间。
### 10.8 被邀请人没有活跃订阅
结果:
- 不创建新订阅。
- 写 skipped gift 日志。
- 后续即使用户后来有订阅,也不会自动补赠,除非另行补偿。
### 10.9 被邀请人或目标家主是永久订阅
结果:
- 识别为活跃订阅。
- 不改变到期时间。
- 写 gift increase 日志。
## 11. 排查 SQL
### 11.1 查邀请配置
```sql
SELECT `key`, `value`, `updated_at`
FROM system
WHERE category = 'invite'
AND `key` IN ('GiftDays', 'OnlyFirstPurchase', 'ReferralPercentage');
```
### 11.2 查某邀请人的被邀请用户
```sql
SELECT id, referer_id, created_at
FROM `user`
WHERE referer_id = 23944
ORDER BY id DESC
LIMIT 100;
```
### 11.3 查被邀请人的订单和首单标记
```sql
SELECT u.id AS invited_user_id,
o.id AS order_id,
o.order_no,
o.type,
o.status,
o.amount,
o.is_new,
o.subscribe_id,
o.subscription_user_id,
o.created_at
FROM `user` u
LEFT JOIN `order` o
ON o.user_id = u.id
AND o.type IN (1, 2)
WHERE u.referer_id = 23944
ORDER BY u.id DESC, o.id ASC
LIMIT 200;
```
### 11.4 查某订单佣金和赠时日志
```sql
SELECT id, type, object_id, content, created_at
FROM system_logs
WHERE content LIKE '%202604281812556044982351822%'
ORDER BY id DESC;
```
### 11.5 查某用户订阅
```sql
SELECT id, user_id, order_id, subscribe_id, status,
expire_time, finished_at, token, created_at, updated_at
FROM user_subscribe
WHERE user_id = 24425
ORDER BY id DESC;
```
### 11.6 查首单但没有赠时日志的被邀请人
```sql
SELECT first_orders.user_id AS invited_user_id,
first_orders.order_no,
first_orders.is_new,
first_orders.status,
first_orders.subscription_user_id,
first_orders.created_at,
(
SELECT COUNT(*)
FROM system_logs sl
WHERE sl.type = 34
AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')
) AS gift_log_count,
(
SELECT COUNT(*)
FROM system_logs sl
WHERE sl.type = 33
AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')
) AS commission_log_count
FROM (
SELECT o.*
FROM `order` o
JOIN (
SELECT user_id, MIN(id) AS first_order_id
FROM `order`
WHERE type IN (1, 2)
AND status IN (2, 5)
GROUP BY user_id
) fo ON fo.first_order_id = o.id
) first_orders
JOIN `user` u ON u.id = first_orders.user_id
WHERE u.referer_id = 23944
ORDER BY first_orders.created_at DESC
LIMIT 100;
```
## 12. 部署注意事项
邀请配置存在两层状态:
1. Redis 缓存:`system:invite_config`
2. 服务进程内存:`svc.Config.Invite`
如果直接修改数据库或 Redis,已经运行的 `ppanel-server` 进程不会自动刷新内存配置。订单激活和赠时发生在服务进程/队列 worker 内,所以修改邀请配置或部署赠时逻辑后,需要重启服务。
推荐步骤:
```bash
docker exec ppanel-redis redis-cli DEL system:invite_config system:global_config
docker restart ppanel-server
```
确认启动时间:
```bash
docker inspect --format '{{.Name}} {{.State.StartedAt}} {{.Config.Image}}' ppanel-server
docker ps --filter name=ppanel-server
```
+190
View File
@@ -0,0 +1,190 @@
# TAPI 文件上传接入说明
本文档说明 `https://tapi.hifast.biz/v1/public/file/upload` 相关上传接口的推荐接入方式、签名规则与常见排查方式。
## 总览
上传能力包含两类接入方式:
- 推荐方式:`init -> S3 PUT -> complete`
- 兼容方式:`/upload` multipart 直传
推荐优先使用预签名三段式,因为:
- 现有签名串包含 `BODY_SHA256`
- `/upload``multipart/form-data`
- multipart 原始 body 的签名和调试成本更高
- `init``complete` 是 JSON,更适合客户端和 Apifox 调试
## 签名生效逻辑
项目保持现有旧逻辑,不做强制签名改造:
- `Signature.EnableSignature = false` 时:不校验签名
- `Signature.EnableSignature = true` 且未携带 `X-App-Id` 时:不校验签名,兼容老客户端
- `Signature.EnableSignature = true` 且携带 `X-App-Id` 时:必须同时携带并校验
- `X-Timestamp`
- `X-Nonce`
- `X-Signature`
这意味着:
- 新客户端建议始终带完整签名头
- 老客户端如果没有 `X-App-Id`,仍可按旧逻辑访问
## 签名头定义
- `X-App-Id`: 客户端标识,例如 `ios-client`
- `X-Timestamp`: Unix 秒级时间戳
- `X-Nonce`: 每次请求唯一随机串
- `X-Signature`: `HMAC-SHA256` 结果的十六进制小写字符串
## StringToSign 规则
StringToSign 由下面 7 段按换行符 `\n` 拼接:
```text
METHOD
PATH
CANONICAL_QUERY
BODY_SHA256
X-App-Id
X-Timestamp
X-Nonce
```
说明:
- `METHOD`HTTP 方法大写,例如 `POST`
- `PATH`:请求路径,例如 `/v1/public/file/upload/init`
- `CANONICAL_QUERY`:按 key 排序后的 query string,没有 query 则为空字符串
- `BODY_SHA256`:请求体原始字节的 SHA-256 十六进制小写
- 其余三项直接使用请求头值
签名计算方式:
```text
signature = hex_lower(HMAC_SHA256(app_secret, string_to_sign))
```
时间窗与防重放:
- `X-Timestamp` 默认有效时间窗是 300 秒
- `X-Nonce` 在有效时间窗内不能重复使用
## 推荐接入:预签名三段式
### 1. 初始化上传
请求:
```bash
curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload/init' \
-H 'Accept: application/json, text/plain, */*' \
-H 'Content-Type: application/json' \
-H 'authorization: your-token' \
-H 'X-App-Id: ios-client' \
-H 'X-Timestamp: 1778776400' \
-H 'X-Nonce: nonce-001' \
-H 'X-Signature: your-signature' \
-d '{
"biz_type": "app-package",
"file_name": "demo.zip",
"content_type": "application/zip",
"size": 123456,
"sha256": ""
}'
```
典型返回:
```json
{
"code": 200,
"msg": "success",
"data": {
"file_id": "c29274ee26ab5aa211e0396e",
"object_key": "app-upload/app-package/519/2026/05/c29274ee26ab5aa211e0396e_demo.zip",
"upload_url": "https://bucket.s3.ap-east-1.amazonaws.com/...",
"method": "PUT",
"headers": {
"Content-Type": "application/zip"
},
"expired_at": 1778776715
}
}
```
### 2. 直传 S3
这一步是直接上传二进制文件到 S3,不走业务签名中间件。
```bash
curl -X PUT 'https://bucket.s3.ap-east-1.amazonaws.com/...' \
-H 'Content-Type: application/zip' \
--upload-file '/tmp/demo.zip'
```
说明:
- `Content-Type` 需和 `init` 返回的 `headers.Content-Type` 一致
- 允许的 `Content-Type` 由服务端 `S3.AllowedContentTypes` 配置控制,默认包含:
- 压缩包:`application/zip``application/x-zip-compressed``application/gzip``application/x-gzip`
- 通用文件:`application/octet-stream``text/plain``application/json`
- 图片:`image/jpeg``image/jpg``image/png``image/webp``image/gif``image/heic``image/heif``image/bmp`
- `upload_url` 有过期时间,通常 300 秒
- 成功时 S3 常见返回 `200``204`
### 3. 完成上传
```bash
curl -X POST 'https://tapi.hifast.biz/v1/public/file/upload/complete' \
-H 'Accept: application/json, text/plain, */*' \
-H 'Content-Type: application/json' \
-H 'authorization: your-token' \
-H 'X-App-Id: ios-client' \
-H 'X-Timestamp: 1778776405' \
-H 'X-Nonce: nonce-002' \
-H 'X-Signature: your-signature' \
-d '{
"file_id": "c29274ee26ab5aa211e0396e"
}'
```
## 兼容接入:单接口 multipart 直传
接口:
- `POST /v1/public/file/upload`
表单字段:
- `biz_type`
- `file`
说明:
- 该接口继续保留,兼容旧客户端
- 如果请求带了 `X-App-Id`,就按现有逻辑验签
- 如果没有 `X-App-Id`,仍按旧逻辑放行
- 如果要给该接口加签,签名时必须对原始 multipart body 计算 `BODY_SHA256`
- 允许的 `Content-Type` 与预签名三段式一致;multipart 文件字段未显式携带 `Content-Type` 时,服务端会基于文件内容嗅探常见类型。
## 常见错误码
- `200`: 成功
- `400`: 参数错误
- `400 content_type is not allowed`: 文件 `Content-Type` 不在 `S3.AllowedContentTypes` 白名单内
- `40008`: 缺少签名头
- `40009`: 签名已过期
- `40010`: 签名无效
- `40011`: nonce 重放
- `10001`: 上传元数据不存在或对象不存在
## 排查建议
- `40008`:确认带了 `X-App-Id` 后,也同时带上 `X-Timestamp / X-Nonce / X-Signature`
- `40009`:检查客户端时间是否偏差过大
- `40010`:确认 `PATH`、query 排序、body 原始字节、secret 是否完全一致
- `40011`:确保每次请求都生成新的 `X-Nonce`
- `complete` 失败:确认 S3 `PUT` 已成功,且上传大小与 `init.size` 一致
+144
View File
@@ -0,0 +1,144 @@
# 提现接口文档
## 基础信息
| 项目 | 值 |
|------|-----|
| Base URL | `/v1/public/user` |
| 认证方式 | JWT Token`AuthMiddleware` + `DeviceMiddleware` |
| 数据表 | `user_withdrawal` |
## 数据模型
### user_withdrawal 表
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int64 | 主键 |
| `user_id` | int64 | 用户 ID |
| `amount` | int64 | 提现金额(单位:分) |
| `content` | text | 收款信息(账号、姓名等) |
| `status` | tinyint | 0=待审核, 1=已通过, 2=已拒绝 |
| `reason` | varchar(500) | 拒绝原因(通过时为空) |
| `created_at` | datetime | 创建时间 |
| `updated_at` | datetime | 更新时间 |
### status 枚举
| 值 | 含义 | 说明 |
|----|------|------|
| 0 | Pending(待审核) | 用户提交申请后的初始状态 |
| 1 | Approved(已通过) | 管理员审核通过,佣金已扣减 |
| 2 | Rejected(已拒绝) | 管理员拒绝,无需退款(申请时未扣款) |
---
## 用户端接口
### 1. 申请提现
申请佣金提现,创建一条待审核记录。申请时**不扣余额**,管理员审核通过后才扣。
```
POST /v1/public/user/commission_withdraw
```
#### 请求体
```json
{
"amount": 1000,
"content": "支付宝:138xxxx1234 / 张三"
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `amount` | int64 | 是 | 提现金额(单位:分) |
| `content` | string | 是 | 收款信息(支付宝/银行卡等) |
#### 成功响应
```json
{
"data": {
"id": 1,
"user_id": 10001,
"amount": 1000,
"content": "支付宝:138xxxx1234 / 张三",
"status": 0,
"reason": "",
"created_at": 1716624000000,
"updated_at": 1716624000000
}
}
```
> **注意**:此接口的 `created_at` / `updated_at` 返回**毫秒级**时间戳(`.UnixMilli()`),与项目其他接口的秒级时间戳不一致。
#### 业务逻辑
1. 查询该用户所有 status=0(待审核)的提现记录,求和得 `pendingTotal`
2. 校验可用余额:`commission >= amount + pendingTotal`
3. 创建 `user_withdrawal` 记录,status=0
4. **不扣减** `user.commission`,等审核通过才扣
#### 错误码
| 错误码 | 常量 | 说明 |
|--------|------|------|
| 20010 | `UserCommissionNotEnough` | 可用余额不足(余额 = commission - 所有 pending 提现总额) |
| 40005 | `InvalidAccess` | 未登录 / Token 无效 |
#### 源码位置
- Handler: `internal/handler/public/user/commissionWithdrawHandler.go`
- Logic: `internal/logic/public/user/commissionWithdrawLogic.go`
---
### 2. 查询提现记录
分页查询当前用户的提现记录。
```
GET /v1/public/user/withdrawal_log
```
#### 请求参数(Query
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `page` | int | 否 | 页码 |
| `size` | int | 否 | 每页数量 |
#### 成功响应
```json
{
"data": {
"list": [
{
"id": 1,
"user_id": 10001,
"amount": 1000,
"content": "支付宝:138xxxx1234",
"status": 0,
"reason": "",
"created_at": 1716624000,
"updated_at": 1716624000
}
],
"total": 1
}
}
```
#### 源码位置
- Handler: `internal/handler/public/user/queryWithdrawalLogHandler.go`
- Logic: `internal/logic/public/user/queryWithdrawalLogLogic.go`
> **Warning**: Logic 层尚未实现(仍为 TODO),调用会返回空响应。
---
+30 -142
View File
@@ -6,9 +6,9 @@
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d # 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
# #
# 网络说明: # 网络说明:
# ppanel-server 使用 host 网络(可出外网,访问 MySQL/Redis/Tempo 用 127.0.0.1 # ppanel-server 使用 host 网络(可出外网,直接访问 AWS RDS / 本机 Redis
# 监控服务(MySQL/Redis/Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中 # 监控服务(Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中
# MySQL(3306)/Redis(6379)/Tempo(4317) 将端口映射到 127.0.0.1ppanel-server 通过 host 网络访问 # Tempo(4317) 将端口映射到 127.0.0.1ppanel-server 通过 host 网络访问
# 监控端口绑定 127.0.0.1,需通过 SSH 隧道或 Nginx 反代访问 # 监控端口绑定 127.0.0.1,需通过 SSH 隧道或 Nginx 反代访问
# #
# 未来多开 ppanel-server 时: # 未来多开 ppanel-server 时:
@@ -18,16 +18,17 @@
services: services:
# ---------------------------------------------------- # ----------------------------------------------------
# 1. 业务后端 (PPanel Server) # 1. 业务后端 (PPanel Server)
# host 网络:可出外网,通过 127.0.0.1 访问 MySQL/Redis/Tempo # host 网络:可出外网,直接访问 AWS RDS/Redis通过 127.0.0.1 访问 Tempo
# PPANEL_SERVER_TAG 由 CI/CD 传入不可变镜像标签(如 git SHA)
# ---------------------------------------------------- # ----------------------------------------------------
ppanel-server: ppanel-server:
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:-latest} image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:?please set PPANEL_SERVER_TAG to an immutable image tag}
container_name: ppanel-server container_name: ppanel-server
restart: always restart: always
volumes: volumes:
- ./configs:/app/etc - ./configs:/app/etc
- ./logs:/app/logs - ./logs:/app/logs
- ./cache:/app/cache # GeoLite2-City.mmdb IP 地理位置数据库 - ./cache:/app/cache # GeoLite2-City.mmdb IP 地理位置数据库
environment: environment:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
network_mode: host network_mode: host
@@ -37,10 +38,6 @@ services:
soft: 65535 soft: 65535
hard: 65535 hard: 65535
depends_on: depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
tempo: tempo:
condition: service_started condition: service_started
logging: logging:
@@ -50,81 +47,7 @@ services:
max-file: "3" max-file: "3"
# ---------------------------------------------------- # ----------------------------------------------------
# 2. MySQL Database # 2. Tempo (链路追踪存储)
# ----------------------------------------------------
mysql:
image: mysql:8.0
container_name: ppanel-mysql
restart: always
ports:
- "3306:3306" # 仅宿主机可访问,ppanel-server(host网络)通过127.0.0.1连接
environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?请在 .env 文件中设置 MYSQL_ROOT_PASSWORD}"
MYSQL_DATABASE: "ppanel"
TZ: Asia/Shanghai
command:
- --default-authentication-plugin=mysql_native_password
- --innodb_buffer_pool_size=16G
- --innodb_buffer_pool_instances=16
- --innodb_log_file_size=2G
- --innodb_flush_log_at_trx_commit=2
- --innodb_io_capacity=5000
- --max_connections=5000
volumes:
- mysql_data:/var/lib/mysql
ulimits:
nproc: 65535
nofile:
soft: 65535
hard: 65535
networks:
- ppanel_net
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 3. Redis
# ----------------------------------------------------
redis:
image: redis:8.2.1
container_name: ppanel-redis
restart: always
ports:
- "127.0.0.1:6379:6379" # 仅宿主机可访问,ppanel-server(host网络)通过127.0.0.1连接
command:
- redis-server
- --tcp-backlog 65535
- --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
ulimits:
nproc: 65535
nofile:
soft: 65535
hard: 65535
networks:
- ppanel_net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 4. Tempo (链路追踪存储)
# ---------------------------------------------------- # ----------------------------------------------------
tempo: tempo:
image: grafana/tempo:2.4.1 image: grafana/tempo:2.4.1
@@ -138,7 +61,7 @@ services:
- ./tempo/tempo-config.yaml:/etc/tempo.yaml - ./tempo/tempo-config.yaml:/etc/tempo.yaml
- ./tempo_data:/var/tempo - ./tempo_data:/var/tempo
ports: ports:
- "127.0.0.1:4317:4317" # OTLP gRPCppanel-server(host网络)通过127.0.0.1:4317发送trace - "127.0.0.1:4317:4317" # OTLP gRPCppanel-server(host网络)通过127.0.0.1:4317发送trace
networks: networks:
- ppanel_net - ppanel_net
logging: logging:
@@ -148,7 +71,7 @@ services:
max-file: "3" max-file: "3"
# ---------------------------------------------------- # ----------------------------------------------------
# 5. Loki (日志存储) # 3. Loki (日志存储)
# ---------------------------------------------------- # ----------------------------------------------------
loki: loki:
image: grafana/loki:3.0.0 image: grafana/loki:3.0.0
@@ -168,7 +91,7 @@ services:
max-file: "3" max-file: "3"
# ---------------------------------------------------- # ----------------------------------------------------
# 6. Promtail (日志采集) # 4. Promtail (日志采集)
# ---------------------------------------------------- # ----------------------------------------------------
promtail: promtail:
image: grafana/promtail:3.0.0 image: grafana/promtail:3.0.0
@@ -192,20 +115,27 @@ services:
max-file: "3" max-file: "3"
# ---------------------------------------------------- # ----------------------------------------------------
# 7. Grafana (可观测面板) # 5. Grafana (可观测面板)
# 访问: ssh -L 3333:localhost:3333 your-server 后浏览器打开 http://localhost:3333 # 访问: ssh -L 3333:localhost:3333 your-server 后浏览器打开 http://localhost:3333
# 或配置 Nginx 反代(建议加认证) # 或配置 Nginx 反代(建议加认证)
# ---------------------------------------------------- # ----------------------------------------------------
grafana: grafana:
image: grafana/grafana:latest image: grafana/grafana:13.0.1
container_name: ppanel-grafana container_name: ppanel-grafana
restart: always restart: always
ports: ports:
- "127.0.0.1:3333:3000" # 仅本机可访问,需 SSH 隧道或 Nginx 反代 - "3333:3000" # 仅本机可访问,需 SSH 隧道或 Nginx 反代
environment: environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:?请在 .env 文件中设置 GRAFANA_PASSWORD} - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:?请在 .env 文件中设置 GRAFANA_PASSWORD}
- GF_USERS_ALLOW_SIGN_UP=false - GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_DOMAIN=${GRAFANA_DOMAIN:-logsx.hifast.biz}
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-https://logsx.hifast.biz}
- GF_FEATURE_TOGGLES_ENABLE=appObservability - GF_FEATURE_TOGGLES_ENABLE=appObservability
- AWS_REGION=${AWS_REGION:-ap-east-1}
- AWS_DEFAULT_REGION=${AWS_REGION:-ap-east-1}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
- AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}
volumes: volumes:
- grafana_data:/var/lib/grafana - grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning - ./grafana/provisioning:/etc/grafana/provisioning
@@ -222,14 +152,14 @@ services:
max-file: "3" max-file: "3"
# ---------------------------------------------------- # ----------------------------------------------------
# 8. Prometheus (指标采集) # 6. Prometheus (指标采集)
# ---------------------------------------------------- # ----------------------------------------------------
prometheus: prometheus:
image: prom/prometheus:latest image: prom/prometheus:v3.11.3
container_name: ppanel-prometheus container_name: ppanel-prometheus
restart: always restart: always
ports: ports:
- "127.0.0.1:9090:9090" # 仅本机可访问 - "127.0.0.1:9090:9090" # 仅本机可访问
volumes: volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus - prometheus_data:/prometheus
@@ -247,29 +177,10 @@ services:
max-file: "3" max-file: "3"
# ---------------------------------------------------- # ----------------------------------------------------
# 9. Redis Exporter # 7. Nginx Exporter (监控宿主机 Nginx)
# ----------------------------------------------------
redis-exporter:
image: oliver006/redis_exporter:latest
container_name: ppanel-redis-exporter
restart: always
environment:
- REDIS_ADDR=redis://redis:6379
networks:
- ppanel_net
depends_on:
- redis
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 10. Nginx Exporter (监控宿主机 Nginx)
# ---------------------------------------------------- # ----------------------------------------------------
nginx-exporter: nginx-exporter:
image: nginx/nginx-prometheus-exporter:latest image: nginx/nginx-prometheus-exporter:1.5.0
container_name: ppanel-nginx-exporter container_name: ppanel-nginx-exporter
restart: always restart: always
command: command:
@@ -285,31 +196,10 @@ services:
max-file: "3" max-file: "3"
# ---------------------------------------------------- # ----------------------------------------------------
# 11. MySQL Exporter # 8. Node Exporter (宿主机监控)
# ----------------------------------------------------
mysql-exporter:
image: prom/mysqld-exporter:latest
container_name: ppanel-mysql-exporter
restart: always
command:
- --config.my-cnf=/etc/.my.cnf
volumes:
- ./mysql/.my.cnf:/etc/.my.cnf:ro
networks:
- ppanel_net
depends_on:
- mysql
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ----------------------------------------------------
# 12. Node Exporter (宿主机监控)
# ---------------------------------------------------- # ----------------------------------------------------
node-exporter: node-exporter:
image: prom/node-exporter:latest image: prom/node-exporter:v1.11.1
container_name: ppanel-node-exporter container_name: ppanel-node-exporter
restart: always restart: always
volumes: volumes:
@@ -329,10 +219,10 @@ services:
max-file: "3" max-file: "3"
# ---------------------------------------------------- # ----------------------------------------------------
# 13. cAdvisor (容器监控) # 9. cAdvisor (容器监控)
# ---------------------------------------------------- # ----------------------------------------------------
cadvisor: cadvisor:
image: gcr.io/cadvisor/cadvisor:latest image: gcr.io/cadvisor/cadvisor:v0.55.1
container_name: ppanel-cadvisor container_name: ppanel-cadvisor
restart: always restart: always
volumes: volumes:
@@ -350,8 +240,6 @@ services:
max-file: "3" max-file: "3"
volumes: volumes:
mysql_data:
redis_data:
loki_data: loki_data:
grafana_data: grafana_data:
prometheus_data: prometheus_data:
+38
View File
@@ -0,0 +1,38 @@
services:
mysql:
image: mysql:8.0
container_name: ppanel-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ppanel_dev
MYSQL_DATABASE: ppanel
MYSQL_USER: ppanel
MYSQL_PASSWORD: ppanel_dev
ports:
- "3306:3306"
volumes:
- ppanel_mysql_data:/var/lib/mysql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pppanel_dev"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: ppanel-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- ppanel_redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
volumes:
ppanel_mysql_data:
ppanel_redis_data:
+21 -1
View File
@@ -15,7 +15,7 @@ Logger: # 日志配置
Level: debug # 日志级别: debug, info, warn, error, panic, fatal Level: debug # 日志级别: debug, info, warn, error, panic, fatal
MySQL: MySQL:
Addr: 103.150.215.44:3306 # host 网络模式; bridge 模式改为 mysql:3306 Addr: 45.43.29.127:3306 # host 网络模式; bridge 模式改为 mysql:3306
Username: root # MySQL用户名 Username: root # MySQL用户名
Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致 Password: jpcV41ppanel # MySQL密码,与 .env MYSQL_ROOT_PASSWORD 一致
Dbname: hifast # MySQL数据库名 Dbname: hifast # MySQL数据库名
@@ -61,6 +61,21 @@ Trace: # 链路追踪配置 (OpenTelemetry)
Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc
Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317 Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317
S3:
Enable: false
Region: ""
Bucket: ""
Endpoint: ""
AccessKey: ""
SecretKey: ""
SessionToken: ""
Prefix: "app-upload"
PublicBaseURL: ""
UsePathStyle: false
PresignExpireSeconds: 300
MaxUploadSize: 104857600
AllowedContentTypes: "application/zip,application/x-zip-compressed,application/gzip,application/x-gzip,application/octet-stream,text/plain,application/json,image/jpeg,image/jpg,image/png,image/webp,image/gif,image/heic,image/heif,image/bmp"
device: device:
enable: true # 开启设备加密通信 enable: true # 开启设备加密通信
security_secret: "" # AES加密密钥,需要和App端一致,key=SHA256(security_secret)[:32] security_secret: "" # AES加密密钥,需要和App端一致,key=SHA256(security_secret)[:32]
@@ -68,3 +83,8 @@ device:
Administrator: Administrator:
Email: admin@ppanel.dev # 后台登录邮箱,请修改 Email: admin@ppanel.dev # 后台登录邮箱,请修改
Password: CHANGE_ME_TO_STRONG_PASSWORD # 后台登录密码,请修改为强密码 Password: CHANGE_ME_TO_STRONG_PASSWORD # 后台登录密码,请修改为强密码
Register:
EnableTrial: true
EnableTrialEmailWhitelist: true
TrialEmailDomainWhitelist: "facebook.com,yahoo.com,qq.com,live.com,outlook.com,msn.com,example.com,go.com,aol.com,free.fr,aliyun.com,163.com,yandex.ru,indiatimes.com,alibaba.com,geocities.com,about.com,naver.com,netscape.com,yahoo.co.jp,earthlink.net,zoho.com,sky.com,mail.ru,angelfire.com,uol.com.br,spb.ru,yandex.com,globo.com,gmail.com,medscape.com,space.com,discovery.com,t-online.de,mac.com,icloud.com,homestead.com,lycos.com,web.id,nus.edu.sg,altavista.com,berlin.de,rambler.ru,pp.ua,comcast.net,sapo.pt,msk.ru,ancestry.com,daum.net,proton.me,law.com,bt.com,techspot.com,icq.com,sina.cn,libero.it,test.com,yourdomain.com,docomo.ne.jp,wp.pl,hotmail.com,orange.fr,onet.pl,me.com,india.com,kansascity.com,wanadoo.fr,fortunecity.com,web.de,terra.com.br,att.net,canada.com,skynet.be,ya.ru,excite.com,detik.com,compuserve.com,ig.com.br,zp.ua,gmx.net,xoom.com,mindspring.com,freeserve.co.uk,interia.pl,excite.co.jp,test.de,shaw.ca,virgilio.it,chez.com,rr.com,freenet.de,ntlworld.com,seznam.cz,arcor.de,tiscali.it,sympatico.ca,sina.com,gazeta.pl,care2.com,yam.com,r7.com,telenet.be,rcn.com,geek.com,sfr.fr,hotbot.com,cox.net,blueyonder.co.uk,tom.com,virginmedia.com,btinternet.com,iinet.net.au,rogers.com,ireland.com,pochta.ru,ozemail.com.au,catholic.org,bluewin.ch,chat.ru,virgin.net,verizon.net,erols.com,lycos.de,lycos.co.uk,protonmail.com,doityourself.com,home.nl,nate.com,casino.com,o2.co.uk,terra.es,mail.com,albawaba.com,126.com,www.com,planet.nl,sanook.com,21cn.com,online.de,name.com,i.ua,centrum.cz,rin.ru,aol.co.uk,voila.fr,walla.co.il,poste.it,netcom.com,parrot.com,charter.net,mydomain.com,mail-tester.com,myway.com,chello.nl,club-internet.fr,sdf.org,tiscali.co.uk,freeuk.com,unican.es,sci.fi,anonymize.com,sify.com,metacrawler.com,go.ro,ivillage.com,telus.net,dailypioneer.com,iespana.es,lycos.es,hey.com,sweb.cz,optusnet.com.au,alice.it,tpg.com.au,hamptonroads.com,saudia.com,lycos.nl,blackplanet.com,frontier.com,looksmart.com,pobox.com,prodigy.net,i.am,freeyellow.com,gmx.com,bigpond.com,crosswinds.net,dejanews.com,wanadoo.es,foxmail.com,eircom.net,islamonline.net,webindia123.com,oath.com,frontiernet.net,hetnet.nl,onmilwaukee.com,ukr.net,bugmenot.com,neuf.fr,kiwibox.com,za.com,iol.it,zonnet.nl,newmail.ru,pacbell.net,cogeco.ca,depechemode.com,concentric.net,aim.com,f5.si,yahoo.jp,terra.com,hot.ee,netzero.net,netins.net,sprynet.com,mailbox.org,mail2web.com,o2.pl,idirect.com,bigfoot.com,netspace.net.au,masrawy.com,supereva.it,yahoo.de,lycos.it,yeah.net,montevideo.com.uy,gmx.de,yahoo.co.uk,yahoofs.com,scubadiving.com,hushmail.com,iprimus.com.au,gportal.hu,swissinfo.org,inbox.com,bolt.com,telstra.com,bellsouth.net,spray.se,c3.hu,attbi.com,talktalk.co.uk,dynu.net,juno.com,yahoo.fr,msn.co.uk,fr.nf,pe.hu,bigpond.net.au,incredimail.com,adelphia.net,elvis.com,interfree.it,starmedia.com,seanet.com,yahoo.com.tw,zip.net,tds.net,she.com,forthnet.gr,land.ru,wow.com,dnsmadeeasy.com,webjump.com,singnet.com.sg,spacewar.com,tin.it,4mg.com,sp.nl,wowway.com,dmv.com,bangkok.com,fastmail.fm,sbcglobal.net,bright.net,usa.com,37.com,aeiou.pt,terra.cl,thirdage.com,btconnect.com,optimum.net,cableone.net,talkcity.com,blogos.com,c2i.net,iwon.com,aver.com,barcelona.com,ddnsfree.com,oi.com.br,lex.bg,roadrunner.com,airmail.net,lawyer.com,yahoo.com.cn,cu.cc,ananzi.co.za,au.ru,pipeline.com,cs.com,3ammagazine.com,gmx.at,qwest.net,btopenworld.com,easypost.com,westnet.com.au,nyc.com,korea.com,front.ru,inbox.lv,yahoo.com.br,ny.com,hispavista.com,abv.bg,mchsi.com,apollo.lv,everyone.net,terra.com.ar,singpost.com,doctor.com,garbage.com,bizhosting.com,go2net.com,clerk.com,games.com,charm.net,onlinehome.de,laposte.net" # 填你的白名单域名,逗号分隔
+26 -11
View File
@@ -1,13 +1,14 @@
module github.com/perfect-panel/server module github.com/perfect-panel/server
go 1.23.3 go 1.24
replace github.com/zeromicro/go-zero => ./internal/gozero
require ( require (
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f
github.com/alibabacloud-go/darabonba-openapi v0.1.18 github.com/alibabacloud-go/darabonba-openapi v0.1.18
github.com/alibabacloud-go/dysmsapi-20170525/v2 v2.0.18 github.com/alibabacloud-go/dysmsapi-20170525/v2 v2.0.18
github.com/alibabacloud-go/tea v1.2.2 github.com/alibabacloud-go/tea v1.2.2
github.com/alicebob/miniredis/v2 v2.34.0
github.com/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72 github.com/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72
github.com/andybalholm/brotli v1.1.1 github.com/andybalholm/brotli v1.1.1
github.com/forgoer/openssl v1.6.0 github.com/forgoer/openssl v1.6.0
@@ -32,9 +33,9 @@ require (
github.com/smartwalle/alipay/v3 v3.2.23 github.com/smartwalle/alipay/v3 v3.2.23
github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
github.com/stripe/stripe-go/v81 v81.1.0 github.com/stripe/stripe-go/v81 v81.1.0
github.com/twilio/twilio-go v1.23.11 github.com/twilio/twilio-go v1.23.11
github.com/zeromicro/go-zero v0.0.0
go.opentelemetry.io/otel v1.29.0 go.opentelemetry.io/otel v1.29.0
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 go.opentelemetry.io/otel/exporters/jaeger v1.17.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0
@@ -51,15 +52,20 @@ require (
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.7 gorm.io/driver/mysql v1.5.7
gorm.io/gorm v1.30.0 gorm.io/gorm v1.30.0
gorm.io/plugin/soft_delete v1.2.1
k8s.io/apimachinery v0.31.1 k8s.io/apimachinery v0.31.1
) )
require ( require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/Masterminds/sprig/v3 v3.3.0 github.com/Masterminds/sprig/v3 v3.3.0
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/credentials v1.19.16
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
github.com/goccy/go-json v0.10.4 github.com/goccy/go-json v0.10.4
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.18.2
github.com/mojocn/base64Captcha v1.3.8
github.com/oschwald/geoip2-golang v1.13.0 github.com/oschwald/geoip2-golang v1.13.0
github.com/spaolacci/murmur3 v1.1.0 github.com/spaolacci/murmur3 v1.1.0
google.golang.org/grpc v1.64.1 google.golang.org/grpc v1.64.1
@@ -79,8 +85,21 @@ require (
github.com/alibabacloud-go/tea-utils v1.4.5 // indirect 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-utils/v2 v2.0.7 // indirect
github.com/alibabacloud-go/tea-xml v1.1.3 // 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/aliyun/credentials-go v1.3.10 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff // indirect
github.com/bytedance/sonic v1.12.7 // indirect github.com/bytedance/sonic v1.12.7 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect github.com/bytedance/sonic/loader v0.2.3 // indirect
@@ -88,12 +107,12 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.5.6 // indirect github.com/clbanning/mxj/v2 v2.5.6 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/glog v1.2.0 // indirect github.com/golang/glog v1.2.0 // indirect
github.com/golang/mock v1.6.0 // indirect github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
@@ -113,7 +132,6 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -121,18 +139,15 @@ require (
github.com/openzipkin/zipkin-go v0.4.2 // indirect github.com/openzipkin/zipkin-go v0.4.2 // indirect
github.com/oschwald/maxminddb-golang v1.13.0 // indirect github.com/oschwald/maxminddb-golang v1.13.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.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 github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/smartwalle/ncrypto v1.0.4 // indirect github.com/smartwalle/ncrypto v1.0.4 // indirect
github.com/smartwalle/ngx v1.0.9 // indirect github.com/smartwalle/ngx v1.0.9 // indirect
github.com/smartwalle/nsign v1.0.9 // indirect github.com/smartwalle/nsign v1.0.9 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect
@@ -140,6 +155,7 @@ require (
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.13.0 // indirect golang.org/x/arch v0.13.0 // indirect
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect
@@ -147,5 +163,4 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
) )
+70 -22
View File
@@ -8,6 +8,8 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f h1:RDkg3pyE1qGbBpRWmvSN9RNZC5nUrOaEPiEpEb8y2f0= github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f h1:RDkg3pyE1qGbBpRWmvSN9RNZC5nUrOaEPiEpEb8y2f0=
github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f/go.mod h1:zA7AF9RTfpluCfz0omI4t5KCMaWHUMicsZoMccnaT44= github.com/GUAIK-ORG/go-snowflake v0.0.0-20200116064823-220c4260e85f/go.mod h1:zA7AF9RTfpluCfz0omI4t5KCMaWHUMicsZoMccnaT44=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
@@ -52,10 +54,6 @@ 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.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 h1:7LYnm+JbOq2B+T/B0fHC4Ies4/FofC4zHzYtqw7dgt0=
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= 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/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw= 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.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA= github.com/aliyun/credentials-go v1.3.10 h1:45Xxrae/evfzQL9V10zL3xX31eqgLWEaIdCoPipOEQA=
@@ -64,6 +62,42 @@ github.com/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72 h1:
github.com/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72/go.mod h1:PsJICrlruG9QcJDYuZ0dO/2KtMDALzRbony8NkxZ2nE= github.com/anaskhan96/go-password-encoder v0.0.0-20201010210601-c765b799fd72/go.mod h1:PsJICrlruG9QcJDYuZ0dO/2KtMDALzRbony8NkxZ2nE=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04= github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff h1:RmdPFa+slIr4SCBg4st/l/vZWVe9QJKMXGO60Bxbe04=
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw= github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
@@ -159,6 +193,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= 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-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 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 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68=
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
@@ -224,14 +260,13 @@ github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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/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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= 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.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -256,9 +291,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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-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.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= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -274,6 +306,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg=
github.com/mojocn/base64Captcha v1.3.8/go.mod h1:QFZy927L8HVP3+VV5z2b1EAEiv1KxVJKZbAucVgLUy4=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
@@ -361,8 +395,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
@@ -405,12 +437,17 @@ golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4= golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4=
golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
@@ -419,6 +456,9 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -434,7 +474,10 @@ golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 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.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -448,6 +491,10 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -466,14 +513,21 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -481,7 +535,10 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 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/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.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -499,6 +556,8 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -547,20 +606,9 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
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.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 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=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU=
gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
@@ -0,0 +1,272 @@
apiVersion: 1
groups:
- orgId: 1
name: ppanel-core
folder: PPanel
interval: 1m
rules:
- uid: ppanel-target-down
title: PPanel monitoring target down
condition: C
for: 2m
noDataState: Alerting
execErrState: Error
annotations:
summary: "Monitoring target is down"
description: "{{ $labels.job }} on {{ $labels.instance }} has been down for more than 2 minutes."
labels:
severity: critical
service: ppanel
data:
- refId: A
relativeTimeRange:
from: 300
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: 'up{job=~"grafana|prometheus|node-exporter|cadvisor|nginx-exporter|loki|tempo"}'
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
- refId: C
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 1
type: lt
operator:
type: and
query:
params:
- A
reducer:
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
- uid: ppanel-host-disk-high
title: PPanel host disk usage high
condition: C
for: 10m
noDataState: NoData
execErrState: Error
annotations:
summary: "Host disk usage is high"
description: "{{ $labels.instance }} {{ $labels.mountpoint }} disk usage is above 85% for 10 minutes."
labels:
severity: warning
service: ppanel
data:
- refId: A
relativeTimeRange:
from: 900
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: '100 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs|aufs",mountpoint!~"/run.*|/var/lib/docker.*"} * 100)'
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
- refId: C
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 85
type: gt
operator:
type: and
query:
params:
- A
reducer:
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
- uid: ppanel-host-memory-high
title: PPanel host memory usage high
condition: C
for: 10m
noDataState: NoData
execErrState: Error
annotations:
summary: "Host memory usage is high"
description: "{{ $labels.instance }} memory usage is above 90% for 10 minutes."
labels:
severity: warning
service: ppanel
data:
- refId: A
relativeTimeRange:
from: 900
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100'
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
- refId: C
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 90
type: gt
operator:
type: and
query:
params:
- A
reducer:
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
- uid: ppanel-host-cpu-high
title: PPanel host CPU usage high
condition: C
for: 10m
noDataState: NoData
execErrState: Error
annotations:
summary: "Host CPU usage is high"
description: "{{ $labels.instance }} CPU usage is above 90% for 10 minutes."
labels:
severity: warning
service: ppanel
data:
- refId: A
relativeTimeRange:
from: 900
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)'
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
- refId: C
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 90
type: gt
operator:
type: and
query:
params:
- A
reducer:
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
- uid: ppanel-container-restarts
title: PPanel container restarted
condition: C
for: 1m
noDataState: NoData
execErrState: Error
annotations:
summary: "Container restarted"
description: "{{ $labels.name }} restarted or changed start time in the last hour."
labels:
severity: warning
service: ppanel
data:
- refId: A
relativeTimeRange:
from: 3600
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: 'sum by (name) (changes(container_start_time_seconds{name!=""}[1h]))'
instant: true
intervalMs: 1000
maxDataPoints: 43200
refId: A
- refId: C
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 0
type: gt
operator:
type: and
query:
params:
- A
reducer:
type: last
type: query
datasource:
type: __expr__
uid: __expr__
expression: A
intervalMs: 1000
maxDataPoints: 43200
refId: C
type: threshold
@@ -0,0 +1,14 @@
apiVersion: 1
providers:
- name: PPanel
orgId: 1
folder: PPanel
folderUid: ppanel
type: file
disableDeletion: false
allowUiUpdates: true
updateIntervalSeconds: 30
options:
path: /etc/grafana/provisioning/dashboards/json
foldersFromFilesStructure: false
@@ -0,0 +1,520 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"gridPos": {
"h": 3,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"content": "<b>AWS CloudWatch overview</b><br/>Region: ap-east-1 (Hong Kong)<br/>RDS DBInstanceIdentifier: hifast-mysql-prod-v2<br/>Redis: current production uses a local Docker Redis container (<code>hifast-redis</code>) on EC2 rather than AWS ElastiCache.<br/><br/>This dashboard keeps the RDS CloudWatch panels. Redis should be observed from the local ops dashboard via Prometheus/cAdvisor instead of ElastiCache metrics.",
"mode": "html"
},
"pluginVersion": "11.0.0",
"title": "Read Me",
"type": "text"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 3
},
"id": 2,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "CPUUtilization",
"namespace": "AWS/RDS",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "RDS CPU",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 3
},
"id": 3,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "DatabaseConnections",
"namespace": "AWS/RDS",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "RDS Connections",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 3
},
"id": 4,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "FreeStorageSpace",
"namespace": "AWS/RDS",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Minimum"
}
],
"title": "RDS Free Storage",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 11
},
"id": 5,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "ReadLatency",
"namespace": "AWS/RDS",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "WriteLatency",
"namespace": "AWS/RDS",
"period": "",
"refId": "B",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "RDS Read / Write Latency",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "iops"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 11
},
"id": 6,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "ReadIOPS",
"namespace": "AWS/RDS",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"DBInstanceIdentifier": "hifast-mysql-prod-v2"
},
"metricName": "WriteIOPS",
"namespace": "AWS/RDS",
"period": "",
"refId": "B",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "RDS Read / Write IOPS",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 19
},
"id": 7,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"ReplicationGroupId": "hifastapp-redis"
},
"metricName": "CPUUtilization",
"namespace": "AWS/ElastiCache",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "Redis Host CPU (Legacy ElastiCache)",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 6,
"y": 19
},
"id": 8,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"ReplicationGroupId": "hifastapp-redis"
},
"metricName": "EngineCPUUtilization",
"namespace": "AWS/ElastiCache",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "Redis Engine CPU (Legacy ElastiCache)",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 12,
"y": 19
},
"id": 9,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"ReplicationGroupId": "hifastapp-redis"
},
"metricName": "CurrConnections",
"namespace": "AWS/ElastiCache",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "Redis Connections (Legacy ElastiCache)",
"type": "timeseries"
},
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"fieldConfig": {
"defaults": {
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 6,
"x": 18,
"y": 19
},
"id": 10,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "cloudwatch",
"uid": "cloudwatch"
},
"dimensions": {
"ReplicationGroupId": "hifastapp-redis"
},
"metricName": "DatabaseMemoryUsagePercentage",
"namespace": "AWS/ElastiCache",
"period": "",
"refId": "A",
"region": "ap-east-1",
"statistic": "Average"
}
],
"title": "Redis Memory Usage % (Legacy ElastiCache)",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 39,
"style": "dark",
"tags": [
"aws",
"cloudwatch",
"rds",
"redis"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "AWS RDS & Redis Overview",
"uid": "aws-rds-redis-overview",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,565 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [
{
"options": {
"0": {
"text": "DOWN"
},
"1": {
"text": "UP"
}
},
"type": "value"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "13.0.1",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "up{job=~\"prometheus|grafana|node-exporter|cadvisor|nginx-exporter|loki|tempo\"}",
"instant": true,
"legendFormat": "{{job}}",
"range": false,
"refId": "A"
}
],
"title": "Service Availability",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 75
},
{
"color": "red",
"value": 90
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 4
},
"id": 2,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"legendFormat": "{{instance}} CPU",
"range": true,
"refId": "A"
}
],
"title": "Host CPU Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 80
},
{
"color": "red",
"value": 90
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 4
},
"id": 3,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
"legendFormat": "{{instance}} memory",
"range": true,
"refId": "A"
}
],
"title": "Host Memory Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"max": 100,
"min": 0,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 80
},
{
"color": "red",
"value": 90
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 4
},
"id": 4,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "100 - (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs|aufs\",mountpoint!~\"/run.*|/var/lib/docker.*\"} * 100)",
"legendFormat": "{{mountpoint}}",
"range": true,
"refId": "A"
}
],
"title": "Host Disk Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "percentunit"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 12
},
"id": 5,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name!=\"\"}[5m]))",
"legendFormat": "{{name}}",
"range": true,
"refId": "A"
}
],
"title": "Container CPU",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 12
},
"id": 6,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (name) (container_memory_working_set_bytes{name!=\"\"})",
"legendFormat": "{{name}}",
"range": true,
"refId": "A"
}
],
"title": "Container Memory",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 12
},
"id": 7,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (name) (changes(container_start_time_seconds{name!=\"\"}[1h]))",
"legendFormat": "{{name}}",
"range": true,
"refId": "A"
}
],
"title": "Container Restarts / Changes",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 20
},
"id": 8,
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "single"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "rate(nginx_http_requests_total[5m])",
"legendFormat": "requests",
"range": true,
"refId": "A"
}
],
"title": "Nginx Requests",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 20
},
"id": 9,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": true,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "loki"
},
"editorMode": "code",
"expr": "{job=~\"ppanel-server|nginx|docker\"} |~ \"(?i)(error|panic|fatal|timeout|exception|failed)\"",
"queryType": "range",
"refId": "A"
}
],
"title": "Recent Errors",
"type": "logs"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 20
},
"id": 10,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (service_name) (rate(traces_spanmetrics_calls_total[5m]))",
"legendFormat": "{{service_name}}",
"range": true,
"refId": "A"
}
],
"title": "Trace Span Calls",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 42,
"tags": [
"ppanel",
"ops",
"prometheus",
"loki",
"tempo"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "PPanel Ops Overview",
"uid": "ppanel-ops-overview",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,330 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "sum(count_over_time({compose_service=\"ppanel-server\"}[5m]))",
"queryType": "range",
"refId": "A"
}
],
"title": "Matched Log Volume",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"legend": {
"displayMode": "table",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "sum(count_over_time({compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\" [5m]))",
"queryType": "range",
"refId": "A"
}
],
"title": "Matched Error Volume",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"gridPos": {
"h": 12,
"w": 24,
"x": 0,
"y": 7
},
"id": 3,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": true,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "{compose_service=\"ppanel-server\"}",
"queryType": "range",
"refId": "A"
}
],
"title": "Filtered Server Logs",
"type": "logs"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 19
},
"id": 4,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": true,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "{compose_service=\"ppanel-server\"} |~ \"(?i)(error|panic|fatal)\"",
"queryType": "range",
"refId": "A"
}
],
"title": "Filtered Server Errors",
"type": "logs"
}
],
"refresh": "30s",
"schemaVersion": 42,
"tags": [
"ppanel",
"logs",
"server",
"loki"
],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": ".",
"value": "."
},
"description": "输入用户 ID;默认 . 表示不过滤",
"hide": 0,
"label": "用户ID",
"name": "user_id",
"options": [],
"query": ".",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"selected": false,
"text": ".",
"value": "."
},
"description": "输入邮箱或邮箱片段;默认 . 表示不过滤",
"hide": 0,
"label": "邮箱",
"name": "email",
"options": [],
"query": ".",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"selected": false,
"text": ".",
"value": "."
},
"description": "输入订单号/支付单号/交易号片段;默认 . 表示不过滤",
"hide": 0,
"label": "订单",
"name": "order",
"options": [],
"query": ".",
"skipUrlSync": false,
"type": "textbox"
},
{
"current": {
"selected": true,
"text": "All",
"value": "."
},
"description": "日志等级",
"hide": 0,
"includeAll": false,
"label": "等级",
"multi": false,
"name": "level",
"options": [
{
"selected": true,
"text": "All",
"value": "."
},
{
"selected": false,
"text": "debug",
"value": "debug"
},
{
"selected": false,
"text": "info",
"value": "info"
},
{
"selected": false,
"text": "warn",
"value": "warn"
},
{
"selected": false,
"text": "error",
"value": "error"
},
{
"selected": false,
"text": "slow",
"value": "slow"
},
{
"selected": false,
"text": "panic",
"value": "panic"
},
{
"selected": false,
"text": "fatal",
"value": "fatal"
}
],
"query": "All : .,debug,info,warn,error,slow,panic,fatal",
"queryValue": "",
"skipUrlSync": false,
"type": "custom"
},
{
"current": {
"selected": false,
"text": ".",
"value": "."
},
"description": "任意关键字;默认 . 表示不过滤",
"hide": 0,
"label": "关键字",
"name": "keyword",
"options": [],
"query": ".",
"skipUrlSync": false,
"type": "textbox"
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "PPanel Server Logs",
"uid": "ppanel-server-logs",
"version": 5,
"weekStart": ""
}
@@ -0,0 +1,57 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
jsonData:
httpMethod: POST
manageAlerts: true
prometheusType: Prometheus
prometheusVersion: 2.50.0
timeInterval: 15s
- name: Loki
uid: loki
type: loki
access: proxy
url: http://loki:3100
editable: true
jsonData:
derivedFields:
- datasourceUid: tempo
matcherRegex: '"(?:trace|traceID|trace_id)"\s*:\s*"([a-f0-9]{32})"'
name: TraceID
url: '$${__value.raw}'
- name: Tempo
uid: tempo
type: tempo
access: proxy
url: http://tempo:3200
editable: true
jsonData:
tracesToLogsV2:
datasourceUid: loki
filterByTraceID: true
filterBySpanID: false
tags:
- key: service.name
value: service_name
tracesToMetrics:
datasourceUid: prometheus
serviceMap:
datasourceUid: prometheus
- name: CloudWatch
uid: cloudwatch
type: cloudwatch
access: proxy
editable: true
jsonData:
authType: default
defaultRegion: ap-east-1
@@ -449,7 +449,7 @@ CREATE TABLE IF NOT EXISTS `user_device`
`subscribe_id` bigint DEFAULT NULL COMMENT 'Subscribe ID', `subscribe_id` bigint DEFAULT NULL COMMENT 'Subscribe ID',
`ip` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Ip.', `ip` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Ip.',
`Identifier` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Identifier.', `Identifier` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device Identifier.',
`user_agent` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device User Agent.', `user_agent` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Device User Agent.',
`online` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Online', `online` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'Online',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'EnableDeviceNumber', `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'EnableDeviceNumber',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time', `created_at` datetime(3) DEFAULT NULL COMMENT 'Creation Time',
@@ -0,0 +1,13 @@
-- Remove app_account_token column from order table if it exists
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'order' AND COLUMN_NAME = 'app_account_token');
SET @sql = IF(@col_exists = 1, 'ALTER TABLE `order` DROP COLUMN `app_account_token`', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Remove subscription_user_id column from order table if it exists
SET @col_exists2 = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'order' AND COLUMN_NAME = 'subscription_user_id');
SET @sql2 = IF(@col_exists2 = 1, 'ALTER TABLE `order` DROP COLUMN `subscription_user_id`', 'SELECT 1');
PREPARE stmt2 FROM @sql2;
EXECUTE stmt2;
DEALLOCATE PREPARE stmt2;
@@ -0,0 +1,28 @@
-- Purpose: Rollback node group management tables
-- Author: Tension
-- Date: 2025-02-23
-- Updated: 2025-03-06
-- ===== Remove system configuration entries =====
DELETE FROM `system` WHERE `category` = 'group' AND `key` IN ('enabled', 'mode', 'auto_create_group');
-- ===== Remove columns and indexes from subscribe table =====
ALTER TABLE `subscribe` DROP INDEX IF EXISTS `idx_node_group_id`;
ALTER TABLE `subscribe` DROP COLUMN IF EXISTS `node_group_id`;
ALTER TABLE `subscribe` DROP COLUMN IF EXISTS `node_group_ids`;
-- ===== Remove columns and indexes from user_subscribe table =====
ALTER TABLE `user_subscribe` DROP INDEX IF EXISTS `idx_node_group_id`;
ALTER TABLE `user_subscribe` DROP COLUMN IF EXISTS `node_group_id`;
-- ===== Remove columns and indexes from nodes table =====
ALTER TABLE `nodes` DROP COLUMN IF EXISTS `node_group_ids`;
-- ===== Drop group_history_detail table =====
DROP TABLE IF EXISTS `group_history_detail`;
-- ===== Drop group_history table =====
DROP TABLE IF EXISTS `group_history`;
-- ===== Drop node_group table =====
DROP TABLE IF EXISTS `node_group`;
@@ -0,0 +1,130 @@
-- Purpose: Add node group management tables with multi-group support
-- Author: Tension
-- Date: 2025-02-23
-- Updated: 2025-03-06
-- ===== Create node_group table =====
DROP TABLE IF EXISTS `node_group`;
CREATE TABLE IF NOT EXISTS `node_group` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Name',
`description` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Group Description',
`sort` int NOT NULL DEFAULT '0' COMMENT 'Sort Order',
`for_calculation` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'For Grouping Calculation: 0=false, 1=true',
`min_traffic_gb` bigint DEFAULT 0 COMMENT 'Minimum Traffic (GB) for this node group',
`max_traffic_gb` bigint DEFAULT 0 COMMENT 'Maximum Traffic (GB) for this node group',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
`updated_at` datetime(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
KEY `idx_sort` (`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Node Groups';
-- ===== Create group_history table =====
DROP TABLE IF EXISTS `group_history`;
CREATE TABLE IF NOT EXISTS `group_history` (
`id` bigint NOT NULL AUTO_INCREMENT,
`group_mode` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Group Mode: average/subscribe/traffic',
`trigger_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'Trigger Type: manual/auto/schedule',
`state` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT 'State: pending/running/completed/failed',
`total_users` int NOT NULL DEFAULT '0' COMMENT 'Total Users',
`success_count` int NOT NULL DEFAULT '0' COMMENT 'Success Count',
`failed_count` int NOT NULL DEFAULT '0' COMMENT 'Failed Count',
`start_time` datetime(3) DEFAULT NULL COMMENT 'Start Time',
`end_time` datetime(3) DEFAULT NULL COMMENT 'End Time',
`operator` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'Operator',
`error_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT 'Error Message',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
PRIMARY KEY (`id`),
KEY `idx_group_mode` (`group_mode`),
KEY `idx_trigger_type` (`trigger_type`),
KEY `idx_state` (`state`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Group Calculation History';
-- ===== Create group_history_detail table =====
-- Note: user_group_id column removed, using user_data JSON field instead
DROP TABLE IF EXISTS `group_history_detail`;
CREATE TABLE IF NOT EXISTS `group_history_detail` (
`id` bigint NOT NULL AUTO_INCREMENT,
`history_id` bigint NOT NULL COMMENT 'History ID',
`node_group_id` bigint NOT NULL COMMENT 'Node Group ID',
`user_count` int NOT NULL DEFAULT '0' COMMENT 'User Count',
`node_count` int NOT NULL DEFAULT '0' COMMENT 'Node Count',
`user_data` TEXT COMMENT 'User data JSON (id and email/phone)',
`created_at` datetime(3) DEFAULT NULL COMMENT 'Create Time',
PRIMARY KEY (`id`),
KEY `idx_history_id` (`history_id`),
KEY `idx_node_group_id` (`node_group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Group History Details';
-- ===== Add columns to nodes table =====
SET @column_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'nodes' AND COLUMN_NAME = 'node_group_ids');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `nodes` ADD COLUMN `node_group_ids` JSON COMMENT ''Node Group IDs (JSON array, multiple groups)''',
'SELECT ''Column node_group_ids already exists''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ===== Add node_group_id column to user_subscribe table =====
SET @column_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_subscribe' AND COLUMN_NAME = 'node_group_id');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `user_subscribe` ADD COLUMN `node_group_id` bigint NOT NULL DEFAULT 0 COMMENT ''Node Group ID (single ID)''',
'SELECT ''Column node_group_id already exists''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ===== Add index for user_subscribe.node_group_id =====
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_subscribe' AND INDEX_NAME = 'idx_node_group_id');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `user_subscribe` ADD INDEX `idx_node_group_id` (`node_group_id`)',
'SELECT ''Index idx_node_group_id already exists''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ===== Add group_locked column to user_subscribe table =====
SET @column_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_subscribe' AND COLUMN_NAME = 'group_locked');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `user_subscribe` ADD COLUMN `group_locked` tinyint(1) NOT NULL DEFAULT 0 COMMENT ''Group Locked''',
'SELECT ''Column group_locked already exists in user_subscribe table''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ===== Add columns to subscribe table =====
SET @column_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'subscribe' AND COLUMN_NAME = 'node_group_ids');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `subscribe` ADD COLUMN `node_group_ids` JSON COMMENT ''Node Group IDs (JSON array, multiple groups)''',
'SELECT ''Column node_group_ids already exists''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ===== Add default node_group_id column to subscribe table =====
SET @column_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'subscribe' AND COLUMN_NAME = 'node_group_id');
SET @sql = IF(@column_exists = 0,
'ALTER TABLE `subscribe` ADD COLUMN `node_group_id` bigint NOT NULL DEFAULT 0 COMMENT ''Default Node Group ID (single ID)''',
'SELECT ''Column node_group_id already exists in subscribe table''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ===== Add index for subscribe.node_group_id =====
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'subscribe' AND INDEX_NAME = 'idx_node_group_id');
SET @sql = IF(@index_exists = 0,
'ALTER TABLE `subscribe` ADD INDEX `idx_node_group_id` (`node_group_id`)',
'SELECT ''Index idx_node_group_id already exists in subscribe table''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ===== Insert system configuration entries =====
INSERT INTO `system` (`category`, `key`, `value`, `desc`) VALUES
('group', 'enabled', 'false', 'Group Management Enabled'),
('group', 'mode', 'average', 'Group Mode: average/subscribe/traffic'),
('group', 'auto_create_group', 'false', 'Auto-create user group when creating subscribe product')
ON DUPLICATE KEY UPDATE
`value` = VALUES(`value`),
`desc` = VALUES(`desc`);
@@ -0,0 +1,17 @@
-- Rollback: restore old verify configuration fields
INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`) VALUES
('verify', 'EnableLoginVerify', 'false', 'bool', 'is enable login verify'),
('verify', 'EnableRegisterVerify', 'false', 'bool', 'is enable register verify'),
('verify', 'EnableResetPasswordVerify', 'false', 'bool', 'is enable reset password verify')
ON DUPLICATE KEY UPDATE
`value` = VALUES(`value`),
`desc` = VALUES(`desc`);
-- Remove new captcha configuration fields
DELETE FROM `system` WHERE `category` = 'verify' AND `key` IN (
'CaptchaType',
'EnableUserLoginCaptcha',
'EnableUserRegisterCaptcha',
'EnableAdminLoginCaptcha',
'EnableUserResetPasswordCaptcha'
);
@@ -0,0 +1,17 @@
-- Add new captcha configuration fields
INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`) VALUES
('verify', 'CaptchaType', 'local', 'string', 'Captcha type: local or turnstile'),
('verify', 'EnableUserLoginCaptcha', 'false', 'bool', 'Enable captcha for user login'),
('verify', 'EnableUserRegisterCaptcha', 'false', 'bool', 'Enable captcha for user registration'),
('verify', 'EnableAdminLoginCaptcha', 'false', 'bool', 'Enable captcha for admin login'),
('verify', 'EnableUserResetPasswordCaptcha', 'false', 'bool', 'Enable captcha for user reset password')
ON DUPLICATE KEY UPDATE
`value` = VALUES(`value`),
`desc` = VALUES(`desc`);
-- Remove old verify configuration fields
DELETE FROM `system` WHERE `category` = 'verify' AND `key` IN (
'EnableLoginVerify',
'EnableRegisterVerify',
'EnableResetPasswordVerify'
);
@@ -0,0 +1,12 @@
-- 回滚 user_subscribe 表的过期流量字段
ALTER TABLE `user_subscribe`
DROP COLUMN `expired_upload`,
DROP COLUMN `expired_download`;
-- 回滚 node_group 表的过期节点组字段
ALTER TABLE `node_group`
DROP INDEX `idx_is_expired_group`,
DROP COLUMN `speed_limit`,
DROP COLUMN `max_traffic_gb_expired`,
DROP COLUMN `expired_days_limit`,
DROP COLUMN `is_expired_group`;
@@ -0,0 +1,14 @@
-- 为 node_group 表添加过期节点组相关字段
ALTER TABLE `node_group`
ADD COLUMN `is_expired_group` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'Is Expired Group: 0=normal, 1=expired group' AFTER `for_calculation`,
ADD COLUMN `expired_days_limit` int NOT NULL DEFAULT 7 COMMENT 'Expired days limit (days)' AFTER `is_expired_group`,
ADD COLUMN `max_traffic_gb_expired` bigint DEFAULT 0 COMMENT 'Max traffic for expired users (GB)' AFTER `expired_days_limit`,
ADD COLUMN `speed_limit` int NOT NULL DEFAULT 0 COMMENT 'Speed limit (KB/s)' AFTER `max_traffic_gb_expired`;
-- 添加索引
ALTER TABLE `node_group` ADD INDEX `idx_is_expired_group` (`is_expired_group`);
-- 为 user_subscribe 表添加过期流量统计字段
ALTER TABLE `user_subscribe`
ADD COLUMN `expired_download` bigint NOT NULL DEFAULT 0 COMMENT 'Expired period download traffic (bytes)' AFTER `upload`,
ADD COLUMN `expired_upload` bigint NOT NULL DEFAULT 0 COMMENT 'Expired period upload traffic (bytes)' AFTER `expired_download`;
@@ -0,0 +1,6 @@
-- Purpose: Rollback traffic_limit rules from subscribe
-- Author: Claude Code
-- Date: 2026-03-12
-- ===== Remove traffic_limit column from subscribe table =====
ALTER TABLE `subscribe` DROP COLUMN IF EXISTS `traffic_limit`;
@@ -0,0 +1,22 @@
-- Purpose: Add traffic_limit rules to subscribe
-- Author: Claude Code
-- Date: 2026-03-12
-- ===== Add traffic_limit column to subscribe table =====
SET @column_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'subscribe'
AND COLUMN_NAME = 'traffic_limit'
);
SET @sql = IF(
@column_exists = 0,
'ALTER TABLE `subscribe` ADD COLUMN `traffic_limit` TEXT NULL COMMENT ''Traffic Limit Rules (JSON)'' AFTER `node_group_id`',
'SELECT ''Column traffic_limit already exists in subscribe table'''
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1 @@
ALTER TABLE `user_device` DROP COLUMN `base_payload`;
@@ -0,0 +1 @@
ALTER TABLE `user_device` ADD COLUMN `base_payload` TEXT DEFAULT NULL COMMENT 'Base Payload' AFTER `short_code`;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS `order_recovery_claims`;
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `order_recovery_claims` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`order_no` VARCHAR(255) NOT NULL COMMENT 'Recovered Order No',
`email` VARCHAR(255) NOT NULL COMMENT 'Claim Email',
`user_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'User ID',
`subscribe_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Subscribe ID',
`order_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'Recovered Order ID',
`user_subscribe_id` BIGINT NOT NULL DEFAULT 0 COMMENT 'User Subscribe ID',
`claimed_at` DATETIME(3) NOT NULL COMMENT 'Claimed Time',
`created_at` DATETIME(3) DEFAULT NULL COMMENT 'Creation Time',
`updated_at` DATETIME(3) DEFAULT NULL COMMENT 'Update Time',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_order_recovery_claim_order_no` (`order_no`),
KEY `idx_order_recovery_claim_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,7 @@
ALTER TABLE `log_message`
MODIFY COLUMN `app_version` VARCHAR(32) NULL,
MODIFY COLUMN `os_name` VARCHAR(32) NULL,
MODIFY COLUMN `os_version` VARCHAR(32) NULL,
MODIFY COLUMN `device_id` VARCHAR(64) NULL,
MODIFY COLUMN `session_id` VARCHAR(64) NULL,
MODIFY COLUMN `error_code` VARCHAR(64) NULL;
@@ -0,0 +1,7 @@
ALTER TABLE `log_message`
MODIFY COLUMN `app_version` VARCHAR(64) NULL,
MODIFY COLUMN `os_name` VARCHAR(64) NULL,
MODIFY COLUMN `os_version` VARCHAR(64) NULL,
MODIFY COLUMN `device_id` VARCHAR(255) NULL,
MODIFY COLUMN `session_id` VARCHAR(255) NULL,
MODIFY COLUMN `error_code` VARCHAR(128) NULL;
@@ -0,0 +1,2 @@
ALTER TABLE `user_device`
MODIFY COLUMN `user_agent` VARCHAR(64) NULL COMMENT 'Device User Agent.';
@@ -0,0 +1,2 @@
ALTER TABLE `user_device`
MODIFY COLUMN `user_agent` VARCHAR(255) NULL COMMENT 'Device User Agent.';
@@ -0,0 +1,19 @@
-- Rollback: re-deduct commission for users with pending (status=0) withdrawals.
-- This re-applies the OLD behaviour where commission is deducted on application.
-- Only run this if you are rolling back to the old code; do NOT run against
-- the new code or commission will be double-deducted on approval.
UPDATE `user` u
JOIN (
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
FROM withdrawals
WHERE status = 0
GROUP BY user_id
) p ON u.id = p.user_id
SET u.commission = u.commission - p.pending_total
WHERE p.pending_total > 0;
-- Remove the migration log entries written by the up migration.
DELETE FROM system_logs
WHERE type = 33
AND content LIKE '%migration: refund pending withdrawal commission (HIF-22)%';
@@ -0,0 +1,65 @@
-- Migration: refund commission for existing pending (status=0) withdrawals
--
-- Under the old logic, commission was deducted when a withdrawal was submitted.
-- Under the new logic, commission is only deducted on approval.
-- This migration refunds the deducted amounts back to each user so that
-- the system is in a consistent state before the new code is deployed.
--
-- Idempotency: the UPDATE only touches rows whose commission would need
-- to increase, and each execution produces the same result because
-- COALESCE(SUM(amount),0) is deterministic given the same pending set.
-- Running this script multiple times is safe only if no new pending
-- withdrawals are created between runs; deploy new code immediately after.
-- Compatibility: some historical databases missed migration 02122, so the
-- withdrawals table may not exist yet. Create it idempotently before the
-- refund logic so this migration can self-heal older installations.
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');
-- Step 1: refund commission for all users with pending withdrawals.
UPDATE `user` u
JOIN (
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
FROM withdrawals
WHERE status = 0
GROUP BY user_id
) p ON u.id = p.user_id
SET u.commission = u.commission + p.pending_total
WHERE p.pending_total > 0;
-- Step 2: write a migration log entry for each refunded user.
INSERT INTO system_logs (type, date, object_id, content, created_at)
SELECT
33 AS type,
DATE(NOW()) AS date,
p.user_id AS object_id,
JSON_OBJECT(
'type', 99,
'amount', p.pending_total,
'order_no', '',
'timestamp', UNIX_TIMESTAMP(NOW()) * 1000,
'note', 'migration: refund pending withdrawal commission (HIF-22)'
) AS content,
NOW() AS created_at
FROM (
SELECT user_id, COALESCE(SUM(amount), 0) AS pending_total
FROM withdrawals
WHERE status = 0
GROUP BY user_id
HAVING pending_total > 0
) p;
@@ -0,0 +1,2 @@
-- Remove activation_context column from order table
ALTER TABLE `order` DROP COLUMN IF EXISTS `activation_context`;
@@ -0,0 +1,6 @@
-- Add activation_context column to order table for Redis fallback persistence (idempotent)
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'order' AND COLUMN_NAME = 'activation_context');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `order` ADD COLUMN `activation_context` TEXT DEFAULT NULL COMMENT ''Activation context JSON (guest/redemption info for DB fallback)'' AFTER `app_account_token`', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,4 @@
ALTER TABLE `withdrawals`
DROP COLUMN `qr_code_url`,
DROP COLUMN `account`,
DROP COLUMN `method`;
@@ -0,0 +1,10 @@
SELECT COUNT(*) INTO @col_exists FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'withdrawals' AND COLUMN_NAME = 'method';
SET @ddl = IF(@col_exists = 0,
'ALTER TABLE `withdrawals` ADD COLUMN `method` TINYINT(1) NOT NULL DEFAULT 0 AFTER `content`, ADD COLUMN `account` VARCHAR(255) NOT NULL DEFAULT '''' AFTER `method`, ADD COLUMN `qr_code_url` VARCHAR(500) NOT NULL DEFAULT '''' AFTER `account`',
'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,35 @@
SET @traffic_limit_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_subscribe'
AND COLUMN_NAME = 'traffic_limit'
);
SET @traffic_limit_sql = IF(
@traffic_limit_exists = 1,
'ALTER TABLE `user_subscribe` DROP COLUMN `traffic_limit`',
'SELECT 1'
);
PREPARE traffic_limit_stmt FROM @traffic_limit_sql;
EXECUTE traffic_limit_stmt;
DEALLOCATE PREPARE traffic_limit_stmt;
SET @speed_limit_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_subscribe'
AND COLUMN_NAME = 'speed_limit'
);
SET @speed_limit_sql = IF(
@speed_limit_exists = 1,
'ALTER TABLE `user_subscribe` DROP COLUMN `speed_limit`',
'SELECT 1'
);
PREPARE speed_limit_stmt FROM @speed_limit_sql;
EXECUTE speed_limit_stmt;
DEALLOCATE PREPARE speed_limit_stmt;
@@ -0,0 +1,35 @@
SET @speed_limit_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_subscribe'
AND COLUMN_NAME = 'speed_limit'
);
SET @speed_limit_sql = IF(
@speed_limit_exists = 0,
'ALTER TABLE `user_subscribe` ADD COLUMN `speed_limit` BIGINT NOT NULL DEFAULT 0 COMMENT ''User-level speed limit override (Mbps), 0 uses plan-level'' AFTER `upload`',
'SELECT 1'
);
PREPARE speed_limit_stmt FROM @speed_limit_sql;
EXECUTE speed_limit_stmt;
DEALLOCATE PREPARE speed_limit_stmt;
SET @traffic_limit_exists = (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_subscribe'
AND COLUMN_NAME = 'traffic_limit'
);
SET @traffic_limit_sql = IF(
@traffic_limit_exists = 0,
'ALTER TABLE `user_subscribe` ADD COLUMN `traffic_limit` TEXT DEFAULT NULL COMMENT ''User-level traffic limit override (JSON), NULL uses plan-level'' AFTER `speed_limit`',
'SELECT 1'
);
PREPARE traffic_limit_stmt FROM @traffic_limit_sql;
EXECUTE traffic_limit_stmt;
DEALLOCATE PREPARE traffic_limit_stmt;

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