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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user