This commit is contained in:
张成
2026-03-24 16:07:02 +08:00
commit aa8eaa6ccd
121 changed files with 34042 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
/**
* 订阅模块通用 CRUD与 admin 约定 POST /{model}/page|add|edit|del GET /{model}/detail|all
*/
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
function getRequestBody(ctx) {
if (ctx.request && ctx.request.body && Object.keys(ctx.request.body).length > 0) {
return ctx.request.body;
}
if (typeof ctx.getBody === "function") {
return ctx.getBody() || {};
}
return {};
}
function getModel(modelName) {
const m = baseModel[modelName];
if (!m) {
throw new Error(`模型不存在: ${modelName}`);
}
return m;
}
function normalizeForWrite(model, data, { forCreate } = {}) {
const attrs = model.rawAttributes;
const out = {};
for (const k of Object.keys(data || {})) {
if (!attrs[k]) continue;
let v = data[k];
if (v === "") {
if (k === "id" && forCreate) continue;
if (k.endsWith("_id") || k === "id") {
v = null;
} else if (attrs[k].allowNull) {
v = null;
}
}
if (k === "enabled_features" && typeof v === "string" && v.trim() !== "") {
try {
v = JSON.parse(v);
} catch (e) {
/* 保持原字符串,由 Sequelize 或 DB 报错 */
}
}
out[k] = v;
}
if (forCreate && out.id !== undefined && (out.id === "" || out.id === null)) {
delete out.id;
}
return out;
}
function buildSearchWhere(model, seachOption) {
const key = seachOption && seachOption.key;
const raw = seachOption && seachOption.value;
if (!key || raw === undefined || raw === null) return {};
const str = String(raw).trim();
if (str === "") return {};
const attr = model.rawAttributes[key];
if (!attr) {
return { [key]: { [op.like]: `%${str}%` } };
}
const typeKey = attr.type && attr.type.key;
if (typeKey === "BOOLEAN") {
if (str === "true" || str === "1" || str === "是") return { [key]: true };
if (str === "false" || str === "0" || str === "否") return { [key]: false };
return {};
}
if (typeKey === "ENUM") {
return { [key]: str };
}
if (
typeKey === "INTEGER" ||
typeKey === "BIGINT" ||
typeKey === "FLOAT" ||
typeKey === "DOUBLE" ||
typeKey === "DECIMAL"
) {
const n = Number(str);
if (!Number.isNaN(n)) return { [key]: n };
return {};
}
if (typeKey === "DATE" || typeKey === "DATEONLY") {
return { [key]: str };
}
return { [key]: { [op.like]: `%${str}%` } };
}
async function page(modelName, body) {
const model = getModel(modelName);
const param = body.param || body;
const pageOption = param.pageOption || {};
const seachOption = param.seachOption || {};
const pageNum = parseInt(pageOption.page, 10) || 1;
const pageSize = parseInt(pageOption.pageSize, 10) || 20;
const offset = (pageNum - 1) * pageSize;
const where = buildSearchWhere(model, seachOption);
const { count, rows } = await model.findAndCountAll({
where,
offset,
limit: pageSize,
order: [["id", "DESC"]],
});
return { rows, count };
}
async function add(modelName, body) {
const model = getModel(modelName);
const payload = normalizeForWrite(model, body, { forCreate: true });
const row = await model.create(payload);
return row;
}
async function edit(modelName, body) {
const model = getModel(modelName);
const id = body.id;
if (id === undefined || id === null || id === "") {
throw new Error("缺少 id");
}
const payload = normalizeForWrite(model, body, { forCreate: false });
delete payload.id;
await model.update(payload, { where: { id } });
return {};
}
async function del(modelName, body) {
const model = getModel(modelName);
const id = body.id !== undefined ? body.id : body;
if (id === undefined || id === null || id === "") {
throw new Error("缺少 id");
}
await model.destroy({ where: { id } });
return {};
}
async function detail(modelName, query) {
const model = getModel(modelName);
const id = query && (query.id || query.ID);
if (id === undefined || id === null || id === "") {
throw new Error("缺少 id");
}
const row = await model.findByPk(id);
return row;
}
async function all(modelName) {
const model = getModel(modelName);
const rows = await model.findAll({
limit: 2000,
order: [["id", "DESC"]],
});
return rows;
}
async function exportCsv(modelName, body) {
const model = getModel(modelName);
const param = body.param || body;
const where = buildSearchWhere(model, param.seachOption || {});
const rows = await model.findAll({
where,
limit: 10000,
order: [["id", "DESC"]],
});
return { rows };
}
module.exports = {
page,
add,
edit,
del,
detail,
all,
exportCsv,
getRequestBody,
buildSearchWhere,
};

View File

@@ -0,0 +1,37 @@
const baseModel = require("../../middleware/baseModel");
const logs = require("../../tool/logs_proxy");
/**
* 记录审计(失败不影响主流程)
* @param {object} p
* @param {number} [p.admin_user_id]
* @param {number} [p.biz_user_id]
* @param {string} p.action
* @param {string} [p.resource_type]
* @param {number} [p.resource_id]
* @param {object} [p.detail]
*/
async function logAudit(p) {
try {
await baseModel.biz_audit_log.create({
admin_user_id: p.admin_user_id || null,
biz_user_id: p.biz_user_id || null,
action: p.action,
resource_type: p.resource_type || "",
resource_id: p.resource_id != null ? p.resource_id : null,
detail: p.detail || null,
created_at: new Date(),
});
} catch (e) {
logs.error("[biz_audit] 写入失败", e);
}
}
function pickAdminId(ctx) {
if (!ctx) return null;
const u = ctx.user || ctx.state?.user || ctx.session?.user;
if (u && (u.id != null || u.userId != null)) return u.id != null ? u.id : u.userId;
return null;
}
module.exports = { logAudit, pickAdminId };

View File

@@ -0,0 +1,107 @@
const baseModel = require("../../middleware/baseModel");
const tokenLogic = require("./biz_token_logic");
const usageSvc = require("./biz_usage_service");
function featureAllowed(plan, feature) {
if (!feature) return true;
const feats = plan.enabled_features;
if (feats == null) return true;
if (Array.isArray(feats)) return feats.includes(feature);
if (typeof feats === "object") {
return feats[feature] === true || feats[feature] === 1 || feats[feature] === "1";
}
return false;
}
function normalizeUsageDelta(raw) {
if (!raw || typeof raw !== "object") return {};
return {
msg: usageSvc.num(raw.msg ?? raw.msg_count),
mass: usageSvc.num(raw.mass ?? raw.mass_count),
friend: usageSvc.num(raw.friend ?? raw.friend_count),
sns: usageSvc.num(raw.sns ?? raw.sns_count),
active_user: usageSvc.num(raw.active_user ?? raw.active_user_count),
};
}
function hasPositiveDelta(delta) {
return Object.values(delta).some((v) => usageSvc.num(v) > 0);
}
/**
* 对外鉴权Token + 用户 + 有效订阅 + 功能点 + 可选用量上报与额度
* body: { token, feature?, usage_delta?: { msg?, mass?, ... } }
*/
async function verifyRequest(body) {
const { token, feature } = body || {};
if (!token) {
return { ok: false, error_code: "TOKEN_INVALID", message: "缺少 token" };
}
const hash = tokenLogic.hashPlainToken(token);
const row = await baseModel.biz_api_token.findOne({ where: { token_hash: hash } });
if (!row) {
return { ok: false, error_code: "TOKEN_INVALID", message: "Token 不存在" };
}
if (row.status === "revoked") {
return { ok: false, error_code: "TOKEN_REVOKED", message: "Token 已吊销" };
}
const now = new Date();
if (new Date(row.expire_at) < now) {
return { ok: false, error_code: "TOKEN_EXPIRED", message: "Token 已过期" };
}
const user = await baseModel.biz_user.findByPk(row.user_id);
if (!user || user.status !== "active") {
return { ok: false, error_code: "SUBSCRIPTION_INACTIVE", message: "用户不可用" };
}
const sub = await tokenLogic.findActiveSubscriptionForUser(row.user_id);
if (!sub) {
return { ok: false, error_code: "SUBSCRIPTION_INACTIVE", message: "无有效订阅" };
}
const plan = await baseModel.biz_plan.findByPk(sub.plan_id);
if (!plan || plan.status !== "active") {
return { ok: false, error_code: "SUBSCRIPTION_INACTIVE", message: "套餐不可用" };
}
if (feature && !featureAllowed(plan, feature)) {
return { ok: false, error_code: "FEATURE_NOT_ALLOWED", message: "功能未在套餐内" };
}
const statMonth = usageSvc.currentStatMonth();
let usageRow = await usageSvc.getOrCreateUsage(row.user_id, sub.plan_id, statMonth);
const delta = normalizeUsageDelta(body.usage_delta || body.usage_report);
if (hasPositiveDelta(delta)) {
const q = usageSvc.checkQuotaAfterDelta(plan, usageRow, delta);
if (!q.ok) {
return { ok: false, error_code: q.error_code || "QUOTA_EXCEEDED", message: q.message || "额度不足" };
}
usageRow = await usageSvc.applyDelta(row.user_id, sub.plan_id, statMonth, delta);
}
await row.update({ last_used_at: now });
return {
ok: true,
context: {
user_id: row.user_id,
plan_id: sub.plan_id,
subscription_id: sub.id,
token_id: row.id,
stat_month: statMonth,
usage_snapshot: {
msg_count: usageSvc.num(usageRow.msg_count),
mass_count: usageSvc.num(usageRow.mass_count),
friend_count: usageSvc.num(usageRow.friend_count),
sns_count: usageSvc.num(usageRow.sns_count),
active_user_count: usageSvc.num(usageRow.active_user_count),
},
},
};
}
module.exports = { verifyRequest };

View File

@@ -0,0 +1,47 @@
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
async function summary() {
const now = new Date();
const in7 = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const [
userTotal,
userActive,
planActive,
subPending,
subActive,
subExpired,
tokenActive,
renewSoon,
] = await Promise.all([
baseModel.biz_user.count(),
baseModel.biz_user.count({ where: { status: "active" } }),
baseModel.biz_plan.count({ where: { status: "active" } }),
baseModel.biz_subscription.count({ where: { status: "pending" } }),
baseModel.biz_subscription.count({ where: { status: "active" } }),
baseModel.biz_subscription.count({ where: { status: "expired" } }),
baseModel.biz_api_token.count({ where: { status: "active" } }),
baseModel.biz_subscription.count({
where: {
status: "active",
end_time: { [op.between]: [now, in7] },
},
}),
]);
return {
users: { total: userTotal, active: userActive },
plans: { active: planActive },
subscriptions: {
pending: subPending,
active: subActive,
expired: subExpired,
renew_within_7d: renewSoon,
},
tokens: { active: tokenActive },
server_time: now.toISOString(),
};
}
module.exports = { summary };

View File

@@ -0,0 +1,130 @@
/**
* 订阅开通 / 升级 / 续费 / 取消 / 到期扫描 / 支付确认
*/
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
async function assertUserActive(userId) {
const u = await baseModel.biz_user.findByPk(userId);
if (!u) throw new Error("用户不存在");
if (u.status !== "active") throw new Error("用户已禁用");
return u;
}
async function assertPlanActive(planId) {
const p = await baseModel.biz_plan.findByPk(planId);
if (!p) throw new Error("套餐不存在");
if (p.status !== "active") throw new Error("套餐未上线");
return p;
}
async function openSubscription(body) {
const {
user_id,
plan_id,
start_time,
end_time,
status = "pending",
renew_mode = "manual",
payment_channel,
payment_ref,
} = body;
await assertUserActive(user_id);
await assertPlanActive(plan_id);
const row = await baseModel.biz_subscription.create({
user_id,
plan_id,
status,
start_time,
end_time,
renew_mode,
payment_channel: payment_channel || null,
payment_ref: payment_ref || null,
});
return row;
}
async function upgradeSubscription(body) {
const { subscription_id, new_plan_id, start_time, end_time } = body;
const sub = await baseModel.biz_subscription.findByPk(subscription_id);
if (!sub) throw new Error("订阅不存在");
await assertPlanActive(new_plan_id);
await sub.update({
plan_id: new_plan_id,
start_time: start_time || sub.start_time,
end_time: end_time || sub.end_time,
});
return sub;
}
async function renewSubscription(body) {
const { subscription_id, end_time } = body;
const sub = await baseModel.biz_subscription.findByPk(subscription_id);
if (!sub) throw new Error("订阅不存在");
await sub.update({
end_time,
status: "active",
});
return sub;
}
async function cancelSubscription(body) {
const { subscription_id } = body;
const sub = await baseModel.biz_subscription.findByPk(subscription_id);
if (!sub) throw new Error("订阅不存在");
await sub.update({ status: "cancelled" });
return sub;
}
/** 每天扫描:将已过期且仍为 active 的订阅置为 expired */
async function expireDueSubscriptions() {
const now = new Date();
const [n] = await baseModel.biz_subscription.update(
{ status: "expired" },
{
where: {
status: "active",
end_time: { [op.lt]: now },
},
}
);
return n;
}
/** 线下确认pending -> active写入 payment_ref */
async function confirmOfflinePayment(body) {
const { subscription_id, payment_ref } = body;
if (!subscription_id) throw new Error("缺少 subscription_id");
const sub = await baseModel.biz_subscription.findByPk(subscription_id);
if (!sub) throw new Error("订阅不存在");
await sub.update({
status: "active",
payment_channel: "offline",
payment_ref: payment_ref || sub.payment_ref,
});
return sub;
}
/** 链接支付确认MVP与线下类似仅标记渠道 */
async function confirmLinkPayment(body) {
const { subscription_id, payment_ref } = body;
if (!subscription_id) throw new Error("缺少 subscription_id");
const sub = await baseModel.biz_subscription.findByPk(subscription_id);
if (!sub) throw new Error("订阅不存在");
await sub.update({
status: "active",
payment_channel: "pay_link",
payment_ref: payment_ref || sub.payment_ref,
});
return sub;
}
module.exports = {
openSubscription,
upgradeSubscription,
renewSubscription,
cancelSubscription,
expireDueSubscriptions,
confirmOfflinePayment,
confirmLinkPayment,
};

View File

@@ -0,0 +1,90 @@
const crypto = require("crypto");
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
const MAX_TOKENS_PER_USER = 5;
function hashPlainToken(plain) {
return crypto.createHash("sha256").update(plain, "utf8").digest("hex");
}
function generatePlainToken() {
return `waw_${crypto.randomBytes(24).toString("hex")}`;
}
/** 当前时间在 [start,end] 内且 status=active 的订阅 */
async function findActiveSubscriptionForUser(userId) {
const now = new Date();
return baseModel.biz_subscription.findOne({
where: {
user_id: userId,
status: "active",
start_time: { [op.lte]: now },
end_time: { [op.gte]: now },
},
order: [["id", "DESC"]],
});
}
async function createToken(body) {
const { user_id, token_name, expire_at } = body;
if (!user_id || !expire_at) throw new Error("缺少 user_id 或 expire_at");
const u = await baseModel.biz_user.findByPk(user_id);
if (!u) throw new Error("用户不存在");
if (u.status !== "active") throw new Error("用户已禁用");
const activeCount = await baseModel.biz_api_token.count({
where: { user_id, status: "active" },
});
if (activeCount >= MAX_TOKENS_PER_USER) {
throw new Error(`单用户最多 ${MAX_TOKENS_PER_USER} 个有效 Token`);
}
const sub = await findActiveSubscriptionForUser(user_id);
const plan_id = sub ? sub.plan_id : null;
const plain = generatePlainToken();
const token_hash = hashPlainToken(plain);
const row = await baseModel.biz_api_token.create({
user_id,
plan_id,
token_name: token_name || "default",
token_hash,
status: "active",
expire_at,
});
return {
row,
plain_token: plain,
warn: sub ? null : "当前无生效中的订阅,鉴权将失败",
};
}
async function revokeToken(body) {
const id = body.id;
if (id == null) throw new Error("缺少 id");
const row = await baseModel.biz_api_token.findByPk(id);
if (!row) throw new Error("Token 不存在");
await row.update({ status: "revoked" });
return row;
}
async function revokeAllForUser(userId) {
if (userId == null) throw new Error("缺少 user_id");
const [n] = await baseModel.biz_api_token.update(
{ status: "revoked" },
{ where: { user_id: userId, status: "active" } }
);
return n;
}
module.exports = {
hashPlainToken,
createToken,
revokeToken,
revokeAllForUser,
findActiveSubscriptionForUser,
MAX_TOKENS_PER_USER,
};

View File

@@ -0,0 +1,110 @@
const baseModel = require("../../middleware/baseModel");
const { op } = baseModel;
function currentStatMonth(d = new Date()) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
return `${y}-${m}`;
}
function num(v) {
if (v === null || v === undefined || v === "") return 0;
const n = Number(v);
return Number.isNaN(n) ? 0 : n;
}
/** 0 表示无限制(不校验) */
function quotaExceeded(used, limit) {
const li = num(limit);
if (li <= 0) return false;
return num(used) >= li;
}
/**
* 取或创建当月用量行
*/
async function getOrCreateUsage(userId, planId, statMonth) {
const [row] = await baseModel.biz_usage_monthly.findOrCreate({
where: { user_id: userId, stat_month: statMonth },
defaults: {
user_id: userId,
plan_id: planId,
stat_month: statMonth,
msg_count: 0,
mass_count: 0,
friend_count: 0,
sns_count: 0,
active_user_count: 0,
},
});
if (num(row.plan_id) !== num(planId)) {
await row.update({ plan_id: planId });
}
return row;
}
async function applyDelta(userId, planId, statMonth, delta) {
const row = await getOrCreateUsage(userId, planId, statMonth);
const next = {
msg_count: num(row.msg_count) + num(delta.msg),
mass_count: num(row.mass_count) + num(delta.mass),
friend_count: num(row.friend_count) + num(delta.friend),
sns_count: num(row.sns_count) + num(delta.sns),
active_user_count: num(row.active_user_count) + num(delta.active_user),
};
await row.update(next);
return row.reload();
}
/**
* 校验「增量」后是否超限(与套餐额度对比)
* feature: msg | mass | friend | sns | active_user
*/
function checkQuotaAfterDelta(plan, usageRow, delta) {
const checks = [
["msg", "msg_count", "msg_quota"],
["mass", "mass_count", "mass_quota"],
["friend", "friend_count", "friend_quota"],
["sns", "sns_count", "sns_quota"],
["active_user", "active_user_count", "active_user_limit"],
];
for (const [key, uCol, pCol] of checks) {
const add = num(delta[key]);
if (add <= 0) continue;
const used = num(usageRow[uCol]) + add;
if (quotaExceeded(used, plan[pCol])) {
return { ok: false, error_code: "QUOTA_EXCEEDED", message: `额度不足: ${key}` };
}
}
return { ok: true };
}
/**
* 为当前月所有有效订阅补用量行(月结/初始化)
*/
async function ensureUsageRowsForCurrentMonth() {
const statMonth = currentStatMonth();
const now = new Date();
const subs = await baseModel.biz_subscription.findAll({
where: {
status: "active",
start_time: { [op.lte]: now },
end_time: { [op.gte]: now },
},
});
let n = 0;
for (const s of subs) {
await getOrCreateUsage(s.user_id, s.plan_id, statMonth);
n += 1;
}
return n;
}
module.exports = {
currentStatMonth,
getOrCreateUsage,
applyDelta,
checkQuotaAfterDelta,
ensureUsageRowsForCurrentMonth,
num,
};

333
api/service/ossTool.js Normal file
View File

@@ -0,0 +1,333 @@
const OSS = require('ali-oss')
const fs = require('fs')
const config = require('../../config/config')['aliyun']
const uuid = require('node-uuid')
const logs = require('../../tool/logs_proxy')
/**
* OSS 文件上传工具类
* 统一管理文件上传、存储路径、文件类型等
*/
class OSSTool {
constructor() {
this.client = new OSS({
region: 'oss-cn-shanghai',
accessKeyId: config.accessKeyId,
accessKeySecret: config.accessKeySecret,
bucket:config.bucket
})
// 基础存储路径前缀
this.basePrefix = 'app/uploads'
// 文件类型映射
this.fileTypeMap = {
// 图片类型
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/gif': 'gif',
'image/webp': 'webp',
'image/svg+xml': 'svg',
// 视频类型
'video/mp4': 'mp4',
'video/avi': 'avi',
'video/mov': 'mov',
'video/wmv': 'wmv',
'video/flv': 'flv',
'video/webm': 'webm',
'video/mkv': 'mkv',
// 音频类型
'audio/mp3': 'mp3',
'audio/wav': 'wav',
'audio/aac': 'aac',
'audio/ogg': 'ogg',
'audio/flac': 'flac',
// 文档类型
'application/pdf': 'pdf',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.ms-excel': 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.ms-powerpoint': 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
'text/plain': 'txt',
'text/html': 'html',
'text/css': 'css',
'application/javascript': 'js',
'application/json': 'json'
}
}
/**
* 获取文件后缀名
* @param {Object} file - 文件对象(兼容 formidable 格式)
* @returns {string} 文件后缀名
*/
getFileSuffix(file) {
// 优先使用 MIME 类型判断(兼容 type 和 mimetype
const mimeType = file.mimetype || file.type
if (mimeType && this.fileTypeMap[mimeType]) {
return this.fileTypeMap[mimeType]
}
// 备用方案:从文件名获取(兼容 originalFilename 和 name
const fileName = file.originalFilename || file.name
if (fileName) {
const lastIndex = fileName.lastIndexOf('.')
if (lastIndex > -1) {
return fileName.substring(lastIndex + 1).toLowerCase()
}
}
return 'bin'
}
/**
* 获取文件存储路径
* @param {Object} file - 文件对象(兼容 formidable 格式)
* @param {string} category - 存储分类
* @returns {string} 完整的存储路径
*/
getStoragePath(file, category = 'files') {
const suffix = this.getFileSuffix(file)
const uid = uuid.v4()
// 根据文件类型确定子路径(兼容 mimetype 和 type
let subPath = category
const mimeType = file.mimetype || file.type
if (mimeType) {
if (mimeType.startsWith('image/')) {
subPath = 'images'
} else if (mimeType.startsWith('video/')) {
subPath = 'videos'
} else if (mimeType.startsWith('audio/')) {
subPath = 'audios'
} else if (mimeType.startsWith('application/') || mimeType.startsWith('text/')) {
subPath = 'documents'
}
}
// 完整路径front/ball/{subPath}/{uid}.{suffix}
return `${this.basePrefix}/${subPath}/${uid}.${suffix}`
}
/**
* 核心文件上传方法
* @param {Object} file - 文件对象(兼容 formidable 格式)
* @param {string} category - 存储分类
* @returns {Object} 上传结果
*/
async uploadFile(file, category = 'files') {
try {
// 兼容不同的文件对象格式filepath 或 path
const filePath = file.filepath || file.path
// 验证文件
if (!file || !filePath) {
return { success: false, error: '无效的文件对象' }
}
const stream = fs.createReadStream(filePath)
const storagePath = this.getStoragePath(file, category)
const suffix = this.getFileSuffix(file)
// 设置 content-type兼容 mimetype 和 type
const contentType = file.mimetype || file.type || 'application/octet-stream'
// 上传到 OSS
const result = await this.client.put(storagePath, stream, {
headers: {
'content-disposition': 'inline',
"content-type": contentType
}
})
if (result.res.status === 200) {
const ossPath = config.ossUrl + '/' + result.name
// 上传成功后删除临时文件
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
} catch (unlinkError) {
logs.error('删除临时文件失败:', unlinkError)
}
// 使用 ossPathhttps作为 path确保返回 https 格式
const path = ossPath
return {
success: true,
name: result.name,
path: path,
ossPath,
fileType: file.mimetype || file.type,
fileSize: file.size,
originalName: file.originalFilename || file.name,
suffix: suffix,
storagePath: storagePath
}
} else {
return { success: false, error: 'OSS 上传失败' }
}
} catch (error) {
logs.error('文件上传错误:', error)
// 上传失败也要清理临时文件
try {
const filePath = file.filepath || file.path
if (filePath && fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
} catch (unlinkError) {
logs.error('删除临时文件失败:', unlinkError)
}
return { success: false, error: error.message }
}
}
/**
* 上传流数据
* @param {Stream} stream - 文件流
* @param {string} contentType - 内容类型
* @param {string} suffix - 文件后缀
* @returns {Object} 上传结果
*/
async uploadStream(stream, contentType, suffix) {
try {
const uid = uuid.v4()
const storagePath = `${this.basePrefix}/files/${uid}.${suffix}`
const result = await this.client.put(storagePath, stream, {
headers: {
'content-disposition': 'inline',
"content-type": contentType
}
})
if (result.res.status === 200) {
const ossPath = config.ossUrl + '/' + result.name
// 使用 ossPathhttps作为 path确保返回 https 格式
const path = ossPath
return {
success: true,
name: result.name,
path: path,
ossPath,
storagePath: storagePath
}
} else {
return { success: false, error: 'OSS 上传失败' }
}
} catch (error) {
logs.error('流上传错误:', error)
return { success: false, error: error.message }
}
}
/**
* 删除文件
* @param {string} filePath - 文件路径
* @returns {Object} 删除结果
*/
async deleteFile(filePath) {
try {
if (!filePath) {
return { success: false, error: '文件路径不能为空' }
}
// 从完整 URL 中提取相对路径
const relativePath = filePath.replace(config.ossUrl + '/', '')
const result = await this.client.delete(relativePath)
if (result.res.status === 204) {
return { success: true, message: '文件删除成功' }
} else {
return { success: false, error: '文件删除失败' }
}
} catch (error) {
logs.error('文件删除错误:', error)
return { success: false, error: error.message }
}
}
/**
* 获取文件信息
* @param {string} filePath - 文件路径
* @returns {Object} 文件信息
*/
async getFileInfo(filePath) {
try {
if (!filePath) {
return { success: false, error: '文件路径不能为空' }
}
const relativePath = filePath.replace(config.ossUrl + '/', '')
const result = await this.client.head(relativePath)
return {
success: true,
size: result.res.headers['content-length'],
type: result.res.headers['content-type'],
lastModified: result.res.headers['last-modified'],
etag: result.res.headers['etag']
}
} catch (error) {
logs.error('获取文件信息错误:', error)
return { success: false, error: error.message }
}
}
// ==================== 便捷方法 ====================
/**
* 上传图片文件(保持向后兼容)
* @param {Object} file - 图片文件
* @returns {Object} 上传结果
*/
async putImg(file) {
return await this.uploadFile(file, 'images')
}
/**
* 上传视频文件
* @param {Object} file - 视频文件
* @returns {Object} 上传结果
*/
async uploadVideo(file) {
return await this.uploadFile(file, 'videos')
}
/**
* 上传音频文件
* @param {Object} file - 音频文件
* @returns {Object} 上传结果
*/
async uploadAudio(file) {
return await this.uploadFile(file, 'audios')
}
/**
* 上传文档文件
* @param {Object} file - 文档文件
* @returns {Object} 上传结果
*/
async uploadDocument(file) {
return await this.uploadFile(file, 'documents')
}
}
// 创建单例实例
const ossTool = new OSSTool()
// 导出实例(保持向后兼容)
module.exports = ossTool