R18-1/R18-3: 接入企业微信入口绑定与免登回退
这个提交包含在:
+82
-4
@@ -29,6 +29,85 @@ function getRequestOrigin(request) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOriginValue(value) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(trimmed).origin.replace(/\/+$/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowedOriginContext(config, request) {
|
||||
const allowedOrigins = new Set();
|
||||
const allowedHosts = new Set();
|
||||
|
||||
for (const item of String(config.appOrigin || "").split(",").map((value) => value.trim()).filter(Boolean)) {
|
||||
const normalized = normalizeOriginValue(item);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
allowedOrigins.add(normalized);
|
||||
try {
|
||||
allowedHosts.add(new URL(normalized).host);
|
||||
} catch {
|
||||
// ignore invalid app origin host
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((value) => value.trim()).filter(Boolean)) {
|
||||
allowedHosts.add(item);
|
||||
}
|
||||
|
||||
if (!allowedOrigins.size) {
|
||||
const fallbackOrigin = normalizeOriginValue(resolvePrimaryAppOrigin(config, request));
|
||||
if (fallbackOrigin) {
|
||||
allowedOrigins.add(fallbackOrigin);
|
||||
try {
|
||||
allowedHosts.add(new URL(fallbackOrigin).host);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allowedOrigins, allowedHosts };
|
||||
}
|
||||
|
||||
function isRequestOriginAllowed(config, request) {
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
if (!requestOrigin) {
|
||||
return { allowed: true, requestOrigin: null, expectedOrigins: [] };
|
||||
}
|
||||
|
||||
const normalizedRequestOrigin = normalizeOriginValue(requestOrigin);
|
||||
if (!normalizedRequestOrigin) {
|
||||
return { allowed: false, requestOrigin, expectedOrigins: [] };
|
||||
}
|
||||
|
||||
const { allowedOrigins, allowedHosts } = parseAllowedOriginContext(config, request);
|
||||
if (allowedOrigins.has(normalizedRequestOrigin)) {
|
||||
return { allowed: true, requestOrigin: normalizedRequestOrigin, expectedOrigins: Array.from(allowedOrigins) };
|
||||
}
|
||||
|
||||
try {
|
||||
const host = new URL(normalizedRequestOrigin).host;
|
||||
if (allowedHosts.has(host)) {
|
||||
return { allowed: true, requestOrigin: normalizedRequestOrigin, expectedOrigins: Array.from(allowedOrigins) };
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid host
|
||||
}
|
||||
|
||||
return { allowed: false, requestOrigin: normalizedRequestOrigin, expectedOrigins: Array.from(allowedOrigins) };
|
||||
}
|
||||
|
||||
function refreshCookieOptions(config) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
@@ -361,16 +440,15 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const expectedOrigin = fastify.config.appOrigin;
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
if (expectedOrigin && requestOrigin && requestOrigin !== expectedOrigin) {
|
||||
const originCheck = isRequestOriginAllowed(fastify.config, request);
|
||||
if (!originCheck.allowed) {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: session.employee_id,
|
||||
action: "AUTH_REFRESH_ORIGIN_MISMATCH",
|
||||
targetType: "employee",
|
||||
targetId: session.employee_id,
|
||||
extra: { expectedOrigin, requestOrigin },
|
||||
extra: { expectedOrigins: originCheck.expectedOrigins, requestOrigin: originCheck.requestOrigin },
|
||||
}),
|
||||
});
|
||||
throw new AppError({
|
||||
|
||||
@@ -19,6 +19,7 @@ async function meRoutes(fastify) {
|
||||
direct_manager_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
wecom_identity_binding: { select: { external_user_id: true, created_at: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,6 +34,7 @@ async function meRoutes(fastify) {
|
||||
directManagerId: employee?.direct_manager_id ?? null,
|
||||
status: employee?.status ?? "active",
|
||||
roleCodes,
|
||||
wecomBinding: employee?.wecom_identity_binding ? { externalUserId: employee.wecom_identity_binding.external_user_id, createdAt: employee.wecom_identity_binding.created_at?.toISOString?.() ?? null } : null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { randomRefreshToken, sha256Hex } = require("../utils/crypto");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { sendData } = require("../utils/reply");
|
||||
|
||||
const BIND_COOKIE_NAME = "wecom_bind";
|
||||
const DATE_WINDOW_MS = 10 * 60 * 1000;
|
||||
const QUERY_SCHEMA = z.object({
|
||||
returnTo: z.string().optional(),
|
||||
mode: z.enum(["login", "bind"]).optional(),
|
||||
});
|
||||
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
|
||||
|
||||
function refreshCookieOptions(config) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: config.cookieSecure,
|
||||
path: "/api/auth",
|
||||
maxAge: config.refreshTtlSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function bindCookieOptions(config) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.cookieSecure,
|
||||
path: "/api/auth/wecom",
|
||||
maxAge: DATE_WINDOW_MS / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePrimaryAppOrigin(config, request) {
|
||||
const configured = typeof config.appOrigin === "string" ? config.appOrigin : "";
|
||||
const first = configured
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)[0];
|
||||
if (first) return first;
|
||||
|
||||
const host = request.headers.host ?? "127.0.0.1:3000";
|
||||
const protocol = request.protocol ?? "http";
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
function parseAllowedReturnHosts(config, request) {
|
||||
const hosts = new Set();
|
||||
const appOrigins = String(config.appOrigin || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
for (const item of appOrigins) {
|
||||
try {
|
||||
hosts.add(new URL(item).host);
|
||||
} catch {
|
||||
// ignore invalid origin
|
||||
}
|
||||
}
|
||||
for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((v) => v.trim()).filter(Boolean)) {
|
||||
hosts.add(item);
|
||||
}
|
||||
if (!hosts.size) {
|
||||
try {
|
||||
hosts.add(new URL(resolvePrimaryAppOrigin(config, request)).host);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
function sanitizeReturnTo(rawValue, config, request) {
|
||||
if (typeof rawValue !== "string") {
|
||||
return "/portal";
|
||||
}
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return "/portal";
|
||||
}
|
||||
if (trimmed.startsWith("/")) {
|
||||
return trimmed.startsWith("//") ? "/portal" : trimmed;
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const allowedHosts = parseAllowedReturnHosts(config, request);
|
||||
if (!allowedHosts.has(url.host)) {
|
||||
return "/portal";
|
||||
}
|
||||
return `${url.pathname || "/portal"}${url.search || ""}${url.hash || ""}`;
|
||||
} catch {
|
||||
return "/portal";
|
||||
}
|
||||
}
|
||||
|
||||
function assertWecomEnabled(config) {
|
||||
if (!config.wecomEnabled || !config.wecomOauthEnabled) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!config.wecomCorpId || !config.wecomAgentId || !config.wecomRedirectUri) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { wecom: "missing_required_config" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getWecomAccessToken(config) {
|
||||
if (!config.wecomAgentSecret) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { wecom: "missing_agent_secret" },
|
||||
});
|
||||
}
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/gettoken");
|
||||
url.searchParams.set("corpid", config.wecomCorpId);
|
||||
url.searchParams.set("corpsecret", config.wecomAgentSecret);
|
||||
const response = await fetch(url);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0 || !payload.access_token) {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "token_exchange_failed", payload },
|
||||
});
|
||||
}
|
||||
return String(payload.access_token);
|
||||
}
|
||||
|
||||
async function exchangeWecomCode(config, code) {
|
||||
const accessToken = await getWecomAccessToken(config);
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo");
|
||||
url.searchParams.set("access_token", accessToken);
|
||||
url.searchParams.set("code", code);
|
||||
const response = await fetch(url);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "userinfo_exchange_failed", payload },
|
||||
});
|
||||
}
|
||||
return {
|
||||
externalUserId: typeof payload.UserId === "string" && payload.UserId.trim() ? payload.UserId.trim() : null,
|
||||
externalOpenUserId: typeof payload.OpenId === "string" && payload.OpenId.trim() ? payload.OpenId.trim() : null,
|
||||
deviceId: typeof payload.DeviceId === "string" ? payload.DeviceId : null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWecomAuthorizeUrl(config, state) {
|
||||
const redirectUri = encodeURIComponent(config.wecomRedirectUri);
|
||||
const scope = "snsapi_base";
|
||||
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`;
|
||||
}
|
||||
|
||||
async function createAuthenticatedSession({ prisma, request, config, employeeId, tokenVersion, deviceFingerprint, auditAction, extra = {} }) {
|
||||
const refreshToken = randomRefreshToken();
|
||||
const refreshTokenHash = sha256Hex(refreshToken);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + config.refreshTtlSeconds * 1000);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const activeSessions = await tx.authSession.findMany({
|
||||
where: {
|
||||
employee_id: employeeId,
|
||||
revoked_at: null,
|
||||
expires_at: { gt: now },
|
||||
},
|
||||
orderBy: { created_at: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
const maxSessions = 5;
|
||||
const allowedExisting = maxSessions - 1;
|
||||
if (activeSessions.length > allowedExisting) {
|
||||
const revokeCount = activeSessions.length - allowedExisting;
|
||||
const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id);
|
||||
await tx.authSession.updateMany({ where: { id: { in: idsToRevoke } }, data: { revoked_at: now } });
|
||||
}
|
||||
|
||||
await tx.authSession.create({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
refresh_token_hash: refreshTokenHash,
|
||||
device_fingerprint: deviceFingerprint,
|
||||
ip_address: request.ip,
|
||||
user_agent: String(request.headers["user-agent"] ?? ""),
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: employeeId,
|
||||
action: auditAction,
|
||||
targetType: "employee",
|
||||
targetId: employeeId,
|
||||
extra,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
refreshToken,
|
||||
accessToken: request.server.issueAccessToken({ id: employeeId, token_version: tokenVersion }),
|
||||
};
|
||||
}
|
||||
|
||||
async function wecomAuthRoutes(fastify) {
|
||||
fastify.get("/wecom/config", async (request, reply) => {
|
||||
const enabled = Boolean(fastify.config.wecomEnabled && fastify.config.wecomOauthEnabled && fastify.config.wecomCorpId && fastify.config.wecomAgentId && fastify.config.wecomRedirectUri);
|
||||
return sendData(reply, request, {
|
||||
enabled,
|
||||
corpId: enabled ? fastify.config.wecomCorpId : "",
|
||||
agentId: enabled ? fastify.config.wecomAgentId : "",
|
||||
oauthEnabled: enabled,
|
||||
loginUrl: enabled ? "/api/auth/wecom/login?returnTo=%2Fportal" : null,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get("/wecom/login", async (request, reply) => {
|
||||
assertWecomEnabled(fastify.config);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
||||
}
|
||||
const mode = parsed.data.mode ?? "login";
|
||||
if (mode === "bind") {
|
||||
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
|
||||
const bindState = verifyToken(bindCookie, fastify.config.jwtSecret);
|
||||
if (!bindState.ok) {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
}
|
||||
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
|
||||
const state = signPayload({
|
||||
exp: Date.now() + DATE_WINDOW_MS,
|
||||
nonce: crypto.randomUUID(),
|
||||
returnTo,
|
||||
mode,
|
||||
}, fastify.config.jwtSecret);
|
||||
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state));
|
||||
});
|
||||
|
||||
fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
assertWecomEnabled(fastify.config);
|
||||
const parsed = BIND_BODY_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
||||
}
|
||||
const returnTo = sanitizeReturnTo(parsed.data?.returnTo, fastify.config, request);
|
||||
const token = signPayload({
|
||||
exp: Date.now() + DATE_WINDOW_MS,
|
||||
employeeId: request.employee.id,
|
||||
returnTo,
|
||||
}, fastify.config.jwtSecret);
|
||||
reply.setCookie(BIND_COOKIE_NAME, token, bindCookieOptions(fastify.config));
|
||||
return sendData(reply, request, {
|
||||
loginUrl: `/api/auth/wecom/login?mode=bind&returnTo=${encodeURIComponent(returnTo)}`,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/wecom/unbind", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await fastify.prisma.wecomIdentityBinding.deleteMany({ where: { employee_id: request.employee.id } });
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "AUTH_WECOM_UNBIND",
|
||||
targetType: "employee",
|
||||
targetId: request.employee.id,
|
||||
extra: {},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "wecom unbind audit failed");
|
||||
}
|
||||
return sendData(reply, request, { ok: true });
|
||||
});
|
||||
|
||||
fastify.get("/wecom/callback", async (request, reply) => {
|
||||
assertWecomEnabled(fastify.config);
|
||||
const stateToken = String(request.query?.state ?? "").trim();
|
||||
const code = String(request.query?.code ?? "").trim();
|
||||
const verified = verifyToken(stateToken, fastify.config.jwtSecret);
|
||||
if (!verified.ok || !code) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const ctx = verified.payload ?? {};
|
||||
if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const mode = ctx.mode === "bind" ? "bind" : "login";
|
||||
const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request);
|
||||
const profile = await exchangeWecomCode(fastify.config, code);
|
||||
if (!profile.externalUserId) {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "missing_user_id" },
|
||||
});
|
||||
}
|
||||
|
||||
const origin = resolvePrimaryAppOrigin(fastify.config, request);
|
||||
|
||||
if (mode === "bind") {
|
||||
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
|
||||
const bindVerified = verifyToken(bindCookie, fastify.config.jwtSecret);
|
||||
if (!bindVerified.ok) {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}?wecomBind=expired`);
|
||||
}
|
||||
const bindPayload = bindVerified.payload ?? {};
|
||||
if (typeof bindPayload.employeeId !== "string") {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}?wecomBind=invalid`);
|
||||
}
|
||||
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: bindPayload.employeeId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!employee || employee.status !== "active") {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}?wecomBind=employee_inactive`);
|
||||
}
|
||||
|
||||
const conflicting = await fastify.prisma.wecomIdentityBinding.findFirst({
|
||||
where: { external_user_id: profile.externalUserId, NOT: { employee_id: employee.id } },
|
||||
select: { employee_id: true },
|
||||
});
|
||||
if (conflicting) {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}?wecomBind=conflict`);
|
||||
}
|
||||
|
||||
await fastify.prisma.wecomIdentityBinding.upsert({
|
||||
where: { employee_id: employee.id },
|
||||
update: {
|
||||
external_user_id: profile.externalUserId,
|
||||
external_open_user_id: profile.externalOpenUserId,
|
||||
updated_by: employee.id,
|
||||
},
|
||||
create: {
|
||||
employee_id: employee.id,
|
||||
external_user_id: profile.externalUserId,
|
||||
external_open_user_id: profile.externalOpenUserId,
|
||||
created_by: employee.id,
|
||||
updated_by: employee.id,
|
||||
},
|
||||
});
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: employee.id,
|
||||
action: "AUTH_WECOM_BIND",
|
||||
targetType: "employee",
|
||||
targetId: employee.id,
|
||||
extra: { externalUserId: profile.externalUserId },
|
||||
}),
|
||||
});
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}?wecomBind=success`);
|
||||
}
|
||||
|
||||
const binding = await fastify.prisma.wecomIdentityBinding.findUnique({
|
||||
where: { external_user_id: profile.externalUserId },
|
||||
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
|
||||
});
|
||||
if (!binding?.employee || binding.employee.status !== "active") {
|
||||
return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=unbound`);
|
||||
}
|
||||
|
||||
const deviceFingerprint = `wecom:${profile.externalUserId}`;
|
||||
const session = await createAuthenticatedSession({
|
||||
prisma: fastify.prisma,
|
||||
request,
|
||||
config: fastify.config,
|
||||
employeeId: binding.employee.id,
|
||||
tokenVersion: binding.employee.token_version,
|
||||
deviceFingerprint,
|
||||
auditAction: "AUTH_WECOM_LOGIN_SUCCESS",
|
||||
extra: { externalUserId: profile.externalUserId },
|
||||
});
|
||||
|
||||
reply.setCookie(fastify.config.refreshCookieName, session.refreshToken, refreshCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = wecomAuthRoutes;
|
||||
在新工单中引用
屏蔽一个用户