From 4c48b04d5e2366166cdf6f78e3ea772ba1896f89 Mon Sep 17 00:00:00 2001 From: andyesfly <35127884@qq.com> Date: Sat, 24 Oct 2026 21:15:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E5=AE=A2=E6=88=B7=E7=AB=AF=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E4=B8=8E=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- debug.log | 1 + preview-qr.png | Bin 0 -> 47503 bytes server/.env | 4 + server/.env.local | 4 + server/src/config/config.default.ts | 6 + server/src/configuration.ts | 5 +- .../src/controller/customer_app.controller.ts | 91 ++++ server/src/dto/customer_app.dto.ts | 44 ++ server/src/middleware/auth.middleware.ts | 4 +- .../middleware/customer_auth.middleware.ts | 52 ++ server/src/middleware/rbac.middleware.ts | 2 +- server/src/service/customer_app.service.ts | 444 ++++++++++++++++++ server/src/util/token.ts | 33 ++ 13 files changed, 685 insertions(+), 5 deletions(-) create mode 100644 debug.log create mode 100644 preview-qr.png create mode 100644 server/src/controller/customer_app.controller.ts create mode 100644 server/src/dto/customer_app.dto.ts create mode 100644 server/src/middleware/customer_auth.middleware.ts create mode 100644 server/src/service/customer_app.service.ts diff --git a/debug.log b/debug.log new file mode 100644 index 0000000..dccf702 --- /dev/null +++ b/debug.log @@ -0,0 +1 @@ +[1024/162159.849:ERROR:registration_protocol_win.cc(102)] CreateFile: ϵͳÕҲ»µ½ָ¶¨µÄÎļþ¡£ (0x2) diff --git a/preview-qr.png b/preview-qr.png new file mode 100644 index 0000000000000000000000000000000000000000..d88f7633a75bdf22201811ccc2532a5b937891f7 GIT binary patch literal 47503 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 Rr(U(zyo>)MfA%wF|38q-hqeF! literal 0 HcmV?d00001 diff --git a/server/.env b/server/.env index 8225d21..4159300 100644 --- a/server/.env +++ b/server/.env @@ -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 diff --git a/server/.env.local b/server/.env.local index bf8cc79..570eff1 100644 --- a/server/.env.local +++ b/server/.env.local @@ -7,3 +7,7 @@ PG_PASSWORD=postgres # 本地开发使用测试桶(RustFS 仍复用服务器上的同一套地址与密钥) RUSTFS_BUCKET=xintong-test + +# 微信小程序配置(静默登录 code2session) +WX_APPID=wxd582e7ae4c9e1d14 +WX_SECRET=37bb9fcd1a9c9e903101364d6b780e93 diff --git a/server/src/config/config.default.ts b/server/src/config/config.default.ts index c8eabd8..c2f03f1 100644 --- a/server/src/config/config.default.ts +++ b/server/src/config/config.default.ts @@ -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", diff --git a/server/src/configuration.ts b/server/src/configuration.ts index e52b0a9..8dec9c5 100644 --- a/server/src/configuration.ts +++ b/server/src/configuration.ts @@ -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(); diff --git a/server/src/controller/customer_app.controller.ts b/server/src/controller/customer_app.controller.ts new file mode 100644 index 0000000..a575841 --- /dev/null +++ b/server/src/controller/customer_app.controller.ts @@ -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[]) { + 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); + } +} diff --git a/server/src/dto/customer_app.dto.ts b/server/src/dto/customer_app.dto.ts new file mode 100644 index 0000000..91aaef9 --- /dev/null +++ b/server/src/dto/customer_app.dto.ts @@ -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; +} diff --git a/server/src/middleware/auth.middleware.ts b/server/src/middleware/auth.middleware.ts index d55a911..60d1647 100644 --- a/server/src/middleware/auth.middleware.ts +++ b/server/src/middleware/auth.middleware.ts @@ -5,8 +5,8 @@ import { TokenUtil } from '../util/token'; @Provide() export class AuthMiddleware implements IMiddleware { - // 中间件匹配范围:全局生效,但跳过公开接口与电工 APP 接口(电工 APP 走独立鉴权) - ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*']; + // 中间件匹配范围:全局生效,但跳过公开接口、电工 APP 与小程序客户接口(各自走独立鉴权) + ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*', '/api/customer-app/*']; @Inject() tokenUtil!: TokenUtil; diff --git a/server/src/middleware/customer_auth.middleware.ts b/server/src/middleware/customer_auth.middleware.ts new file mode 100644 index 0000000..0f072bb --- /dev/null +++ b/server/src/middleware/customer_auth.middleware.ts @@ -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 { + 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(); + }; + } +} diff --git a/server/src/middleware/rbac.middleware.ts b/server/src/middleware/rbac.middleware.ts index 448cfb7..68d2489 100644 --- a/server/src/middleware/rbac.middleware.ts +++ b/server/src/middleware/rbac.middleware.ts @@ -51,7 +51,7 @@ export class RbacMiddleware implements IMiddleware { // 中间件匹配范围:全局生效,跳过公开接口、仪表盘与电工 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"]; + ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/customer-app/*", "/api/workers/options", "/api/workers/recommend"]; @Inject() permissionService!: PermissionService; diff --git a/server/src/service/customer_app.service.ts b/server/src/service/customer_app.service.ts new file mode 100644 index 0000000..4956b8d --- /dev/null +++ b/server/src/service/customer_app.service.ts @@ -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; + + @InjectRepository(CustomerBuilding) + buildingModel!: Repository; + + @InjectRepository(CustomerCircuit) + circuitModel!: Repository; + + @InjectRepository(CustomerEquipment) + equipmentModel!: Repository; + + @InjectRepository(Contract) + contractModel!: Repository; + + @InjectRepository(RepairRequest) + requestModel!: Repository; + + @InjectRepository(RepairDispatch) + dispatchModel!: Repository; + + @InjectRepository(Worker) + workerModel!: Repository; + + @InjectRepository(SysUser) + sysUserModel!: Repository; + + // ================= 登录 ================= + + async login(phone: string, password: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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(); + 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(); + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/server/src/util/token.ts b/server/src/util/token.ts index c8c555b..4f4d194 100644 --- a/server/src/util/token.ts +++ b/server/src/util/token.ts @@ -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); }