276 行
8.5 KiB
JavaScript
276 行
8.5 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const DEFAULT_BASE_URL = "https://oa.hyinda.com";
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
baseUrl: process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL,
|
|
json: false,
|
|
month: new Date().toISOString().slice(0, 7),
|
|
};
|
|
for (const arg of argv) {
|
|
if (arg === "--json") {
|
|
options.json = true;
|
|
} else if (arg.startsWith("--base-url=")) {
|
|
options.baseUrl = arg.slice("--base-url=".length);
|
|
} else if (arg.startsWith("--month=")) {
|
|
options.month = arg.slice("--month=".length);
|
|
}
|
|
}
|
|
options.baseUrl = options.baseUrl.trim().replace(/\/$/, "");
|
|
if (!/^\d{4}-\d{2}$/.test(options.month)) {
|
|
throw new Error(`invalid month: ${options.month}`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function today() {
|
|
return new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
function getCredential(role) {
|
|
const prefixByRole = {
|
|
admin: "LIVE_SMOKE_ADMIN",
|
|
employee: "LIVE_SMOKE_EMPLOYEE",
|
|
};
|
|
const prefix = prefixByRole[role];
|
|
const identifier = process.env[`${prefix}_IDENTIFIER`]?.trim();
|
|
const password = process.env[`${prefix}_PASSWORD`]?.trim();
|
|
if (!identifier || !password) {
|
|
return null;
|
|
}
|
|
return { identifier, password };
|
|
}
|
|
|
|
async function fetchJson(url, init = {}) {
|
|
const response = await fetch(url, {
|
|
...init,
|
|
signal: AbortSignal.timeout(25_000),
|
|
});
|
|
const text = await response.text();
|
|
let json = null;
|
|
try {
|
|
json = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
json = null;
|
|
}
|
|
return { ok: response.ok, status: response.status, json };
|
|
}
|
|
|
|
function errorSummary(json) {
|
|
const error = json?.error ?? {};
|
|
const details = error.details ?? {};
|
|
return {
|
|
code: typeof error.code === "string" ? error.code : null,
|
|
featureFlag: typeof details.featureFlag === "string" ? details.featureFlag : null,
|
|
reason: typeof details.reason === "string" ? details.reason : null,
|
|
};
|
|
}
|
|
|
|
async function login(baseUrl, role) {
|
|
const credential = getCredential(role);
|
|
if (!credential) {
|
|
return { ok: false, status: "SKIP", role, reason: "missing_credentials" };
|
|
}
|
|
const result = await fetchJson(`${baseUrl}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-device-fingerprint": `launch-auto-attendance-${role}`,
|
|
},
|
|
body: JSON.stringify({
|
|
identifier: credential.identifier,
|
|
password: credential.password,
|
|
}),
|
|
});
|
|
const accessToken = result.json?.data?.accessToken;
|
|
if (!result.ok || typeof accessToken !== "string" || !accessToken) {
|
|
return {
|
|
ok: false,
|
|
status: "FAIL",
|
|
role,
|
|
httpStatus: result.status,
|
|
error: errorSummary(result.json),
|
|
};
|
|
}
|
|
return { ok: true, status: "PASS", role, accessToken };
|
|
}
|
|
|
|
async function callApi(baseUrl, token, method, pathName, body) {
|
|
return fetchJson(`${baseUrl}${pathName}`, {
|
|
method,
|
|
headers: {
|
|
authorization: `Bearer ${token}`,
|
|
accept: "application/json",
|
|
...(body ? { "content-type": "application/json" } : {}),
|
|
},
|
|
...(body ? { body: JSON.stringify(body) } : {}),
|
|
});
|
|
}
|
|
|
|
function passStep(name, details = {}) {
|
|
return { name, status: "PASS", ...details };
|
|
}
|
|
|
|
function failStep(name, details = {}) {
|
|
return { name, status: "FAIL", ...details };
|
|
}
|
|
|
|
function skipStep(name, details = {}) {
|
|
return { name, status: "SKIP", ...details };
|
|
}
|
|
|
|
function printHuman(summary) {
|
|
console.log(`[live-attendance-unified] status=${summary.status} base=${summary.baseUrl} month=${summary.month}`);
|
|
for (const step of summary.steps) {
|
|
const detail = step.httpStatus ? ` http=${step.httpStatus}` : "";
|
|
const code = step.code ? ` code=${step.code}` : "";
|
|
console.log(`[live-attendance-unified] ${step.name} status=${step.status}${detail}${code}`);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const employee = await login(options.baseUrl, "employee");
|
|
const admin = await login(options.baseUrl, "admin");
|
|
const steps = [];
|
|
|
|
if (!employee.ok) {
|
|
steps.push(employee.status === "SKIP" ? skipStep("employee-login", employee) : failStep("employee-login", employee));
|
|
} else {
|
|
steps.push(passStep("employee-login"));
|
|
|
|
const clockIn = await callApi(options.baseUrl, employee.accessToken, "POST", "/api/attendance/clock-in", {});
|
|
const clockError = errorSummary(clockIn.json);
|
|
if (clockIn.status === 409 && clockError.featureFlag === "attendance_v1_disabled") {
|
|
steps.push(passStep("legacy-clock-in-disabled", {
|
|
httpStatus: clockIn.status,
|
|
featureFlag: clockError.featureFlag,
|
|
reason: clockError.reason,
|
|
}));
|
|
} else {
|
|
steps.push(failStep("legacy-clock-in-disabled", {
|
|
httpStatus: clockIn.status,
|
|
error: clockError,
|
|
}));
|
|
}
|
|
|
|
const monthly = await callApi(
|
|
options.baseUrl,
|
|
employee.accessToken,
|
|
"GET",
|
|
`/api/attendance/monthly?month=${encodeURIComponent(options.month)}`,
|
|
);
|
|
if (monthly.ok) {
|
|
const data = monthly.json?.data ?? {};
|
|
steps.push(passStep("attendance-monthly", {
|
|
httpStatus: monthly.status,
|
|
employeeCount: data.summary?.employeeCount ?? null,
|
|
workDays: data.summary?.workDays ?? null,
|
|
checkinDays: data.summary?.checkinDays ?? null,
|
|
missingDays: data.summary?.missingDays ?? null,
|
|
}));
|
|
} else {
|
|
steps.push(failStep("attendance-monthly", {
|
|
httpStatus: monthly.status,
|
|
error: errorSummary(monthly.json),
|
|
}));
|
|
}
|
|
|
|
const corrections = await callApi(
|
|
options.baseUrl,
|
|
employee.accessToken,
|
|
"GET",
|
|
`/api/attendance-corrections?month=${encodeURIComponent(options.month)}&page=1&pageSize=5`,
|
|
);
|
|
if (corrections.ok) {
|
|
const data = corrections.json?.data ?? {};
|
|
steps.push(passStep("attendance-corrections-list", {
|
|
httpStatus: corrections.status,
|
|
total: data.total ?? null,
|
|
itemCount: Array.isArray(data.items) ? data.items.length : null,
|
|
}));
|
|
} else {
|
|
steps.push(failStep("attendance-corrections-list", {
|
|
httpStatus: corrections.status,
|
|
error: errorSummary(corrections.json),
|
|
}));
|
|
}
|
|
}
|
|
|
|
if (!admin.ok) {
|
|
steps.push(admin.status === "SKIP" ? skipStep("admin-login", admin) : failStep("admin-login", admin));
|
|
} else {
|
|
steps.push(passStep("admin-login"));
|
|
const syncRuns = await callApi(options.baseUrl, admin.accessToken, "GET", "/api/attendance/wecom/sync-runs?page=1&pageSize=5");
|
|
if (syncRuns.ok) {
|
|
const data = syncRuns.json?.data ?? {};
|
|
steps.push(passStep("wecom-sync-runs", {
|
|
httpStatus: syncRuns.status,
|
|
total: data.total ?? null,
|
|
itemCount: Array.isArray(data.items) ? data.items.length : null,
|
|
}));
|
|
} else {
|
|
steps.push(failStep("wecom-sync-runs", {
|
|
httpStatus: syncRuns.status,
|
|
error: errorSummary(syncRuns.json),
|
|
}));
|
|
}
|
|
|
|
if (process.env.LAUNCH_AUTO_RUN_WECOM_SYNC === "1") {
|
|
const sync = await callApi(options.baseUrl, admin.accessToken, "POST", "/api/attendance/wecom/sync", {
|
|
startDate: today(),
|
|
endDate: today(),
|
|
});
|
|
if (sync.ok && sync.json?.data?.skipped) {
|
|
steps.push(skipStep("wecom-checkin-sync", {
|
|
httpStatus: sync.status,
|
|
code: sync.json?.data?.code ?? null,
|
|
employeeCount: sync.json?.data?.employeeCount ?? null,
|
|
boundUserCount: sync.json?.data?.boundUserCount ?? null,
|
|
recordCount: sync.json?.data?.recordCount ?? null,
|
|
}));
|
|
} else if (sync.ok) {
|
|
steps.push(passStep("wecom-checkin-sync", {
|
|
httpStatus: sync.status,
|
|
code: sync.json?.data?.code ?? null,
|
|
employeeCount: sync.json?.data?.employeeCount ?? null,
|
|
boundUserCount: sync.json?.data?.boundUserCount ?? null,
|
|
recordCount: sync.json?.data?.recordCount ?? null,
|
|
}));
|
|
} else {
|
|
steps.push(failStep("wecom-checkin-sync", {
|
|
httpStatus: sync.status,
|
|
error: errorSummary(sync.json),
|
|
}));
|
|
}
|
|
} else {
|
|
steps.push(skipStep("wecom-checkin-sync", { reason: "LAUNCH_AUTO_RUN_WECOM_SYNC not set" }));
|
|
}
|
|
}
|
|
|
|
const failed = steps.some((step) => step.status === "FAIL");
|
|
const summary = {
|
|
checkedAt: new Date().toISOString(),
|
|
baseUrl: options.baseUrl,
|
|
month: options.month,
|
|
status: failed ? "FAIL" : "PASS",
|
|
steps,
|
|
};
|
|
|
|
if (options.json) {
|
|
console.log(JSON.stringify(summary, null, 2));
|
|
} else {
|
|
printHuman(summary);
|
|
}
|
|
|
|
if (failed) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`[live-attendance-unified][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exit(1);
|
|
});
|