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
@@ -12,11 +12,14 @@
|
||||
"build": "midway-bin build -c"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1131.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1131.0",
|
||||
"@midwayjs/cache-manager": "^3.19.0",
|
||||
"@midwayjs/core": "^3.19.0",
|
||||
"@midwayjs/koa": "^3.19.0",
|
||||
"@midwayjs/logger": "^4.0.1",
|
||||
"@midwayjs/sequelize": "^3.19.0",
|
||||
"@midwayjs/upload": "^3.20.0",
|
||||
"@midwayjs/validate": "^3.19.0",
|
||||
"@types/validator": "^13.15.10",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MidwayConfig } from "@midwayjs/core";
|
||||
import { existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { config as loadEnv } from "dotenv";
|
||||
|
||||
// 加载项目根目录的环境变量文件:
|
||||
@@ -65,6 +66,35 @@ export default {
|
||||
refreshExpiresIn: "7d"
|
||||
},
|
||||
|
||||
// multipart/form-data 解析配置(现场照片上传)
|
||||
upload: {
|
||||
mode: "file",
|
||||
fileSize: "10mb",
|
||||
whitelist: [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"],
|
||||
tmpdir: join(tmpdir(), "smart-grid-upload"),
|
||||
cleanTimeout: 5 * 60 * 1000
|
||||
},
|
||||
|
||||
// RustFS 对象存储(S3 兼容协议、path-style 访问)
|
||||
rustfs: {
|
||||
endpoint: process.env.RUSTFS_ENDPOINT || "127.0.0.1",
|
||||
port: Number(process.env.RUSTFS_PORT) || 9000,
|
||||
useSSL: process.env.RUSTFS_USE_SSL === "true",
|
||||
accessKey: process.env.RUSTFS_ACCESS_KEY || "",
|
||||
secretKey: process.env.RUSTFS_SECRET_KEY || "",
|
||||
region: process.env.RUSTFS_REGION || "us-east-1",
|
||||
// 当前生效的桶(生产/测试通过 .env 与 .env.local 切换)
|
||||
bucket: process.env.RUSTFS_BUCKET || process.env.RUSTFS_BUCKET_PROD || "",
|
||||
bucketProd: process.env.RUSTFS_BUCKET_PROD || "",
|
||||
bucketTest: process.env.RUSTFS_BUCKET_TEST || "",
|
||||
// RustFS 仅支持 path-style 访问
|
||||
forcePathStyle: true,
|
||||
// 预签名 URL 有效期(秒)
|
||||
presignExpires: 3600,
|
||||
// 上传对象 key 前缀
|
||||
keyPrefix: "customer-visit"
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: "*",
|
||||
allowMethods: "GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS",
|
||||
|
||||
@@ -2,13 +2,14 @@ import { Configuration, App, ILifeCycle } from "@midwayjs/core";
|
||||
import * as Koa from "@midwayjs/koa";
|
||||
import * as Sequelize from "@midwayjs/sequelize";
|
||||
import * as CacheManager from "@midwayjs/cache-manager";
|
||||
import * as Upload from "@midwayjs/upload";
|
||||
import { join } from "path";
|
||||
import { AuthMiddleware } from "./middleware/auth.middleware";
|
||||
import { RbacMiddleware } from "./middleware/rbac.middleware";
|
||||
import { LogMiddleware } from "./middleware/log.middleware";
|
||||
|
||||
@Configuration({
|
||||
imports: [Koa, Sequelize, CacheManager],
|
||||
imports: [Koa, Sequelize, CacheManager, Upload],
|
||||
importConfigs: [join(__dirname, "./config")]
|
||||
})
|
||||
export class ContainerConfigLifeCycle implements ILifeCycle {
|
||||
|
||||
@@ -8,8 +8,26 @@ export class CustomerVisitController {
|
||||
@Inject() customerVisitService!: CustomerVisitService;
|
||||
|
||||
@Get("/") async list(@Query() query: any) { return this.customerVisitService.list(query, this.ctx); }
|
||||
|
||||
// KPI 统计卡;必须声明在 /:id 之前,否则会被动态路由吞掉
|
||||
@Get("/stats") async stats() { return this.customerVisitService.stats(this.ctx); }
|
||||
|
||||
// 批量安排拜访(多选客户生成待执行计划)
|
||||
@Post("/batch") async batch(@Body() body: any) { return this.customerVisitService.batchCreate(body, this.ctx); }
|
||||
|
||||
@Get("/:id") async detail(@Param("id") id: string) { return this.customerVisitService.detail(Number(id), this.ctx); }
|
||||
@Post("/") async create(@Body() body: any) { return this.customerVisitService.create(body, this.ctx); }
|
||||
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerVisitService.update(Number(id), body, this.ctx); }
|
||||
|
||||
// 状态流转:执行跟进 / 审核现场取证 / 归档 / 取消
|
||||
@Put("/:id/status") async changeStatus(@Param("id") id: string, @Body() body: any) {
|
||||
return this.customerVisitService.changeStatus(Number(id), body, this.ctx);
|
||||
}
|
||||
|
||||
// 改派:更换跟进业务员
|
||||
@Put("/:id/reassign") async reassign(@Param("id") id: string, @Body() body: any) {
|
||||
return this.customerVisitService.reassign(Number(id), body, this.ctx);
|
||||
}
|
||||
|
||||
@Del("/:id") async delete(@Param("id") id: string) { return this.customerVisitService.delete(Number(id), this.ctx); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller, Post, Inject, Files } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { UploadFileInfo } from "@midwayjs/upload";
|
||||
import { UploadService } from "../service/upload.service";
|
||||
|
||||
@Controller("/api/upload")
|
||||
export class UploadController {
|
||||
@Inject()
|
||||
ctx!: Context;
|
||||
|
||||
@Inject()
|
||||
uploadService!: UploadService;
|
||||
|
||||
// 上传现场照片,返回对象 key(用于持久化)与预签名 URL(用于即时预览)
|
||||
@Post("/image")
|
||||
async uploadImage(@Files() files: UploadFileInfo<string>[]) {
|
||||
const file = files && files[0];
|
||||
if (!file) {
|
||||
return { code: 400, message: "请选择要上传的图片", data: null };
|
||||
}
|
||||
try {
|
||||
const data = await this.uploadService.uploadFile(file);
|
||||
return { code: 200, message: "上传成功", data };
|
||||
} catch (err: any) {
|
||||
return { code: 500, message: `图片上传失败:${err?.message || "对象存储不可用"}`, data: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, Matches, Min, Max, MaxLength } from "class-validator";
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsInt,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
ArrayNotEmpty,
|
||||
Matches,
|
||||
Min,
|
||||
Max,
|
||||
MaxLength
|
||||
} from "class-validator";
|
||||
|
||||
export class CustomerVisitQueryDTO {
|
||||
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||
@@ -6,24 +18,70 @@ export class CustomerVisitQueryDTO {
|
||||
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||
@IsOptional() @IsInt() customerId?: number;
|
||||
@IsOptional() @IsInt() salesmanId?: number;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(5) visitType?: number;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(5) status?: number;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
|
||||
// 计划拜访日期窗口(YYYY-MM-DD)
|
||||
@IsOptional() @IsString() @MaxLength(10) planStartDate?: string;
|
||||
@IsOptional() @IsString() @MaxLength(10) planEndDate?: string;
|
||||
}
|
||||
|
||||
export class CustomerVisitCreateDTO {
|
||||
@IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number;
|
||||
@IsNotEmpty({ message: '拜访人不能为空' }) @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) visitPerson!: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) visitPerson?: string;
|
||||
@IsOptional() @Matches(/^1[3-9]\d{9}$/, { message: '手机号格式不正确' }) visitPhone?: string;
|
||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) visitContent?: string;
|
||||
@IsOptional() @IsString() @MaxLength(10) visitTime?: string;
|
||||
@IsOptional() @IsInt() salesmanId?: number;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(1) willPlan?: number;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(5) visitType?: number;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(4) visitMode?: number;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
|
||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) subject?: string;
|
||||
@IsOptional() @IsDateString() planTime?: string;
|
||||
@IsOptional() @IsDateString() planEndTime?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) spaceDesc?: string;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(3) intentLevel?: number;
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) intentDesc?: string;
|
||||
@IsOptional() @IsString() @MaxLength(512) @Matches(/^[^<>]*$/) demands?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) companion?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) receptionPerson?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) receptionTitle?: string;
|
||||
@IsOptional() @Matches(/^1[3-9]\d{9}$/, { message: '接待人联系方式格式不正确' }) receptionPhone?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) resultRating?: string;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(5) status?: number;
|
||||
@IsOptional() @IsString() @MaxLength(10) nextVisitTime?: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) nextAction?: string;
|
||||
// 现场照片对象 key 列表(对象存储),最多 2 张
|
||||
@IsOptional() @IsArray() @MaxLength(2) photos?: string[];
|
||||
}
|
||||
|
||||
export class CustomerVisitUpdateDTO {
|
||||
@IsOptional() @IsInt() customerId?: number;
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) visitPerson?: string;
|
||||
@IsOptional() @Matches(/^1[3-9]\d{9}$/) visitPhone?: string;
|
||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) visitContent?: string;
|
||||
@IsOptional() @IsString() @MaxLength(10) visitTime?: string;
|
||||
export class CustomerVisitUpdateDTO extends CustomerVisitCreateDTO {
|
||||
@IsOptional() @IsInt() declare customerId: number;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(5) declare status?: number;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(1) declare checkinStatus?: number;
|
||||
@IsOptional() @IsDateString() declare checkinTime?: string;
|
||||
@IsOptional() declare checkinLng?: number;
|
||||
@IsOptional() declare checkinLat?: number;
|
||||
}
|
||||
|
||||
// 批量安排拜访:多选客户 + 统一计划信息,批量生成待执行(status=0)记录
|
||||
export class CustomerVisitBatchCreateDTO {
|
||||
@IsArray() @ArrayNotEmpty({ message: '请至少选择一个客户' }) @IsInt({ each: true }) customerIds!: number[];
|
||||
@IsNotEmpty({ message: '计划拜访时间不能为空' }) @IsDateString() planTime!: string;
|
||||
@IsOptional() @IsDateString() planEndTime?: string;
|
||||
@IsOptional() @IsInt() salesmanId?: number;
|
||||
@IsOptional() @IsInt() @Min(0) @Max(1) willPlan?: number;
|
||||
}
|
||||
@IsOptional() @IsInt() @Min(1) @Max(5) visitType?: number;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(4) visitMode?: number;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
|
||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) subject?: string;
|
||||
}
|
||||
|
||||
// 状态流转(执行跟进 / 审核现场取证 / 取消 / 归档)
|
||||
export class CustomerVisitStatusDTO {
|
||||
@IsNotEmpty({ message: '目标状态不能为空' }) @IsInt() @Min(0) @Max(5) status!: number;
|
||||
@IsOptional() @IsDateString() checkinTime?: string;
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) resultRating?: string;
|
||||
@IsOptional() @IsInt() surveyOrderId?: number;
|
||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) projectNo?: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
|
||||
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
|
||||
import Customer from "./customer.entity";
|
||||
|
||||
@Table({
|
||||
@@ -6,7 +6,7 @@ import Customer from "./customer.entity";
|
||||
comment: "客户拜访信息表",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: false,
|
||||
updatedAt: "updated_at",
|
||||
indexes: [
|
||||
{ name: "idx_visit_customer", fields: ["customer_id"] },
|
||||
{ name: "idx_visit_salesman", fields: ["salesman_id"] }
|
||||
@@ -29,10 +29,10 @@ export default class CustomerVisit extends Model {
|
||||
@Column({ type: DataType.STRING(20), field: "visit_phone", comment: "拜访人电话" })
|
||||
visitPhone!: string;
|
||||
|
||||
@Column({ type: DataType.TEXT, field: "visit_content", comment: "拜访内容" })
|
||||
@Column({ type: DataType.TEXT, field: "visit_content", comment: "拜访内容/沟通纪要" })
|
||||
visitContent!: string;
|
||||
|
||||
@Column({ type: DataType.DATEONLY, field: "visit_time", comment: "拜访时间" })
|
||||
@Column({ type: DataType.DATEONLY, field: "visit_time", comment: "拜访时间(日期)" })
|
||||
visitTime!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "salesman_id", comment: "业务员(关联sys_user.id)" })
|
||||
@@ -41,9 +41,87 @@ export default class CustomerVisit extends Model {
|
||||
@Column({ type: DataType.SMALLINT, defaultValue: 0, field: "will_plan", comment: "是否做方案:0不同意 1同意" })
|
||||
willPlan!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, defaultValue: 1, field: "visit_type", comment: "拜访类型:1首访摸排 2方案沟通 3商务二访 4维保回访 5应急巡检" })
|
||||
visitType!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, defaultValue: 1, field: "visit_mode", comment: "拜访方式:1现场拜访 2商务拜访 3电话沟通 4线上会议" })
|
||||
visitMode!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, defaultValue: 3, field: "priority", comment: "优先级:1特急P1 2加急P2 3常规P3" })
|
||||
priority!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "subject", comment: "拜访主题/目的" })
|
||||
subject!: string;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "plan_time", comment: "计划拜访开始时间" })
|
||||
planTime!: Date;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "plan_end_time", comment: "计划拜访结束时间" })
|
||||
planEndTime!: Date;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "checkin_time", comment: "到场签到时间" })
|
||||
checkinTime!: Date;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, defaultValue: 0, field: "checkin_status", comment: "签到状态:0未签到 1已签到" })
|
||||
checkinStatus!: number;
|
||||
|
||||
@Column({ type: DataType.DECIMAL(10, 6), field: "checkin_lng", comment: "签到经度" })
|
||||
checkinLng!: number;
|
||||
|
||||
@Column({ type: DataType.DECIMAL(10, 6), field: "checkin_lat", comment: "签到纬度" })
|
||||
checkinLat!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "space_desc", comment: "客户空间性质/场所描述" })
|
||||
spaceDesc!: string;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, field: "intent_level", comment: "意向评级:1=A高意向 2=B中意向 3=C低意向" })
|
||||
intentLevel!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "intent_desc", comment: "现场意向说明" })
|
||||
intentDesc!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(512), field: "demands", comment: "诉求痛点(逗号分隔)" })
|
||||
demands!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "companion", comment: "陪同人员" })
|
||||
companion!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "reception_person", comment: "客户接待人" })
|
||||
receptionPerson!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "reception_title", comment: "接待人职务" })
|
||||
receptionTitle!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(20), field: "reception_phone", comment: "接待人联系方式" })
|
||||
receptionPhone!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "result_rating", comment: "现场成果评级/结论" })
|
||||
resultRating!: string;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, defaultValue: 0, field: "status", comment: "状态:0待执行 1已完成待复核 2取证待技术复核 3已立项转勘察 4已归档 5已取消" })
|
||||
status!: number;
|
||||
|
||||
@Column({ type: DataType.DATEONLY, field: "next_visit_time", comment: "预计下次回访日期" })
|
||||
nextVisitTime!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "next_action", comment: "下一步行动目标" })
|
||||
nextAction!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(1024), field: "photos", comment: "现场照片对象key(JSON数组字符串)" })
|
||||
photos!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "survey_order_id", comment: "关联勘察单ID" })
|
||||
surveyOrderId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "project_no", comment: "关联工程编号" })
|
||||
projectNo!: string;
|
||||
|
||||
@BelongsTo(() => Customer)
|
||||
customer!: Customer;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
}
|
||||
|
||||
@UpdatedAt
|
||||
updated_at!: Date;
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import SysOperationLog from '../entity/sysOperationLog.entity';
|
||||
|
||||
@Provide()
|
||||
export class LogMiddleware implements IMiddleware<Context, NextFunction> {
|
||||
// 中间件匹配范围:全局生效,跳过日志查询、健康检查与登录(登录由内部记录)
|
||||
ignore = ['/api/logs/*', '/api/health', '/api/auth/login'];
|
||||
// 中间件匹配范围:全局生效,跳过日志查询、健康检查、登录(登录由内部记录)与文件上传(multipart 无记录价值)
|
||||
ignore = ['/api/logs/*', '/api/health', '/api/auth/login', '/api/upload/*'];
|
||||
|
||||
@InjectRepository(SysOperationLog)
|
||||
logModel!: typeof SysOperationLog;
|
||||
|
||||
@@ -17,6 +17,7 @@ const PERMISSION_MAP: Record<string, string> = {
|
||||
'/api/customer-departments': 'customer:department',
|
||||
'/api/customer-accounts': 'customer:account',
|
||||
'/api/customer-visits': 'customer:visit',
|
||||
'/api/upload': 'customer:visit',
|
||||
'/api/customer-buildings': 'customer:building',
|
||||
'/api/customer-rooms': 'customer:room',
|
||||
'/api/survey-orders': 'project:survey',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+539
-162
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user