WFULLSMOKE1 增加三天全域最终目标测试
这个提交包含在:
+466
@@ -0,0 +1,466 @@
|
||||
#!/usr/bin/env bash
|
||||
set -u -o pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
CLIENT_DIR="$ROOT_DIR/client"
|
||||
SERVER_DIR="$ROOT_DIR/server"
|
||||
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||
REPORT_DIR="${WFULLSMOKE_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/three-day-full-goal-smoke-$(date +%Y%m%d-%H%M%S)}"
|
||||
SUMMARY_TXT="$REPORT_DIR/summary.txt"
|
||||
STATUS=0
|
||||
FINDING_COUNT=0
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
: >"$SUMMARY_TXT"
|
||||
|
||||
log() {
|
||||
printf '%s\n' "$*" | tee -a "$SUMMARY_TXT"
|
||||
}
|
||||
|
||||
write_step_json() {
|
||||
local name="$1"
|
||||
local status="$2"
|
||||
local kind="$3"
|
||||
local code="${4:-0}"
|
||||
local detail="${5:-}"
|
||||
node - "$REPORT_DIR" "$name" "$status" "$kind" "$code" "$detail" <<'NODE' || true
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const [reportDir, name, status, kind, code, detail] = process.argv.slice(2);
|
||||
const file = path.join(reportDir, `${name}.step.json`);
|
||||
fs.writeFileSync(file, `${JSON.stringify({
|
||||
name,
|
||||
status,
|
||||
kind,
|
||||
code: Number(code || 0),
|
||||
detail: String(detail || "").slice(0, 500),
|
||||
logPath: path.resolve(path.join(reportDir, `${name}.log`)),
|
||||
checkedAt: new Date().toISOString(),
|
||||
}, null, 2)}\n`, "utf8");
|
||||
NODE
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local name="$1"
|
||||
shift
|
||||
local log_file="$REPORT_DIR/${name}.log"
|
||||
|
||||
log ">>> [wfullsmoke1] $name"
|
||||
if "$@" > >(tee "$log_file") 2>&1; then
|
||||
log "[wfullsmoke1][PASS] $name"
|
||||
write_step_json "$name" "PASS" "required" 0 "ok"
|
||||
else
|
||||
local code="$?"
|
||||
log "[wfullsmoke1][FAIL] $name exit=$code"
|
||||
STATUS=1
|
||||
write_step_json "$name" "FAIL" "required" "$code" "required step failed"
|
||||
fi
|
||||
}
|
||||
|
||||
run_finding_step() {
|
||||
local name="$1"
|
||||
shift
|
||||
local log_file="$REPORT_DIR/${name}.log"
|
||||
|
||||
log ">>> [wfullsmoke1] $name"
|
||||
if "$@" > >(tee "$log_file") 2>&1; then
|
||||
log "[wfullsmoke1][PASS] $name"
|
||||
write_step_json "$name" "PASS" "finding_allowed" 0 "ok"
|
||||
else
|
||||
local code="$?"
|
||||
log "[wfullsmoke1][FINDING] $name exit=$code"
|
||||
FINDING_COUNT=$((FINDING_COUNT + 1))
|
||||
write_step_json "$name" "FINDING" "finding_allowed" "$code" "finding or missing external config"
|
||||
fi
|
||||
}
|
||||
|
||||
run_client_finding_step() {
|
||||
local name="$1"
|
||||
shift
|
||||
local log_file="$REPORT_DIR/${name}.log"
|
||||
|
||||
log ">>> [wfullsmoke1] $name"
|
||||
if (cd "$CLIENT_DIR" && env "$@") > >(tee "$log_file") 2>&1; then
|
||||
log "[wfullsmoke1][PASS] $name"
|
||||
write_step_json "$name" "PASS" "finding_allowed" 0 "ok"
|
||||
else
|
||||
local code="$?"
|
||||
log "[wfullsmoke1][FINDING] $name exit=$code"
|
||||
FINDING_COUNT=$((FINDING_COUNT + 1))
|
||||
write_step_json "$name" "FINDING" "finding_allowed" "$code" "finding or missing external config"
|
||||
fi
|
||||
}
|
||||
|
||||
load_default_env() {
|
||||
local env_file="${WFULLSMOKE_ENV_FILE:-$HOME/.hyb-oa/live-smoke.env}"
|
||||
if [[ "${WFULLSMOKE_SKIP_ENV_FILE:-0}" == "1" || ! -f "$env_file" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$env_file"
|
||||
set +a
|
||||
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}"
|
||||
log "[wfullsmoke1] loaded masked live smoke env file"
|
||||
}
|
||||
|
||||
has_live_business_credentials() {
|
||||
local keys=(
|
||||
LIVE_SMOKE_ADMIN_IDENTIFIER
|
||||
LIVE_SMOKE_ADMIN_PASSWORD
|
||||
LIVE_SMOKE_EMPLOYEE_IDENTIFIER
|
||||
LIVE_SMOKE_EMPLOYEE_PASSWORD
|
||||
LIVE_SMOKE_MANAGER_IDENTIFIER
|
||||
LIVE_SMOKE_MANAGER_PASSWORD
|
||||
LIVE_SMOKE_FINANCE_IDENTIFIER
|
||||
LIVE_SMOKE_FINANCE_PASSWORD
|
||||
LIVE_SMOKE_FINANCE_MGR_IDENTIFIER
|
||||
LIVE_SMOKE_FINANCE_MGR_PASSWORD
|
||||
)
|
||||
local key
|
||||
for key in "${keys[@]}"; do
|
||||
if [[ -z "${!key:-}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
run_r7e_flow() {
|
||||
local flow="$1"
|
||||
local flow_report="$REPORT_DIR/r7e-$flow"
|
||||
local flow_screens="$REPORT_DIR/r7e-$flow-screens"
|
||||
mkdir -p "$flow_report" "$flow_screens"
|
||||
run_client_finding_step "r7e-$flow" \
|
||||
PLAYWRIGHT_MANUAL=1 \
|
||||
E2E_MODE=live \
|
||||
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
QA_ISSUE_ID=WFULLSMOKE1 \
|
||||
TRIALRUN_ISSUE_ID=WFULLSMOKE1 \
|
||||
TRIALRUN_MARKER_PREFIX="${WFULLSMOKE_MARKER_PREFIX:-WFULL}" \
|
||||
TRIALRUN_REPORT_TITLE="WFULLSMOKE1 ${flow} 主路径报告" \
|
||||
TRIALRUN_REPORT_DIR="$flow_report" \
|
||||
TRIALRUN_SCREENSHOT_DIR="$flow_screens" \
|
||||
R7E_BROWSER_FLOW="$flow" \
|
||||
npx playwright test tests/e2e/manual/r7e-browser-business-trialrun.spec.ts --workers=1 --reporter=list
|
||||
}
|
||||
|
||||
run_procurement_contract_project_regression() {
|
||||
local marker
|
||||
marker="$(date +%Y%m%d%H%M%S)"
|
||||
run_finding_step "procurement-contract-project" env \
|
||||
BASE_URL="$PUBLIC_BASE_URL" \
|
||||
DEVICE_FINGERPRINT="wfullsmoke1-procurement-contract" \
|
||||
EMPLOYEE_IDENTIFIER="$LIVE_SMOKE_EMPLOYEE_IDENTIFIER" \
|
||||
EMPLOYEE_PASSWORD="$LIVE_SMOKE_EMPLOYEE_PASSWORD" \
|
||||
MANAGER_IDENTIFIER="$LIVE_SMOKE_MANAGER_IDENTIFIER" \
|
||||
MANAGER_PASSWORD="$LIVE_SMOKE_MANAGER_PASSWORD" \
|
||||
FINANCE_MGR_IDENTIFIER="$LIVE_SMOKE_FINANCE_MGR_IDENTIFIER" \
|
||||
FINANCE_MGR_PASSWORD="$LIVE_SMOKE_FINANCE_MGR_PASSWORD" \
|
||||
PROCUREMENT_REGRESSION_MARKER_PREFIX="${WFULLSMOKE_MARKER_PREFIX:-WFULL}" \
|
||||
PROCUREMENT_PO_NO="PO-${WFULLSMOKE_MARKER_PREFIX:-WFULL}-${marker}" \
|
||||
PROCUREMENT_CONTRACT_NO="CT-${WFULLSMOKE_MARKER_PREFIX:-WFULL}-${marker}" \
|
||||
bash "$ROOT_DIR/server/scripts/regression-procurement.sh"
|
||||
}
|
||||
|
||||
run_ready_and_flags() {
|
||||
run_step "public-ready" curl -fsS "$PUBLIC_BASE_URL/ready"
|
||||
run_finding_step "live-feature-flags" env \
|
||||
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
node "$ROOT_DIR/scripts/qa/check-live-feature-flags.mjs" \
|
||||
--expect payment_batch_v1=force_on \
|
||||
--expect erp_sync=force_off \
|
||||
--expect attendance_v1=disabled \
|
||||
--expect attendance_unified_v1=enabled \
|
||||
--json
|
||||
}
|
||||
|
||||
run_wecom_and_archive_chain() {
|
||||
run_finding_step "warch3-ops" env \
|
||||
WARCH3_SEND_ALERT=0 \
|
||||
WARCH3_OPS_REPORT_DIR="$REPORT_DIR/warch3-ops" \
|
||||
bash "$ROOT_DIR/scripts/qa/run-warch3-ops-daily.sh"
|
||||
|
||||
run_finding_step "wauto1-evidence" env \
|
||||
WAUTO1_REPORT_DIR="$REPORT_DIR/wecom-controlled-trial-evidence" \
|
||||
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
bash "$ROOT_DIR/scripts/qa/collect-wecom-controlled-trial-evidence.sh"
|
||||
}
|
||||
|
||||
run_oa_business_chain() {
|
||||
run_finding_step "live-smoke-env" env \
|
||||
LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json
|
||||
|
||||
if [[ "${WFULLSMOKE_ALLOW_PUBLIC_WRITES:-1}" != "1" ]]; then
|
||||
log "[wfullsmoke1][SKIP] oa-public-write-flows WFULLSMOKE_ALLOW_PUBLIC_WRITES!=1"
|
||||
write_step_json "oa-public-write-flows" "SKIP" "manual_or_config" 0 "public OA test data writes disabled"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! has_live_business_credentials; then
|
||||
log "[wfullsmoke1][FINDING] oa-public-write-flows missing live smoke business credentials"
|
||||
FINDING_COUNT=$((FINDING_COUNT + 1))
|
||||
write_step_json "oa-public-write-flows" "PAUSED_MANUAL_CONFIRMATION" "manual_or_config" 0 "missing live smoke business credentials"
|
||||
return 0
|
||||
fi
|
||||
|
||||
run_r7e_flow "reimbursement-finance"
|
||||
run_r7e_flow "purchase-attachment"
|
||||
run_procurement_contract_project_regression
|
||||
}
|
||||
|
||||
run_mobile_and_wecom_entry() {
|
||||
run_client_finding_step "mobile-entry-matrix" \
|
||||
E2E_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
npx playwright test tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list
|
||||
run_client_finding_step "wecom-rendering-matrix" \
|
||||
E2E_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
npx playwright test tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts --workers=1 --reporter=list
|
||||
run_client_finding_step "wecom-entry" \
|
||||
E2E_BASE_URL="$PUBLIC_BASE_URL" \
|
||||
npx playwright test tests/e2e/wecom-entry.spec.ts --workers=1 --reporter=list
|
||||
}
|
||||
|
||||
write_manual_checklist() {
|
||||
cat >"$REPORT_DIR/manual-gates.md" <<'EOF'
|
||||
# WFULLSMOKE1 人工门禁
|
||||
|
||||
## Day 1
|
||||
- SMOKE002:尝试企微上/下班打卡,提交低风险请假和补卡。
|
||||
- SMOKE101:每条审批先打开详情,再同意或驳回。
|
||||
- HYD000006:查看 OA 企微审批历史和打卡摘要。
|
||||
|
||||
## Day 2
|
||||
- SMOKE002:继续按企微规则打卡。
|
||||
- HYD000006:检查请假、补卡、外出、加班四类模板审批规则。
|
||||
- WAPPR1 选项:A 审批人自动带出且申请人不能任意选择;B 默认带出但申请人仍可改人;C 必须申请人手选;D 当前看不到或模板未配置。只有 A 放行。
|
||||
|
||||
## Day 3
|
||||
- HYD000006:确认三天业务结果和 OA 历史可读性。
|
||||
- 若 WAPPR1 为 A 且外出/加班 dry-run 通过,再问答是否进入 WEXT1-LIVE。
|
||||
|
||||
边界:不要记录 Secret、token、EncodingAESKey、完整 userid、完整审批单号、完整 template_id、表单值、附件、评论或原始 payload。
|
||||
EOF
|
||||
}
|
||||
|
||||
run_privacy_smoke() {
|
||||
local sensitive_pattern='access_token=[A-Za-z0-9._-]{8,}|1[3-9][0-9]{9}|[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}|-----BEGIN|BEGIN [A-Z ]*KEY|"((token|secret|password|EncodingAESKey|encodingAESKey|sp_no|spNo|template_id|templateId|userid|userId|external_userid|externalUserId|external_user_id))"[[:space:]]*:[[:space:]]*"[^"*]{8,}"|\b20[0-9]{12,}\b|"(raw|rawPayload|raw_payload|payload|xml|ciphertext|Encrypt|formValue|formValues|attachments|comments)"[[:space:]]*:'
|
||||
if grep -RInE "$sensitive_pattern" "$REPORT_DIR"; then
|
||||
log "[wfullsmoke1][FAIL] report-privacy-smoke matched sensitive pattern"
|
||||
STATUS=1
|
||||
else
|
||||
log "[wfullsmoke1][PASS] report-privacy-smoke"
|
||||
fi
|
||||
}
|
||||
|
||||
write_summary_json() {
|
||||
node - "$ROOT_DIR" "$REPORT_DIR" "$PUBLIC_BASE_URL" "$STATUS" "$FINDING_COUNT" <<'NODE' || return 1
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
const [rootDir, reportDir, baseURL, statusInput, findingCountInput] = process.argv.slice(2);
|
||||
|
||||
function readJson(file) {
|
||||
if (!file || !fs.existsSync(file)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function step(name) {
|
||||
return readJson(path.join(reportDir, `${name}.step.json`)) || {
|
||||
name,
|
||||
status: "MISSING",
|
||||
kind: "unknown",
|
||||
logPath: path.resolve(path.join(reportDir, `${name}.log`)),
|
||||
};
|
||||
}
|
||||
|
||||
function child(relativePath) {
|
||||
const file = path.join(reportDir, relativePath, "summary.json");
|
||||
const json = readJson(file);
|
||||
return {
|
||||
status: json?.status || json?.conclusion || "MISSING",
|
||||
reportPath: fs.existsSync(file) ? path.resolve(file) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function safeStatus(value) {
|
||||
return String(value || "UNKNOWN").slice(0, 80);
|
||||
}
|
||||
|
||||
function isBad(value) {
|
||||
return /FAIL|ERROR|BLOCKED|SKIP/i.test(value);
|
||||
}
|
||||
|
||||
function isGap(value) {
|
||||
return /PAUSED|PARTIAL|NEEDS|MISSING|FINDING/i.test(value);
|
||||
}
|
||||
|
||||
let commit = "unknown";
|
||||
try {
|
||||
commit = execSync("git rev-parse --short HEAD", { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
} catch {}
|
||||
|
||||
const stepNames = [
|
||||
"public-ready",
|
||||
"live-feature-flags",
|
||||
"warch3-ops",
|
||||
"wauto1-evidence",
|
||||
"live-smoke-env",
|
||||
"oa-public-write-flows",
|
||||
"r7e-reimbursement-finance",
|
||||
"r7e-purchase-attachment",
|
||||
"procurement-contract-project",
|
||||
"mobile-entry-matrix",
|
||||
"wecom-rendering-matrix",
|
||||
"wecom-entry",
|
||||
];
|
||||
|
||||
const steps = Object.fromEntries(stepNames.map((name) => [name, step(name)]));
|
||||
const childReports = {
|
||||
warch3: child("warch3-ops"),
|
||||
wroad2: child("warch3-ops/wroad2-ops"),
|
||||
wauto1: child("wecom-controlled-trial-evidence"),
|
||||
};
|
||||
|
||||
const findings = [];
|
||||
for (const [name, item] of Object.entries(steps)) {
|
||||
const status = safeStatus(item.status);
|
||||
if (status === "PASS" || status === "SKIP") continue;
|
||||
if (name === "oa-public-write-flows" && /PAUSED/.test(status)) {
|
||||
findings.push({ source: name, status, severity: "manual_or_config" });
|
||||
} else if (isBad(status)) {
|
||||
findings.push({ source: name, status, severity: item.kind === "required" ? "blocker" : "finding" });
|
||||
} else if (isGap(status)) {
|
||||
findings.push({ source: name, status, severity: "manual_or_config" });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, item] of Object.entries(childReports)) {
|
||||
const status = safeStatus(item.status);
|
||||
if (status === "PASS" || /^PASS_/.test(status)) {
|
||||
if (/FINDINGS/.test(status)) {
|
||||
findings.push({ source: name, status, severity: "finding" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
findings.push({ source: name, status, severity: isBad(status) ? "finding" : "manual_or_config" });
|
||||
}
|
||||
|
||||
const manualGates = [
|
||||
{
|
||||
id: "WROAD2-BIZ1",
|
||||
status: "PENDING_MANUAL_CONFIRMATION",
|
||||
owner: "SMOKE002/SMOKE101/HYD000006",
|
||||
requiredEvidence: "三天打卡、请假、补卡、详情后审批和 OA 历史查看",
|
||||
},
|
||||
{
|
||||
id: "WAPPR1",
|
||||
status: "PENDING_MANUAL_CONFIRMATION",
|
||||
owner: "HYD000006",
|
||||
requiredEvidence: "四类模板均为 A:审批人自动带出且申请人不能任意选择",
|
||||
},
|
||||
{
|
||||
id: "WEXT1-LIVE",
|
||||
status: "PENDING_USER_APPROVAL",
|
||||
owner: "human_lead",
|
||||
requiredEvidence: "WAPPR1=A 且 WEXT1LD dry-run 通过后,是否允许 live 创建外出/加班样件",
|
||||
},
|
||||
{
|
||||
id: "WREAL1",
|
||||
status: "PENDING_USER_APPROVAL",
|
||||
owner: "human_lead",
|
||||
requiredEvidence: "三天全域主路径、四类测试号证据和 WAPPR1 通过后,是否引入真实员工",
|
||||
},
|
||||
];
|
||||
|
||||
let status = "PASS_FULL_GOAL_SMOKE_READY";
|
||||
if (Number(statusInput || 0) !== 0 || findings.some((item) => item.severity === "blocker")) {
|
||||
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
} else if (findings.length || Number(findingCountInput || 0) > 0) {
|
||||
status = "PASS_WITH_FINDINGS";
|
||||
} else if (manualGates.some((gate) => gate.status.includes("PENDING"))) {
|
||||
status = "PAUSED_MANUAL_CONFIRMATION";
|
||||
}
|
||||
|
||||
const summary = {
|
||||
issueId: "WFULLSMOKE1",
|
||||
mode: "three-day-full-goal-smoke",
|
||||
status,
|
||||
conclusion: status,
|
||||
baseURL,
|
||||
commit,
|
||||
checkedAt: new Date().toISOString(),
|
||||
reportDir: path.resolve(reportDir),
|
||||
accounts: {
|
||||
applicant: "SMOKE002",
|
||||
approver: "SMOKE101",
|
||||
adminObserverEmployeeNo: "HYD000006",
|
||||
},
|
||||
scope: {
|
||||
fullDomain: true,
|
||||
wecomHrAttendance: true,
|
||||
oaPrimaryDomains: ["reimbursement", "procurement", "contracts_projects", "payment_batch", "mobile_wecom_entry"],
|
||||
noRealEmployees: true,
|
||||
},
|
||||
boundaries: {
|
||||
oaTestDataAllowed: true,
|
||||
testMarkerRequired: "TEST/SMOKE/WFULL",
|
||||
noRealPayment: true,
|
||||
noErpWrite: true,
|
||||
noBankStatementImport: true,
|
||||
noWeComWrites: true,
|
||||
noOutingOvertimeFixtureLive: true,
|
||||
},
|
||||
steps,
|
||||
childReports,
|
||||
findingCount: findings.length,
|
||||
findings,
|
||||
manualGates,
|
||||
nextQuestions: {
|
||||
wappr1: [
|
||||
"A:审批人自动带出,申请人不能任意选择。",
|
||||
"B:审批人默认带出,但申请人仍可改人。",
|
||||
"C:必须申请人手选审批人。",
|
||||
"D:当前看不到或模板未配置。",
|
||||
],
|
||||
wext1Live: [
|
||||
"A:批准一次 live 创建外出+加班 OA-POC 样件,保留审计。",
|
||||
"B:只保留 dry-run,不创建样件。",
|
||||
"C:外出/加班移出 V1。",
|
||||
],
|
||||
finalCloseout: [
|
||||
"A:接受主路径闭环通过,进入外出/加班 live 或 WREAL1 准备。",
|
||||
"B:接受 PASS_WITH_FINDINGS,但先处理指定 finding。",
|
||||
"C:暂停,不进入 live、真实员工试用或镜像。",
|
||||
],
|
||||
},
|
||||
privacy: "status/count/report-path summaries only; no phone/email/token/Secret/EncodingAESKey/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload/XML/ciphertext",
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
||||
console.log(`[wfullsmoke1] summary=${path.join(reportDir, "summary.json")}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
finish() {
|
||||
write_manual_checklist
|
||||
write_summary_json || STATUS=1
|
||||
run_privacy_smoke
|
||||
log "[wfullsmoke1] report=$REPORT_DIR"
|
||||
exit "$STATUS"
|
||||
}
|
||||
|
||||
trap finish EXIT
|
||||
|
||||
load_default_env
|
||||
run_ready_and_flags
|
||||
run_wecom_and_archive_chain
|
||||
run_oa_business_chain
|
||||
run_mobile_and_wecom_entry
|
||||
在新工单中引用
屏蔽一个用户