feat: migrate app routes and business modules
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
export const PASSWORD_POLICY_MESSAGE =
|
||||
"密码需为 8–32 位,并同时包含字母和数字";
|
||||
|
||||
export const validatePassword = (value) => {
|
||||
const valid =
|
||||
typeof value === "string" &&
|
||||
value.length >= 8 &&
|
||||
value.length <= 32 &&
|
||||
/[A-Za-z]/.test(value) &&
|
||||
/\d/.test(value);
|
||||
|
||||
return { valid, message: valid ? "" : PASSWORD_POLICY_MESSAGE };
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
const DEFAULT_COOLDOWN_SECONDS = 60;
|
||||
const sceneExpiresAt = new Map();
|
||||
|
||||
const requireFunction = (value, name) => {
|
||||
if (typeof value !== "function") {
|
||||
throw new TypeError(`${name} must be a function`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const createAuthSmsCooldown = ({
|
||||
operationCode,
|
||||
onChange,
|
||||
now = Date.now,
|
||||
setIntervalFn = setInterval,
|
||||
clearIntervalFn = clearInterval,
|
||||
}) => {
|
||||
if (typeof operationCode !== "string" || !operationCode.trim()) {
|
||||
throw new TypeError("operationCode must be a non-empty string");
|
||||
}
|
||||
const normalizedOperationCode = operationCode.trim();
|
||||
const publish = requireFunction(onChange, "onChange");
|
||||
const readNow = requireFunction(now, "now");
|
||||
const scheduleInterval = requireFunction(setIntervalFn, "setIntervalFn");
|
||||
const cancelInterval = requireFunction(clearIntervalFn, "clearIntervalFn");
|
||||
let timer = null;
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timer === null) return;
|
||||
cancelInterval(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
const expiresAt = sceneExpiresAt.get(normalizedOperationCode) || 0;
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
Math.ceil((expiresAt - Number(readNow())) / 1000),
|
||||
);
|
||||
if (remaining === 0) {
|
||||
sceneExpiresAt.delete(normalizedOperationCode);
|
||||
stopTimer();
|
||||
} else if (timer === null) {
|
||||
timer = scheduleInterval(sync, 1000);
|
||||
}
|
||||
publish(remaining);
|
||||
return remaining;
|
||||
};
|
||||
|
||||
const start = (seconds = DEFAULT_COOLDOWN_SECONDS) => {
|
||||
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 300) {
|
||||
throw new TypeError("cooldown seconds must be an integer from 1 to 300");
|
||||
}
|
||||
sceneExpiresAt.set(
|
||||
normalizedOperationCode,
|
||||
Number(readNow()) + seconds * 1000,
|
||||
);
|
||||
return sync();
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
start,
|
||||
sync,
|
||||
dispose: stopTimer,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { isWriteOutcomeUnknown } from "@/utils/request-outcome.js";
|
||||
|
||||
export const AUTH_VERIFICATION_OPERATION = Object.freeze({
|
||||
PASSWORD_LOGIN: "password-login",
|
||||
SMS_LOGIN: "sms-login",
|
||||
REGISTER: "register",
|
||||
FORGOT_PASSWORD: "forgot-password",
|
||||
PHONE_CHANGE: "phone-change",
|
||||
ACCOUNT_DEACTIVATE: "account-deactivate",
|
||||
});
|
||||
|
||||
const SUPPORTED_TAC_TYPES = new Set([
|
||||
"SLIDER",
|
||||
"ROTATE",
|
||||
"CONCAT",
|
||||
"WORD_IMAGE_CLICK",
|
||||
]);
|
||||
const PHONE_PATTERN = /^1[3-9]\d{9}$/;
|
||||
|
||||
export const isAuthPhone = (value) =>
|
||||
typeof value === "string" && PHONE_PATTERN.test(value);
|
||||
|
||||
const contractError = (message, code) => {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
};
|
||||
|
||||
const requireText = (value, label) => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw contractError(`${label}不能为空`, "AUTH_TAC_CONTRACT_INVALID");
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
export const assertSmsCode = (value) => {
|
||||
if (typeof value !== "string" || !/^\d{4}$/.test(value)) {
|
||||
throw contractError("请输入 4 位短信验证码", "SMS_CODE_INVALID");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const assertAuthVerificationOperation = (operationCode) => {
|
||||
if (!Object.values(AUTH_VERIFICATION_OPERATION).includes(operationCode)) {
|
||||
throw contractError("认证动作不属于当前认证合同", "AUTH_OPERATION_INVALID");
|
||||
}
|
||||
return operationCode;
|
||||
};
|
||||
|
||||
export const normalizeCaptchaRequirement = (value) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw contractError("行为验证策略响应无效", "AUTH_TAC_REQUIREMENT_INVALID");
|
||||
}
|
||||
const sceneCode = requireText(value.sceneCode, "行为验证场景");
|
||||
if (value.required === false) {
|
||||
return { required: false, sceneCode };
|
||||
}
|
||||
if (value.required !== true) {
|
||||
throw contractError(
|
||||
"行为验证策略缺少 required 布尔值",
|
||||
"AUTH_TAC_POLICY_INCOMPLETE",
|
||||
);
|
||||
}
|
||||
const providerCode = requireText(value.providerCode, "行为验证服务商").toUpperCase();
|
||||
if (providerCode !== "TIANAI") {
|
||||
throw contractError("当前仅支持 TIANAI 行为验证服务", "AUTH_TAC_PROVIDER_UNSUPPORTED");
|
||||
}
|
||||
const captchaType = requireText(value.captchaType, "行为验证码类型").toUpperCase();
|
||||
if (!SUPPORTED_TAC_TYPES.has(captchaType)) {
|
||||
throw contractError("服务端返回了客户端不支持的验证码类型", "AUTH_TAC_TYPE_UNSUPPORTED");
|
||||
}
|
||||
const ttlSeconds = Number(value.ttlSeconds);
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) {
|
||||
throw contractError("行为验证策略缺少有效期", "AUTH_TAC_TTL_INVALID");
|
||||
}
|
||||
return { required: true, providerCode, captchaType, sceneCode, ttlSeconds };
|
||||
};
|
||||
|
||||
export const createTacRenderContext = ({
|
||||
requestId,
|
||||
baseUrl,
|
||||
clientId,
|
||||
tenantId,
|
||||
operationCode,
|
||||
subject,
|
||||
requirement,
|
||||
}) => {
|
||||
const normalizedRequirement = normalizeCaptchaRequirement(requirement);
|
||||
if (!normalizedRequirement.required) {
|
||||
throw contractError("当前认证动作无需行为验证", "AUTH_TAC_NOT_REQUIRED");
|
||||
}
|
||||
const normalizedBaseUrl = requireText(baseUrl, "后端地址").replace(/\/+$/, "");
|
||||
if (!/^https:\/\/[^/]+/i.test(normalizedBaseUrl)) {
|
||||
throw contractError("行为验证只允许使用 HTTPS 后端地址", "AUTH_TAC_HTTPS_REQUIRED");
|
||||
}
|
||||
const normalizedSubject = requireText(subject, "手机号");
|
||||
if (!isAuthPhone(normalizedSubject)) {
|
||||
throw contractError("请输入正确的手机号", "AUTH_PHONE_INVALID");
|
||||
}
|
||||
return {
|
||||
requestId: requireText(requestId, "验证请求标识"),
|
||||
baseUrl: normalizedBaseUrl,
|
||||
challengeUrl: `${normalizedBaseUrl}/genealogy/app/auth/verification/${assertAuthVerificationOperation(operationCode)}/challenge`,
|
||||
verifyUrl: `${normalizedBaseUrl}/genealogy/app/auth/verification/${assertAuthVerificationOperation(operationCode)}/verify`,
|
||||
clientId: requireText(clientId, "客户端标识"),
|
||||
tenantId: requireText(tenantId, "租户标识"),
|
||||
operationCode,
|
||||
subject: normalizedSubject,
|
||||
providerCode: normalizedRequirement.providerCode,
|
||||
captchaType: normalizedRequirement.captchaType,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeTacSuccess = (value, expectedRequestId) => {
|
||||
if (!value || typeof value !== "object" || value.requestId !== expectedRequestId) {
|
||||
throw contractError("行为验证结果已过期或与当前请求不匹配", "AUTH_TAC_RESULT_STALE");
|
||||
}
|
||||
const validToken = requireText(value.validToken, "行为验证票据");
|
||||
const expireSeconds = Number(value.expireSeconds);
|
||||
if (!Number.isInteger(expireSeconds) || expireSeconds < 1) {
|
||||
throw contractError("行为验证票据缺少有效期", "AUTH_TAC_RESULT_INVALID");
|
||||
}
|
||||
return { requestId: value.requestId, validToken, expireSeconds };
|
||||
};
|
||||
|
||||
export const isSmsDeliveryOutcomeUnknown = isWriteOutcomeUnknown;
|
||||
Reference in New Issue
Block a user