Files
ledgerly-docs/scripts/ts/upload-oss.ts
T

269 lines
9.0 KiB
TypeScript
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.
// 将 public/screenshots 下的文档配图上传到阿里云 OSS。
//
// 流程:读取 .env.local 配置 -> STS AssumeRole 换取临时凭证 -> 遍历本地截图 ->
// 逐张上传到 <OSS_KEY_PREFIX>/<相对路径> -> 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<AssumeRoleResult>
}
/** STS 客户端构造函数 */
type StsClientConstructor = new (config: $OpenApiUtil.Config) => StsClientInstance
/**
* 解析 .env 文件为键值对,忽略空行与以 # 开头的注释。
*
* @param filePath .env 文件路径
* @returns 键值对映射
*/
function parseEnvFile(filePath: string): Record<string, string> {
const result: Record<string, string> = {}
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<StsCredentials> {
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<string, string | undefined>
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<T>(tasks: (() => Promise<T>)[]): Promise<T[]> {
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<void> {
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)
})