chore: 初始化文档站仓库
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
// 将 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)
|
||||
})
|
||||
Reference in New Issue
Block a user