This commit is contained in:
wk authored and trae-solo-agent committed 2026-10-25 02:35:55 +08:00
commit 318317c4a5
13 files changed
+687 -6

No files matched your search

+1
View File
@@ -1 +1,2 @@
*.zip
preview-qr.png
+1
View File
@@ -0,0 +1 @@
[1024/162159.849:ERROR:registration_protocol_win.cc(102)] CreateFile: 系统找不到指定的文件。 (0x2)
+4
View File
@@ -18,6 +18,10 @@ RUSTFS_BUCKET_TEST=xintong-test
# 当前生效的存储桶:生产=xintong,本地测试桶在 .env.local 中覆盖为 xintong-test
RUSTFS_BUCKET=xintong
# 微信小程序配置(静默登录 code2session)
WX_APPID=wxd582e7ae4c9e1d14
WX_SECRET=37bb9fcd1a9c9e903101364d6b780e93
# Redis 配置(与 PostgreSQL / RustFS 同机部署)
REDIS_HOST=222.243.55.42
REDIS_PORT=26678
+4
View File
@@ -7,3 +7,7 @@ PG_PASSWORD=postgres
# 本地开发使用测试桶(RustFS 仍复用服务器上的同一套地址与密钥)
RUSTFS_BUCKET=xintong-test
# 微信小程序配置(静默登录 code2session)
WX_APPID=wxd582e7ae4c9e1d14
WX_SECRET=37bb9fcd1a9c9e903101364d6b780e93
+6
View File
@@ -83,6 +83,12 @@ export default {
refreshExpiresIn: "7d"
},
// 微信小程序(静默登录 code2session)
wx: {
appId: process.env.WX_APPID || "",
secret: process.env.WX_SECRET || ""
},
// multipart/form-data 解析配置(现场照片上传)
upload: {
mode: "file",
+3 -2
View File
@@ -7,6 +7,7 @@ import * as Upload from "@midwayjs/upload";
import { join } from "path";
import { AuthMiddleware } from "./middleware/auth.middleware";
import { WorkerAuthMiddleware } from "./middleware/worker_auth.middleware";
import { CustomerAuthMiddleware } from "./middleware/customer_auth.middleware";
import { RbacMiddleware } from "./middleware/rbac.middleware";
import { LogMiddleware } from "./middleware/log.middleware";
import { JobSchedulerService } from "./service/job_scheduler.service";
@@ -20,8 +21,8 @@ export class ContainerConfigLifeCycle implements ILifeCycle {
app!: Koa.Application;
async onReady() {
// 注册全局中间件(顺序:电工鉴权 → 鉴权 → 权限 → 日志)
this.app.useMiddleware([WorkerAuthMiddleware, AuthMiddleware, RbacMiddleware, LogMiddleware]);
// 注册全局中间件(顺序:电工鉴权 → 小程序客户鉴权 → 管理端鉴权 → 权限 → 日志)
this.app.useMiddleware([WorkerAuthMiddleware, CustomerAuthMiddleware, AuthMiddleware, RbacMiddleware, LogMiddleware]);
try {
const scheduler = await this.app.getApplicationContext().getAsync(JobSchedulerService);
await scheduler.start();
@@ -0,0 +1,91 @@
import { Body, Controller, Get, Inject, Param, Post, Put, Query, Files } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { UploadFileInfo } from "@midwayjs/upload";
import { CustomerAppService } from "../service/customer_app.service";
import { UploadService } from "../service/upload.service";
import {
CustomerAcceptRepairDTO,
CustomerCreateRepairDTO,
CustomerLoginDTO,
CustomerWxBindDTO,
CustomerWxLoginDTO
} from "../dto/customer_app.dto";
@Controller("/api/customer-app")
export class CustomerAppController {
@Inject()
ctx!: Context;
@Inject()
customerAppService!: CustomerAppService;
@Inject()
uploadService!: UploadService;
// 账号密码登录
@Post("/auth/login")
async login(@Body() dto: CustomerLoginDTO) {
return this.customerAppService.login(dto.phone, dto.password);
}
// 微信静默登录(未绑定返回 bindToken)
@Post("/auth/wx-login")
async wxLogin(@Body() dto: CustomerWxLoginDTO) {
return this.customerAppService.wxLogin(dto.code);
}
// 微信首次绑定:手机号 + 密码 + bindToken
@Post("/auth/wx-bind")
async wxBind(@Body() dto: CustomerWxBindDTO) {
return this.customerAppService.wxBind(dto.bindToken, dto.phone, dto.password);
}
// 我的:账号、客户、统计、客户经理
@Get("/profile")
async profile() {
return this.customerAppService.profile(this.currentAccountId());
}
// 报修工单列表(按状态页签过滤)
@Get("/repairs")
async listRepairs(
@Query("tab") tab: string,
@Query("page") page: string,
@Query("pageSize") pageSize: string
) {
return this.customerAppService.listRepairs(this.currentAccountId(), tab, Number(page) || 1, Number(pageSize) || 20);
}
// 客户提交报修,落库为待处理工单
@Post("/repairs")
async createRepair(@Body() dto: CustomerCreateRepairDTO) {
return this.customerAppService.createRepair(this.currentAccountId(), dto);
}
// 客户验收并评价
@Put("/repairs/:id/accept")
async acceptRepair(@Param("id") id: string, @Body() dto: CustomerAcceptRepairDTO) {
return this.customerAppService.acceptRepair(this.currentAccountId(), Number(id), dto.rating);
}
// 报修照片上传
@Post("/upload/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 };
}
}
private currentAccountId(): number {
const customer = this.ctx.state.customer;
if (!customer?.accountId) {
throw new Error("未登录");
}
return Number(customer.accountId);
}
}
+44
View File
@@ -0,0 +1,44 @@
import {
IsArray,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength
} from "class-validator";
// 账号密码登录
export class CustomerLoginDTO {
@IsString() @Matches(/^1\d{10}$/, { message: "请输入正确的手机号" }) phone!: string;
@IsString() @MinLength(6) @MaxLength(32) password!: string;
}
// 微信静默登录
export class CustomerWxLoginDTO {
@IsString() @IsNotEmpty() code!: string;
}
// 微信首次绑定(未绑定 openid 时,用手机号密码完成绑定)
export class CustomerWxBindDTO {
@IsString() @IsNotEmpty() bindToken!: string;
@IsString() @Matches(/^1\d{10}$/, { message: "请输入正确的手机号" }) phone!: string;
@IsString() @MinLength(6) @MaxLength(32) password!: string;
}
// 发起报修
export class CustomerCreateRepairDTO {
@IsString() @MaxLength(256) location!: string;
@IsString() @MaxLength(2000) repairContent!: string;
@IsOptional() @IsArray() photos?: string[];
@IsOptional() @IsInt() buildingId?: number;
@IsOptional() @IsString() @MaxLength(16) floorNo?: string;
}
// 验收评价
export class CustomerAcceptRepairDTO {
@IsInt() @Min(1) @Max(5) rating!: number;
}
+2 -2
View File
@@ -5,8 +5,8 @@ import { TokenUtil } from '../util/token';
@Provide()
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
// 中间件匹配范围:全局生效,但跳过公开接口、ICC 事件回调与电工 APP 接口(电工 APP 走独立鉴权)
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*', '/api/icc-event/callback'];
// 中间件匹配范围:全局生效,但跳过公开接口、ICC 事件回调、电工 APP 与小程序客户接口(各自走独立鉴权)
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*', '/api/customer-app/*', '/api/icc-event/callback'];
@Inject()
tokenUtil!: TokenUtil;
@@ -0,0 +1,52 @@
import { Middleware, IMiddleware } from "@midwayjs/core";
import { Context, NextFunction } from "@midwayjs/koa";
import { Provide, Inject } from "@midwayjs/core";
import { TokenUtil } from "../util/token";
// 小程序客户侧独立鉴权:仅作用于 /api/customer-app/*,
// 登录、微信登录与微信绑定接口无需鉴权。
@Provide()
export class CustomerAuthMiddleware implements IMiddleware<Context, NextFunction> {
match = ["/api/customer-app/*"];
@Inject()
tokenUtil!: TokenUtil;
resolve() {
return async (ctx: Context, next: NextFunction) => {
if (ctx.path.startsWith("/api/customer-app/auth/")) {
await next();
return;
}
const authHeader = ctx.header.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
ctx.status = 401;
ctx.body = { code: 401, message: "未登录或登录已过期" };
return;
}
const token = authHeader.substring(7);
let decoded: any;
try {
decoded = this.tokenUtil.verifyCustomerToken(token);
} catch (error: any) {
ctx.status = 401;
ctx.body = {
code: 401,
message: error.name === "TokenExpiredError" ? "登录已过期,请重新登录" : "未登录或登录已过期",
expired: error.name === "TokenExpiredError"
};
return;
}
ctx.state.customer = {
accountId: Number(decoded.accountId),
customerId: Number(decoded.customerId),
phone: decoded.phone
};
await next();
};
}
}
+2 -2
View File
@@ -54,10 +54,10 @@ const ADMIN_ROLE_CODE = "ADMIN";
@Provide()
export class RbacMiddleware implements IMiddleware<Context, NextFunction> {
// 中间件匹配范围:全局生效,跳过公开接口、ICC 事件回调(URL token 校验)、仪表盘与电工 APP 接口
// 中间件匹配范围:全局生效,跳过公开接口、ICC 事件回调(URL token 校验)、仪表盘、电工 APP 与小程序客户接口
// /api/workers/options 与 /api/workers/recommend 放行:业务员在「项目勘察派单」页
// 仍可调用电工下拉与推荐接口,不受 dispatch:worker 权限限制(本次需求有意设计)
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/workers/options", "/api/workers/recommend", "/api/icc-event/callback"];
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/customer-app/*", "/api/workers/options", "/api/workers/recommend", "/api/icc-event/callback"];
@Inject()
permissionService!: PermissionService;
+444
View File
@@ -0,0 +1,444 @@
import { Config, Inject, Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { request } from "undici";
import CustomerAccount from "../entity/customer_account.entity";
import Customer from "../entity/customer.entity";
import CustomerBuilding from "../entity/customer_building.entity";
import CustomerCircuit from "../entity/customer_circuit.entity";
import CustomerEquipment from "../entity/customer_equipment.entity";
import Contract from "../entity/contract.entity";
import RepairRequest from "../entity/repair_request.entity";
import RepairDispatch from "../entity/repair_dispatch.entity";
import Worker from "../entity/worker.entity";
import SysUser from "../entity/sysUser.entity";
import { TokenUtil } from "../util/token";
import { comparePassword } from "../util/crypto";
import { UploadService } from "./upload.service";
// 小程序报修页签:待接单/处置中/待确认验收/已完成
export type CustomerRepairTab = "all" | "pending" | "processing" | "acceptance" | "completed";
@Provide()
export class CustomerAppService {
@Config("wx")
wxConfig!: { appId: string; secret: string };
@Inject()
tokenUtil!: TokenUtil;
@Inject()
uploadService!: UploadService;
@InjectRepository(CustomerAccount)
accountModel!: Repository<CustomerAccount>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
@InjectRepository(CustomerCircuit)
circuitModel!: Repository<CustomerCircuit>;
@InjectRepository(CustomerEquipment)
equipmentModel!: Repository<CustomerEquipment>;
@InjectRepository(Contract)
contractModel!: Repository<Contract>;
@InjectRepository(RepairRequest)
requestModel!: Repository<RepairRequest>;
@InjectRepository(RepairDispatch)
dispatchModel!: Repository<RepairDispatch>;
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(SysUser)
sysUserModel!: Repository<SysUser>;
// ================= 登录 =================
async login(phone: string, password: string): Promise<any> {
const account = await this.findActiveAccount({ phone });
if (!account) return { code: 400, message: "账号不存在或已被禁用", data: null };
const valid = await comparePassword(password, account.passwordHash);
if (!valid) return { code: 400, message: "密码错误", data: null };
await account.update({ lastLoginAt: new Date() });
return this.buildLoginResult(account, "登录成功");
}
// 微信静默登录:已绑定 openid 直接登录,未绑定返回 bindToken 供绑定
async wxLogin(code: string): Promise<any> {
if (!this.wxConfig?.appId || !this.wxConfig?.secret) {
return { code: 503, message: "微信登录未配置,请使用账号密码登录", data: null };
}
let openid = "";
try {
const url =
"https://api.weixin.qq.com/sns/jscode2session" +
`?appid=${encodeURIComponent(this.wxConfig.appId)}` +
`&secret=${encodeURIComponent(this.wxConfig.secret)}` +
`&js_code=${encodeURIComponent(code)}` +
"&grant_type=authorization_code";
const res = await request(url);
const body: any = await res.body.json();
if (body.errcode) {
return { code: 400, message: `微信登录失败:${body.errmsg || body.errcode}`, data: null };
}
openid = body.openid || "";
} catch (err: any) {
return { code: 502, message: `微信登录请求失败:${err?.message || "网络异常"}`, data: null };
}
if (!openid) return { code: 400, message: "微信登录失败:未返回 openid", data: null };
const account = await this.findActiveAccount({ wxOpenid: openid });
if (!account) {
return {
code: 200,
message: "微信尚未绑定客户账号",
data: { needBind: true, bindToken: this.tokenUtil.generateWxBindToken(openid) }
};
}
await account.update({ lastLoginAt: new Date() });
return this.buildLoginResult(account, "登录成功");
}
// 微信首次绑定:校验账号密码后写入 openid 并直接登录
async wxBind(bindToken: string, phone: string, password: string): Promise<any> {
let decoded: any;
try {
decoded = this.tokenUtil.verifyWxBindToken(bindToken);
} catch {
return { code: 400, message: "绑定凭据已失效,请重新进入小程序", data: null };
}
const openid = String(decoded.openid || "");
const account = await this.findActiveAccount({ phone }, false);
if (!account) return { code: 400, message: "账号不存在或已被禁用", data: null };
const valid = await comparePassword(password, account.passwordHash);
if (!valid) return { code: 400, message: "密码错误", data: null };
if (account.wxOpenid && account.wxOpenid !== openid) {
return { code: 400, message: "该账号已绑定其他微信,请联系管理员解绑", data: null };
}
const occupied = await this.accountModel.findOne({ where: { wxOpenid: openid } });
if (occupied && Number(occupied.id) !== Number(account.id)) {
return { code: 400, message: "该微信已绑定其他客户账号", data: null };
}
await account.update({ wxOpenid: openid, lastLoginAt: new Date() });
const fresh = await this.findActiveAccount({ phone });
return this.buildLoginResult(fresh || account, "微信绑定并登录成功");
}
// ================= 我的 =================
async profile(accountId: number): Promise<any> {
const account = await this.accountModel.findByPk(accountId, { include: [Customer] });
if (!account || Number(account.status) !== 1) {
return { code: 401, message: "账号不存在或已被禁用", data: null };
}
const customer = (account as any).customer as Customer;
if (!customer) return { code: 404, message: "客户信息不存在", data: null };
const customerId = Number(customer.id);
const [buildingCount, circuitCount, gatewayCount, contract] = await Promise.all([
this.buildingModel.count({ where: { customerId } }),
this.circuitModel.count({ where: { customerId } }),
this.equipmentModel.count({
where: {
customerId,
[Op.or]: [{ equipType: { [Op.like]: "%网关%" } }, { equipName: { [Op.like]: "%网关%" } }]
}
}),
this.contractModel.findOne({
where: { customerId, status: 1 },
order: [["startDate", "ASC"]]
})
]);
let startDate = (customer as any).created_at ? new Date((customer as any).created_at) : new Date();
if (contract?.startDate) {
const parsed = new Date(`${contract.startDate}T00:00:00+08:00`);
if (!Number.isNaN(parsed.getTime())) startDate = parsed;
}
const safetyDays = Math.max(1, Math.floor((Date.now() - startDate.getTime()) / 86400000));
let salesman: any = null;
if (customer.salesmanId) {
const user = await this.sysUserModel.findByPk(Number(customer.salesmanId), {
attributes: ["id", "realName", "phone"]
});
if (user) salesman = { id: user.id, name: user.realName || "客户经理", phone: user.phone || "" };
}
return {
code: 200,
message: "获取成功",
data: {
account: this.toAccountVO(account),
customer: this.toCustomerVO(customer),
stats: {
buildingCount,
circuitCount,
gatewayCount,
// 暂无网关在线遥测数据,按已接入网关数展示
gatewayOnline: gatewayCount,
safetyDays
},
salesman,
hotline: "400-880-9119"
}
};
}
// ================= 报修 =================
async listRepairs(accountId: number, tab: string = "all", page: number = 1, pageSize: number = 20): Promise<any> {
const account = await this.accountModel.findByPk(accountId);
if (!account) return { code: 401, message: "账号不存在", data: null };
const customerId = Number(account.customerId);
const [requests, dispatches] = await Promise.all([
this.requestModel.findAll({ where: { customerId }, order: [["requestTime", "DESC"]] }),
this.dispatchModel.findAll({
where: { customerId },
include: [{ model: Worker, attributes: ["id", "workerName", "phone", "workerTitle", "rating", "certInfo"] }],
order: [["id", "DESC"]]
})
]);
// 同一报修单取最新一次派单
const latestDispatch = new Map<number, RepairDispatch>();
for (const row of dispatches) {
const repairId = Number(row.repairId);
if (!latestDispatch.has(repairId)) latestDispatch.set(repairId, row);
}
const all = requests.map((item) => this.toRepairVO(item, latestDispatch.get(Number(item.id))));
// 报修照片与派单处理照片均以对象 key 入库,读取时统一换为预签名 URL
const keys = new Set<string>();
for (const item of all) {
for (const key of item.photoKeys) keys.add(key);
for (const key of item.processPhotoKeys) keys.add(key);
}
const urlMap = await this.uploadService.getPresignedUrlMap(Array.from(keys));
for (const item of all) {
item.photos = item.photoKeys.map((key: string) => urlMap[key] || key);
item.dispatch && (item.dispatch.processPhotos = item.processPhotoKeys.map((key: string) => urlMap[key] || key));
delete item.photoKeys;
delete item.processPhotoKeys;
}
const counts = { all: all.length, pending: 0, processing: 0, acceptance: 0, completed: 0 };
for (const item of all) counts[item.category as CustomerRepairTab] += 1;
const normalizedTab: CustomerRepairTab = ["all", "pending", "processing", "acceptance", "completed"].includes(tab)
? (tab as CustomerRepairTab)
: "all";
const filtered = normalizedTab === "all" ? all : all.filter((item) => item.category === normalizedTab);
const safePage = Math.max(1, Number(page) || 1);
const safeSize = Math.min(100, Math.max(1, Number(pageSize) || 20));
const list = filtered.slice((safePage - 1) * safeSize, safePage * safeSize);
return {
code: 200,
message: "获取报修工单成功",
data: { list, counts, tab: normalizedTab, total: filtered.length, page: safePage, pageSize: safeSize }
};
}
async createRepair(accountId: number, body: any): Promise<any> {
const account = await this.accountModel.findByPk(accountId, { include: [Customer] });
if (!account) return { code: 401, message: "账号不存在", data: null };
const customer = (account as any).customer as Customer;
const repairNo = await this.generateRepairNo();
const photos = Array.isArray(body.photos) ? body.photos.filter((p: any) => typeof p === "string").slice(0, 9) : [];
const record = await this.requestModel.create({
repairNo,
customerId: Number(account.customerId),
requester: customer?.contactPerson || account.phone,
requesterPhone: account.phone,
requestTime: new Date(),
buildingId: body.buildingId ? Number(body.buildingId) : undefined,
floorNo: body.floorNo || undefined,
location: String(body.location || "").trim(),
repairContent: String(body.repairContent || "").trim(),
photos,
status: 0
} as any);
return {
code: 200,
message: "报修已提交,等待调度派单",
data: {
id: record.id,
repairNo: record.repairNo,
status: record.status,
requestTime: record.requestTime
}
};
}
// 客户验收并评价:派单已完成且未评分 → 写入评分、报修单置为已完成
async acceptRepair(accountId: number, repairId: number, rating: number): Promise<any> {
const account = await this.accountModel.findByPk(accountId);
if (!account) return { code: 401, message: "账号不存在", data: null };
const req = await this.requestModel.findOne({
where: { id: repairId, customerId: Number(account.customerId) }
});
if (!req) return { code: 404, message: "报修工单不存在", data: null };
if (Number(req.status) === 2) return { code: 400, message: "该工单已验收", data: null };
const dispatch = await this.dispatchModel.findOne({
where: { repairId, customerId: Number(account.customerId) },
order: [["id", "DESC"]]
});
if (!dispatch) return { code: 400, message: "该工单尚未派单,暂不能验收", data: null };
if (Number(dispatch.status) !== 1) return { code: 400, message: "电工尚未完成处理,暂不能验收", data: null };
if (dispatch.rating !== null && dispatch.rating !== undefined) {
return { code: 400, message: "该工单已评价", data: null };
}
await dispatch.update({ rating } as any);
await req.update({ status: 2 } as any);
return {
code: 200,
message: "验收评价成功",
data: { repairId, dispatchId: dispatch.id, rating, status: 2 }
};
}
// ================= 内部方法 =================
private async findActiveAccount(where: any, withCustomer: boolean = true): Promise<CustomerAccount | null> {
return this.accountModel.findOne({
where,
include: withCustomer ? [Customer] : []
});
}
private buildLoginResult(account: CustomerAccount, message: string): any {
const customer = (account as any).customer as Customer;
const accessToken = this.tokenUtil.generateCustomerToken({
accountId: Number(account.id),
customerId: Number(account.customerId),
phone: account.phone
});
return {
code: 200,
message,
data: {
accessToken,
account: this.toAccountVO(account),
customer: customer ? this.toCustomerVO(customer) : null
}
};
}
private toAccountVO(account: CustomerAccount): any {
return {
id: Number(account.id),
phone: account.phone,
wxBound: !!account.wxOpenid,
lastLoginAt: account.lastLoginAt || null
};
}
private toCustomerVO(customer: Customer): any {
return {
id: Number(customer.id),
customerNo: customer.customerNo,
customerName: customer.customerName,
contactPerson: customer.contactPerson || "",
contactPhone: customer.contactPhone || "",
address: customer.address || "",
customerLevel: customer.customerLevel || "",
status: Number(customer.status)
};
}
private toRepairVO(req: RepairRequest, dispatch?: RepairDispatch): any {
const category = this.resolveCategory(Number(req.status), dispatch);
const worker = dispatch ? ((dispatch as any).worker as Worker) : null;
const photoKeys = Array.isArray(req.photos) ? req.photos.filter((p: any) => typeof p === "string") : [];
const processPhotoKeys =
dispatch && Array.isArray(dispatch.processPhotos)
? dispatch.processPhotos.filter((p: any) => typeof p === "string")
: [];
return {
id: Number(req.id),
repairNo: req.repairNo,
requestTime: req.requestTime,
location: req.location,
repairContent: req.repairContent,
floorNo: req.floorNo || "",
photos: photoKeys,
photoKeys,
processPhotoKeys,
status: Number(req.status),
category,
worker: worker
? {
id: Number(worker.id),
name: worker.workerName,
phone: worker.phone,
title: worker.workerTitle || "共享电工",
rating: worker.rating === null || worker.rating === undefined ? null : Number(worker.rating),
certInfo: worker.certInfo || ""
}
: null,
dispatch: dispatch
? {
id: Number(dispatch.id),
dispatchNo: dispatch.dispatchNo,
status: Number(dispatch.status),
processPhotos: processPhotoKeys,
rating: dispatch.rating === null || dispatch.rating === undefined ? null : Number(dispatch.rating),
completedAt: dispatch.completedAt || null,
requestTime: dispatch.requestTime || null
}
: null
};
}
private resolveCategory(status: number, dispatch?: RepairDispatch): CustomerRepairTab {
if (!dispatch) return status === 2 ? "completed" : "pending";
const dispatchStatus = Number(dispatch.status);
const rated = dispatch.rating !== null && dispatch.rating !== undefined;
if (dispatchStatus === 0) return "processing";
if (dispatchStatus === 1 && !rated) return "acceptance";
if (dispatchStatus === 1 && rated) return "completed";
return status === 2 ? "completed" : "pending";
}
// 报修单号:BX-YYYYMMDD-NNN(当日序号)
private async generateRepairNo(retry: number = 0): Promise<string> {
const now = new Date(Date.now() + 8 * 3600 * 1000);
const ymd = `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(
now.getUTCDate()
).padStart(2, "0")}`;
const prefix = `BX-${ymd}-`;
const last = await this.requestModel.findOne({
where: { repairNo: { [Op.like]: `${prefix}%` } },
order: [["repairNo", "DESC"]]
});
const seq = last ? (parseInt(String(last.repairNo).slice(-3), 10) || 0) + 1 : 1;
const repairNo = `${prefix}${String(seq).padStart(3, "0")}`;
const exists = await this.requestModel.findOne({ where: { repairNo } });
if (exists) {
if (retry < 1) return this.generateRepairNo(retry + 1);
throw new Error("报修单号生成冲突,请重试");
}
return repairNo;
}
}
+33
View File
@@ -72,6 +72,39 @@ export class TokenUtil {
return decoded;
}
// 生成小程序客户令牌:携带 userType=customer 以区分管理端/电工端 token
generateCustomerToken(payload: { accountId: number; customerId: number; phone: string }): string {
return jwt.sign({ ...payload, userType: "customer" }, this.jwtConfig.secret, {
expiresIn: this.jwtConfig.expiresIn as jwt.SignOptions["expiresIn"]
});
}
// 校验小程序客户令牌:除有效期/签名外,还需 userType 为 customer
verifyCustomerToken(token: string): any {
const decoded = this.verifyToken(token);
if (!decoded || decoded.userType !== "customer") {
const invalidError = new JsonWebTokenError("无效的认证令牌");
invalidError.name = "JsonWebTokenError";
throw invalidError;
}
return decoded;
}
// 微信静默登录未绑定时的临时凭据:仅用于绑定,10 分钟有效
generateWxBindToken(openid: string): string {
return jwt.sign({ openid, userType: "wx-bind" }, this.jwtConfig.secret, { expiresIn: "10m" });
}
verifyWxBindToken(token: string): any {
const decoded = this.verifyToken(token);
if (!decoded || decoded.userType !== "wx-bind") {
const invalidError = new JsonWebTokenError("无效的绑定凭据");
invalidError.name = "JsonWebTokenError";
throw invalidError;
}
return decoded;
}
decodeToken(token: string): any {
return jwt.decode(token);
}