// Copyright (c) 未来飞马 // // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // Trademark Notice: // The MPL-2.0 license grants copyright permissions for source code only. // It does NOT grant any rights to use trademarks including "未来飞马", // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步". // Any use of these trademarks requires separate written permission. // 端点常量 —— 仅在此处定义,禁止在其他地方硬编码 export const ENDPOINTS = { functions: 'https://server.fmode.cn/api/functions', publicBase: 'https://s3.fmode.cn', }; const FUNCTION_ID = 'AlP56LCKFm'; // fmodeagent-upload-url v0.2.2 /** * 向云函数申请预签名 PUT URL。 * @param {object} opts * @param {string} opts.sessionToken - 用户 session token (r:xxx) * @param {string} opts.key - 相对路径,如 log/20260923/1400.json * @param {number} opts.size - 文件字节数 * @returns {Promise<{uploadUrl, key, publicUrl, headers, expiresAt}>} */ export async function getUploadUrl({ sessionToken, key, size }) { const filename = key.split('/').pop(); const res = await fetch(ENDPOINTS.functions, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: sessionToken, id: FUNCTION_ID, params: { filename, mimeType: 'application/json', size, namespace: 'log', key, }, }), signal: AbortSignal.timeout(15000), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`云函数请求失败 HTTP ${res.status}: ${text.slice(0, 200)}`); } const data = await res.json(); if (data.code !== 200) { throw new Error(`云函数返回错误 code=${data.code}: ${JSON.stringify(data).slice(0, 300)}`); } return data; // { uploadUrl, key, publicUrl, headers, expiresAt } } /** * 采集 JSON 对象并直传到个人 S3 空间。 * @param {object} opts * @param {string} opts.sessionToken - 用户 session token * @param {string} opts.key - 对象相对路径,如 log/20260923/1400.json * @param {object} opts.content - 要上传的 JSON 对象 * @returns {Promise<{publicUrl, key}>} */ export async function uploadJson({ sessionToken, key, content }) { const buf = Buffer.from(JSON.stringify(content, null, 2), 'utf-8'); const size = buf.length; const { uploadUrl, headers, publicUrl, key: serverKey } = await getUploadUrl({ sessionToken, key, size }); // PUT 时必须携带云函数返回的 headers(Content-Type + x-obs-acl),否则 403 const putRes = await fetch(uploadUrl, { method: 'PUT', headers: { ...headers }, body: buf, signal: AbortSignal.timeout(30000), }); if (!putRes.ok) { const text = await putRes.text().catch(() => ''); throw new Error(`PUT 上传失败 HTTP ${putRes.status}: ${text.slice(0, 300)}`); } return { publicUrl, key: serverKey }; } /** * 尝试列举旧日志(滚动删除用)。 * 注:OBS 的 S3 列举需 AK/SK 鉴权,无法通过预签名仅读操作完成; * 此函数仅为 API 兼容占位,总是返回空列表并附提示。 */ export async function listOldLogs(userid, cutoffDay) { // 旧日志自动删除需 AK/SK 权限,当前免密钥通道不支持列举操作。 // 运维001 可通过 OBS 控制台或 obsutil 定期清理 user//log/ 目录。 return { skipped: true, reason: '列举操作需 AK/SK 权限,云函数通道不支持;请在 OBS 控制台或 obsutil 手动清理旧日志' }; }