test(loadtest),docs: monitoring, cdn checklist and k6 framework
- monitoring.md: 指标→实现映射(1Panel+云监控+结构化日志,不引入额外监控 系统);pino 字段表如实列自代码(访问日志 req.id/res.statusCode/ responseTime + sync/worker 自定义 msg 字段);六类告警 jq 检索语句与 阈值建议总表;日志保留(compose json-file 10m×3 已设 + 每日归档建议) - cdn-checklist.md: 全步骤待凭证执行;图片加速三选一架构核对(双层签名 冲突分析,推荐 c 起步=CDN 仅加速 viewer 静态、presigned 直链不变, 图片收益后评估 b=CDN 鉴权+私有桶回源授权);缓存规则对齐源站 immutable; 失效/预热 CLI 样例与多地域冷缓存测试点模板 - loadtest/: k6 框架——read-feed.js(N 观众四 mix:latest/hot 分页+大图+ 点赞+浏览,ramp 阶段)、upload-burst.js(M 并发上传全链含 complete 与 发布/同步可选轮询,兼作数据种子)、lib/helpers.js(登录/建册/上传全链 +内嵌 JPEG fixture 与 prod-smoke 同源)、README(运行方式/参数表/ P95 输出解读对齐计划 §6 候选指标;D05 未定→阈值占位注释) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
# 阿里云全球 CDN 接入核对单(P5-prep Task 3)
|
||||
|
||||
> **全部步骤「待凭证执行」**:本清单只固化决策与命令样例,不预设任何已执行
|
||||
> 状态。凭证/域名到位(G5)后按 T5 逐项勾选。关联:RUNBOOK.md §5.3(无 CDN
|
||||
> 基线的缓存故障排查)、总纲 P5 行、`docs/plans/2026-09-21-P5-真实环境接入.md` T5。
|
||||
|
||||
## 0. 前置决策:图片加速三选一(重要架构核对,登记为 P5 决策项)
|
||||
|
||||
**现状(代码事实)**:图片一律走 **OSS presigned GET 直链**(S3 兼容 SigV4 签名,
|
||||
`SIGNED_URL_TTL_SECONDS=900s`;variants 内容寻址键 `photoId+规格`,公开列表每次
|
||||
现签 `thumb.url` / `browse.url`)。若 CDN 图片域名开启 **CDN 自己的 URL 鉴权**,
|
||||
客户端 URL 将出现「CDN 签名参数 + OSS 签名参数」双层签名:CDN 校验自己的鉴权
|
||||
参数后回源时带原 query 请求 OSS,OSS 再验自己的签名——两层参数互不冲突但
|
||||
**缓存键含全部 query**,每个 900s 签名版本各成一册缓存,命中率崩塌;若为命中率
|
||||
忽略 query,鉴权语义又互相残废。三个候选方案:
|
||||
|
||||
| 方案 | 内容 | 评估 |
|
||||
| --- | --- | --- |
|
||||
| a) 图片域名 CDN 仅缓存不鉴权 | 关 CDN 鉴权 + 忽略 query 参数缓存,依赖「OSS presign 过期」兜底 | **失效语义劣化**:presign 过期只约束回源,CDN 缓存内对象在 TTL 内仍可命中;忽略 query 后鉴权退化为「对象键不可猜」(cuid 随机,强度可接受但低于签名);隐藏照片失效窗口 = CDN 缓存 TTL,需配合主动失效。仅缓存静态 variants 可接受,收益有限 |
|
||||
| b) CDN 鉴权 + 回源 OSS 私有桶 | 图片域名开 CDN URL 鉴权(B 型),回源用阿里云「CDN 授权访问 OSS 私有 Bucket」(§2),服务端 `presignGet` 改签 CDN 鉴权 URL | **正解但需改造**:签名安全模型从「OSS SigV4」迁到「CDN 鉴权」,`packages/storage` 的 presignGet 需按环境切换签名后端(或双签并存灰度);改造量集中在 storage 适配层 + 契约(URL 形态变化对前端透明,仍是无差别 URL 字符串)。**图片加速收益明确后采用** |
|
||||
| **c) 【推荐起步】保持 presigned 直链 + CDN 仅加速 viewer 静态资源** | 只建 viewer 域名加速区(§1 Zone 1),图片域名 Zone(§1 Zone 2)暂不接入 | **最小改动、签名安全模型不变、零代码**;图片加速收益(900s 内重复读同图省回源带宽)先观察 OSS 流量账单再评估 b |
|
||||
|
||||
**推荐:c 起步**,图片流量账单 / 海外首屏时延数据到位后按 b 改造(a 不推荐,
|
||||
失效语义与鉴权强度双损)。已登记为 P5 决策项(T5 内定案)。
|
||||
|
||||
## 1. 添加加速域名(待凭证执行;两 Zone)
|
||||
|
||||
| # | 加速域名 | 业务 | 源站 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Zone 1 | `<ov viewer 域名>`(如 photo.example.com) | viewer/admin SPA 静态 + `/v1/` API 透传 | ov 服务器 OpenResty 公网 IP(源站协议 https 回源 8089 需开白名单) | ☐ 待凭证执行 |
|
||||
| Zone 2 | `<图片域名>`(如 img.example.com,CNAME 至同一 OSS 桶) | OSS 图片加速 | OSS Bucket(私有,需 §2 回源授权) | ☐ 待凭证执行(**方案 c 起步 = 本 Zone 暂缓创建**) |
|
||||
|
||||
每个 Zone 通用步骤(控制台:CDN → 域名管理 → 添加域名,业务类型「图片小
|
||||
文件」/「小文件下载」;全球加速 = 不限区域或选「全球」):
|
||||
|
||||
- [ ] 添加域名 + 选择业务类型 + 源站信息(Zone 1 填 ov 服务器 IP;Zone 2 选
|
||||
OSS 域名源站)
|
||||
- [ ] CNAME 解析(DNS 服务商把域名 CNAME 到 CDN 分配的 `*.w.kunlun*.com`)
|
||||
- [ ] HTTPS:申请/绑定证书 + 开启「强制 HTTPS」+(可选)HTTP/2
|
||||
- [ ] 回源 Host 按默认(Zone 2 回源 Host = OSS 桶域名,控制台自动处理)
|
||||
|
||||
## 2. 源站 = OSS 私有 Bucket 回源授权(仅 Zone 2 / 方案 b 时需要;待凭证执行)
|
||||
|
||||
阿里云 CDN 回源私有 OSS 需授权二选一:
|
||||
|
||||
| 方式 | 操作 | 适用 |
|
||||
| --- | --- | --- |
|
||||
| 方式一:控制台一键 OSS 授权 | CDN 控制台源站配置时点「授权」,按提示同意创建 `AliyunCDNDefaultRole`(云账号级,授予 CDN 读 OSS 权限) | 最快,个人账号推荐 |
|
||||
| 方式二:自定义 RAM 角色 | RAM 控制台创建角色(受信实体=CDN)+ 最小权限策略(仅目标桶 `oss:GetObject`)+ CDN 源站配置选该角色 | 多项目共用账号、需权限隔离时 |
|
||||
|
||||
- [ ] 按 §0 决策结果确定是否执行本节(方案 c 起步则整节跳过)
|
||||
|
||||
## 3. URL 鉴权选型(仅 Zone 2 / 方案 b;待凭证执行)
|
||||
|
||||
| 型式 | URL 形态 | 特点 |
|
||||
| --- | --- | --- |
|
||||
| A 型 | `md5(<uri>-<timestamp>-<rand>-<uid>-<key>)` 拼接,时间戳进 MD5 不可读 | 更防猜测,但排障时无法从 URL 直接看出过期时刻 |
|
||||
| **B 型【推荐】** | `http://…/<uri>-<timestamp>-<md5hash>`,时间戳明文 | **时间戳在 URL 可读可调试**(一眼判过期);CDN 控制台配置简单(主备 key 各一个);鉴权粒度与 A 型同级 |
|
||||
|
||||
推荐 B 型理由:现场排障(观众报「图裂」)能直接从 URL 读出时间戳判断是否过期
|
||||
窗口问题;主备 key 支持平滑轮换。**与现有 presigned GET 的关系**:方案 b 下
|
||||
`presignGet` 输出从 OSS SigV4 URL 改为 CDN B 型鉴权 URL(§0 表格);方案 c 下
|
||||
无任何 URL 鉴权变更(Zone 1 静态资源不鉴权,本来就公开)。
|
||||
|
||||
- [ ] 若定案方案 b:Zone 2 配置 B 型鉴权 + 主备 key(`openssl rand -hex 32`)
|
||||
- [ ] 服务端 presignGet 签名后端改造排期(storage 适配层 + 测试)
|
||||
|
||||
## 4. 缓存规则表(Zone 1;待凭证执行)
|
||||
|
||||
原则:**源站 photo-live-web 已对内容寻址路径发 `Cache-Control: immutable`**
|
||||
(`infra/deploy/nginx.conf.template`),CDN 遵循源站头即可,无需重配长 TTL。
|
||||
计划占位稿中的「/assets/ 7d」按此修正为遵循源站 immutable(内容寻址键允许,
|
||||
发新版 = 新 URL,无失效问题)。
|
||||
|
||||
| 路径 | CDN 缓存策略 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| `/embed/v1/*` | 遵循源站:immutable 一年 | 版本目录即内容版本(nginx 同规则) |
|
||||
| `/assets/*`、`/admin/assets/*` | 遵循源站:immutable 一年 | vite 内容寻址产物 |
|
||||
| `/`、`/index.html`、`/admin/`、`/admin/index.html`、SPA 回退路径 | **不缓存**(no-cache,每次回源) | 发布新版必须立即生效,HTML 引用新 hash 的 assets |
|
||||
| `/v1/*`(API/WS) | **不缓存** + 透传 `x-request-id` 头 | 动态 API;WS 经 CDN 需在 CDN 侧确认支持或对 `/v1/realtime` 单独跳过 CDN(建议 DNS 直连源站,见 §7 注意) |
|
||||
|
||||
- [ ] 按上表在 CDN 控制台配置缓存规则(HTML/回退路径显式 no-cache 兜底——
|
||||
源站对 `index.html` 未显式发 no-cache 头,CDN 侧补齐这道保险)
|
||||
- [ ] 验证:`curl -sI https://<域名>/ | grep -i cache` 确认未命中缓存
|
||||
|
||||
## 5. 失效 / 预热 API 样例(待凭证执行)
|
||||
|
||||
阿里云 CDN OpenAPI 需 AccessKey 签名(ACS3-HMAC-SHA256),raw curl 组签名繁琐,
|
||||
**推荐 aliyun CLI 形态**(`aliyun` CLI 配置凭证后即用;curl 等价样例在凭证到位
|
||||
后按官方签名文档补齐——本节全部待凭证执行):
|
||||
|
||||
```bash
|
||||
# 隐藏照片 → 失效该照片全部 variants 路径(对象键含 photoId 与内容版本;
|
||||
# 方案 b 下才需要——variants 是 immutable 缓存,隐藏 = 主动失效 + 公开 API 404 双保险)
|
||||
aliyun cdn RefreshObjectCaches \
|
||||
--ObjectPath "https://<图片域名>/variants/thumb/<photoId>/<版本>.webp;https://<图片域名>/variants/browse/<photoId>/<版本>.webp" \
|
||||
--ObjectType File
|
||||
|
||||
# 新活动发布 → 预热首页与首屏静态资源(.viewer 域名,降低冷缓存首访时延)
|
||||
aliyun cdn PreloadObjectCaches \
|
||||
--ObjectPath "https://<viewer域名>/;https://<viewer域名>/assets/<新版本hash>.js;https://<viewer域名>/assets/<新版本hash>.css"
|
||||
|
||||
# 目录级失效(发版后 SPA HTML 变更;范围大时用,注意每日失效配额)
|
||||
aliyun cdn RefreshObjectCaches --ObjectPath "https://<viewer域名>/" --ObjectType Directory
|
||||
```
|
||||
|
||||
- [ ] 隐藏照片失效窗口验证(方案 b 时):上传 → 公开可读 → 隐藏 → 立即请求
|
||||
variants URL:公开 API 应 404(PHOTO_NOT_PUBLIC),CDN 失效执行完成后
|
||||
图片 URL 应 403/404;**失效前的窗口 = 设计允许的失效窗口**(对齐 P4a
|
||||
契约决策与 RUNBOOK §5.3,CDN 侧窗口上限 = 失效 API 生效延迟,需实测
|
||||
登记进 RUNBOOK)
|
||||
- [ ] 发版后预热纳入发布检查单(RUNBOOK §1 增补一行,待 CDN 实际接入后回填)
|
||||
|
||||
## 6. 冷缓存测试点(海外多地域时延记录表模板)
|
||||
|
||||
各地域单点拨测(冷缓存 = 首次请求回源;建议每地域 `curl` 3 次取中位),模板:
|
||||
|
||||
| 地域(测试点) | 测试方式 | 冷缓存 TTFB/总时延 | 热缓存复测 | 时间 | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 新加坡 | `curl -s -o /dev/null -w '%{time_starttransfer} %{time_total}\n' https://<viewer域名>/` | ___ / ___ | ___ | ___ | |
|
||||
| 东京 | 同上 | ___ / ___ | ___ | ___ | |
|
||||
| 法兰克福 | 同上 | ___ / ___ | ___ | ___ | |
|
||||
| 美西(硅谷) | 同上 | ___ / ___ | ___ | ___ | |
|
||||
| 美东(弗吉尼亚) | 同上 | ___ / ___ | ___ | ___ | |
|
||||
| 国内对照(杭州) | 同上 | ___ / ___ | ___ | ___ | 不走全球 CDN 国内段时的对照基线 |
|
||||
|
||||
可选公共拨测源:阿里云云监控站点监控(多地域)、第三方(如 GTmetrix / WebPageTest)
|
||||
留档截图。记录表随 T5 产出归档进 P5 验收记录。
|
||||
|
||||
## 7. 注意事项与边界
|
||||
|
||||
- **WS(`/v1/realtime`)不适合过 CDN**:长连接透传稳定性依赖 CDN 支持;建议
|
||||
viewer 前端 WS 地址直连源站域名(或单独一个不接 CDN 的子域),CDN 只加速
|
||||
静态与普通 API。核对前端 realtime 连接 URL 的构造(P5 T4/T5 联合验证项)。
|
||||
- Zone 1 源站是 ov 服务器 OpenResty:CDN 回源 IP 段需加入 OpenResty 白名单
|
||||
(1Panel 防火墙),并确认回源走 https(证书已就绪)。
|
||||
- 速率/配额:CDN 每日失效/预热有配额(控制台可见),发版高频期优先用文件级
|
||||
精确失效;目录级失效作为兜底。
|
||||
- 全部步骤执行前保持「无 CDN」基线可用(RUNBOOK §5.3),CDN 故障时 DNS 切回
|
||||
直连即回滚(T8 演练项之一)。
|
||||
@@ -0,0 +1,152 @@
|
||||
# 监控告警方案(P5-prep Task 3)
|
||||
|
||||
> 适用对象:photo-live 双地域生产栈(编排见 `docker-compose.cn.yml` /
|
||||
> `docker-compose-ov.yml`)。**治理决策(登记):P5 起步不引入额外监控系统**
|
||||
>(Prometheus/Grafana/ELK 均不上)——1Panel 应用监控 + 云监控 + 结构化日志
|
||||
> 检索三件套覆盖本阶段全部指标面;业务量或团队规模变化时再评估升级,
|
||||
> 届时优先补「日志聚合 + 指标导出」,而非替换现有日志结构。
|
||||
|
||||
## 1. 指标 → 实现方式映射总表
|
||||
|
||||
| 指标 | 实现方式 | 位置 |
|
||||
| --- | --- | --- |
|
||||
| 服务存活 / health | 容器 healthcheck(api-cn/api-ov/web 有 `/v1/health` 探针;worker/sync 无 HTTP 面,以进程存活 + restart 策略承担)+ 1Panel 容器状态页 | 两台 1Panel |
|
||||
| 容器重启次数 | 1Panel 容器监控(重启计数突增 = crash loop) | 两台 1Panel |
|
||||
| CPU / 内存 / 磁盘容量 | 1Panel 应用监控 + 主机概览;云监控(ECS 自带)做兜底告警 | 两台 |
|
||||
| MySQL / Redis 连接与资源 | 1Panel 数据库监控(连接数、内存);连接池上限已在连接串 `connection_limit=10` 固化 | 两台 |
|
||||
| 同步失败 / 同步水位延迟 | sync 容器结构化日志检索(§3 检索语句)+ `outbox_events` 未投递计数(SQL) | cn 服务器 |
|
||||
| 上传失败率 | api-cn 访问日志(pino-http)按 `req.url` + `res.statusCode` 计数(§3) | cn 服务器 |
|
||||
| 计数差异(点赞/浏览对不上) | sync `stat return cycle failed` 日志 + `stat-snapshots` statVersion 前进检查(§3) | cn 服务器 |
|
||||
| 队列积压(图片处理) | worker `sweep 完成` 日志的 `claimed` 持续 >0 + `photo_jobs` pending 行龄 SQL(§3) | cn 服务器 |
|
||||
| 磁盘 / 流量(OSS) | OSS 控制台用量 + 云监控;CDN 接入后加 CDN 控制台流量看板 | 阿里云控制台 |
|
||||
| 证书到期 / 域名解析 | 1Panel 证书管理提示 + 云监控站点监控 | 各自 |
|
||||
|
||||
要点:**业务指标不带独立监控系统,全部落在结构化日志检索**。三端(api /
|
||||
worker / sync)日志均为 pino JSON 单行 stdout,`docker logs` 直接可查;字段
|
||||
口径见 §2(如实列自代码,非设想)。
|
||||
|
||||
## 2. pino 结构化日志字段表
|
||||
|
||||
日志器装配(代码事实):api = `nestjs-pino` + `pino-http`(`services/api/src/
|
||||
shared/logger-options.ts`,每请求自动访问日志);worker / sync = 原生 pino
|
||||
(`services/worker/src/main.ts` / `services/sync/src/main.ts`)。级别映射 pino
|
||||
数值:20=debug 30=info 40=warn 50=error。凭据打码:`req.headers.authorization`
|
||||
与 `req.headers.cookie` 已按 `logger-options.ts` redact 为 `[REDACTED]`。
|
||||
|
||||
### 2.1 通用字段(每行日志必有)
|
||||
|
||||
| 字段 | 来源 | 示例 / 说明 |
|
||||
| --- | --- | --- |
|
||||
| `level` | pino | `30`(数值;人读用 `docker logs … \| jq -r '.level'` 映射) |
|
||||
| `time` | pino | Unix 毫秒时间戳 |
|
||||
| `pid` / `hostname` | pino | 进程 / 容器主机名(排障定位容器) |
|
||||
| `msg` | 各调用点 | 人类可读消息(检索主键,见表 2.3) |
|
||||
| `context` | nestjs-pino(仅 api) | Nest Logger 上下文名,如 `AllExceptionsFilter`、`UserSyncService` |
|
||||
|
||||
### 2.2 HTTP 访问日志字段(api 每请求一行,pino-http 自动打点)
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `req.id` | 请求 ID:透传上游 `x-request-id` 或现生成 UUID;**同时回写响应头 `x-request-id`**,全链路排障锚点(`request-id.middleware.ts`) |
|
||||
| `req.method` / `req.url` | 方法与含 query 的路径(如 `GET /v1/public/albums/tok/photos?limit=30`) |
|
||||
| `req.headers` | 已 redact 授权头/Cookie |
|
||||
| `req.remoteAddress` / `req.remotePort` | 客户端地址(反代后是网关地址,trust proxy 登记为 P5 项) |
|
||||
| `res.statusCode` | 响应状态码(错误率计数的取数字段) |
|
||||
| `responseTime` | 服务端耗时毫秒(粗粒度性能观测;精测用压测框架 `loadtest/`) |
|
||||
| `msg` | 固定 `request completed` |
|
||||
|
||||
### 2.3 业务/后台进程自定义字段(msg 为检索主键,如实列自调用点)
|
||||
|
||||
| 进程 | `msg`(检索用) | 级别 | 自定义字段 | 语义 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| sync | `outbox batch pushed` | info | `picked`(取数)`acceptedEventIds[]`(ov 确认)`applied`(实际应用) | 推送健康;`picked≥batchSize` 连续出现 = 排积压 |
|
||||
| sync | `outbox push failed(整批未标记,下轮重推)` | error | `err` `failureStreak` `backoffMs` | **同步失败告警主信号**;连续出现 = cn→ov 中断 |
|
||||
| sync | `reconcile cycle failed(幂等安全,下轮再试)` | error | `err` | 对账循环失败(5min 一轮自愈) |
|
||||
| sync | `stat snapshots returned` | info | `picked` `snapshots` `accepted` | 统计回传健康;长期不出现 = ov 聚合停滞 |
|
||||
| sync | `stat return cycle failed(未确认行留待下轮重发,幂等安全)` | error | `err` | **计数差异告警信号**(回传断流) |
|
||||
| sync | `sync sender + reconciler + stat-return started` | info | `cnApiUrl` `ovApiUrl` `intervalMs` `batchSize` `reconcileIntervalMs` `statReturnIntervalMs` | 启动自描述(排障先看这行核对 env) |
|
||||
| sync | `env 非正整数,回退契约默认值` / `缺少内部同步密钥,发送器退出` | warn / error | `name,raw` / `env` | 配置异常 |
|
||||
| worker | `process job 完成` | info | `photoId` `jobId` `outcome` | 单照片流水线完成 |
|
||||
| worker | `process job 终败(重试耗尽)` | error | `photoId` `jobId` `errorCode` | **图片处理失败告警**(errorCode 对照契约错误码) |
|
||||
| worker | `sweep 完成` | info | `claimed`(本轮认领孤儿)`enqueueFailures` `staleReEnqueued` | **队列积压观测锚**:稳态应恒为 `claimed:0` |
|
||||
| worker | `sweep 轮次异常` / `photo-process 入队失败(sweep 将补偿)` | error/warn | `err` / `photoId` | 补偿通道异常 |
|
||||
| worker | `redis 连接异常(ioredis 自动重连)` | warn | `label` `err` | Redis 闪断(自愈,频发则查 Redis) |
|
||||
| api | `ApiException <status> -> <code> (requestId=…)` | warn | 消息内嵌 code 与 requestId | 业务异常计数(含上传 complete 失败码) |
|
||||
| api | `HttpException …` / `Unhandled exception …` | warn/error | 同上 + stack | 非业务异常(5xx 必查 requestId 关联访问日志行) |
|
||||
|
||||
## 3. 每类告警的检索语句样例(docker logs + jq,两台服务器上直接执行)
|
||||
|
||||
容器名约定(compose 项目名前缀):`photo-live-cn-sync-1` /
|
||||
`photo-live-cn-worker-1` / `photo-live-cn-api-cn-1`(ov:`photo-live-ov-api-ov-1`)。
|
||||
|
||||
```bash
|
||||
# ① 同步失败(告警口径:近 10 分钟出现 error 级 outbox push failed)
|
||||
docker logs --since 10m photo-live-cn-sync-1 2>&1 \
|
||||
| jq -c 'select(.msg | contains("outbox push failed"))'
|
||||
|
||||
# ①' 同步水位延迟(未投递账本堆积——发布前快照同款 SQL,阈值见 §4)
|
||||
docker exec <cn-mysql容器> mysql -uphoto -p -e \
|
||||
"SELECT COUNT(*) AS undelivered, MIN(createdAt) AS oldest FROM photo_cn.outbox_events WHERE deliveredAt IS NULL;"
|
||||
|
||||
# ② 上传失败率(api 访问日志:uploads 路径非 2xx 占比;complete 失败码单独数)
|
||||
docker logs --since 10m photo-live-cn-api-cn-1 2>&1 \
|
||||
| jq -c 'select(.msg=="request completed" and (.req.url | contains("/v1/uploads")) and (.res.statusCode < 200 or .res.statusCode > 299))'
|
||||
# 业务失败码(ALBUM_UPLOAD_DISABLED / UPLOAD_SESSION_CONFLICT / UPLOAD_VERIFY_FAILED 等):
|
||||
docker logs --since 10m photo-live-cn-api-cn-1 2>&1 \
|
||||
| jq -r 'select(.msg | startswith("ApiException")) | .msg' | sort | uniq -c | sort -rn
|
||||
|
||||
# ③ 计数差异(stat 回传断流 + 版本不前进)
|
||||
docker logs --since 30m photo-live-cn-sync-1 2>&1 \
|
||||
| jq -c 'select(.msg | contains("stat return cycle failed"))'
|
||||
# 版本前进检查(statVersion 应随互动增长单调;长期不变即聚合停滞):
|
||||
docker exec <cn-mysql容器> mysql -uphoto -p -e \
|
||||
"SELECT albumId, MAX(statVersion) AS v, MAX(updatedAt) AS lastAt FROM photo_cn.stat_snapshots GROUP BY albumId ORDER BY lastAt DESC LIMIT 10;"
|
||||
|
||||
# ④ 队列积压(sweep claimed 持续 >0 = 孤儿持续被重新认领 = 消费不及或 worker 停)
|
||||
docker logs --since 10m photo-live-cn-worker-1 2>&1 \
|
||||
| jq -c 'select(.msg == "sweep 完成")' | jq -s 'map(.claimed) | {samples: length, nonzero: map(select(.>0)) | length}'
|
||||
# 兜底 SQL:pending 行龄( oldest 超过 5 分钟即真积压)
|
||||
docker exec <cn-mysql容器> mysql -uphoto -p -e \
|
||||
"SELECT COUNT(*) AS pending, MIN(createdAt) AS oldest FROM photo_cn.photo_jobs WHERE state='pending';"
|
||||
|
||||
# ⑤ 图片处理终败(重试耗尽的照片,需人工补处理)
|
||||
docker logs --since 1h photo-live-cn-worker-1 2>&1 \
|
||||
| jq -c 'select(.msg | contains("process job 终败"))'
|
||||
|
||||
# ⑥ 5xx 洪峰(api 全量非 5xx 校验;冒烟/压测期配合使用)
|
||||
docker logs --since 10m photo-live-cn-api-cn-1 2>&1 \
|
||||
| jq -c 'select(.msg=="request completed" and .res.statusCode >= 500)'
|
||||
```
|
||||
|
||||
## 4. 告警项总表(阈值 = P5 起步建议值,试运行后按观测校准)
|
||||
|
||||
| 告警项 | 阈值建议 | 检测方式 | 接收人 |
|
||||
| --- | --- | --- | --- |
|
||||
| sync 推送失败 | 10 分钟内 `outbox push failed` ≥ 3 次(对应退避 3 轮未自愈) | 日志检索 ①(人工/计划任务) | 【占位:运维负责人】 |
|
||||
| 同步水位延迟 | `outbox_events` 未投递 > 1000 行,或 oldest > 10min | SQL ①'(发布检查单同款,可入 1Panel 计划任务) | 【占位】 |
|
||||
| 上传失败率 | uploads 路径非 2xx 占比 > 5%(10 分钟窗,样本 ≥ 20) | 日志检索 ② | 【占位】 |
|
||||
| 计数差异 | `stat return cycle failed` 连续 30 分钟,或 stat_snapshots 最新 lastAt > 30min 前且互动非零 | 日志/SQL ③ | 【占位】 |
|
||||
| 队列积压 | sweep `claimed` 连续 6 轮(约 3min)>0,或 photo_jobs pending oldest > 5min | 日志/SQL ④ | 【占位】 |
|
||||
| 图片处理终败 | 1h 内 `process job 终败` ≥ 5 张 | 日志检索 ⑤ | 【占位】 |
|
||||
| API 5xx 洪峰 | 10 分钟 5xx ≥ 10 次 | 日志检索 ⑥ / 云监控站点监控 | 【占位】 |
|
||||
| 容器重启 | 1Panel 重启计数 1h 内 +3(crash loop) | 1Panel 应用监控 | 【占位】 |
|
||||
| 磁盘容量 | 1Panel 磁盘 > 80% 告警 / > 90% 严重 | 1Panel 主机监控(自带阈值告警) | 【占位】 |
|
||||
| ECS / 网络可用性 | ping / 站点探测失败 | 阿里云监控(站点监控免费档即可) | 【占位】 |
|
||||
| OSS 流量异常 | 月度流量突增(对比基线 ×3) | OSS 控制台 + 云监控账单告警 | 【占位】 |
|
||||
| 证书到期 | 剩余 < 14 天 | 1Panel 证书管理 / 云监控 | 【占位】 |
|
||||
|
||||
> 1Panel 计划任务可承载 SQL 类巡检(①'/③/④ 的 SQL + 失败时发通知),把
|
||||
> 「人工检索」升级为「定时巡检」零新增组件;通知通道用 1Panel 的通知设置。
|
||||
|
||||
## 5. 日志保留策略
|
||||
|
||||
- **已固化**(compose 两栈统一 `x-logging`):docker json-file 轮转
|
||||
`max-size=10m × max-file=3`,单容器上限约 30MB,六容器合计 ~180MB 上限,
|
||||
磁盘不会被日志撑爆。轮转之外无中心化留存——重启/删除容器丢历史,属已知取舍。
|
||||
- **建议归档(P5 试运行起执行)**:1Panel 计划任务每日低峰
|
||||
`docker logs <容器> --since 24h > /opt/photo-live/logs/<容器>-$(date +%F).json`
|
||||
后 gzip 存本地 30 天(约每天几 MB),异常事件窗口按 RUNBOOK §1 的「水位与
|
||||
账本快照登记」随发布记录另存。归档目录加入 1Panel 备份计划即可,不建独立
|
||||
日志系统。
|
||||
- 排障现场优先用 `--since` 缩窗 + §3 jq 语句;跨容器串事件用 `req.id` /
|
||||
`requestId=`(响应头同值,用户报障时让前端从 DevTools 复制即可定位)。
|
||||
@@ -0,0 +1,96 @@
|
||||
# loadtest —— k6 压测框架(P5-prep Task 3)
|
||||
|
||||
对齐项目计划 §6 首轮容量验证建议的 L1/L2 阶梯(L1 基础:相册 1000 照片 /
|
||||
3 人上传 / 100 观众;L2 目标候选:10000 / 10 / 1000;L3 扩展探测不设默认门槛)。
|
||||
**D05(数量并发与网络目标)未定——所有 thresholds 为占位注释**,确认后取消
|
||||
注释启用(候选口径见 §3)。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| `read-feed.js` | 观众读路径:N 观众(ramp 阶段)持续分页读最新 + 热门切换 + 大图请求 + 点赞批次 + 浏览上报四 mix |
|
||||
| `upload-burst.js` | 工作人员 M 并发小图上传全链(presign → OSS PUT → complete),可选轮询发布可见 / ov 同步;兼作数据种子 |
|
||||
| `lib/helpers.js` | 登录 / 建相册 / 上传全链 / 分页 / 点赞 / 浏览 / 轮询助手 + 内嵌 800x600 JPEG fixture(与 `scripts/prod-smoke.sh` 同源) |
|
||||
|
||||
安装:[k6](https://grafana.com/docs/k6/latest/set-up/install/) ≥ 0.45(Windows/
|
||||
macOS/Linux 均有安装包)。脚本在仓库根 `loadtest/` 下直接 `k6 run`。
|
||||
|
||||
## 运行方式
|
||||
|
||||
```bash
|
||||
# ① 种子:建时间戳相册并上传照片(L1 档:3 人 × 20 张;setup 会打印 ALBUM_TOKEN)
|
||||
k6 run --env BASE_CN=https://photo.example.cn \
|
||||
--env ACCOUNT=admin --env PASSWORD='…' \
|
||||
--env VUS=3 --env ITERATIONS=20 upload-burst.js
|
||||
|
||||
# ② 观众读路径压测(L1 档:100 观众;用 ① 打印的 ALBUM_TOKEN)
|
||||
k6 run --env BASE=https://photo.example.com --env ALBUM_TOKEN=<打印值> \
|
||||
--env VUS=100 --env STEADY=5m read-feed.js
|
||||
|
||||
# ②' L2 档:观众 1000 / 上传 10 人(先种更大相册:10 × 100)
|
||||
k6 run --env BASE=… --env ALBUM_TOKEN=… --env VUS=1000 --env STEADY=10m read-feed.js
|
||||
|
||||
# ③ 上传链含发布观测(传输完成→发布;ov 同步端到端可选)
|
||||
k6 run --env BASE_CN=… --env ACCOUNT=… --env PASSWORD=… \
|
||||
--env ALBUM_ID=<albumId> --env ALBUM_TOKEN=<token> \
|
||||
--env WAIT_PUBLISH=1 --env WAIT_SYNC=1 --env BASE_OV=https://photo.example.com \
|
||||
upload-burst.js
|
||||
|
||||
# 结果归档:--out json=results-$(date +%F-%H%M).json 或 --summary-export=summary.json
|
||||
```
|
||||
|
||||
### 参数总表(--env KEY=VALUE)
|
||||
|
||||
| KEY | 脚本 | 缺省 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `BASE` | 两者 | 必填(read)/ upload 的 BASE_CN 回退 | 被测 API 基址(read 用 ov 域,upload 用 cn 域),末尾不带 `/v1` |
|
||||
| `ALBUM_TOKEN` | read 必填 / upload 可选 | — | 公开相册 token(种子模式首次运行打印) |
|
||||
| `ACCOUNT` / `PASSWORD` | upload | 必填 | 工作人员(或 admin)账号 |
|
||||
| `ALBUM_ID` | upload | 空=建新册 | 复用相册;配合 `ALBUM_TOKEN` 才有发布轮询 |
|
||||
| `VUS` | 两者 | 100 / 3 | 观众数 / 上传并发人数 |
|
||||
| `ITERATIONS` | upload | 20 | 每人上传张数(L1 合计 = VUS×ITERATIONS) |
|
||||
| `STEADY` / `RAMP_UP` / `RAMP_DOWN` | read | 5m / 1m / 1m | ramp 阶段时长 |
|
||||
| `PAGES` / `PAGE_LIMIT` | read | 3 / 30 | 翻页深度 / 页大小(契约上限 60) |
|
||||
| `HOT_SHARE` / `BIGIMG_SHARE` / `LIKE_SHARE` | read | 0.5 / 0.3 / 0.4 | 迭代内行为混合概率 |
|
||||
| `THINK_MIN` / `THINK_MAX` | read | 0.5 / 3 | 观众思考时间秒 |
|
||||
| `WAIT_PUBLISH` / `WAIT_SYNC` / `BASE_OV` | upload | 0 / 0 / 空 | 公开可见轮询开关(cn 发布 / ov 同步) |
|
||||
| `POLL_TIMEOUT` / `POLL_INTERVAL` | upload | 60 / 2 | 轮询上限/间隔秒 |
|
||||
|
||||
## 输出解读(P95 关注点 ←→ 项目计划 §6 候选指标)
|
||||
|
||||
| 候选指标(计划原文) | k6 指标名 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 普通列表 API P95 < 500ms | `feed_list_duration` / `feed_hot_duration` | 按 tag `list_public_photos(_hot)` 分组的 Trend;「首批照片 P95<3s」是浏览器端口径(含渲染/图载),k6 以首页列表请求近似 + §冷缓存另测 |
|
||||
| 传输完成到发布 P95 < 10s | `publish_wait_duration` | `WAIT_PUBLISH=1` 时 complete → cn 公开列表可见的等待;上传耗时(`upload_oss_put_duration`)单独统计,不计入处理时延(计划注记) |
|
||||
| (同上,跨境) | `ov_sync_wait_duration` | `WAIT_SYNC=1`:complete → ov 公开可见(含推送 2s 间隔) |
|
||||
| 点赞确认及广播 P95 < 2s | `like_confirm_duration` | API 确认口径;WS 广播时延属前端观测面,k6 不覆盖 |
|
||||
| — | `upload_presign_duration` / `upload_complete_duration` | 上传链分步;`upload_chain_duration` 为三步端到端 |
|
||||
| — | `like_rejected_total` / `publish_timeout_total` | 非目标信号:前者多为限速触发(见下),后者=发布超时需查 worker |
|
||||
|
||||
通用看板项:`http_req_duration`(按 name tag 细分)、`http_req_failed`、
|
||||
`checks` 通过率、VU 数与迭代吞吐(`iteration_duration` 倒数)。**冷缓存/地域
|
||||
时延不在 k6 范围**——用 `infra/deploy/cdn-checklist.md` §6 的多地域 curl 记录表。
|
||||
|
||||
## 阈值占位(D05 确认后启用)
|
||||
|
||||
两脚本 `options.thresholds` 均为注释占位(候选值即上表)。确认 D05 后:
|
||||
1. 取消注释并按定案值调整;
|
||||
2. L2 档跑 `k6 run … --tag testid=l2-$(date +%F)` 便于结果对比归档;
|
||||
3. 阈值失败会让 k6 以非零码退出——可直接作为 P5 容量报告的机器判定。
|
||||
|
||||
## 操作注意(如实阅读再跑)
|
||||
|
||||
- **限速会污染指标**:likes 有 Redis 滑窗限速(`LIKE_IP_WINDOW_DELTA=2000` /
|
||||
24h / IP;`LIKE_SESSION_WINDOW_DELTA=500` / 会话)——高并发点赞从单一压测机
|
||||
出发很快触发 429(计入 `like_rejected_total`,且账面不是真实服务容量)。
|
||||
staging 压测调大对应 env 或 `LIKE_SHARE=0`;需要真实验证限速本身时反向利用。
|
||||
visits 的键含 visitorId(每 VU 唯一),`VISIT_WINDOW_REQUESTS=60`/窗 通常够用。
|
||||
- **反代后 IP 归并**:经 OpenResty 反代时服务端 req.ip 是网关地址,trust proxy
|
||||
未配置前所有限速按网关 IP 聚合——压测读数要意识到这一点(登记为 P5 项,
|
||||
likes.controller.ts 文件头同注)。
|
||||
- **对象存储是外部依赖**:`oss_put` / `oss_image_get` 打到 MinIO/OSS/CDN 直链,
|
||||
其时延与被测 API 分开解读(tag 已区分);生产桶上跑会产生真实流量费用。
|
||||
- **不自动清理**:种子照片留在相册内(时间戳标题可辨认),清理=后台软删或
|
||||
`DELETE /v1/photos/{id}`(prod-smoke 同款);相册无删除端点。
|
||||
- 对生产环境执行前必须获得明确许可——上传/点赞/浏览写路径全部落真实数据。
|
||||
@@ -0,0 +1,220 @@
|
||||
// loadtest/lib/helpers.js —— k6 公共助手(P5-prep Task 3)。
|
||||
// 被 read-feed.js / upload-burst.js 复用:登录 / 建相册 / 上传全链 / 公开读
|
||||
// 分页 / 点赞批次 / 浏览上报 / 公开可见轮询。端点与请求体形态对齐
|
||||
// scripts/prod-smoke.sh(契约权威 = packages/contracts/openapi.yaml)。
|
||||
//
|
||||
// 内置测试图与 prod-smoke.sh 同一张:800x600 JPEG(quality 55,3118 字节),
|
||||
// base64 内嵌(下方 JPEG_B64 由 scripts/prod-smoke.sh 同步生成,勿手改),
|
||||
// k6 侧用 k6/encoding 解码为二进制。刻意不用 1x1——避免变体流水线踩最小
|
||||
// 尺寸边界。
|
||||
import http from 'k6/http';
|
||||
import { sleep } from 'k6';
|
||||
import encoding from 'k6/encoding';
|
||||
|
||||
export const JPEG_B64 = '/9j/2wBDAA4KCw0LCQ4NDA0QDw4RFiQXFhQUFiwgIRokNC43NjMuMjI6QVNGOj1OPjIySGJJTlZYXV5dOEVmbWVabFNbXVn/2wBDAQ8QEBYTFioXFypZOzI7WVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVn/wAARCAJYAyADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCsAkKIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//2Q==';
|
||||
|
||||
/** 解码一次,全脚本复用(Uint8Array 可直接作 http 请求体)。 */
|
||||
export const JPEG_BYTES = new Uint8Array(encoding.b64decode(JPEG_B64, 'binary'));
|
||||
|
||||
// ── env 参数读取(--env KEY=VALUE → __ENV.KEY;缺省回退)──
|
||||
|
||||
export function envStr(name, fallback) {
|
||||
const v = __ENV[name];
|
||||
return v === undefined || v === '' ? fallback : v;
|
||||
}
|
||||
|
||||
export function envInt(name, fallback) {
|
||||
const raw = __ENV[name];
|
||||
if (raw === undefined || raw === '') {
|
||||
return fallback;
|
||||
}
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
export function envFloat(name, fallback) {
|
||||
const raw = __ENV[name];
|
||||
if (raw === undefined || raw === '') {
|
||||
return fallback;
|
||||
}
|
||||
const n = Number.parseFloat(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
}
|
||||
|
||||
/** 压测内唯一键(clientTaskId/batchId/requestId 等幂等键;时间戳+随机足够)。 */
|
||||
export function uniqId(prefix) {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
|
||||
}
|
||||
|
||||
// ── 认证与相册 ──
|
||||
|
||||
export function authHeaders(token) {
|
||||
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
/** 密码登录 → sessionToken(响应体 {sessionToken,...},同 prod-smoke ②)。 */
|
||||
export function login(base, account, password) {
|
||||
const res = http.post(
|
||||
`${base}/v1/auth/password`,
|
||||
JSON.stringify({ account, password }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_password' } },
|
||||
);
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`登录失败 HTTP ${res.status}: ${res.body}`);
|
||||
}
|
||||
const token = res.json('sessionToken');
|
||||
if (!token) {
|
||||
throw new Error(`登录响应缺 sessionToken: ${res.body}`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 建时间戳相册 → { albumId, publicToken }(同 prod-smoke ③)。 */
|
||||
export function createAlbum(base, token, title, description) {
|
||||
const res = http.post(
|
||||
`${base}/v1/albums`,
|
||||
JSON.stringify({
|
||||
translations: [
|
||||
{ locale: 'zh-CN', title, description: description || 'k6 loadtest fixture' },
|
||||
],
|
||||
defaultLocale: 'zh-CN',
|
||||
startAt: new Date().toISOString(),
|
||||
}),
|
||||
{ headers: authHeaders(token), tags: { name: 'albums_create' } },
|
||||
);
|
||||
if (res.status !== 201) {
|
||||
throw new Error(`建相册失败 HTTP ${res.status}: ${res.body}`);
|
||||
}
|
||||
return { albumId: res.json('id'), publicToken: res.json('publicToken') };
|
||||
}
|
||||
|
||||
// ── 上传全链(presign → OSS PUT → complete)──
|
||||
|
||||
/**
|
||||
* 单照片全链上传。返回逐步耗时(毫秒,k6 timings)与 id,供脚本各自打点:
|
||||
* { photoId, uploadId, timings: { presign, put, complete, chainMs } }
|
||||
* 任一步非预期状态码即抛错(k6 计一次 error 并终止单迭代)。
|
||||
*/
|
||||
export function uploadPhoto(base, token, albumId, clientTaskId) {
|
||||
const started = Date.now();
|
||||
const presign = http.post(
|
||||
`${base}/v1/uploads`,
|
||||
JSON.stringify({
|
||||
albumId,
|
||||
clientTaskId,
|
||||
filename: 'k6.jpg',
|
||||
bytes: JPEG_BYTES.byteLength,
|
||||
contentType: 'image/jpeg',
|
||||
}),
|
||||
{ headers: authHeaders(token), tags: { name: 'uploads_presign' } },
|
||||
);
|
||||
if (presign.status !== 201) {
|
||||
throw new Error(`presign 失败 HTTP ${presign.status}: ${presign.body}`);
|
||||
}
|
||||
const uploadId = presign.json('uploadId');
|
||||
const photoId = presign.json('photoId');
|
||||
const url = presign.json('url');
|
||||
if (!uploadId || !url) {
|
||||
throw new Error(`presign 响应缺 uploadId/url: ${presign.body}`);
|
||||
}
|
||||
// PUT 目标是 presigned 对象存储 URL(staging=MinIO / 生产=OSS),非被测 API 域。
|
||||
const put = http.put(url, JPEG_BYTES, {
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
tags: { name: 'oss_put' },
|
||||
});
|
||||
if (put.status < 200 || put.status > 299) {
|
||||
throw new Error(`OSS PUT 失败 HTTP ${put.status}`);
|
||||
}
|
||||
const complete = http.post(
|
||||
`${base}/v1/uploads/${uploadId}/complete`,
|
||||
'',
|
||||
{ headers: authHeaders(token), tags: { name: 'uploads_complete' } },
|
||||
);
|
||||
if (complete.status !== 200) {
|
||||
throw new Error(`complete 失败 HTTP ${complete.status}: ${complete.body}`);
|
||||
}
|
||||
return {
|
||||
photoId,
|
||||
uploadId,
|
||||
timings: {
|
||||
presign: presign.timings.duration,
|
||||
put: put.timings.duration,
|
||||
complete: complete.timings.duration,
|
||||
chainMs: Date.now() - started,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 观众端四类操作(ov 域公开端点;亦可打 cn 做对照)──
|
||||
|
||||
/**
|
||||
* 公开照片列表一页(契约:limit 1..60 缺省 30;cursor 不透明需 encode;
|
||||
* sort=latest|hot 仅 ov 实现非法值 400)。tag 参数让脚本能按 name 分组统计。
|
||||
*/
|
||||
export function fetchFeedPage(base, albumToken, options) {
|
||||
const opts = options || {};
|
||||
let qs = `limit=${opts.limit || 30}`;
|
||||
if (opts.cursor) {
|
||||
qs += `&cursor=${encodeURIComponent(opts.cursor)}`;
|
||||
}
|
||||
if (opts.sort) {
|
||||
qs += `&sort=${opts.sort}`;
|
||||
}
|
||||
return http.get(`${base}/v1/public/albums/${albumToken}/photos?${qs}`, {
|
||||
tags: { name: opts.tag || 'list_public_photos' },
|
||||
});
|
||||
}
|
||||
|
||||
/** 浏览上报(匿名;visitorId 客户端持久 8..128 字符,requestId 幂等键)。 */
|
||||
export function reportVisit(base, albumToken, visitorId, requestId) {
|
||||
return http.post(
|
||||
`${base}/v1/public/albums/${albumToken}/visits`,
|
||||
JSON.stringify({ visitorId, requestId }),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
tags: { name: 'visit_report' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 点赞批次(匿名;sessionId 限速键 / batchId 幂等键 / delta 1..20 整数)。 */
|
||||
export function likeBatch(base, albumToken, photoId, sessionId, batchId, delta) {
|
||||
return http.post(
|
||||
`${base}/v1/public/albums/${albumToken}/photos/${photoId}/likes`,
|
||||
JSON.stringify({ sessionId, batchId, delta }),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
tags: { name: 'like_batch' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询公开列表直到 photoId 可见(worker 发布事务异步 + cn→ov 推送 2s 间隔;
|
||||
* 同 prod-smoke ④ 口径)。返回 { found, waitedMs, polls }。
|
||||
*/
|
||||
export function pollUntilVisible(
|
||||
base,
|
||||
albumToken,
|
||||
photoId,
|
||||
timeoutSec,
|
||||
intervalSec,
|
||||
tag,
|
||||
) {
|
||||
const started = Date.now();
|
||||
let polls = 0;
|
||||
for (;;) {
|
||||
polls += 1;
|
||||
const res = fetchFeedPage(base, albumToken, { limit: 30, tag: tag || 'public_read_poll' });
|
||||
if (res.status === 200) {
|
||||
const items = res.json('items');
|
||||
if (Array.isArray(items) && items.some((it) => it.photoId === photoId)) {
|
||||
return { found: true, waitedMs: Date.now() - started, polls };
|
||||
}
|
||||
}
|
||||
if (Date.now() - started > timeoutSec * 1000) {
|
||||
return { found: false, waitedMs: Date.now() - started, polls };
|
||||
}
|
||||
sleep(intervalSec);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// loadtest/read-feed.js —— L1/L2 观众读路径压测场景(P5-prep Task 3)。
|
||||
//
|
||||
// 模拟 N 名观众(VU=观众,ramp 阶段爬坡-稳态-退坡)在 ov 域公开相册上的四类
|
||||
// 行为混合:持续分页读最新(latest)/ 热门(hot)切换 / 大图请求(presigned
|
||||
// 直链打到对象存储,单独 tag 区分)/ 点赞批次 / 浏览上报(每 VU 一次,
|
||||
// 30min 去重窗口内重复上报 counted=false 属正常语义)。
|
||||
//
|
||||
// 用法(README.md 有完整参数表):
|
||||
// k6 run --env BASE=https://photo.example.com --env ALBUM_TOKEN=xxx \
|
||||
// --env VUS=100 --env STEADY=5m read-feed.js
|
||||
//
|
||||
// 阈值(thresholds)为占位注释:D05(数量并发与网络目标)未定,候选口径
|
||||
// 对齐项目计划 §6 候选指标,确认后在 options.thresholds 取消注释启用。
|
||||
import http from 'k6/http';
|
||||
import exec from 'k6/execution';
|
||||
import { check, sleep } from 'k6';
|
||||
import { Counter, Trend } from 'k6/metrics';
|
||||
import {
|
||||
envFloat,
|
||||
envInt,
|
||||
envStr,
|
||||
fetchFeedPage,
|
||||
likeBatch,
|
||||
reportVisit,
|
||||
uniqId,
|
||||
} from './lib/helpers.js';
|
||||
|
||||
// ── 参数(全部可 --env 覆盖;缺省 = L1 基础档)──
|
||||
const BASE = envStr('BASE', '');
|
||||
const ALBUM_TOKEN = envStr('ALBUM_TOKEN', '');
|
||||
const VUS = envInt('VUS', 100); // L1=100 / L2=1000(在线观众)
|
||||
const RAMP_UP = envStr('RAMP_UP', '1m');
|
||||
const STEADY = envStr('STEADY', '5m');
|
||||
const RAMP_DOWN = envStr('RAMP_DOWN', '1m');
|
||||
const PAGES = envInt('PAGES', 3); // 每迭代翻页深度
|
||||
const PAGE_LIMIT = envInt('PAGE_LIMIT', 30); // 契约上限 60
|
||||
const HOT_SHARE = envFloat('HOT_SHARE', 0.5); // 迭代内切热门排序概率
|
||||
const BIGIMG_SHARE = envFloat('BIGIMG_SHARE', 0.3); // 迭代内取大图概率
|
||||
const LIKE_SHARE = envFloat('LIKE_SHARE', 0.4); // 迭代内点赞批次概率
|
||||
const THINK_MIN = envFloat('THINK_MIN', 0.5); // 观众思考时间(秒)
|
||||
const THINK_MAX = envFloat('THINK_MAX', 3);
|
||||
|
||||
// ── 指标(按操作分组,输出解读见 README)──
|
||||
const listLatency = new Trend('feed_list_duration'); // latest 分页请求
|
||||
const hotLatency = new Trend('feed_hot_duration'); // hot 排序页
|
||||
const imageLatency = new Trend('image_get_duration'); // 对象存储直链大图(外部依赖)
|
||||
const likeLatency = new Trend('like_confirm_duration'); // 点赞批次确认
|
||||
const visitLatency = new Trend('visit_report_duration'); // 浏览上报
|
||||
const likeAccepted = new Counter('like_accepted_total');
|
||||
const likeRejected = new Counter('like_rejected_total'); // 429 限速 / batchAccepted=false
|
||||
const listBroken = new Counter('feed_list_error_total'); // 非 200 / 解析失败
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
viewers: {
|
||||
executor: 'ramping-vus',
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{ duration: RAMP_UP, target: VUS },
|
||||
{ duration: STEADY, target: VUS },
|
||||
{ duration: RAMP_DOWN, target: 0 },
|
||||
],
|
||||
gracefulRampDown: '30s',
|
||||
},
|
||||
},
|
||||
// thresholds 占位 —— D05 确认后取消注释(候选口径 = 项目计划 §6):
|
||||
// 普通列表 API P95 < 500ms;点赞确认 P95 < 2s(广播链路另由前端观测)。
|
||||
// thresholds: {
|
||||
// feed_list_duration: ['p(95)<500'],
|
||||
// feed_hot_duration: ['p(95)<500'],
|
||||
// like_confirm_duration: ['p(95)<2000'],
|
||||
// visit_report_duration: ['p(95)<500'],
|
||||
// 'checks': ['rate>0.99'],
|
||||
// http_req_failed: ['rate<0.01'],
|
||||
// },
|
||||
};
|
||||
|
||||
// k6 每个 VU 独立初始化模块:以下状态天然按 VU 隔离(观众身份)。
|
||||
const visitorId = uniqId(`visitor-${exec.vu.idInTest}`);
|
||||
const likeSessionId = uniqId(`sess-${exec.vu.idInTest}`);
|
||||
let visitReported = false;
|
||||
|
||||
export function setup() {
|
||||
if (!BASE || !ALBUM_TOKEN) {
|
||||
throw new Error('缺少 --env BASE=<ov 基址> 与 --env ALBUM_TOKEN=<公开相册 token>');
|
||||
}
|
||||
const res = http.get(`${BASE}/v1/public/albums/${ALBUM_TOKEN}`);
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`相册不可访问(HTTP ${res.status}):核对 BASE/ALBUM_TOKEN`);
|
||||
}
|
||||
return { albumTitle: res.json('title') };
|
||||
}
|
||||
|
||||
export default function () {
|
||||
// ① 浏览上报(每观众会话一次;窗口语义见文件头)
|
||||
if (!visitReported) {
|
||||
visitReported = true;
|
||||
const res = reportVisit(BASE, ALBUM_TOKEN, visitorId, uniqId('vr'));
|
||||
visitLatency.add(res.timings.duration);
|
||||
check(res, { 'visit report 200': (r) => r.status === 200 });
|
||||
}
|
||||
|
||||
// ② 最新分页(游标推进至 nextCursor=null 或 PAGES 页)
|
||||
const items = [];
|
||||
let cursor;
|
||||
for (let page = 0; page < PAGES && cursor !== null; page += 1) {
|
||||
const res = fetchFeedPage(BASE, ALBUM_TOKEN, {
|
||||
cursor,
|
||||
limit: PAGE_LIMIT,
|
||||
tag: 'list_public_photos',
|
||||
});
|
||||
listLatency.add(res.timings.duration);
|
||||
if (
|
||||
!check(res, {
|
||||
'list 200': (r) => r.status === 200,
|
||||
'list has items': (r) => Array.isArray(r.json('items')),
|
||||
})
|
||||
) {
|
||||
listBroken.add(1);
|
||||
break;
|
||||
}
|
||||
const body = res.json();
|
||||
for (const item of body.items) {
|
||||
items.push(item);
|
||||
}
|
||||
cursor = body.nextCursor; // null = 末页,循环条件止
|
||||
}
|
||||
|
||||
// ③ 热门排序一页(sort=hot 仅 ov 实现)
|
||||
if (Math.random() < HOT_SHARE) {
|
||||
const res = fetchFeedPage(BASE, ALBUM_TOKEN, {
|
||||
limit: PAGE_LIMIT,
|
||||
sort: 'hot',
|
||||
tag: 'list_public_photos_hot',
|
||||
});
|
||||
hotLatency.add(res.timings.duration);
|
||||
check(res, { 'hot list 200': (r) => r.status === 200 });
|
||||
}
|
||||
|
||||
// ④ 大图请求(presigned 直链 → 对象存储/CDN,非被测 API;tag 区分解读)
|
||||
if (items.length > 0 && Math.random() < BIGIMG_SHARE) {
|
||||
const item = items[Math.floor(Math.random() * items.length)];
|
||||
const url = item.browse && item.browse.url;
|
||||
if (url) {
|
||||
const res = http.get(url, { tags: { name: 'oss_image_get' } });
|
||||
imageLatency.add(res.timings.duration);
|
||||
check(res, { 'image 200': (r) => r.status === 200 });
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 点赞批次(匿名端点;限速口径见 README——压测会话请注意 LIKE_IP_WINDOW_DELTA)
|
||||
if (items.length > 0 && Math.random() < LIKE_SHARE) {
|
||||
const item = items[Math.floor(Math.random() * items.length)];
|
||||
const delta = 1 + Math.floor(Math.random() * 3); // 契约 1..20,取保守值
|
||||
const res = likeBatch(
|
||||
BASE,
|
||||
ALBUM_TOKEN,
|
||||
item.photoId,
|
||||
likeSessionId,
|
||||
uniqId('batch'),
|
||||
delta,
|
||||
);
|
||||
likeLatency.add(res.timings.duration);
|
||||
if (res.status === 200 && res.json('batchAccepted') === true) {
|
||||
likeAccepted.add(1);
|
||||
} else {
|
||||
likeRejected.add(1); // 429=限速触发(压测口径问题,见 README);非 200=链路异常
|
||||
}
|
||||
}
|
||||
|
||||
sleep(THINK_MIN + Math.random() * (THINK_MAX - THINK_MIN));
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// loadtest/upload-burst.js —— L1/L2 工作人员并发上传压测场景(P5-prep Task 3)。
|
||||
//
|
||||
// M 名工作人员(VU)并发执行小图上传全链:presign → 对象存储 PUT → complete,
|
||||
// 并可选轮询「公开可见」(传输完成→发布,worker 变体流水线 + 发布事务异步)
|
||||
// 与「ov 侧可见」(cn→ov 同步端到端)。数据面与 scripts/prod-smoke.sh ③④ 同径。
|
||||
//
|
||||
// 兼作 read-feed.js 的数据种子:ALBUM_ID 缺省时 setup 建时间戳相册并打印
|
||||
// albumId/publicToken——用打印出的 ALBUM_TOKEN 跑 read-feed.js 即得固定测试相册。
|
||||
//
|
||||
// 用法(README.md 有完整参数表):
|
||||
// k6 run --env BASE_CN=https://photo.example.cn --env ACCOUNT=a --env PASSWORD=p \
|
||||
// --env VUS=3 --env ITERATIONS=20 --env WAIT_PUBLISH=1 upload-burst.js
|
||||
//
|
||||
// 注意:fixtures 用内嵌 800x600 小图(3118 字节)——只压「链路与并发正确性」;
|
||||
// 真实尺寸图片的吞吐/处理时延另用真实样本按计划 §6 注记单独验证。
|
||||
import { Counter, Trend } from 'k6/metrics';
|
||||
import { check } from 'k6';
|
||||
import {
|
||||
createAlbum,
|
||||
envInt,
|
||||
envStr,
|
||||
login,
|
||||
pollUntilVisible,
|
||||
uniqId,
|
||||
uploadPhoto,
|
||||
} from './lib/helpers.js';
|
||||
|
||||
// ── 参数 ──
|
||||
const BASE_CN = envStr('BASE_CN', envStr('BASE', ''));
|
||||
const BASE_OV = envStr('BASE_OV', ''); // 留空 = 跳过 ov 同步观测
|
||||
const ACCOUNT = envStr('ACCOUNT', '');
|
||||
const PASSWORD = envStr('PASSWORD', '');
|
||||
const ALBUM_ID = envStr('ALBUM_ID', ''); // 空 = setup 建新相册(种子模式)
|
||||
const VUS = envInt('VUS', 3); // L1=3 / L2=10(同时上传人数)
|
||||
const ITERATIONS = envInt('ITERATIONS', 20); // 每人上传张数
|
||||
const WAIT_PUBLISH = envStr('WAIT_PUBLISH', '0') === '1'; // 轮询 cn 公开可见
|
||||
const WAIT_SYNC = envStr('WAIT_SYNC', '0') === '1'; // 轮询 ov 公开可见(需 BASE_OV)
|
||||
const POLL_TIMEOUT = envInt('POLL_TIMEOUT', 60); // 轮询上限秒
|
||||
const POLL_INTERVAL = envInt('POLL_INTERVAL', 2); // 轮询间隔秒
|
||||
|
||||
// ── 指标 ──
|
||||
const presignLatency = new Trend('upload_presign_duration');
|
||||
const putLatency = new Trend('upload_oss_put_duration'); // 对象存储外部依赖
|
||||
const completeLatency = new Trend('upload_complete_duration');
|
||||
const chainLatency = new Trend('upload_chain_duration'); // 三步端到端
|
||||
const publishWait = new Trend('publish_wait_duration'); // complete → cn 公开可见
|
||||
const syncWait = new Trend('ov_sync_wait_duration'); // complete → ov 公开可见
|
||||
const publishTimeout = new Counter('publish_timeout_total');
|
||||
const syncTimeout = new Counter('ov_sync_timeout_total');
|
||||
const uploadFailed = new Counter('upload_failed_total');
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
uploaders: {
|
||||
executor: 'per-vu-iterations',
|
||||
vus: VUS,
|
||||
iterations: ITERATIONS,
|
||||
maxDuration: '30m',
|
||||
},
|
||||
},
|
||||
// thresholds 占位 —— D05 确认后取消注释(候选口径 = 项目计划 §6:
|
||||
// 传输完成到发布 P95 < 10s,上传耗时单独统计不计入处理时延):
|
||||
// thresholds: {
|
||||
// upload_presign_duration: ['p(95)<500'],
|
||||
// upload_complete_duration: ['p(95)<2000'],
|
||||
// publish_wait_duration: ['p(95)<10000'],
|
||||
// http_req_failed: ['rate<0.01'],
|
||||
// },
|
||||
};
|
||||
|
||||
export function setup() {
|
||||
if (!BASE_CN || !ACCOUNT || !PASSWORD) {
|
||||
throw new Error('缺少 --env BASE_CN=<cn 基址> --env ACCOUNT=… --env PASSWORD=…');
|
||||
}
|
||||
if (WAIT_SYNC && !BASE_OV) {
|
||||
throw new Error('WAIT_SYNC=1 需要 --env BASE_OV=<ov 基址>');
|
||||
}
|
||||
const token = login(BASE_CN, ACCOUNT, PASSWORD);
|
||||
let albumId = ALBUM_ID;
|
||||
let publicToken = '';
|
||||
if (!albumId) {
|
||||
const album = createAlbum(BASE_CN, token, `k6-upload-${Date.now()}`);
|
||||
albumId = album.albumId;
|
||||
publicToken = album.publicToken;
|
||||
console.log(
|
||||
`[seed] 已建相册 albumId=${albumId} ALBUM_TOKEN=${publicToken}` +
|
||||
'(read-feed.js 用此 token;照片不会自动清理,软删见 README)',
|
||||
);
|
||||
} else {
|
||||
// 复用既有相册:publicToken 由调用方提供(种子模式首次运行会打印);
|
||||
// 未提供则 WAIT_PUBLISH/WAIT_SYNC 轮询自动跳过(默认函数内按空值守护)。
|
||||
publicToken = envStr('ALBUM_TOKEN', '');
|
||||
console.log(
|
||||
`[seed] 复用相册 albumId=${albumId}` +
|
||||
(publicToken ? ` ALBUM_TOKEN=${publicToken}` : '(未传 ALBUM_TOKEN,跳过公开可见轮询)'),
|
||||
);
|
||||
}
|
||||
return { token, albumId, publicToken };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
try {
|
||||
const result = uploadPhoto(BASE_CN, data.token, data.albumId, uniqId('k6up'));
|
||||
presignLatency.add(result.timings.presign);
|
||||
putLatency.add(result.timings.put);
|
||||
completeLatency.add(result.timings.complete);
|
||||
chainLatency.add(result.timings.chainMs);
|
||||
check(result, { 'upload chain ok': (r) => Boolean(r.photoId) });
|
||||
|
||||
if (WAIT_PUBLISH && data.publicToken && result.photoId) {
|
||||
const poll = pollUntilVisible(
|
||||
BASE_CN,
|
||||
data.publicToken,
|
||||
result.photoId,
|
||||
POLL_TIMEOUT,
|
||||
POLL_INTERVAL,
|
||||
'publish_poll_cn',
|
||||
);
|
||||
if (poll.found) {
|
||||
publishWait.add(poll.waitedMs);
|
||||
} else {
|
||||
publishTimeout.add(1);
|
||||
}
|
||||
}
|
||||
if (WAIT_SYNC && BASE_OV && data.publicToken && result.photoId) {
|
||||
const poll = pollUntilVisible(
|
||||
BASE_OV,
|
||||
data.publicToken,
|
||||
result.photoId,
|
||||
POLL_TIMEOUT,
|
||||
POLL_INTERVAL,
|
||||
'publish_poll_ov',
|
||||
);
|
||||
if (poll.found) {
|
||||
syncWait.add(poll.waitedMs);
|
||||
} else {
|
||||
syncTimeout.add(1);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
uploadFailed.add(1);
|
||||
console.error(`upload chain failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user