chore: 初始化文档站仓库

This commit is contained in:
2026-09-21 13:38:20 +08:00
commit a4ae7ac979
58 changed files with 4622 additions and 0 deletions
+268
View File
@@ -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)
})