Files
photo-live/loadtest/lib/helpers.js
T
浅念 d01cecd2f6 docs: p5 preparation complete and implementation draft
- docs/plans/2026-09-21-P5-真实环境接入.md 草案(头部标注待 G5 凭证评审
  转正):T1-T12 任务序(每任务 产出/依赖用户输入/关口)——G1 OSS 冒烟→
  国内起栈→ov 部署+bootstrap 对账→域名证书→CDN(含图片加速三选一)→
  跨境 MySQL 通道定案(白名单+SSL/隧道/HTTP 化三选项,含改造量评估)→
  G4 用户源→故障演练→压测 D05→G2 真机→G6 试运行→发布交接;用户输入
  汇总表 + 决策 D03-D13 状态速查 + 风险表(跨境链路/双层签名/MySQL
  8.0.30 门槛/镜像仓库通道等)
- 总纲 P5 行注记:准备件完成 2026-09-21,实装待 G5 + G4
- P5-prep 计划全项勾选回填(评审项注明自审已修 + 独立 reviewer 由编排方追加)
- 自审修正:monitoring 统计副本表名(无 stat_snapshots 表,副本位在
  albums/photos 行)与 outbox_events.occurredAt/photo_jobs.updatedAt 列名、
  jq 管道括号;cdn 失效多 URL 换行分隔;k6 helpers 请求体用 ArrayBuffer
  原生形态、read-feed 身份串延迟生成(init 上下文 VU 编号恒 0)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 01:56:56 +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==';
/** 解码一次,全脚本复用(ArrayBuffer 可直接作 k6 http 请求体)。 */
export const JPEG_BYTES = encoding.b64decode(JPEG_B64, true);
// ── 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);
}
}