feat: 完成客户拜访模块全功能开发
本次提交实现了完整的客户拜访业务系统: 1. 新增对象存储上传服务,支持S3兼容协议的RustFS存储 2. 完善客户拜访实体与数据库表结构,补充全业务字段 3. 开发拜访台账与记录的前后端接口,包含增删改查、批量创建、状态流转等能力 4. 新增拜访表单页面与路由,支持父级菜单补全面包屑 5. 新增勘察列表页预填客户ID跳转逻辑 6. 补充UI设计文档与页面截图 7. 调整日志中间件跳过上传路由,优化RBAC权限配置
This commit is contained in:
1 parent
68af363ca9
commit
1c83e09fa1
33 files changed
+6920
-587
No files matched your search
@@ -1,12 +1,19 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { Provide, Inject } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op, UniqueConstraintError } from "sequelize";
|
||||
import { Op, UniqueConstraintError, literal } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import CustomerVisit from "../entity/customer_visit.entity";
|
||||
import Customer from "../entity/customer.entity";
|
||||
import SysUser from "../entity/sysUser.entity";
|
||||
import SysUserRole from "../entity/sysUserRole.entity";
|
||||
import SysRole from "../entity/sysRole.entity";
|
||||
import { UploadService } from "./upload.service";
|
||||
|
||||
// 客户拜访状态:0待执行 1已完成待复核 2取证待技术复核 3已立项转勘察 4已归档 5已取消
|
||||
const STATUS = { PENDING: 0, DONE: 1, EVIDENCE: 2, SURVEY: 3, ARCHIVED: 4, CANCELED: 5 };
|
||||
// 有转化价值的意向评级:1=A高意向 2=B中意向
|
||||
const INTENT_LEVELS = [1, 2];
|
||||
|
||||
@Provide()
|
||||
export class CustomerVisitService {
|
||||
@@ -16,12 +23,18 @@ export class CustomerVisitService {
|
||||
@InjectRepository(Customer)
|
||||
customerModel!: Repository<Customer>;
|
||||
|
||||
@InjectRepository(SysUser)
|
||||
userModel!: Repository<SysUser>;
|
||||
|
||||
@InjectRepository(SysUserRole)
|
||||
userRoleModel!: Repository<SysUserRole>;
|
||||
|
||||
@InjectRepository(SysRole)
|
||||
roleModel!: Repository<SysRole>;
|
||||
|
||||
@Inject()
|
||||
uploadService!: UploadService;
|
||||
|
||||
private async isAdmin(userId: number): Promise<boolean> {
|
||||
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
|
||||
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
|
||||
@@ -33,6 +46,13 @@ export class CustomerVisitService {
|
||||
return !!customer && customer.salesmanId === userId;
|
||||
}
|
||||
|
||||
// 业务负责人姓名(用于填充必填的 visit_person 字段)
|
||||
private async getUserRealName(userId: number): Promise<string> {
|
||||
if (!userId) return "未知";
|
||||
const user = await this.userModel.findByPk(userId, { attributes: ["id", "realName", "username"] });
|
||||
return user?.realName || (user as any)?.username || "未知";
|
||||
}
|
||||
|
||||
private async generateVisitNo(retry: number = 0): Promise<string> {
|
||||
const now = new Date();
|
||||
const offsetMs = now.getTime() + 8 * 60 * 60 * 1000;
|
||||
@@ -51,61 +71,239 @@ export class CustomerVisitService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async list(query: any, ctx: Context): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize) || 10;
|
||||
const userId = ctx.state.user.userId;
|
||||
// 把 query 参数转换为 Sequelize where 条件
|
||||
private buildWhere(query: any): any {
|
||||
const where: any = {};
|
||||
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [
|
||||
{ visitNo: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ subject: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ visitPerson: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ visitPhone: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
if (query.customerId !== undefined) where.customerId = query.customerId;
|
||||
if (query.salesmanId) where.salesmanId = query.salesmanId;
|
||||
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
|
||||
if (query.salesmanId) where.salesmanId = Number(query.salesmanId);
|
||||
if (query.visitType) where.visitType = Number(query.visitType);
|
||||
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
|
||||
if (query.priority) where.priority = Number(query.priority);
|
||||
if (query.planStartDate || query.planEndDate) {
|
||||
const range: any = {};
|
||||
if (query.planStartDate) range[Op.gte] = new Date(`${query.planStartDate}T00:00:00+08:00`);
|
||||
if (query.planEndDate) range[Op.lte] = new Date(`${query.planEndDate}T23:59:59+08:00`);
|
||||
where.planTime = range;
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
if (!(await this.isAdmin(userId))) {
|
||||
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
|
||||
const ids = myCustomers.map((c) => c.id);
|
||||
if (ids.length === 0) return { code: 200, message: "获取客户拜访列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
|
||||
if (where.customerId !== undefined) {
|
||||
if (!ids.includes(where.customerId)) return { code: 200, message: "获取客户拜访列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
|
||||
} else {
|
||||
where.customerId = { [Op.in]: ids };
|
||||
}
|
||||
// 行级隔离:非管理员仅能查看自己名下客户的拜访记录;返回 null 表示无可见数据
|
||||
private async scopeWhere(baseWhere: any, ctx: Context): Promise<any | null> {
|
||||
const userId = ctx.state.user.userId;
|
||||
const where: any = { ...baseWhere };
|
||||
if (await this.isAdmin(userId)) return where;
|
||||
|
||||
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
|
||||
const ids = myCustomers.map((c) => c.id);
|
||||
if (ids.length === 0) return null;
|
||||
if (where.customerId !== undefined) {
|
||||
if (!ids.includes(Number(where.customerId))) return null;
|
||||
} else {
|
||||
where.customerId = { [Op.in]: ids };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
private parsePhotos(raw: any): string[] {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw;
|
||||
try {
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 输出前把 photos 从 JSON 字符串转为 key 数组,并附带预签名访问 URL
|
||||
private async toVO(record: CustomerVisit, urlMap?: Record<string, string>): Promise<any> {
|
||||
const json: any = record.toJSON();
|
||||
const keys = this.parsePhotos(json.photos);
|
||||
json.photos = keys;
|
||||
if (keys.length === 0) {
|
||||
json.photoUrls = [];
|
||||
return json;
|
||||
}
|
||||
const map = urlMap || (await this.uploadService.getPresignedUrlMap(keys));
|
||||
json.photoUrls = keys.map((k) => map[k] || "");
|
||||
return json;
|
||||
}
|
||||
|
||||
// 补充客户与业务员信息,便于台账列表直接渲染
|
||||
private async decorate(rows: any[]): Promise<any[]> {
|
||||
if (rows.length === 0) return rows;
|
||||
const allKeys = rows.flatMap((r) => this.parsePhotos(r.rawPhotos));
|
||||
const urlMap = allKeys.length ? await this.uploadService.getPresignedUrlMap(allKeys) : {};
|
||||
|
||||
const salesmanIds = [...new Set(rows.map((r) => r.salesmanId).filter(Boolean))];
|
||||
const users = salesmanIds.length
|
||||
? await this.userModel.findAll({ where: { id: { [Op.in]: salesmanIds } }, attributes: ["id", "realName", "phone"] })
|
||||
: [];
|
||||
const userMap = new Map(users.map((u: any) => [u.id, u]));
|
||||
|
||||
for (const row of rows) {
|
||||
const keys = this.parsePhotos(row.rawPhotos);
|
||||
row.photos = keys;
|
||||
row.photoUrls = keys.map((k) => urlMap[k] || "");
|
||||
delete row.rawPhotos;
|
||||
const user: any = userMap.get(row.salesmanId);
|
||||
row.salesmanName = user?.realName || "";
|
||||
row.salesmanPhone = user?.phone || "";
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async list(query: any, ctx: Context): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize) || 10;
|
||||
|
||||
const where = await this.scopeWhere(this.buildWhere(query), ctx);
|
||||
if (!where) {
|
||||
return { code: 200, message: "获取客户拜访列表成功", data: { list: [], total: 0, page, pageSize } };
|
||||
}
|
||||
|
||||
const { count, rows } = await this.visitModel.findAndCountAll({ where, limit: pageSize, offset: (page - 1) * pageSize, order: [["created_at", "DESC"]] });
|
||||
return { code: 200, message: "获取客户拜访列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) } };
|
||||
const { count, rows } = await this.visitModel.findAndCountAll({
|
||||
where,
|
||||
include: [{ model: Customer, attributes: ["id", "customerNo", "customerName", "customerLevel", "contactPerson", "contactPhone", "address"] }],
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["plan_time", "DESC"], ["id", "DESC"]],
|
||||
distinct: true
|
||||
});
|
||||
|
||||
const list = rows.map((r: any) => {
|
||||
const json = r.toJSON();
|
||||
json.rawPhotos = json.photos;
|
||||
json.photos = [];
|
||||
return json;
|
||||
});
|
||||
return { code: 200, message: "获取客户拜访列表成功", data: { list: await this.decorate(list), total: count, page, pageSize } };
|
||||
}
|
||||
|
||||
// KPI 统计卡(仅按当前用户可见范围统计,不受列表筛选条件影响)
|
||||
async stats(ctx: Context): Promise<any> {
|
||||
const where = await this.scopeWhere({}, ctx);
|
||||
const empty = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
todayPlan: 0,
|
||||
urgentPending: 0,
|
||||
conversionRate: 0,
|
||||
pendingReview: 0,
|
||||
completed: 0,
|
||||
converted: 0,
|
||||
intentTotal: 0
|
||||
};
|
||||
if (!where) return { code: 200, message: "获取拜访统计成功", data: empty };
|
||||
|
||||
const countBy = (extra: any) => this.visitModel.count({ where: { ...where, ...extra } });
|
||||
|
||||
const [total, pending, todayPlan, urgentPending, pendingReview, completed, converted, intentTotal] = await Promise.all([
|
||||
countBy({}),
|
||||
countBy({ status: STATUS.PENDING }),
|
||||
countBy({
|
||||
status: STATUS.PENDING,
|
||||
[Op.and]: [literal("(plan_time AT TIME ZONE 'Asia/Shanghai')::date = (NOW() AT TIME ZONE 'Asia/Shanghai')::date")]
|
||||
}),
|
||||
countBy({ status: STATUS.PENDING, priority: 1 }),
|
||||
countBy({ status: STATUS.EVIDENCE }),
|
||||
countBy({ status: { [Op.in]: [STATUS.DONE, STATUS.ARCHIVED] } }),
|
||||
countBy({ status: STATUS.SURVEY }),
|
||||
countBy({ intentLevel: { [Op.in]: INTENT_LEVELS } })
|
||||
]);
|
||||
|
||||
const conversionRate = intentTotal > 0 ? Number(((converted / intentTotal) * 100).toFixed(1)) : 0;
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取拜访统计成功",
|
||||
data: { total, pending, todayPlan, urgentPending, conversionRate, pendingReview, completed, converted, intentTotal }
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: number, ctx: Context): Promise<any> {
|
||||
const record = await this.visitModel.findByPk(id);
|
||||
const record: any = await this.visitModel.findByPk(id, {
|
||||
include: [{ model: Customer, attributes: ["id", "customerNo", "customerName", "customerLevel", "contactPerson", "contactPhone", "address", "salesmanId"] }]
|
||||
});
|
||||
if (!record) return { code: 404, message: "客户拜访不存在", data: null };
|
||||
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该拜访记录", data: null };
|
||||
return { code: 200, message: "获取客户拜访详情成功", data: record.toJSON() };
|
||||
const vo = await this.toVO(record);
|
||||
vo.salesmanName = await this.getUserRealName(record.salesmanId);
|
||||
return { code: 200, message: "获取客户拜访详情成功", data: vo };
|
||||
}
|
||||
|
||||
async create(data: any, ctx: Context): Promise<any> {
|
||||
const userId = ctx.state.user.userId;
|
||||
const isAdmin = await this.isAdmin(userId);
|
||||
if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建拜访记录", data: null };
|
||||
if (!(await this.isAdmin(userId))) data.salesmanId = userId;
|
||||
if (!isAdmin) data.salesmanId = userId;
|
||||
|
||||
const photos = this.parsePhotos(data.photos);
|
||||
if (photos.length > 2) return { code: 400, message: "现场照片最多上传 2 张", data: null };
|
||||
const payload = this.normalizePayload(data, photos);
|
||||
if (!payload.visitPerson) payload.visitPerson = await this.getUserRealName(payload.salesmanId || userId);
|
||||
|
||||
try {
|
||||
data.visitNo = await this.generateVisitNo();
|
||||
const created = await this.visitModel.create(data);
|
||||
return { code: 200, message: "创建客户拜访成功", data: created.toJSON() };
|
||||
payload.visitNo = await this.generateVisitNo();
|
||||
const created = await this.visitModel.create(payload);
|
||||
return { code: 200, message: "创建客户拜访成功", data: await this.toVO(created) };
|
||||
} catch (err: any) {
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
data.visitNo = await this.generateVisitNo();
|
||||
const created = await this.visitModel.create(data);
|
||||
return { code: 200, message: "创建客户拜访成功", data: created.toJSON() };
|
||||
payload.visitNo = await this.generateVisitNo();
|
||||
const created = await this.visitModel.create(payload);
|
||||
return { code: 200, message: "创建客户拜访成功", data: await this.toVO(created) };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 批量安排拜访:多选客户生成统一的待执行计划
|
||||
async batchCreate(data: any, ctx: Context): Promise<any> {
|
||||
const userId = ctx.state.user.userId;
|
||||
const isAdmin = await this.isAdmin(userId);
|
||||
const customerIds = [...new Set((data.customerIds || []).map(Number).filter(Boolean))];
|
||||
if (customerIds.length === 0) return { code: 400, message: "请至少选择一个客户", data: null };
|
||||
if (!data.planTime) return { code: 400, message: "请选择计划拜访时间", data: null };
|
||||
|
||||
const salesmanId = isAdmin && data.salesmanId ? Number(data.salesmanId) : userId;
|
||||
if (!isAdmin) {
|
||||
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId, id: { [Op.in]: customerIds } }, attributes: ["id"] });
|
||||
if (myCustomers.length !== customerIds.length) return { code: 403, message: "存在无权限安排的客户", data: null };
|
||||
} else {
|
||||
const exists = await this.customerModel.count({ where: { id: { [Op.in]: customerIds } } });
|
||||
if (exists !== customerIds.length) return { code: 400, message: "存在无效的客户", data: null };
|
||||
}
|
||||
|
||||
const visitPerson = await this.getUserRealName(salesmanId);
|
||||
const ids: number[] = [];
|
||||
for (const customerId of customerIds) {
|
||||
const payload: any = {
|
||||
visitNo: await this.generateVisitNo(),
|
||||
customerId,
|
||||
visitPerson,
|
||||
salesmanId,
|
||||
status: STATUS.PENDING,
|
||||
visitType: data.visitType || 1,
|
||||
visitMode: data.visitMode || 1,
|
||||
priority: data.priority || 3,
|
||||
subject: data.subject || "批量安排的拜访计划",
|
||||
planTime: data.planTime,
|
||||
planEndTime: data.planEndTime || null
|
||||
};
|
||||
const created = await this.visitModel.create(payload);
|
||||
ids.push(created.id);
|
||||
}
|
||||
return { code: 200, message: `成功创建 ${ids.length} 条拜访计划`, data: { total: ids.length, ids } };
|
||||
}
|
||||
|
||||
async update(id: number, data: any, ctx: Context): Promise<any> {
|
||||
const record = await this.visitModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "客户拜访不存在", data: null };
|
||||
@@ -116,8 +314,56 @@ export class CustomerVisitService {
|
||||
delete data.customerId;
|
||||
}
|
||||
delete data.visitNo;
|
||||
await record.update(data);
|
||||
return { code: 200, message: "更新客户拜访成功", data: record.toJSON() };
|
||||
|
||||
const photos = data.photos === undefined ? undefined : this.parsePhotos(data.photos);
|
||||
if (photos && photos.length > 2) return { code: 400, message: "现场照片最多上传 2 张", data: null };
|
||||
|
||||
const payload = this.normalizePayload(data, photos);
|
||||
await record.update(payload);
|
||||
const fresh = await this.visitModel.findByPk(id);
|
||||
return { code: 200, message: "更新客户拜访成功", data: await this.toVO(fresh!) };
|
||||
}
|
||||
|
||||
// 状态流转:执行跟进(1) / 审核现场取证(2、3) / 归档(4) / 取消(5)
|
||||
async changeStatus(id: number, data: any, ctx: Context): Promise<any> {
|
||||
const record = await this.visitModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "客户拜访不存在", data: null };
|
||||
const isAdmin = await this.isAdmin(ctx.state.user.userId);
|
||||
if (!isAdmin && !(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) {
|
||||
return { code: 403, message: "无权限变更该拜访记录状态", data: null };
|
||||
}
|
||||
|
||||
const target = Number(data.status);
|
||||
if (!Object.values(STATUS).includes(target)) return { code: 400, message: "状态值不合法", data: null };
|
||||
|
||||
const patch: any = { status: target };
|
||||
// 标记完成/取证时同步到场签到信息
|
||||
if (target === STATUS.DONE || target === STATUS.EVIDENCE) {
|
||||
patch.checkinStatus = 1;
|
||||
patch.checkinTime = data.checkinTime || record.checkinTime || new Date();
|
||||
}
|
||||
if (data.resultRating) patch.resultRating = data.resultRating;
|
||||
if (data.surveyOrderId) patch.surveyOrderId = Number(data.surveyOrderId);
|
||||
if (data.projectNo) patch.projectNo = data.projectNo;
|
||||
|
||||
await record.update(patch);
|
||||
return { code: 200, message: "更新拜访状态成功", data: await this.toVO(record) };
|
||||
}
|
||||
|
||||
// 改派:更换跟进业务员
|
||||
async reassign(id: number, data: any, ctx: Context): Promise<any> {
|
||||
const record = await this.visitModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "客户拜访不存在", data: null };
|
||||
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) {
|
||||
return { code: 403, message: "无权限改派该拜访记录", data: null };
|
||||
}
|
||||
const salesmanId = Number(data.salesmanId);
|
||||
if (!salesmanId) return { code: 400, message: "请选择改派业务员", data: null };
|
||||
const user = await this.userModel.findByPk(salesmanId, { attributes: ["id"] });
|
||||
if (!user) return { code: 400, message: "改派业务员不存在", data: null };
|
||||
|
||||
await record.update({ salesmanId, visitPerson: await this.getUserRealName(salesmanId) });
|
||||
return { code: 200, message: "改派成功", data: await this.toVO(record) };
|
||||
}
|
||||
|
||||
async delete(id: number, ctx: Context): Promise<any> {
|
||||
@@ -127,4 +373,14 @@ export class CustomerVisitService {
|
||||
await record.destroy();
|
||||
return { code: 200, message: "删除客户拜访成功", data: null };
|
||||
}
|
||||
}
|
||||
|
||||
// 统一清洗入参:空串转 null、photos 数组转 JSON 字符串
|
||||
private normalizePayload(data: any, photos?: string[]): any {
|
||||
const payload: any = { ...data };
|
||||
if (photos !== undefined) payload.photos = photos.length ? JSON.stringify(photos) : null;
|
||||
for (const key of Object.keys(payload)) {
|
||||
if (payload[key] === "") payload[key] = null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Provide, Config } from "@midwayjs/core";
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { createReadStream, statSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import { extname } from "path";
|
||||
import { UploadFileInfo } from "@midwayjs/upload";
|
||||
|
||||
// RustFS 配置结构(见 config.default.ts 的 rustfs 段)
|
||||
interface RustFsConfig {
|
||||
endpoint: string;
|
||||
port: number;
|
||||
useSSL: boolean;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
bucketProd: string;
|
||||
bucketTest: string;
|
||||
forcePathStyle: boolean;
|
||||
presignExpires: number;
|
||||
keyPrefix: string;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
key: string;
|
||||
url: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@Provide()
|
||||
export class UploadService {
|
||||
@Config("rustfs")
|
||||
rustfsConfig!: RustFsConfig;
|
||||
|
||||
private client: S3Client | null = null;
|
||||
|
||||
private getClient(): S3Client {
|
||||
if (!this.client) {
|
||||
const protocol = this.rustfsConfig.useSSL ? "https" : "http";
|
||||
this.client = new S3Client({
|
||||
endpoint: `${protocol}://${this.rustfsConfig.endpoint}:${this.rustfsConfig.port}`,
|
||||
region: this.rustfsConfig.region,
|
||||
credentials: {
|
||||
accessKeyId: this.rustfsConfig.accessKey,
|
||||
secretAccessKey: this.rustfsConfig.secretKey
|
||||
},
|
||||
// RustFS 仅支持 path-style 访问
|
||||
forcePathStyle: this.rustfsConfig.forcePathStyle
|
||||
});
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
private get bucket(): string {
|
||||
return this.rustfsConfig.bucket;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单个文件到 RustFS,返回对象 key 与预签名访问 URL
|
||||
*/
|
||||
async uploadFile(file: UploadFileInfo<string>): Promise<UploadResult> {
|
||||
if (!file || !file.data) {
|
||||
throw new Error("上传文件为空");
|
||||
}
|
||||
const ext = extname(file.filename || "").toLowerCase();
|
||||
const now = new Date();
|
||||
const yyyymm = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
const key = `${this.rustfsConfig.keyPrefix}/${yyyymm}/${randomUUID()}${ext}`;
|
||||
|
||||
await this.getClient().send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: createReadStream(file.data),
|
||||
ContentType: file.mimeType || "application/octet-stream"
|
||||
})
|
||||
);
|
||||
|
||||
let size = 0;
|
||||
try {
|
||||
size = statSync(file.data).size;
|
||||
} catch {
|
||||
size = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
url: await this.getPresignedUrl(key),
|
||||
name: file.filename,
|
||||
size
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按对象 key 生成预签名访问 URL(默认 1 小时有效)
|
||||
*/
|
||||
async getPresignedUrl(key: string): Promise<string> {
|
||||
if (!key) return "";
|
||||
return getSignedUrl(
|
||||
this.getClient(),
|
||||
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
|
||||
{ expiresIn: this.rustfsConfig.presignExpires }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量把对象 key 转成预签名 URL,返回 key → url 映射(key 非法时返回空串)
|
||||
*/
|
||||
async getPresignedUrlMap(keys: string[]): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {};
|
||||
for (const key of keys) {
|
||||
try {
|
||||
map[key] = await this.getPresignedUrl(key);
|
||||
} catch {
|
||||
map[key] = "";
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user