From c4b2ebf7e14a497f462dc81931ad199f4be63ce0 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Sun, 10 May 2026 10:41:50 -0700 Subject: [PATCH] x --- .../public/user/getAgentRealtimeLogic.go | 24 +- ops/aws-rds-external-replica-runbook.md | 144 ++++++ ops/hifast-aws-standby-architecture-zh.md | 438 ++++++++++++++++++ pkg/loki/loki.go | 28 ++ 4 files changed, 622 insertions(+), 12 deletions(-) create mode 100644 ops/aws-rds-external-replica-runbook.md create mode 100644 ops/hifast-aws-standby-architecture-zh.md diff --git a/internal/logic/public/user/getAgentRealtimeLogic.go b/internal/logic/public/user/getAgentRealtimeLogic.go index 47c8b2a..3a75415 100644 --- a/internal/logic/public/user/getAgentRealtimeLogic.go +++ b/internal/logic/public/user/getAgentRealtimeLogic.go @@ -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 diff --git a/ops/aws-rds-external-replica-runbook.md b/ops/aws-rds-external-replica-runbook.md new file mode 100644 index 0000000..c8fcd84 --- /dev/null +++ b/ops/aws-rds-external-replica-runbook.md @@ -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`,把 `` 和 `` 替换成 `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='', + SOURCE_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 6379 +redis-cli INFO replication +``` + +如果源端有密码: + +```bash +redis-cli CONFIG SET masterauth '' +redis-cli REPLICAOF 6379 +redis-cli INFO replication +``` + +持久化写入 `/etc/redis/redis.conf`: + +```conf +replicaof 6379 +masterauth +replica-read-only yes +``` + +然后: + +```bash +systemctl restart redis-server +redis-cli INFO replication +``` + diff --git a/ops/hifast-aws-standby-architecture-zh.md b/ops/hifast-aws-standby-architecture-zh.md new file mode 100644 index 0000000..489e557 --- /dev/null +++ b/ops/hifast-aws-standby-architecture-zh.md @@ -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 '' 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` 的接管与回切执行清单 diff --git a/pkg/loki/loki.go b/pkg/loki/loki.go index 554119a..93af245 100644 --- a/pkg/loki/loki.go +++ b/pkg/loki/loki.go @@ -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 查询