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 0000000..d88f763 Binary files /dev/null and b/preview-qr.png differ 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); }