Compare commits
2 Commits
7d2f98b7c9
...
c4b2ebf7e1
| Author | SHA1 | Date | |
|---|---|---|---|
| c4b2ebf7e1 | |||
| f946504cb8 |
@@ -9,3 +9,10 @@ GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||
|
||||
# PPanel Server 镜像标签(留空使用 latest)
|
||||
PPANEL_SERVER_TAG=latest
|
||||
|
||||
# AWS 区域(香港)
|
||||
AWS_REGION=ap-east-1
|
||||
|
||||
# Grafana 公开域名(如需反代)
|
||||
GRAFANA_DOMAIN=logs-new.hifast.biz
|
||||
GRAFANA_ROOT_URL=https://logs-new.hifast.biz
|
||||
|
||||
@@ -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 ~ %s,referer_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)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
# PPanel 香港区新 AWS 账号部署说明
|
||||
|
||||
本目录用于在 **新 AWS 账号** 中按 **香港区 `ap-east-1`** 重建一套全新空环境。
|
||||
|
||||
目标架构:
|
||||
|
||||
`DNS -> ALB -> WAF -> EC2(Nginx + ppanel-server + observability) -> RDS MySQL + ElastiCache Redis`
|
||||
|
||||
## 1. 资源清单
|
||||
|
||||
按下面顺序创建资源:
|
||||
|
||||
1. VPC
|
||||
2. 2 个公有子网 + 2 个私有子网
|
||||
3. Internet Gateway
|
||||
4. 公有 / 私有路由表
|
||||
5. 安全组
|
||||
6. RDS MySQL
|
||||
7. ElastiCache Redis OSS
|
||||
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: `ppanel-redis-hk`
|
||||
- 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(如使用)
|
||||
|
||||
### 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`
|
||||
|
||||
### ElastiCache Redis OSS
|
||||
|
||||
- Engine: Redis OSS 7.x
|
||||
- Node type: `cache.m7g.large`
|
||||
- Cluster mode: disabled
|
||||
- Multi-AZ: 按预算决定,正式环境建议开启
|
||||
- Public access: 不开放
|
||||
- TLS: 当前模板按 **关闭** 规划
|
||||
|
||||
## 3. 网络与安全组
|
||||
|
||||
### 子网布局
|
||||
|
||||
- `public-a`, `public-b`: ALB / EC2
|
||||
- `private-a`, `private-b`: RDS / Redis
|
||||
|
||||
### 安全组建议
|
||||
|
||||
#### `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-redis`
|
||||
|
||||
- Inbound
|
||||
- `6379/tcp` from `sg-ec2`
|
||||
|
||||
## 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`
|
||||
- `JwtAuth.AccessSecret`
|
||||
- `Administrator.Email`
|
||||
- `Administrator.Password`
|
||||
- `AppSignature.AppSecrets.*`
|
||||
- `device.security_secret`
|
||||
- `Site.Host`
|
||||
- `Site.SiteName`
|
||||
|
||||
Redis 约定保持不变:
|
||||
|
||||
- 业务缓存:DB `0`
|
||||
- Asynq:DB `5`(代码内部已固定使用)
|
||||
|
||||
## 8. 部署步骤
|
||||
|
||||
### 8.1 初始化 EC2
|
||||
|
||||
把脚本上传到 EC2 后执行:
|
||||
|
||||
```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=<new-redis-endpoint> \
|
||||
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 `EngineCPUUtilization`, `CurrConnections`, `BytesUsedForCache`
|
||||
|
||||
## 12. 这次方案的边界
|
||||
|
||||
本目录交付的是:
|
||||
|
||||
- 香港区新账号的部署模板
|
||||
- 新空环境启动与验证流程
|
||||
- ALB / WAF / EC2 / RDS / Redis 的落地约定
|
||||
|
||||
不包含:
|
||||
|
||||
- 旧数据迁移
|
||||
- Terraform / CloudFormation 自动建资源
|
||||
- Redis TLS 改造
|
||||
- 多活 / 自动扩缩容
|
||||
@@ -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: YOUR_REDIS_PRIMARY_ENDPOINT:6379
|
||||
Pass:
|
||||
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,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;
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,11 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@@ -47,29 +45,6 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
||||
req.Code = strings.TrimSpace(req.Code)
|
||||
|
||||
// Verify Code
|
||||
scenes := []string{constant.Security.String(), constant.Register.String(), "unknown"}
|
||||
var verified bool
|
||||
var cacheKeyUsed string
|
||||
var payload common.CacheKeyPayload
|
||||
for _, scene := range scenes {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, req.Email)
|
||||
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if err != nil || value == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||
continue
|
||||
}
|
||||
if payload.Code == req.Code && time.Now().Unix()-payload.LastAt <= l.svcCtx.Config.VerifyCode.VerifyCodeExpireTime {
|
||||
verified = true
|
||||
cacheKeyUsed = cacheKey
|
||||
break
|
||||
}
|
||||
}
|
||||
if !verified {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired")
|
||||
}
|
||||
l.svcCtx.Redis.Del(l.ctx, cacheKeyUsed)
|
||||
|
||||
// Check User
|
||||
userInfo, err = l.svcCtx.UserModel.FindOneByEmail(l.ctx, req.Email)
|
||||
|
||||
@@ -37,6 +37,10 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
lastMonthStart := currentMonthStart.AddDate(0, -1, 0)
|
||||
|
||||
var views, lastMonthViews int64
|
||||
var installs int64
|
||||
var paidCount int64
|
||||
@@ -45,7 +49,7 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
||||
lokiCfg := l.svcCtx.Config.Loki
|
||||
if lokiCfg.Enable && lokiCfg.URL != "" && u.ReferCode != "" {
|
||||
lokiClient := loki.NewClient(lokiCfg.URL)
|
||||
lokiStats, err := lokiClient.GetInviteCodeStats(l.ctx, u.ReferCode, 30)
|
||||
lokiStats, err := lokiClient.GetInviteCodeMonthlyStats(l.ctx, u.ReferCode, now)
|
||||
if err != nil {
|
||||
l.Errorw("[GetAgentRealtime] Failed to fetch Loki stats",
|
||||
logger.Field("error", err.Error()),
|
||||
@@ -66,7 +70,7 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
||||
// 3. 从数据库获取安装量(被邀请注册用户数)
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&user.User{}).
|
||||
Where("referer_id = ?", u.Id).
|
||||
Where("referer_id = ? AND created_at >= ? AND created_at < ?", u.Id, currentMonthStart, now).
|
||||
Count(&installs).Error
|
||||
if err != nil {
|
||||
l.Errorw("[GetAgentRealtime] Failed to count installs",
|
||||
@@ -79,7 +83,8 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("`order`").
|
||||
Joins("LEFT JOIN user ON user.id = `order`.user_id").
|
||||
Where("user.referer_id = ? AND `order`.status IN ?", u.Id, []int{2, 5}).
|
||||
Where("user.referer_id = ? AND `order`.status IN ? AND `order`.updated_at >= ? AND `order`.updated_at < ?",
|
||||
u.Id, []int{2, 5}, currentMonthStart, now).
|
||||
Distinct("`order`.user_id").
|
||||
Count(&paidCount).Error
|
||||
if err != nil {
|
||||
@@ -93,7 +98,7 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
||||
growthRate := calculateGrowthRate([]int{int(lastMonthViews), int(views)})
|
||||
|
||||
// 6. 计算付费用户环比增长率
|
||||
paidGrowthRate := l.calculatePaidGrowthRate(u.Id)
|
||||
paidGrowthRate := l.calculatePaidGrowthRate(u.Id, currentMonthStart, lastMonthStart)
|
||||
|
||||
return &types.GetAgentRealtimeResponse{
|
||||
Total: views,
|
||||
@@ -107,19 +112,14 @@ func (l *GetAgentRealtimeLogic) GetAgentRealtime(req *types.GetAgentRealtimeRequ
|
||||
}
|
||||
|
||||
// calculatePaidGrowthRate 计算付费用户的环比增长率
|
||||
func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64) string {
|
||||
func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64, currentMonthStart, lastMonthStart time.Time) string {
|
||||
db := l.svcCtx.DB
|
||||
|
||||
// 获取本月第一天和上月第一天
|
||||
now := time.Now()
|
||||
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
lastMonthStart := currentMonthStart.AddDate(0, -1, 0)
|
||||
|
||||
// 查询本月付费用户数(本月有新订单的)
|
||||
var currentMonthCount int64
|
||||
err := db.Table("`order` o").
|
||||
Joins("JOIN user u ON o.user_id = u.id").
|
||||
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.created_at >= ?",
|
||||
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.updated_at >= ?",
|
||||
userId, 2, 5, currentMonthStart).
|
||||
Distinct("o.user_id").
|
||||
Count(¤tMonthCount).Error
|
||||
@@ -135,7 +135,7 @@ func (l *GetAgentRealtimeLogic) calculatePaidGrowthRate(userId int64) string {
|
||||
var lastMonthCount int64
|
||||
err = db.Table("`order` o").
|
||||
Joins("JOIN user u ON o.user_id = u.id").
|
||||
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.created_at >= ? AND o.created_at < ?",
|
||||
Where("u.referer_id = ? AND o.status IN (?, ?) AND o.updated_at >= ? AND o.updated_at < ?",
|
||||
userId, 2, 5, lastMonthStart, currentMonthStart).
|
||||
Distinct("o.user_id").
|
||||
Count(&lastMonthCount).Error
|
||||
|
||||
@@ -43,7 +43,7 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
|
||||
db := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Table("`order` o").
|
||||
Joins("JOIN user u ON o.user_id = u.id").
|
||||
Where("u.referer_id = ? AND o.status = ?", userId, 5)
|
||||
Where("u.referer_id = ? AND o.status IN ?", userId, []int{2, 5})
|
||||
|
||||
if req.StartTime > 0 {
|
||||
db = db.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
|
||||
@@ -88,7 +88,7 @@ func (l *GetInviteSalesLogic) GetInviteSales(req *types.GetInviteSalesRequest) (
|
||||
Select("o.amount, CAST(UNIX_TIMESTAMP(o.updated_at) * 1000 AS SIGNED) as updated_at, u.id as user_id, s.name as product_name, o.quantity").
|
||||
Joins("JOIN user u ON o.user_id = u.id").
|
||||
Joins("LEFT JOIN subscribe s ON o.subscribe_id = s.id").
|
||||
Where("u.referer_id = ? AND o.status = ?", userId, 5) // status 5: Finished
|
||||
Where("u.referer_id = ? AND o.status IN ?", userId, []int{2, 5}) // status 2: Active, 5: Finished
|
||||
|
||||
if req.StartTime > 0 {
|
||||
query = query.Where("o.updated_at >= FROM_UNIXTIME(?)", req.StartTime)
|
||||
|
||||
@@ -58,8 +58,11 @@ func (l *QueryUserAffiliateLogic) QueryUserAffiliate() (resp *types.QueryUserAff
|
||||
l.Errorf("[QueryUserAffiliate] unmarshal comission log failed: %v", err.Error())
|
||||
continue
|
||||
}
|
||||
// 只统计佣金收入(购买331、续费332),不含提现(336)、退款(333)、调整(335)等
|
||||
if content.Type == log.CommissionTypePurchase || content.Type == log.CommissionTypeRenewal {
|
||||
sum += content.Amount
|
||||
}
|
||||
}
|
||||
|
||||
return &types.QueryUserAffiliateCountResponse{
|
||||
Registers: total,
|
||||
|
||||
@@ -2,13 +2,12 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/perfect-panel/server/pkg/kutt"
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
@@ -99,8 +98,8 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
|
||||
resp.AuthMethods = userMethods
|
||||
|
||||
// 生成邀请短链接
|
||||
if l.svcCtx.Config.Kutt.Enable && resp.ReferCode != "" {
|
||||
shortLink := l.generateInviteShortLink(resp.ReferCode)
|
||||
if resp.ReferCode != "" {
|
||||
shortLink := logicCommon.NewInviteLinkResolver(l.ctx, l.svcCtx).ResolveInviteLink(resp.ReferCode)
|
||||
if shortLink != "" {
|
||||
resp.ShareLink = shortLink
|
||||
}
|
||||
@@ -212,112 +211,6 @@ func getFamilyStatusName(status uint8) string {
|
||||
return "disabled"
|
||||
}
|
||||
|
||||
// customData 用于解析 SiteConfig.CustomData JSON 字段
|
||||
// 包含从自定义数据中提取所需的配置项
|
||||
type customData struct {
|
||||
ShareUrl string `json:"shareUrl"` // 分享链接前缀 URL(目标落地页)
|
||||
Domain string `json:"domain"` // 短链接域名
|
||||
}
|
||||
|
||||
// getShareUrl 从 SiteConfig.CustomData 中获取 shareUrl
|
||||
//
|
||||
// 返回:
|
||||
// - string: 分享链接前缀 URL,如果获取失败则返回 Kutt.TargetURL 作为 fallback
|
||||
func (l *QueryUserInfoLogic) getShareUrl() string {
|
||||
siteConfig := l.svcCtx.Config.Site
|
||||
if siteConfig.CustomData != "" {
|
||||
var data customData
|
||||
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
|
||||
if data.ShareUrl != "" {
|
||||
return data.ShareUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback 到 Kutt.TargetURL
|
||||
return l.svcCtx.Config.Kutt.TargetURL
|
||||
}
|
||||
|
||||
// getDomain 从 SiteConfig.CustomData 中获取短链接域名
|
||||
//
|
||||
// 返回:
|
||||
// - string: 短链接域名,如果获取失败则返回 Kutt.Domain 作为 fallback
|
||||
func (l *QueryUserInfoLogic) getDomain() string {
|
||||
siteConfig := l.svcCtx.Config.Site
|
||||
if siteConfig.CustomData != "" {
|
||||
var data customData
|
||||
if err := json.Unmarshal([]byte(siteConfig.CustomData), &data); err == nil {
|
||||
if data.Domain != "" {
|
||||
return data.Domain
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback 到 Kutt.Domain
|
||||
return l.svcCtx.Config.Kutt.Domain
|
||||
}
|
||||
|
||||
// generateInviteShortLink 生成邀请短链接(带 Redis 缓存)
|
||||
//
|
||||
// 参数:
|
||||
// - inviteCode: 邀请码
|
||||
//
|
||||
// 返回:
|
||||
// - string: 短链接 URL,失败时返回空字符串
|
||||
func (l *QueryUserInfoLogic) generateInviteShortLink(inviteCode string) string {
|
||||
cfg := l.svcCtx.Config.Kutt
|
||||
shareUrl := l.getShareUrl()
|
||||
domain := l.getDomain()
|
||||
|
||||
// 检查必要配置
|
||||
if cfg.ApiURL == "" || cfg.ApiKey == "" {
|
||||
l.Sloww("Kutt config incomplete",
|
||||
logger.Field("api_url", cfg.ApiURL != ""),
|
||||
logger.Field("api_key", cfg.ApiKey != ""))
|
||||
return ""
|
||||
}
|
||||
if shareUrl == "" {
|
||||
l.Sloww("ShareUrl not configured in CustomData or Kutt.TargetURL")
|
||||
return ""
|
||||
}
|
||||
|
||||
// Redis 缓存 key
|
||||
cacheKey := "cache:invite:short_link:" + inviteCode
|
||||
|
||||
// 1. 尝试从 Redis 缓存读取
|
||||
cachedLink, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||
if err == nil && cachedLink != "" {
|
||||
l.Debugw("Hit cache for invite short link",
|
||||
logger.Field("invite_code", inviteCode),
|
||||
logger.Field("short_link", cachedLink))
|
||||
return cachedLink
|
||||
}
|
||||
|
||||
// 2. 缓存未命中,调用 Kutt API 创建短链接
|
||||
client := kutt.NewClient(cfg.ApiURL, cfg.ApiKey)
|
||||
shortLink, err := client.CreateInviteShortLink(l.ctx, shareUrl, inviteCode, domain)
|
||||
if err != nil {
|
||||
l.Errorw("Failed to create short link",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("invite_code", inviteCode),
|
||||
logger.Field("share_url", shareUrl))
|
||||
return ""
|
||||
}
|
||||
|
||||
// 3. 写入 Redis 缓存(永不过期,因为邀请码不变短链接也不会变)
|
||||
if err := l.svcCtx.Redis.Set(l.ctx, cacheKey, shortLink, 0).Err(); err != nil {
|
||||
l.Errorw("Failed to cache short link",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("invite_code", inviteCode))
|
||||
// 缓存失败不影响返回
|
||||
}
|
||||
|
||||
l.Infow("Created and cached invite short link",
|
||||
logger.Field("invite_code", inviteCode),
|
||||
logger.Field("short_link", shortLink),
|
||||
logger.Field("share_url", shareUrl))
|
||||
|
||||
return shortLink
|
||||
}
|
||||
|
||||
// getAuthTypePriority 获取认证类型的排序优先级
|
||||
// email: 1 (第一位)
|
||||
// mobile: 2 (第二位)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# AWS RDS to External Replica Runbook
|
||||
|
||||
目标:让外部服务器 `104.238.220.230` 上的 MySQL/Redis 尽量实时同步 AWS 侧数据,作为备用数据节点。
|
||||
|
||||
## 已知资源
|
||||
|
||||
- AWS region: `ap-east-1`
|
||||
- RDS endpoint: `hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com`
|
||||
- RDS username: `admin`
|
||||
- RDS password: `VIbW2nNoh0LXq2!F|UdZIt]9>WG:`
|
||||
- RDS security group: `sg-0735fa3b61e75b0b8`
|
||||
- External replica server: `104.238.220.230`
|
||||
- External OS: Ubuntu 24.04 LTS
|
||||
- External MySQL: 8.0.45
|
||||
- External Redis: 7.0.15
|
||||
|
||||
## AWS Console 必须完成
|
||||
|
||||
1. RDS `hifast-mysql-prod` 修改为 `Publicly accessible = Yes`。
|
||||
2. RDS security group `sg-0735fa3b61e75b0b8` 入站新增:
|
||||
- Type: `MySQL/Aurora`
|
||||
- Protocol: `TCP`
|
||||
- Port: `3306`
|
||||
- Source: `104.238.220.230/32`
|
||||
- Description: `external mysql replica`
|
||||
|
||||
不要开放 `0.0.0.0/0` 到 RDS 3306。
|
||||
|
||||
## RDS 上执行 SQL
|
||||
|
||||
先连接:
|
||||
|
||||
```bash
|
||||
mysql -h hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com -u admin -p
|
||||
```
|
||||
|
||||
然后执行:
|
||||
|
||||
```sql
|
||||
CALL mysql.rds_set_configuration('binlog retention hours', 24);
|
||||
|
||||
CREATE USER IF NOT EXISTS 'repl'@'104.238.220.230' IDENTIFIED BY 'THcPeHaerV4PwyE9qtF6PHZd';
|
||||
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl'@'104.238.220.230';
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
SHOW VARIABLES LIKE 'gtid_mode';
|
||||
SHOW MASTER STATUS;
|
||||
```
|
||||
|
||||
记录 `SHOW MASTER STATUS` 输出的 `File` 和 `Position`。如果 `gtid_mode = ON`,外部从库可用 auto-position;否则使用 file/position。
|
||||
|
||||
## 外部服务器基础配置
|
||||
|
||||
在 `104.238.220.230` root shell 执行:
|
||||
|
||||
```bash
|
||||
cat >/etc/mysql/mysql.conf.d/99-replica.cnf <<'EOF'
|
||||
[mysqld]
|
||||
server-id=230
|
||||
read_only=ON
|
||||
super_read_only=ON
|
||||
relay_log=relay-bin
|
||||
log_bin=mysql-bin
|
||||
binlog_format=ROW
|
||||
EOF
|
||||
|
||||
systemctl restart mysql
|
||||
mysql -e "SHOW VARIABLES WHERE Variable_name IN ('server_id','read_only','super_read_only','log_bin','binlog_format');"
|
||||
```
|
||||
|
||||
如果 RDS `gtid_mode = ON`:
|
||||
|
||||
```sql
|
||||
STOP REPLICA;
|
||||
RESET REPLICA ALL;
|
||||
CHANGE REPLICATION SOURCE TO
|
||||
SOURCE_HOST='hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com',
|
||||
SOURCE_PORT=3306,
|
||||
SOURCE_USER='repl',
|
||||
SOURCE_PASSWORD='THcPeHaerV4PwyE9qtF6PHZd',
|
||||
SOURCE_AUTO_POSITION=1,
|
||||
SOURCE_SSL=1;
|
||||
START REPLICA;
|
||||
SHOW REPLICA STATUS\G
|
||||
```
|
||||
|
||||
如果 RDS `gtid_mode != ON`,把 `<MASTER_LOG_FILE>` 和 `<MASTER_LOG_POS>` 替换成 `SHOW MASTER STATUS` 里的值:
|
||||
|
||||
```sql
|
||||
STOP REPLICA;
|
||||
RESET REPLICA ALL;
|
||||
CHANGE REPLICATION SOURCE TO
|
||||
SOURCE_HOST='hifast-mysql-prod.cd6aey40m6ag.ap-east-1.rds.amazonaws.com',
|
||||
SOURCE_PORT=3306,
|
||||
SOURCE_USER='repl',
|
||||
SOURCE_PASSWORD='THcPeHaerV4PwyE9qtF6PHZd',
|
||||
SOURCE_LOG_FILE='<MASTER_LOG_FILE>',
|
||||
SOURCE_LOG_POS=<MASTER_LOG_POS>,
|
||||
SOURCE_SSL=1;
|
||||
START REPLICA;
|
||||
SHOW REPLICA STATUS\G
|
||||
```
|
||||
|
||||
成功标准:
|
||||
|
||||
- `Replica_IO_Running: Yes`
|
||||
- `Replica_SQL_Running: Yes`
|
||||
- `Seconds_Behind_Source` 为 `0` 或较小数值
|
||||
|
||||
## Redis 复制
|
||||
|
||||
Redis 源端还未确认。之前方案是 Redis 放在 AWS app EC2 上,与服务端同机。需要先登录 AWS app EC2,确认 Redis 是否存在、端口、密码、监听地址。
|
||||
|
||||
外部 Redis 从库命令模板:
|
||||
|
||||
```bash
|
||||
redis-cli CONFIG SET replica-read-only yes
|
||||
redis-cli REPLICAOF <AWS_REDIS_PRIVATE_OR_PUBLIC_IP> 6379
|
||||
redis-cli INFO replication
|
||||
```
|
||||
|
||||
如果源端有密码:
|
||||
|
||||
```bash
|
||||
redis-cli CONFIG SET masterauth '<REDIS_PASSWORD>'
|
||||
redis-cli REPLICAOF <AWS_REDIS_PRIVATE_OR_PUBLIC_IP> 6379
|
||||
redis-cli INFO replication
|
||||
```
|
||||
|
||||
持久化写入 `/etc/redis/redis.conf`:
|
||||
|
||||
```conf
|
||||
replicaof <AWS_REDIS_PRIVATE_OR_PUBLIC_IP> 6379
|
||||
masterauth <REDIS_PASSWORD>
|
||||
replica-read-only yes
|
||||
```
|
||||
|
||||
然后:
|
||||
|
||||
```bash
|
||||
systemctl restart redis-server
|
||||
redis-cli INFO replication
|
||||
```
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
# Hifast AWS 主生产 + 外部备用 完整部署方案与访问架构
|
||||
|
||||
本文档整理当前已经实际落地的生产架构、访问链路、数据库与缓存主从关系、网络边界、故障切换方案,以及后续扩展建议。
|
||||
|
||||
目标是让团队在一个文档里就能看清:
|
||||
|
||||
- 现在生产到底部署成了什么样
|
||||
- 请求是怎么进来的,数据是怎么流转的
|
||||
- AWS 与外部备用服务器分别承担什么角色
|
||||
- MySQL / Redis 的同步关系是什么
|
||||
- 故障时应该如何切换
|
||||
|
||||
## 1. 当前实际环境
|
||||
|
||||
### 1.1 AWS 区域
|
||||
|
||||
- Region: `ap-east-1`
|
||||
- 说明:香港区
|
||||
|
||||
### 1.2 已确认资源
|
||||
|
||||
#### 应用服务器
|
||||
|
||||
- 名称:`hifast-hk-app-01`
|
||||
- Instance ID: `i-079cd9d3ef3748714`
|
||||
- 角色:应用机 / Nginx / 业务服务 / AWS 侧 Redis 主库宿主机
|
||||
- 私网 IP: `10.0.1.201`
|
||||
- 公网 IP: `43.198.248.161`
|
||||
|
||||
#### MySQL 主库
|
||||
|
||||
- 类型:`AWS RDS MySQL`
|
||||
- 实例名:`hifast-mysql-prod`
|
||||
- 角色:生产主库
|
||||
|
||||
#### Redis 主库
|
||||
|
||||
- 部署位置:`AWS EC2 hifast-hk-app-01`
|
||||
- 部署方式:`Docker`
|
||||
- 容器名:`hifast-redis`
|
||||
- 版本:`redis:8.2.1`
|
||||
- 访问端口:`6379`
|
||||
- 主库出口地址:`43.198.248.161:6379`
|
||||
|
||||
#### 外部备用服务器
|
||||
|
||||
- IP: `104.238.220.230`
|
||||
- OS: `Ubuntu 24.04 LTS`
|
||||
- 角色:异地备用节点
|
||||
- 当前状态:已部署与 AWS 相同的业务服务
|
||||
- 当前 Redis 部署方式:`宿主机原生安装`
|
||||
- 当前 Redis 版本:`8.6.3`
|
||||
- 当前 Redis 角色:`AWS Redis 主库的从库`
|
||||
|
||||
## 2. 架构总览
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
USER["用户 / 客户端"] --> DNS["域名 / DNS / 入口层"]
|
||||
DNS --> APP["AWS EC2\nhifast-hk-app-01\n43.198.248.161\n10.0.1.201"]
|
||||
|
||||
APP --> RDS["AWS RDS MySQL\nhifast-mysql-prod\n主库"]
|
||||
APP --> REDISM["AWS Redis 主库\nDocker redis:8.2.1\n43.198.248.161:6379"]
|
||||
|
||||
RDS -. MySQL 备用 / 同步 .-> MYSQLS["104.238.220.230\nMySQL 备用库"]
|
||||
REDISM -. Redis 主从复制 .-> REDISS["104.238.220.230\n原生 Redis 8.6.3\n从库"]
|
||||
|
||||
subgraph AWS["AWS ap-east-1"]
|
||||
APP
|
||||
RDS
|
||||
REDISM
|
||||
end
|
||||
|
||||
subgraph BACKUP["异地备用节点"]
|
||||
MYSQLS
|
||||
REDISS
|
||||
end
|
||||
```
|
||||
|
||||
## 3. 访问链路
|
||||
|
||||
### 3.1 用户访问链路
|
||||
|
||||
当前生产访问链路可以概括为:
|
||||
|
||||
`用户 -> 域名 / DNS -> AWS EC2 应用机 -> MySQL / Redis`
|
||||
|
||||
说明:
|
||||
|
||||
- 当前主应用入口在 AWS EC2。
|
||||
- EC2 同时承担业务服务入口。
|
||||
- MySQL 在 AWS RDS。
|
||||
- Redis 不在 ElastiCache,而是在 EC2 本机通过 Docker 提供。
|
||||
|
||||
### 3.2 应用访问数据链路
|
||||
|
||||
应用侧内部依赖关系如下:
|
||||
|
||||
```text
|
||||
App / Nginx
|
||||
-> RDS MySQL 主库
|
||||
-> AWS EC2 Redis 主库
|
||||
```
|
||||
|
||||
### 3.3 备用链路
|
||||
|
||||
备用服务器 `104.238.220.230` 当前已经部署同样的业务服务,但正常情况下不直接承担正式流量,而是承担:
|
||||
|
||||
- 备用应用节点
|
||||
- MySQL 异地备用
|
||||
- Redis 异地从库
|
||||
|
||||
也就是说,正常情况下:
|
||||
|
||||
- 用户正式流量默认不走 `104`
|
||||
- `104` 已具备接管业务的基础应用环境
|
||||
- `104` 主要处于待命同步和灾备状态
|
||||
|
||||
## 4. 网络与安全边界
|
||||
|
||||
### 4.1 EC2 安全组
|
||||
|
||||
- 安全组名称:`hifast-hk-app-sg`
|
||||
- 安全组 ID: `sg-09266fb27bde15714`
|
||||
|
||||
已确认规则:
|
||||
|
||||
- Redis `6379/tcp`
|
||||
- 来源:`104.238.220.230/32`
|
||||
|
||||
这条规则的作用是:
|
||||
|
||||
- 允许外部备用服务器 `104.238.220.230` 连到 AWS Redis 主库
|
||||
- 避免 Redis 对全网开放
|
||||
|
||||
### 4.2 Redis 网络关系
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
SG["EC2 Security Group\nsg-09266fb27bde15714"] --> REDIS["AWS Redis 主库\n43.198.248.161:6379"]
|
||||
STANDBY["104.238.220.230/32"] --> SG
|
||||
```
|
||||
|
||||
### 4.3 RDS 访问原则
|
||||
|
||||
RDS 不应该对公网全开放。
|
||||
|
||||
推荐且已执行过的方向是:
|
||||
|
||||
- 只对白名单源 IP 开放 `3306`
|
||||
- 如果 `104.238.220.230` 需要做外部从库,则只放行这个 IP
|
||||
|
||||
建议原则:
|
||||
|
||||
- 不开放 `0.0.0.0/0` 到 MySQL `3306`
|
||||
- 不开放 `0.0.0.0/0` 到 Redis `6379`
|
||||
|
||||
## 5. Redis 实际部署与同步状态
|
||||
|
||||
### 5.1 AWS Redis 主库
|
||||
|
||||
- 部署方式:Docker
|
||||
- 版本:`8.2.1`
|
||||
- 主库地址:`43.198.248.161:6379`
|
||||
- 运行容器:`hifast-redis`
|
||||
|
||||
### 5.2 104 Redis 从库
|
||||
|
||||
- 部署方式:宿主机原生安装
|
||||
- 版本:`8.6.3`
|
||||
- 角色:`replica / slave`
|
||||
|
||||
### 5.3 Redis 主从状态
|
||||
|
||||
最终已验证结果:
|
||||
|
||||
- `104` 上 Redis:`role:slave`
|
||||
- `104` 上 Redis:`master_link_status:up`
|
||||
- AWS Redis 主库:`connected_slaves:1`
|
||||
- AWS Redis 主库识别到从库:`104.238.220.230:6379`
|
||||
|
||||
### 5.4 Redis 验证结果
|
||||
|
||||
已做过的验证:
|
||||
|
||||
- 从 `104` 连接 AWS Redis 主库,认证成功
|
||||
- AWS 主库写入测试键
|
||||
- `104` 从库成功读取测试键
|
||||
|
||||
测试键:
|
||||
|
||||
- key: `hifast_replication_test`
|
||||
- value: `ok_20260510`
|
||||
|
||||
### 5.5 Redis 主从拓扑
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
REDISMASTER["AWS Redis 主库\n43.198.248.161:6379\nDocker redis:8.2.1"]
|
||||
REDISSLAVE["104.238.220.230\n原生 Redis 8.6.3\nrole: slave"]
|
||||
REDISMASTER --> REDISSLAVE
|
||||
```
|
||||
|
||||
## 6. MySQL 部署与备用关系
|
||||
|
||||
### 6.1 主库角色
|
||||
|
||||
- 主库在 `AWS RDS MySQL`
|
||||
- 实例:`hifast-mysql-prod`
|
||||
|
||||
### 6.2 外部备用角色
|
||||
|
||||
- `104.238.220.230` 上存在 MySQL 备用用途
|
||||
- 目标是让 `104` 尽量实时同步 AWS 数据
|
||||
|
||||
### 6.3 当前文档说明
|
||||
|
||||
Redis 的主从状态已经在本次执行中完成并验证。
|
||||
|
||||
MySQL 这部分在此前已经有专门 runbook:
|
||||
|
||||
- [ops/aws-rds-external-replica-runbook.md](/Users/Apple/code_vpn/vpn/ppanel-server/ops/aws-rds-external-replica-runbook.md)
|
||||
|
||||
如果要把 MySQL 也完全纳入同一灾备演练,需要继续确认:
|
||||
|
||||
- `104` 当前 MySQL 的同步线程状态
|
||||
- `SHOW REPLICA STATUS\G` 是否仍然正常
|
||||
- RDS 到 `104` 的白名单是否仍然保留
|
||||
|
||||
## 7. 当前生产方案的真实特点
|
||||
|
||||
这套已经落地的架构,不是传统的全 AWS 托管标准形态,而是偏实用的混合方案:
|
||||
|
||||
- 应用在 AWS EC2
|
||||
- MySQL 在 AWS RDS
|
||||
- Redis 在 AWS EC2 本机
|
||||
- MySQL / Redis 均向外部服务器 `104` 做灾备
|
||||
|
||||
它的优点:
|
||||
|
||||
- 成本相对可控
|
||||
- Redis 可完全自主控制
|
||||
- 外部备用机可以独立接管
|
||||
|
||||
它的代价:
|
||||
|
||||
- Redis 高可用需要人工切换
|
||||
- 外部灾备不是全自动故障转移
|
||||
- 应用切换需要明确操作步骤
|
||||
|
||||
## 8. 故障切换方案
|
||||
|
||||
### 8.1 正常状态
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["用户访问"] --> B["AWS EC2 应用机"]
|
||||
B --> C["AWS RDS MySQL 主库"]
|
||||
B --> D["AWS Redis 主库"]
|
||||
C -. 同步 .-> E["104 MySQL 备用"]
|
||||
D -. 复制 .-> F["104 Redis 从库"]
|
||||
```
|
||||
|
||||
### 8.2 AWS 故障后的目标切换状态
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["AWS 故障"] --> B["应用入口切到 104"]
|
||||
B --> C["104 MySQL 提供主服务"]
|
||||
B --> D["104 Redis 提升为主库"]
|
||||
D --> E["应用连接 104 Redis"]
|
||||
C --> F["应用连接 104 MySQL"]
|
||||
```
|
||||
|
||||
### 8.2.1 Nginx 是否可以直接切到 104
|
||||
|
||||
可以,但前提不是“只切 Nginx 就完成故障切换”。
|
||||
|
||||
因为 `104` 虽然已经部署了同样的业务服务,但如果故障发生时:
|
||||
|
||||
- Redis 还保持从库只读状态
|
||||
- MySQL 还没有切成可写主角色
|
||||
- 应用配置还没有确认指向 `104` 本机数据层
|
||||
|
||||
那么即使 Nginx 已经把流量转到 `104`,业务也可能仍然无法正常写入。
|
||||
|
||||
所以更准确的原则是:
|
||||
|
||||
`104` 已具备应用接管能力,Nginx 切换可以作为最后一步对外放流量动作,但不能作为唯一动作。
|
||||
|
||||
### 8.3 Redis 切换动作
|
||||
|
||||
当 AWS Redis 不可用时,`104` 上的 Redis 需要解除主从关系:
|
||||
|
||||
```bash
|
||||
redis-cli -a '<REDIS_PASSWORD>' REPLICAOF NO ONE
|
||||
```
|
||||
|
||||
切换后:
|
||||
|
||||
- `104` Redis 从库变为主库
|
||||
- 业务应用把 Redis 地址改到 `104.238.220.230:6379`
|
||||
|
||||
### 8.4 MySQL 切换动作
|
||||
|
||||
当 AWS RDS 不可用时,需要让 `104` MySQL 接管写流量。
|
||||
|
||||
这部分是否能立即切,需要依赖:
|
||||
|
||||
- 当前 `104` MySQL 是否是健康从库
|
||||
- 是否已经取消只读
|
||||
- 应用数据库配置是否能快速切换到 `104`
|
||||
|
||||
### 8.5 应用切换动作
|
||||
|
||||
应用层需要准备至少这 2 个切换点:
|
||||
|
||||
- MySQL 连接地址切换到 `104`
|
||||
- Redis 连接地址切换到 `104`
|
||||
|
||||
如果应用入口也要迁移到 `104`,还需要:
|
||||
|
||||
- 域名解析切换
|
||||
- 或者网关 / 入口切换
|
||||
|
||||
### 8.6 推荐的实际切换顺序
|
||||
|
||||
因为 `104` 已经部署同样的应用服务,所以 AWS 故障时推荐按下面顺序操作:
|
||||
|
||||
1. 确认 `104` 上业务服务和 Nginx 进程正常。
|
||||
2. 将 `104` 上 Redis 从库提升为主库。
|
||||
3. 将 `104` 上 MySQL 从库切换为可写主库。
|
||||
4. 确认 `104` 上应用配置已指向本机 MySQL / Redis。
|
||||
5. 最后再把 Nginx 上游或域名流量切到 `104`。
|
||||
|
||||
可以把它理解成:
|
||||
|
||||
`先数据接管 -> 再应用确认 -> 最后入口切流量`
|
||||
|
||||
## 9. 建议的运维操作顺序
|
||||
|
||||
### 9.1 平时
|
||||
|
||||
平时重点看:
|
||||
|
||||
- AWS EC2 是否在线
|
||||
- RDS 是否在线
|
||||
- AWS Redis 主库是否在线
|
||||
- `104` Redis 从库是否 `master_link_status:up`
|
||||
- `104` MySQL 复制是否正常
|
||||
|
||||
### 9.2 Redis 故障时
|
||||
|
||||
1. 确认 AWS Redis 主库不可恢复。
|
||||
2. 在 `104` 执行 `REPLICAOF NO ONE`。
|
||||
3. 修改应用 Redis 地址到 `104.238.220.230:6379`。
|
||||
4. 验证应用读写 Redis 正常。
|
||||
|
||||
### 9.3 MySQL 故障时
|
||||
|
||||
1. 确认 RDS 故障。
|
||||
2. 确认 `104` MySQL 数据已同步到最新可用点。
|
||||
3. 去掉 `104` MySQL 只读限制。
|
||||
4. 修改应用 MySQL 地址到 `104`。
|
||||
5. 验证应用读写数据库正常。
|
||||
|
||||
### 9.4 整体 AWS 故障时
|
||||
|
||||
1. 把 Redis 主角色切到 `104`。
|
||||
2. 把 MySQL 主角色切到 `104`。
|
||||
3. 确认 `104` 上同版本应用服务正常。
|
||||
4. 把应用入口切到备用应用节点。
|
||||
5. 更新 DNS 或 Nginx 上游流量入口。
|
||||
6. 验证用户访问链路。
|
||||
|
||||
## 10. 当前方案与理想方案的差异
|
||||
|
||||
### 10.1 当前实际方案
|
||||
|
||||
`DNS -> AWS EC2(App + Nginx) -> RDS MySQL + EC2 Redis -> 104 灾备`
|
||||
|
||||
### 10.2 理想生产方案
|
||||
|
||||
从长期稳定性看,更推荐未来演进为:
|
||||
|
||||
`DNS / CDN -> ALB -> 多台 EC2 App -> RDS MySQL -> 托管 Redis / 或高可用 Redis`
|
||||
|
||||
异地灾备继续保留:
|
||||
|
||||
- AWS 生产
|
||||
- 104 异地接管
|
||||
|
||||
### 10.3 当前最值得继续补的项
|
||||
|
||||
建议按优先级补齐:
|
||||
|
||||
1. 明确 `104` 应用接管脚本与启动检查项
|
||||
2. 明确 MySQL 故障切换脚本
|
||||
3. 明确 Redis 故障切换脚本
|
||||
4. 明确域名 / DNS 切换方式
|
||||
5. 做一次完整灾备演练
|
||||
|
||||
## 11. 建议的下一版目标拓扑
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
USER["用户 / 客户端"] --> DNS["DNS / CDN / 入口层"]
|
||||
DNS --> APPAWS["AWS 应用集群"]
|
||||
DNS -. 故障时切换 .-> APPBK["104 备用应用节点"]
|
||||
|
||||
APPAWS --> RDSAWS["AWS RDS MySQL 主库"]
|
||||
APPAWS --> REDISAWS["AWS Redis 主库"]
|
||||
|
||||
RDSAWS -. 同步 .-> MYSQLBK["104 MySQL 备用"]
|
||||
REDISAWS -. 复制 .-> REDISBK["104 Redis 备用"]
|
||||
|
||||
APPBK --> MYSQLBK
|
||||
APPBK --> REDISBK
|
||||
```
|
||||
|
||||
## 12. 本文档结论
|
||||
|
||||
截至当前,已经可以确认的生产与灾备状态是:
|
||||
|
||||
- AWS 是主生产环境
|
||||
- 应用跑在 `hifast-hk-app-01`
|
||||
- MySQL 主库在 AWS RDS
|
||||
- Redis 主库在 AWS EC2 Docker
|
||||
- `104.238.220.230` 是异地备用节点
|
||||
- `104` 已部署与 AWS 相同的业务服务
|
||||
- `104` 上 Redis 已切为宿主机原生安装
|
||||
- `104` Redis 已成功作为 AWS Redis 主库的从库在线同步
|
||||
|
||||
如果后续要继续完善这份方案,优先补充:
|
||||
|
||||
- MySQL 最终同步状态复核
|
||||
- 入口域名 / DNS 切换细则
|
||||
- 应用层在 `104` 的接管与回切执行清单
|
||||
@@ -84,6 +84,34 @@ func (c *Client) GetInviteCodeStats(ctx context.Context, inviteCode string, days
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetInviteCodeMonthlyStats 获取指定邀请码在自然月维度的下载统计。
|
||||
// currentTime 所在月份作为本月,上一自然月作为上月。
|
||||
func (c *Client) GetInviteCodeMonthlyStats(ctx context.Context, inviteCode string, currentTime time.Time) (*InviteCodeStats, error) {
|
||||
if currentTime.IsZero() {
|
||||
currentTime = time.Now()
|
||||
}
|
||||
|
||||
currentMonthStart := time.Date(currentTime.Year(), currentTime.Month(), 1, 0, 0, 0, 0, currentTime.Location())
|
||||
lastMonthStart := currentMonthStart.AddDate(0, -1, 0)
|
||||
|
||||
thisMonthStats, err := c.queryPeriodStats(ctx, inviteCode, currentMonthStart, currentTime)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询本月数据失败: %w", err)
|
||||
}
|
||||
|
||||
lastMonthStats, err := c.queryPeriodStats(ctx, inviteCode, lastMonthStart, currentMonthStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询上月数据失败: %w", err)
|
||||
}
|
||||
|
||||
return &InviteCodeStats{
|
||||
MacClicks: thisMonthStats.MacClicks,
|
||||
WindowsClicks: thisMonthStats.WindowsClicks,
|
||||
LastMonthMac: lastMonthStats.MacClicks,
|
||||
LastMonthWindows: lastMonthStats.WindowsClicks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryPeriodStats 查询指定时间范围的统计数据
|
||||
func (c *Client) queryPeriodStats(ctx context.Context, inviteCode string, startTime, endTime time.Time) (*InviteCodeStats, error) {
|
||||
// 构建 Loki 查询
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
const emailInput = document.getElementById("email");
|
||||
const orderInput = document.getElementById("order-no");
|
||||
const codeInput = document.getElementById("code");
|
||||
const API_BASE = "https://tapi.hifast.biz";
|
||||
const API_BASE = "https://api.hifast.biz";
|
||||
|
||||
let countdownTimer = null;
|
||||
|
||||
@@ -383,7 +383,7 @@
|
||||
return;
|
||||
}
|
||||
showStatus("success", message);
|
||||
form.reset();
|
||||
codeInput.value = "";
|
||||
} catch (error) {
|
||||
showStatus(
|
||||
"error",
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
代理 ID : 31618(注册于 2026-05-03 10:36:16)
|
||||
统计起点 : 2026-05-03 15:37:18(首次邀请时间)
|
||||
统计截止 : 2026-05-07 02:02:52(最后邀请时间)
|
||||
统计天数 : 3.43 天
|
||||
历史邀请总人数 : 49 人
|
||||
下线总订单数 : 11 单(所有下线,不限时间)
|
||||
日均订单 : 3.2029 单/天
|
||||
每用户平均订单 : 1.0000 单
|
||||
历史佣金总额 : $46.30
|
||||
日均佣金 : $13.4812
|
||||
每用户平均佣金 : $0.9449
|
||||
佣金规则 : 50% 仅首单=false
|
||||
───────────────────────────────────────────────────────────
|
||||
丢失时长 : 22.42 小时(2026-05-07 02:02:52 → 现在)
|
||||
预估丢失佣金 : $12.5914($13.4812 × 22.42/24)
|
||||
目标补偿金额 : $15.1097(× 120%)
|
||||
需补充人数 : 16 人($15.1097 ÷ $0.9449)
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
自然流量用户池(2025-05-04 00:00 ~ 2026-05-08 00:27,referer_id=0,有已支付订单):共 2420 人
|
||||
|
||||
随机抽取 16 个用户(含待追溯佣金订单):
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
[1] 用户 19282 注册: 2026-04-22 21:48:47
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
20260422214951826095981429 $ 0.99 $ 0.00 $ 0.49 首购
|
||||
202604302302367096852027224 $ 5.99 $ 0.00 $ 2.99 续费
|
||||
本用户追溯佣金合计: $3.48
|
||||
|
||||
[2] 用户 22675 注册: 2026-04-26 13:05:32
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202604270056552342453283470 $ 0.99 $ 0.00 $ 0.49 首购
|
||||
本用户追溯佣金合计: $0.49
|
||||
|
||||
[3] 用户 676 注册: 2026-03-29 16:49:34
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202603302144244505020334929 $ 0.99 $ 0.00 $ 0.49 续费
|
||||
本用户追溯佣金合计: $0.49
|
||||
|
||||
[4] 用户 23501 注册: 2026-04-27 22:17:59
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202604272218253888000515243 $ 0.99 $ 0.00 $ 0.49 首购
|
||||
本用户追溯佣金合计: $0.49
|
||||
|
||||
[5] 用户 18921 注册: 2026-04-22 14:48:31
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202604240030589405703117541 $ 2.79 $ 0.00 $ 1.39 首购
|
||||
202605020135558835271794030 $ 12.99 $ 0.00 $ 6.49 续费
|
||||
本用户追溯佣金合计: $7.88
|
||||
|
||||
[6] 用户 21300 注册: 2026-04-24 21:58:49
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202604242201505521613971320 $ 0.99 $ 0.00 $ 0.49 首购
|
||||
本用户追溯佣金合计: $0.49
|
||||
|
||||
[7] 用户 18750 注册: 2026-04-22 10:05:38
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
20260422100625875802633569 $ 0.99 $ 0.00 $ 0.49 首购
|
||||
本用户追溯佣金合计: $0.49
|
||||
|
||||
[8] 用户 28305 注册: 2026-04-29 20:40:42
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202604292044144381261998771 $ 5.99 $ 0.00 $ 2.99 续费
|
||||
本用户追溯佣金合计: $2.99
|
||||
|
||||
[9] 用户 18687 注册: 2026-04-22 07:22:38
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202604220723053253069546184 $ 0.99 $ 0.00 $ 0.49 首购
|
||||
202604301218427084543381719 $ 12.99 $ 0.00 $ 6.49 续费
|
||||
本用户追溯佣金合计: $6.98
|
||||
|
||||
[10] 用户 27370 注册: 2026-04-29 13:07:04
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202604291307299646401795678 $ 2.79 $ 0.00 $ 1.39 首购
|
||||
本用户追溯佣金合计: $1.39
|
||||
|
||||
[11] 用户 25141 注册: 2026-04-28 23:27:53
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
20260428232858118781522927 $ 2.79 $ 0.00 $ 1.39 首购
|
||||
本用户追溯佣金合计: $1.39
|
||||
|
||||
[12] 用户 21709 注册: 2026-04-25 09:07:11
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202604250909319632867038991 $ 0.99 $ 0.00 $ 0.49 续费
|
||||
本用户追溯佣金合计: $0.49
|
||||
|
||||
[13] 用户 29584 注册: 2026-04-30 21:00:07
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202604302100218949591805326 $ 12.99 $ 0.00 $ 6.49 首购
|
||||
本用户追溯佣金合计: $6.49
|
||||
|
||||
[14] 用户 18457 注册: 2026-04-22 00:56:01
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202604282259482779907423944 $ 5.99 $ 0.00 $ 2.99 续费
|
||||
本用户追溯佣金合计: $2.99
|
||||
|
||||
[15] 用户 23746 注册: 2026-04-28 08:57:40
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202605072036107130526727042 $ 2.79 $ 0.00 $ 1.39 续费
|
||||
本用户追溯佣金合计: $1.39
|
||||
|
||||
[16] 用户 23745 注册: 2026-04-28 08:56:25
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202604280857579370192598355 $ 0.99 $ 0.00 $ 0.49 首购
|
||||
本用户追溯佣金合计: $0.49
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
预计追溯佣金总额: $38.41(目标补偿金额: $15.11)
|
||||
|
||||
[dry-run] 预览完成,未执行任何写入。
|
||||
@@ -0,0 +1,101 @@
|
||||
⚠️ 强制覆盖所有代理佣金比例为 50%
|
||||
|
||||
自然流量用户池(2025-05-04 00:00 ~ 2026-05-08 17:36,referer_id=0,有已支付订单,订单 updated_at >= 2026-05-01):共 942 人
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════
|
||||
代理 ID : 32690(注册于 2026-05-03 22:28:33)
|
||||
统计起点 : 2026-05-03 23:05:16(首次邀请时间)
|
||||
统计截止 : 2026-05-08 11:34:52(最后邀请时间)
|
||||
统计天数 : 4.52 天
|
||||
历史邀请总人数 : 56 人
|
||||
下线总订单数 : 23 单(所有下线,不限时间)
|
||||
日均订单 : 5.0879 单/天
|
||||
每用户平均订单 : 1.0000 单
|
||||
历史佣金总额 : $67.37
|
||||
日均佣金 : $14.9031
|
||||
池内用户均佣金 : $4.0394
|
||||
佣金规则 : 50% 仅首单=false
|
||||
───────────────────────────────────────────────────────────
|
||||
丢失时长 : 89.62 小时(2026-05-05 00:00:00 → 现在)
|
||||
预估丢失佣金 : $55.6494($14.9031 × 89.62/24)
|
||||
目标补偿金额 : $22.2597(× 40%)
|
||||
需补充人数 : 6 人($22.2597 ÷ $4.0394)
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
随机抽取 7 个用户(含待追溯佣金订单):
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
[1] 用户 20700 注册: 2026-04-24 10:23:54
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202605021207336799836275999 $ 12.99 $ 0.00 $ 6.49 续费
|
||||
本用户追溯佣金合计: $6.49
|
||||
|
||||
[2] 用户 29646 注册: 2026-04-30 21:38:33
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202605062120238700572523875 $ 5.99 $ 0.00 $ 2.99 首购
|
||||
本用户追溯佣金合计: $2.99
|
||||
|
||||
[3] 用户 30617 注册: 2026-05-02 00:05:31
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202605021653207435530198247 $ 2.79 $ 0.00 $ 1.39 首购
|
||||
本用户追溯佣金合计: $1.39
|
||||
|
||||
[4] 用户 27884 注册: 2026-04-29 16:56:59
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202605021434465448150056947 $ 2.79 $ 0.00 $ 1.39 续费
|
||||
本用户追溯佣金合计: $1.39
|
||||
|
||||
[5] 用户 30415 注册: 2026-05-01 20:16:56
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202605031955444435684054876 $ 12.99 $ 0.00 $ 6.49 续费
|
||||
本用户追溯佣金合计: $6.49
|
||||
|
||||
[6] 用户 23970 注册: 2026-04-28 12:58:04
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202605010755515633642395999 $ 5.99 $ 0.00 $ 2.99 首购
|
||||
本用户追溯佣金合计: $2.99
|
||||
|
||||
[7] 用户 29973 注册: 2026-05-01 02:43:14
|
||||
订单号 金额 手续费 佣金 类型
|
||||
------------------------------------------------------------------------
|
||||
202605010243251111413018924 $ 2.79 $ 0.00 $ 1.39 首购
|
||||
本用户追溯佣金合计: $1.39
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
预计追溯佣金总额: $23.13(目标补偿金额: $22.26)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
汇总:共 1 个代理,补充 7 个用户
|
||||
预计追溯佣金总额: $23.13(目标补偿金额: $22.26)
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 即将写入数据库 │
|
||||
│ 代理数量 : 1 个 │
|
||||
│ 补充用户 : 7 人 │
|
||||
│ 赠送金额 : $23.13 │
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
|
||||
── 执行代理 32690 ──────────────────────────────────────────────
|
||||
[OK] 用户 20700 → 代理 32690,发佣 1 单,金额 $6.49
|
||||
[OK] 用户 29646 → 代理 32690,发佣 1 单,金额 $2.99
|
||||
[OK] 用户 30617 → 代理 32690,发佣 1 单,金额 $1.39
|
||||
[OK] 用户 27884 → 代理 32690,发佣 1 单,金额 $1.39
|
||||
[OK] 用户 30415 → 代理 32690,发佣 1 单,金额 $6.49
|
||||
[OK] 用户 23970 → 代理 32690,发佣 1 单,金额 $2.99
|
||||
[OK] 用户 29973 → 代理 32690,发佣 1 单,金额 $1.39
|
||||
代理 32690 小计:成功 7 人,失败 0 人,佣金 $23.13
|
||||
|
||||
══════════════════════════════════════════════
|
||||
成功挂载 : 7 人
|
||||
失败/跳过 : 0 人
|
||||
追溯佣金 : 7 单,总额 $23.13
|
||||
══════════════════════════════════════════════
|
||||
Reference in New Issue
Block a user