Files
photo-live/loadtest/lib/helpers.js
T
浅念 f71a887f17 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>
2026-09-21 01:52:47 +08:00

221 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}
}