// 将 public/screenshots 下的文档配图上传到阿里云 OSS。 // // 流程:读取 .env.local 配置 -> STS AssumeRole 换取临时凭证 -> 遍历本地截图 -> // 逐张上传到 /<相对路径> -> HEAD 校验远端大小 -> 打印公共访问 URL。 // // 凭证来自 .env.local(不纳入版本管理),也可用同名环境变量覆盖。 import { existsSync, readFileSync, readdirSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import OSS from 'ali-oss' import type { AssumeRoleRequest as AssumeRoleRequestType } from '@alicloud/sts20150401' import { $OpenApiUtil } from '@alicloud/openapi-core' // @alicloud/sts20150401 为 CommonJS 包,其类型声明在 NodeNext 下无法直接解析为构造签名, // 故用 createRequire 加载并显式声明最小类型。 const require = createRequire(import.meta.url) const stsSdk = require('@alicloud/sts20150401') as { default: StsClientConstructor AssumeRoleRequest: new (options: { roleArn: string roleSessionName: string durationSeconds: number }) => AssumeRoleRequestType } const StsClient = stsSdk.default const AssumeRoleRequest = stsSdk.AssumeRoleRequest const currentDir = dirname(fileURLToPath(import.meta.url)) /** doc-site 根目录 */ const ROOT = resolve(currentDir, '..', '..') /** 截图输出目录(截图脚本的产物,也是本脚本的上传源) */ const SCREENSHOT_DIR = resolve(ROOT, 'public', 'screenshots') /** 本地凭证文件(优先级低于环境变量) */ const ENV_FILE = resolve(ROOT, '.env.local') /** STS 服务接入点 */ const STS_ENDPOINT = 'sts.aliyuncs.com' /** 上传并发数 */ const CONCURRENCY = 5 /** OSS 上传配置 */ interface OssConfig { /** 调用 STS 的长期访问密钥 ID */ accessKeyId: string /** 调用 STS 的长期访问密钥 Secret */ accessKeySecret: string /** 被扮演的 RAM 角色 ARN */ roleArn: string /** 目标桶名 */ bucket: string /** 区域(如 cn-beijing) */ region: string /** 对象键前缀(截图统一放置其下) */ keyPrefix: string } /** STS 临时凭证 */ interface StsCredentials { /** 临时访问密钥 ID */ accessKeyId: string /** 临时访问密钥 Secret */ accessKeySecret: string /** 临时安全令牌 */ securityToken: string /** 过期时间(UTC) */ expiration: string } /** STS AssumeRole 响应体(仅声明用到的字段) */ interface AssumeRoleResult { body?: { credentials?: { accessKeyId?: string accessKeySecret?: string securityToken?: string expiration?: string } } } /** STS 客户端实例的最小签名 */ interface StsClientInstance { assumeRole(request: AssumeRoleRequestType): Promise } /** STS 客户端构造函数 */ type StsClientConstructor = new (config: $OpenApiUtil.Config) => StsClientInstance /** * 解析 .env 文件为键值对,忽略空行与以 # 开头的注释。 * * @param filePath .env 文件路径 * @returns 键值对映射 */ function parseEnvFile(filePath: string): Record { const result: Record = {} for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/)) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith('#')) continue const index = trimmed.indexOf('=') if (index === -1) continue const key = trimmed.slice(0, index).trim() let value = trimmed.slice(index + 1).trim() const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) if (quoted) value = value.slice(1, -1) result[key] = value } return result } /** * 读取 OSS 上传配置,环境变量优先于 .env.local。 * * @returns OSS 上传配置 * @throws 当必填项缺失时抛出异常 */ function loadConfig(): OssConfig { const fromFile = existsSync(ENV_FILE) ? parseEnvFile(ENV_FILE) : {} const pick = (name: string): string => process.env[name] ?? fromFile[name] ?? '' const config: OssConfig = { accessKeyId: pick('OSS_ACCESS_KEY_ID'), accessKeySecret: pick('OSS_ACCESS_KEY_SECRET'), roleArn: pick('OSS_ROLE_ARN'), bucket: pick('OSS_BUCKET'), region: pick('OSS_REGION'), keyPrefix: pick('OSS_KEY_PREFIX') || 'finance-app-docs/screenshots', } const missing = (['accessKeyId', 'accessKeySecret', 'roleArn', 'bucket', 'region'] as const).filter( (key) => !config[key], ) if (missing.length > 0) { throw new Error(`缺少 OSS 配置: ${missing.join(', ')}(请在 .env.local 中配置)`) } return config } /** * 递归收集目录下的全部文件(按路径排序,保证输出稳定)。 * * @param dir 目标目录 * @returns 文件的绝对路径列表 */ function collectFiles(dir: string): string[] { const files: string[] = [] for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name) if (entry.isDirectory()) files.push(...collectFiles(full)) else if (entry.isFile()) files.push(full) } return files.sort() } /** * 通过 STS AssumeRole 获取临时凭证。 * * @param config OSS 上传配置 * @returns STS 临时凭证 * @throws 当 STS 未返回完整凭证时抛出异常 */ async function assumeRole(config: OssConfig): Promise { const client = new StsClient( new $OpenApiUtil.Config({ accessKeyId: config.accessKeyId, accessKeySecret: config.accessKeySecret, endpoint: STS_ENDPOINT, }), ) const request = new AssumeRoleRequest({ roleArn: config.roleArn, roleSessionName: 'doc-site-screenshot-upload', durationSeconds: 3600, }) const response = await client.assumeRole(request) const credentials = response.body?.credentials if (!credentials?.accessKeyId || !credentials.accessKeySecret || !credentials.securityToken) { throw new Error('STS AssumeRole 未返回有效的临时凭证') } return { accessKeyId: credentials.accessKeyId, accessKeySecret: credentials.accessKeySecret, securityToken: credentials.securityToken, expiration: credentials.expiration ?? '', } } /** * 上传单张截图,并通过 HEAD 请求校验远端大小。 * * @param client 已认证的 OSS 客户端 * @param key 对象键 * @param file 本地文件绝对路径 * @returns 对象键与文件大小 * @throws 当远端大小与本地不一致时抛出异常 */ async function uploadOne(client: OSS, key: string, file: string): Promise<{ key: string; size: number }> { const content = readFileSync(file) await client.put(key, content, { mime: 'image/png', headers: { 'Cache-Control': 'public, max-age=86400' }, }) const head = await client.head(key) const headers = head.res.headers as Record const remoteSize = Number(headers['content-length'] ?? headers['Content-Length']) if (remoteSize !== content.length) { throw new Error(`大小校验失败: ${key} 远端=${remoteSize} 本地=${content.length}`) } return { key, size: content.length } } /** * 按并发上限批量执行上传任务。 * * @param tasks 返回 Promise 的任务函数列表 * @returns 各任务结果(顺序与输入一致) */ async function runWithConcurrency(tasks: (() => Promise)[]): Promise { const results: T[] = [] for (let i = 0; i < tasks.length; i += CONCURRENCY) { const batch = tasks.slice(i, i + CONCURRENCY) results.push(...(await Promise.all(batch.map((task) => task())))) } return results } /** * 脚本入口:上传全部截图并打印公共访问 URL。 */ async function main(): Promise { const config = loadConfig() if (!existsSync(SCREENSHOT_DIR)) { throw new Error(`截图目录不存在: ${SCREENSHOT_DIR}(请先执行 npm run capture)`) } const files = collectFiles(SCREENSHOT_DIR).filter((file) => file.toLowerCase().endsWith('.png')) if (files.length === 0) { console.log('未找到可上传的截图') return } const credentials = await assumeRole(config) console.log(`STS 凭证获取成功,有效期至 ${credentials.expiration}`) const client = new OSS({ region: `oss-${config.region}`, accessKeyId: credentials.accessKeyId, accessKeySecret: credentials.accessKeySecret, stsToken: credentials.securityToken, bucket: config.bucket, secure: true, }) const endpoint = `${config.bucket}.oss-${config.region}.aliyuncs.com` const tasks = files.map((file) => { const relativePath = relative(SCREENSHOT_DIR, file).split(sep).join('/') return () => uploadOne(client, `${config.keyPrefix}/${relativePath}`, file) }) const results = await runWithConcurrency(tasks) for (const result of results) { console.log(`OK ${result.key} (${result.size} 字节) https://${endpoint}/${result.key}`) } console.log(`\n共上传 ${results.length} 张截图到 oss://${config.bucket}/${config.keyPrefix}/`) } main().catch((error: unknown) => { console.error(`上传失败: ${error instanceof Error ? error.message : String(error)}`) process.exit(1) })