feat: 新增微信小程序客户端鉴权与登录功能
This commit is contained in:
1 parent
9d3bb13ecf
commit
4c48b04d5e
13 files changed
+685
-5
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
[1024/162159.849:ERROR:registration_protocol_win.cc(102)] CreateFile: 系统找不到指定的文件。 (0x2)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -18,6 +18,10 @@ RUSTFS_BUCKET_TEST=xintong-test
|
|||||||
# 当前生效的存储桶:生产=xintong,本地测试桶在 .env.local 中覆盖为 xintong-test
|
# 当前生效的存储桶:生产=xintong,本地测试桶在 .env.local 中覆盖为 xintong-test
|
||||||
RUSTFS_BUCKET=xintong
|
RUSTFS_BUCKET=xintong
|
||||||
|
|
||||||
|
# 微信小程序配置(静默登录 code2session)
|
||||||
|
WX_APPID=wxd582e7ae4c9e1d14
|
||||||
|
WX_SECRET=37bb9fcd1a9c9e903101364d6b780e93
|
||||||
|
|
||||||
# Redis 配置(与 PostgreSQL / RustFS 同机部署)
|
# Redis 配置(与 PostgreSQL / RustFS 同机部署)
|
||||||
REDIS_HOST=222.243.55.42
|
REDIS_HOST=222.243.55.42
|
||||||
REDIS_PORT=26678
|
REDIS_PORT=26678
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ PG_PASSWORD=postgres
|
|||||||
|
|
||||||
# 本地开发使用测试桶(RustFS 仍复用服务器上的同一套地址与密钥)
|
# 本地开发使用测试桶(RustFS 仍复用服务器上的同一套地址与密钥)
|
||||||
RUSTFS_BUCKET=xintong-test
|
RUSTFS_BUCKET=xintong-test
|
||||||
|
|
||||||
|
# 微信小程序配置(静默登录 code2session)
|
||||||
|
WX_APPID=wxd582e7ae4c9e1d14
|
||||||
|
WX_SECRET=37bb9fcd1a9c9e903101364d6b780e93
|
||||||
@@ -83,6 +83,12 @@ export default {
|
|||||||
refreshExpiresIn: "7d"
|
refreshExpiresIn: "7d"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 微信小程序(静默登录 code2session)
|
||||||
|
wx: {
|
||||||
|
appId: process.env.WX_APPID || "",
|
||||||
|
secret: process.env.WX_SECRET || ""
|
||||||
|
},
|
||||||
|
|
||||||
// multipart/form-data 解析配置(现场照片上传)
|
// multipart/form-data 解析配置(现场照片上传)
|
||||||
upload: {
|
upload: {
|
||||||
mode: "file",
|
mode: "file",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as Upload from "@midwayjs/upload";
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { AuthMiddleware } from "./middleware/auth.middleware";
|
import { AuthMiddleware } from "./middleware/auth.middleware";
|
||||||
import { WorkerAuthMiddleware } from "./middleware/worker_auth.middleware";
|
import { WorkerAuthMiddleware } from "./middleware/worker_auth.middleware";
|
||||||
|
import { CustomerAuthMiddleware } from "./middleware/customer_auth.middleware";
|
||||||
import { RbacMiddleware } from "./middleware/rbac.middleware";
|
import { RbacMiddleware } from "./middleware/rbac.middleware";
|
||||||
import { LogMiddleware } from "./middleware/log.middleware";
|
import { LogMiddleware } from "./middleware/log.middleware";
|
||||||
import { JobSchedulerService } from "./service/job_scheduler.service";
|
import { JobSchedulerService } from "./service/job_scheduler.service";
|
||||||
@@ -20,8 +21,8 @@ export class ContainerConfigLifeCycle implements ILifeCycle {
|
|||||||
app!: Koa.Application;
|
app!: Koa.Application;
|
||||||
|
|
||||||
async onReady() {
|
async onReady() {
|
||||||
// 注册全局中间件(顺序:电工鉴权 → 鉴权 → 权限 → 日志)
|
// 注册全局中间件(顺序:电工鉴权 → 小程序客户鉴权 → 管理端鉴权 → 权限 → 日志)
|
||||||
this.app.useMiddleware([WorkerAuthMiddleware, AuthMiddleware, RbacMiddleware, LogMiddleware]);
|
this.app.useMiddleware([WorkerAuthMiddleware, CustomerAuthMiddleware, AuthMiddleware, RbacMiddleware, LogMiddleware]);
|
||||||
try {
|
try {
|
||||||
const scheduler = await this.app.getApplicationContext().getAsync(JobSchedulerService);
|
const scheduler = await this.app.getApplicationContext().getAsync(JobSchedulerService);
|
||||||
await scheduler.start();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import { TokenUtil } from '../util/token';
|
|||||||
|
|
||||||
@Provide()
|
@Provide()
|
||||||
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
|
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
|
||||||
// 中间件匹配范围:全局生效,但跳过公开接口与电工 APP 接口(电工 APP 走独立鉴权)
|
// 中间件匹配范围:全局生效,但跳过公开接口、电工 APP 与小程序客户接口(各自走独立鉴权)
|
||||||
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*'];
|
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*', '/api/customer-app/*'];
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
tokenUtil!: TokenUtil;
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export class RbacMiddleware implements IMiddleware<Context, NextFunction> {
|
|||||||
// 中间件匹配范围:全局生效,跳过公开接口、仪表盘与电工 APP 接口
|
// 中间件匹配范围:全局生效,跳过公开接口、仪表盘与电工 APP 接口
|
||||||
// /api/workers/options 与 /api/workers/recommend 放行:业务员在「项目勘察派单」页
|
// /api/workers/options 与 /api/workers/recommend 放行:业务员在「项目勘察派单」页
|
||||||
// 仍可调用电工下拉与推荐接口,不受 dispatch:worker 权限限制(本次需求有意设计)
|
// 仍可调用电工下拉与推荐接口,不受 dispatch:worker 权限限制(本次需求有意设计)
|
||||||
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/workers/options", "/api/workers/recommend"];
|
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/customer-app/*", "/api/workers/options", "/api/workers/recommend"];
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
permissionService!: PermissionService;
|
permissionService!: PermissionService;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,39 @@ export class TokenUtil {
|
|||||||
return decoded;
|
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 {
|
decodeToken(token: string): any {
|
||||||
return jwt.decode(token);
|
return jwt.decode(token);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user