2026-09-21 01:52:47 +08:00
|
|
|
|
// 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/9oADAMBAAIRAxEAPwCsAkKIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
|
|
|
|
|
|
2026-09-21 01:56:56 +08:00
|
|
|
|
/** 解码一次,全脚本复用(ArrayBuffer 可直接作 k6 http 请求体)。 */
|
|
|
|
|
|
export const JPEG_BYTES = encoding.b64decode(JPEG_B64, true);
|
2026-09-21 01:52:47 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|