// 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); } }