文件
hyb-oa/.trellis/spec/hyb-oa/wecom-boundary.md
T
wangsli da35591bd5
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
2026-08-01 09:25:44 +08:00

35 KiB

Enterprise WeChat Boundary

Ownership

Use an Enterprise-WeChat-first, OA-complements model for this single-company, 5–10-person internal system:

  • Enterprise WeChat owns mature commodity capabilities: workbench entry, OAuth/SSO, directory and basic organization, application messages, HR/attendance actions and low-risk, simple-field, mobile-first approvals.
  • General administration and simple procurement with no downstream financial handling, sensitive attachment or cross-business ledger may use Enterprise WeChat for submission and approval after a dedicated task verifies the real template and approval chain. OA retains only a minimum reference when needed.
  • OA owns the complete reimbursement journey: request, invoice upload/PDF/OCR and duplicate detection, approval, finance review, payment status and receipt. Enterprise WeChat provides SSO/workbench entry, dehydrated notifications and exact deeplinks into the relevant OA page; users must not re-enter or chase the same reimbursement across both systems.
  • OA also owns financial ledgers, payment/AP and maker-checker, contract bodies, sensitive attachments, cross-business relationships and long-term audit.

This boundary is a selection rule, not evidence that any existing procurement or administration workflow has already migrated.

Rules

  • Enterprise WeChat API checks that need trusted IP must run from the deployment server, not local.
  • Do not write Enterprise WeChat, create templates, create approvals, expand real users, or enable live trials without explicit approval and a Trellis task.
  • Approval-rule configuration cannot be fully read through official APIs; keep manual backend/real-device gates where specified.
  • Messages to Enterprise WeChat must be dehydrated and link back to OA; do not send sensitive attachments or business documents into native WeCom approvals.
  • Prefer a WeCom-native mobile form over duplicating it in OA when the process is simple and low-risk. Do not build full payload mirrors, bidirectional writeback, duplicate native approval UIs or generic approval-rule operations unless a later task proves a concrete business and audit need.
  • For every WeCom approval promoted to formal use, manually verify the backend rule, applicant-visible mobile submission page and actual post-submission approval chain; API template metadata alone is insufficient.

Scenario: Native File Preview With Narrow Short-Lived Grants

1. Scope / Trigger

  • Trigger: an authenticated OA page inside the Enterprise WeChat self-built app must preview a reimbursement invoice or supporting file with the official previewFile JSAPI.
  • Purpose: WeCom's native downloader does not reliably carry the WebView cookie or OA bearer header, while ordinary PDF links may download instead of preview. This is a narrow native-preview bridge, not a general query-token download design.

2. Signatures

  • JS-SDK signature: POST /api/auth/wecom/js-sdk-signature, authenticated body { url: string }; response { corpId, timestamp, nonceStr, signature }.
  • Invoice grant: POST /api/invoice-pool/:id/signed-url; response includes { signedUrl, expiresAt, originalName, mimeType, fileSize }.
  • Reimbursement attachment grant: POST /api/reimbursements/:id/attachments/:attachmentId/preview-url, optional body { reason }; response uses the same file metadata shape.
  • Reimbursement material bundle: POST /api/reimbursements/:id/materials/preview-bundle, optional body { reason }; response contains one short-lived merged PDF URL plus exact name, size and pageCount.
  • Native call: previewFile({ url, name, size }); name includes an extension and size is the persisted positive byte count.

3. Contracts

  • Fetch and cache the WeCom JSAPI ticket only on the server. Never return or log the ticket, app access token, Agent Secret or raw provider response.
  • Sign only the first configured OA APP_ORIGIN; reject cross-origin URLs, URL credentials and fragments. The signed page URL excludes the hash.
  • A native file grant expires in at most 120 seconds and binds purpose, exact business object, exact file object and actor. It is signed only after current authentication, business-read permission and download-reason checks pass.
  • Invoice access may reuse the existing current-pending-task exception. Do not grant the approver invoice-management, edit, unbind or delete permission.
  • The native GET writes a successful access audit before sending headers or bytes and returns private, no-store plus no-referrer.
  • PC keeps its browser/authenticated download behavior. Only a detected WeCom WebView dynamically loads @wecom/jssdk and calls previewFile; do not send the actual file through a WeCom message or native approval payload.
  • Whole-reimbursement continuous preview derives one deterministic PDF: PDF pages are copied and images are embedded as pages, then WeCom receives one previewFile call. The derivative is explicitly not an original or digital- signature fact source; individual originals remain separately authorized.
  • JS-SDK is allowed only on authenticated business pages that need a native capability and is dynamically imported behind WeCom detection. The hosted PC login/password/email-code page remains script-free as required by the Hosted PC WeCom Web Login scenario below.

4. Validation & Error Matrix

  • Missing authentication on signature/grant issue -> 401.
  • Cross-origin page URL -> 403; malformed, credential-bearing or fragment URL -> 400, before any provider call.
  • Ticket/token provider failure -> stable 503 detail without provider values.
  • Missing file metadata, extension or positive integer size -> frontend stops before previewFile and shows a stable error.
  • Missing required download reason -> 400 { reason: "required" }.
  • Expired, tampered, wrong-purpose or object-mismatched file grant -> 403.
  • Access audit failure -> 5xx with no file body.
  • Missing/invalid merged PDF URL, name or size -> frontend stops before the bridge call. Bundle expiry/cache loss -> safe fallback to individually authorized originals; do not broaden token scope or extend it silently.

5. Good/Base/Bad Cases

  • Good: a pending reimbursement approver opens a 120-second invoice URL through previewFile, sees the original in WeCom and receives no edit capability.
  • Base: a PC user opens the same invoice through the browser and downloads an attachment through the existing authenticated Blob path.
  • Base: a WeCom approver requests one authorized reimbursement PDF bundle, then previewFile opens it for continuous native scrolling.
  • Bad: expose a long-lived generic ?token=, trust a caller-supplied size/name, put a provider ticket in the response, or use async window.open(blob:) in WeCom and assume desktop popup behavior applies.

6. Tests Required

  • Route tests cover same-origin signing, provider failure/redaction and rejection of cross-origin, credential-bearing and fragment URLs before provider access.
  • Grant tests assert exact object/actor binding, at-most-120-second expiry, metadata, reason gate, issue audit, access audit, tamper/expiry/mismatch denial and audit-failure fail-closed behavior using synthetic files only.
  • Playwright injects a synthetic WeixinJSBridge, asserts previewFile receives the exact URL/name/size for invoice and attachment, and asserts no window.open call in WeCom. PC assertions keep browser URL and Blob behavior.
  • Continuous-preview Playwright asserts one previewFile call with exact URL, filename and byte size. Backend tests separately prove deterministic output, exact response size and object/actor/bundle binding.
  • Production deployment and real-device preview remain separate approvals.

7. Wrong vs Correct

Wrong

await downloadWithBearer();
window.open(URL.createObjectURL(blob)); // WeCom may block after await

Correct

authenticated grant issue -> <=120s object-bound URL
-> ww.previewFile({ url, nameWithExtension, exactSize })
-> audit before file bytes

authenticated material-bundle issue -> one <=120s deterministic PDF URL
-> ww.previewFile({ url, nameWithExtension, exactSize })
-> audit before exact cached PDF bytes

Scenario: WeCom Member Data Is A Read-Only Source For OA

1. Scope / Trigger

  • Trigger: importing directory members, changing OA employee/profile or manager routes, enabling directory sync, or adding any WeCom member create/update/disable call.
  • Purpose: keep WeCom as the member-account fact source and prevent an ordinary OA account-management action from unexpectedly changing the real WeCom directory.

2. Signatures

  • Approved inbound CLI: npm run import:wecom-directory-export -- --input=- --dry-run|--apply; apply retains its existing dual gate and writes OA only.
  • OA workflow manager field: Employee.direct_manager_id; it is not the WeCom directory supervisor field.
  • Outbound safety gates: WECOM_CONTACT_SYNC_ENABLED=false and effective feature flag hr_wecom_directory_sync_v1=force_off for formal internal operation.
  • Existing outbound API family /cgi-bin/user/create|update and disable payloads remain unavailable to ordinary OA user/manager maintenance.

3. Contracts

  • A directory export or approved read API may create/update the minimum OA employee mirror and WecomIdentityBinding; it must not create, update or disable the WeCom member.
  • WeCom name, department, supervisor, phone, email and active state are edited by the member in an allowed WeCom surface or by a WeCom administrator. OA learns later changes through another approved inbound read/import.
  • Setting direct_manager_id changes only OA workflow routing. It does not imply or trigger a WeCom supervisor update.
  • An OA account route may record a skipped directory-sync run while the outbound gate is disabled, but it must stop before token acquisition or an external member-write request. The skipped state is evidence of zero external writes.
  • Any future reverse member sync requires a separate Trellis task, per-field side-effect design, dry-run, explicit production approval and rollback. It may not be introduced as an implicit side effect of an existing OA route.

4. Validation & Error Matrix

  • Import input conflicts or is ambiguous -> block the OA write; never merge or repair the WeCom member automatically.
  • OA manager/profile action with outbound gate disabled -> OA transaction may succeed, outbound sync is skipped, zero WeCom access-token/member-write calls.
  • Effective outbound flag unexpectedly enabled during ordinary internal operation -> deployment/operation finding; do not exercise the write path to diagnose it.
  • A requested WeCom profile change -> direct the member or WeCom administrator to WeCom; do not patch the OA mirror and push it outward.

5. Good/Base/Bad Cases

  • Good: administrator exports one existing member, OA imports one employee account and identity binding, then sets an OA-only manager while outbound sync remains skipped.
  • Base: a member later changes phone/email in WeCom; OA keeps the previous mirror until the next approved inbound refresh.
  • Bad: POST /api/users/batch/set-manager silently calls /cgi-bin/user/update, or OA profile editing becomes a back door for changing the WeCom directory.

6. Tests Required

  • Directory-export tests prove stdin-only parsing, dual apply gate, employee-only role and no outbound client invocation.
  • User manager/profile route tests assert an effective disabled outbound gate produces skipped before token acquisition/fetch while preserving OA audit.
  • Env/feature-contract checks keep the formal default off and reject a production claim that relies on an unapproved reverse-sync flag.
  • A separately proposed reverse-sync task must add mocked API payload assertions, negative permission/conflict cases and explicit live approval evidence.

7. Wrong vs Correct

Wrong

OA set-manager/profile update -> WeCom /user/update -> real member changed

Correct

WeCom member/admin edits source data -> approved inbound read/import -> OA mirror
OA direct_manager_id update -> OA workflow only; outbound member sync skipped

Scenario: Compensatory Leave Is A WeCom Leave Category

1. Scope / Trigger

  • Trigger: adding, displaying, validating or operating compensatory leave (调休) in the WeCom-owned HR/attendance flow.
  • Purpose: support the required leave category without creating a fifth approval integration, a parallel OA submission journey or an unneeded balance engine.

2. Signatures

  • WeCom approval target remains leave; the existing target set remains leave, missed_punch, outing, overtime.
  • WeCom template/form category label: 调休 under the leave template.
  • OA read-only archive fields: normalized leave category, interval, status and minimum approval summary. No new ordinary OA submit/approve API is implied.

3. Contracts

  • WeCom owns employee submission, approval and normal mobile history. OA must distinguish 调休 from other leave categories in read-only history but must not expose a parallel ordinary submission or approval flow.
  • The initial internal-team scope does not build an OA compensatory-leave balance, automatic overtime-to-leave conversion, carry-over engine or payroll calculation. If WeCom native balance management is enabled, it is the source of truth; otherwise HR uses a controlled manual rule until a later task proves automation is worth maintaining.
  • Template metadata is insufficient acceptance evidence. Formal use requires a backend check, applicant-visible real-device form and actual submitted approval chain with no self-approval.

4. Validation & Error Matrix

  • WeCom backend lacks 调休 -> remain not ready; do not emulate it with an OA ordinary form.
  • Real-device form does not show 调休 -> PAUSED_MANUAL_CONFIRMATION.
  • Applicant can select/change the approver or becomes the approver -> blocker; do not enable real-team use.
  • OA archive cannot distinguish the leave category -> keep the data read-only but do not claim compensatory-leave acceptance.
  • WeCom native balance is absent -> use the approved manual HR rule; do not silently create an OA balance ledger.

5. Good/Base/Bad Cases

  • Good: a test user selects 调休 in WeCom, a different real approver handles it, and OA read-only history displays 调休, interval and final status.
  • Base: WeCom manages the request while HR maintains the small-team balance by a controlled manual rule; OA stores no balance.
  • Bad: introduce a compensatory_leave approval target and duplicate submit, balance and approval state across OA and WeCom.

6. Tests Required

  • Unit/fixture coverage proves a WeCom leave detail containing 调休 remains target leave and preserves the category in the minimum archive/display path.
  • Mocked frontend coverage proves ordinary employees see read-only history and no OA ordinary compensatory-leave submit action.
  • Formal-use evidence must include the backend option, real-device form and actual no-self-approval chain; live writes require a separately approved task.

7. Wrong vs Correct

Wrong

调休 -> new compensatory_leave target -> OA balance -> OA approval -> WeCom sync

Correct

WeCom leave(category=调休) -> WeCom approval -> OA minimum read-only history

HR / Attendance UI Contracts

  • 请假记录 is the PC leave entry label and page title. Ordinary employees must see records, balances and a WeCom-owned explanation, not an OA leave creation form.
  • OA leave creation, edit, submit and withdraw controls are emergency-only UI. They require leave.manage and must be labeled with OA 应急 / 应急草稿 / 提交应急审批; do not expose them based only on leave.create.
  • Mobile leave defaults to records for ordinary employees. Hide the form tab and row actions unless the user has leave.manage; keep the first card explicit: 企业微信主办 and 只读.
  • Attendance correction forms are emergency-only UI. They require attendance.manage and use OA 应急修正 / 提交应急修正. Ordinary employees see a WeCom handling explanation for missed punch, outing and overtime.
  • WeCom approval archive pages are read-only history and reconciliation. They may show status/action history labels from WeCom data, but must not offer OA approve/reject/return/transfer actions.

Tests Required

  • Mocked frontend Playwright coverage should assert that ordinary employees do not see OA leave/correction create actions, while admin/HR roles retain explicitly labeled emergency entry points.
  • Full-stack Playwright coverage that writes local data still requires a running local backend/API environment; a Vite-only run is not a valid failure signal for those real-chain tests.

Scenario: One-Time Private-Info OAuth Onboarding

1. Scope / Trigger

  • Trigger: obtaining phone/email for an app-visible member or allowing an unbound active member to create the minimum OA account.
  • This is a one-time onboarding path, not the ordinary WeCom login path and not a replacement for administrator-assigned finance, CEO or admin roles.

2. Signatures

  • Entry: GET /api/auth/wecom/login?mode=onboard&returnTo=<internal-path>.
  • Ordinary entry: the same route with mode=login|bind.
  • Provider chain: snsapi_privateinfo -> /cgi-bin/auth/getuserinfo -> /cgi-bin/auth/getuserdetail with { "user_ticket": "<short-lived>" }.
  • Config: WECOM_OAUTH_ONBOARDING_ENABLED=false by default.
  • Directory chain: WECOM_CONTACT_SECRET only for /user/list_id; WECOM_AGENT_SECRET for /user/get, /department/list and /hr/get_staff_info.

3. Contracts

  • login|bind always keeps snsapi_base; only explicit onboard uses snsapi_privateinfo.
  • The member must be visible to the app, active, return the same userid from both identity calls, and authorize a valid personal email or business email. Phone may be absent; when returned it must be normalized and checked for uniqueness. A blank/invalid personal email may fall back to a valid business email.
  • user_ticket, OAuth code, access token and raw provider responses exist only in request memory. Do not persist them or include them in Cookie, audit, response details or logs; provider/network failures use fixed safe codes.
  • Resolve userid, phone and email conflicts before writing. A member already bound to another employee or an employee bound to another member is rejected.
  • New accounts are created transactionally with employee, exactly the employee role, identity binding and dehydrated audit. Existing active accounts may complete normalized profile data and binding but keep all roles.
  • The feature flag controls the entry and callback provisioning path. Enabling it and changing real app permissions are production configuration operations requiring separate approval.

4. Validation & Error Matrix

  • Flag disabled -> entry is 404; no private scope or account write.
  • Missing user_ticket -> safe consent_required redirect; no write.
  • Provider/network failure -> wecom_api_failed or safe auth error; no raw payload, token or ticket in output/logs.
  • Userid mismatch -> identity_mismatch; inactive member -> member_inactive.
  • Both emails invalid or unresolved department -> missing_required_field.
  • Phone absent -> continue with NULL; phone present but conflicting -> employee_conflict.
  • Multiple employees, unique-field race or cross-binding -> employee_conflict.
  • Department binding points to missing/archived data -> department_invalid; multiple same-name OA departments -> department_ambiguous.

5. Good/Base/Bad Cases

  • Good: an unbound active member explicitly consents, receives an employee-only account and authenticated session, then an administrator separately assigns any business role with existing audit/session-revocation semantics.
  • Base: a bound active employee repeats onboarding to complete normalized profile data; no role is added or removed.
  • Bad: change ordinary login to snsapi_privateinfo, use batch /user/get absence as proof that email is unavailable, or auto-grant finance/ceo.

6. Tests Required

  • Route tests for default-off, scope separation, missing ticket, safe provider failure, successful session and employee-only provisioning.
  • Service tests for active state, userid match, personal-to-business email fallback, invalid fields, multiple matches, cross-binding and existing-role preservation.
  • Assertions must prove ticket, token, raw payload, phone and email are absent from audit/error serialization; run full backend tests and Prisma validation.

7. Wrong vs Correct

Wrong

ordinary login -> snsapi_privateinfo -> batch sync guesses missing fields
-> auto-grant finance/ceo

Correct

ordinary login -> snsapi_base
one-time onboard -> explicit private consent -> safe unique employee-only write
business role assignment -> separate administrator-audited operation

Scenario: Passwordless First WeCom Login For Existing OA Accounts

1. Scope / Trigger

  • Trigger: changing ordinary mode=login, identity binding, directory-email matching, the password login fallback or account-activation readiness.
  • Purpose: every existing active OA employee must be able to enter from the WeCom workbench on the first attempt without entering an OA password, while ambiguous identities remain fail-closed.

2. Signatures

  • Entry: GET /api/auth/wecom/login?returnTo=<internal-path> with snsapi_base.
  • OAuth callback identity: internal userid from /cgi-bin/auth/getuserinfo.
  • Unbound member profile: GET /cgi-bin/user/get?userid=<oauth-userid> using the in-memory app access token.
  • Binding DB: unique WecomIdentityBinding.employee_id and WecomIdentityBinding.external_user_id.
  • Audit actions: AUTH_WECOM_AUTO_BOUND followed by AUTH_WECOM_LOGIN_SUCCESS; the latter records only bindingSource and the fixed allowlisted entrySource.

3. Contracts

  • Existing active binding is the fast path and does not call /user/get.
  • Without a binding, exact OA employee_no == userid is a valid stable match. A normalized WeCom biz_mail/email may also match one existing active OA Employee.email using case-insensitive exact equality. If userid and email point to different employees, stop.
  • Transactionally recheck both unique binding directions before create. A concurrent unique-constraint race is a conflict, not permission to overwrite.
  • Ordinary login never creates an employee, updates roles, writes the WeCom directory, matches by name/phone, or consumes a pending cookie during OA password login.
  • Access tokens, full userid, enterprise email and raw provider payload stay in request memory. Auto-bind audit contains only matchBy and emailMatched; errors and redirects use stable categories.
  • Missing enterprise email does not block an exact userid/employee-number match. Without either stable match, no session or binding is created.

4. Validation & Error Matrix

  • Existing binding + active employee -> direct session.
  • Existing binding + inactive employee -> account_mapping_inactive.
  • /user/get network/API failure or no OA match -> account_mapping_required; no write.
  • WeCom member inactive or matched OA employee inactive -> account_mapping_inactive; no write.
  • Userid/email point to different employees, either side already has a different binding, identity mismatch or binding race -> account_mapping_conflict; no overwrite.
  • Password login with a legacy wecom_login_bind cookie -> normal password login only, clear the cookie, return wecomBindingStatus=null.

5. Good/Base/Bad Cases

  • Good: an unbound active member's enterprise email matches exactly one active OA employee; callback creates one binding and audit, then redirects into the requested OA page without a password.
  • Base: a previously bound member uses the existing fast path and creates only the normal WeCom login session/audit.
  • Bad: bind by display name, overwrite another userid, create a new employee, or ask the member to enter an OA password to complete first binding.

6. Tests Required

  • Service tests: profile userid equality, member state, business-email normalization, userid-only match, email-only match, split-key conflict, inactive employee, cross-binding and redacted audit.
  • Route tests: existing binding fast path, passwordless auto-bind + session, provider-unavailable fail-closed redirect and no pending binding cookie.
  • Password-route regression: a legacy pending cookie cannot create/update a binding and is cleared.
  • Frontend typecheck/build and focused WeCom notice tests; full backend suite, Prisma validation, sensitive-content and Trellis governance gates.

7. Wrong vs Correct

Wrong

unbound OAuth userid -> password login -> consume pending cookie -> bind

Correct

OAuth userid -> existing binding OR exact userid/unique enterprise email match
-> transactional binding -> redacted audit -> direct WeCom session

Scenario: Hosted PC WeCom Web Login

1. Scope / Trigger

  • Trigger: adding or changing ordinary-browser WeCom login, the public WeCom config response, OAuth state/callback handling or login-entry audit.
  • Purpose: let an existing OA employee use the WeCom-hosted Web login page from a normal PC browser without loading third-party scripts beside OA password or email-code inputs.

2. Signatures

  • Public config: GET /api/auth/wecom/config returns pcWebLoginEnabled: boolean and pcLoginUrl: string | null separately from the workbench loginUrl.
  • OA entry: GET /api/auth/wecom/login?returnTo=<internal-path>&entrySource=pc_qr.
  • Provider entry: https://login.work.weixin.qq.com/wwlogin/sso/login with login_type=CorpApp, CorpID, AgentID, callback and signed state.
  • Shared callback: GET /api/auth/wecom/callback?code=<one-time-code>&state=<signed-state>.
  • Env gate: WECOM_PC_WEB_LOGIN_ENABLED=false by default.
  • Audit source: entrySource=pc_qr | wecom_workbench; mode remains login | bind | onboard.

3. Contracts

  • The OA login page uses a normal redirect to the WeCom-hosted page. Do not add @wecom/jssdk, a WeCom CDN script or a QR iframe to the credential page.
  • entrySource is independent of mode, is validated against a fixed server allowlist and never accepts pc_qr for bind/onboard. Missing source in an in-flight legacy state means wecom_workbench; an unknown signed value fails.
  • PC state remains a short-lived HMAC-signed payload and is correlated to the initiating browser with a short-lived HttpOnly, Secure-in-production, SameSite=Lax cookie. Validate and clear it before code exchange.
  • The cookie is a CSRF/browser-correlation control, not server-side nonce storage. Normal-browser replay fails after cookie clearing; deliberate replay of both URL and old cookie is finally stopped by WeCom's one-time, five-minute code contract and must not create a second OA session.
  • Reuse existing userid mapping, fail-closed auto-binding, session limit, device fingerprint, returnTo allowlist and structured mapping notices.
  • Successful login audit contains only bindingSource and entrySource. Code, state, nonce, cookies, full userid, Secret and raw provider payload stay out of logs, audit and browser error details.

4. Validation & Error Matrix

  • PC env gate off or base entry config incomplete -> pcLoginUrl=null; direct PC entry is unavailable while workbench/password/email-code behavior remains.
  • pc_qr + bind/onboard or unknown query source -> validation failure before redirecting to WeCom.
  • Tampered/expired state, unknown callback source, missing/mismatched/expired correlation cookie or missing code -> fail before provider code exchange.
  • Signed PC state after the PC gate is turned off -> clear the cookie and fail.
  • Existing binding/unique auto-match -> shared session flow; mapping required, conflict or inactive -> existing stable notice and no session.
  • Replayed URL plus old cookie -> provider rejects the already-consumed code; OA session and success audit counts remain unchanged.
  • External/absolute/protocol-relative returnTo -> safe internal default.

5. Good/Base/Bad Cases

  • Good: normal PC browser redirects to the hosted page, scans, returns with a correlated callback and reaches the requested internal OA page.
  • Base: WeCom workbench continues through snsapi_base with entrySource=wecom_workbench; missing source remains compatible during a rolling deployment.
  • Bad: add mode=qr, load an SDK next to password fields, expose Secret in the frontend, accept callback without browser correlation, or claim a signed cookie is a server-consumed nonce.

6. Tests Required

  • Route tests assert config gate, exact hosted host/path and parameters, signed source/state, secure cookie attributes and PC bind/onboard rejection.
  • Callback tests assert missing/mismatched/expired cookie, expired/tampered state, missing code, unknown source and gate-off failures occur before fetch.
  • Success/replay tests assert shared mapping and session behavior, sanitized entrySource, cookie clearing and exactly one created session.
  • Frontend E2E asserts PC-only button, safe returnTo/device fingerprint and no duplicate button in a WeCom user agent; typecheck and production build pass.
  • Run full backend tests, Prisma validation, external/readiness contracts, sensitive-content scan, process/Trellis gates and git diff --check.

7. Wrong vs Correct

Wrong

normal browser -> mode=qr -> third-party SDK on OA credential page
-> callback trusts signed state alone -> audit raw userid

Correct

normal browser -> entrySource=pc_qr -> OA sets correlation cookie
-> WeCom-hosted login -> shared callback validates state + cookie
-> one-time code -> existing userid mapping -> sanitized session audit

Scenario: WeCom Directory Export With Optional Phone

1. Scope / Trigger

  • Trigger: importing an administrator-exported WeCom directory XLSX, or changing Employee.phone across database, API, HR sync, OAuth and UI layers.
  • Purpose: allow a real member to remain temporarily without a phone while preserving stable identity, uniqueness and privacy controls.

2. Signatures

  • DB: Employee.phone String? @unique; migration drops only the NOT NULL constraint and keeps the unique index.
  • CLI: npm run import:wecom-directory-export -- --input=- --dry-run.
  • Apply: WECOM_DIRECTORY_EXPORT_APPLY=1 ... --input=- --apply --actor-employee-no=<employee-no>.
  • When apply runs through Compose v1, pass the confirmation variable into the container explicitly: docker-compose exec -T -e WECOM_DIRECTORY_EXPORT_APPLY server ...; exporting it only in the host shell does not satisfy the in-container gate.
  • Binary stdin reader: fs.readFileSync(0); do not assume fs.promises.readFile(0) accepts a numeric file descriptor on the production Node runtime.
  • User API and frontend employee/user types expose phone: string | null; create/update accepts an omitted or explicit null phone.

3. Contracts

  • Missing phone is stored as SQL NULL, never "", a repeated placeholder or a synthesized fake number. Multiple NULL rows are valid; non-null phones remain unique.
  • WeCom account and valid enterprise email remain required for directory-export onboarding. Account, non-null phone and enterprise email duplicates block the input.
  • An empty source phone never clears an existing OA phone. Only an explicitly supplied valid phone participates in update and unique-conflict checks.
  • The XLSX is accepted only from stdin and defaults to dry-run. Apply requires both --apply and WECOM_DIRECTORY_EXPORT_APPLY=1, plus an active actor.
  • Read the XLSX directly from fd 0 without a temporary file. A runtime that rejects the stdin read must stop before Prisma sync; do not fall back to persisting the workbook on the host or in the container.
  • CLI output contains counts and categories only. The workbook, full local path, names, accounts, phones, emails, departments and raw rows must not enter Git, Trellis artifacts or reports.
  • New accounts receive only employee; finance, CEO and admin roles remain a separate administrator-audited operation.

4. Validation & Error Matrix

  • Missing phone cell -> valid profile with phone=null.
  • Supplied malformed phone -> incomplete/blocked; do not downgrade it to an absent phone and do not write it.
  • Missing/invalid enterprise email, account, name or department -> skipped or blocked as missing_required_field.
  • Duplicate account/non-null phone/enterprise email in the workbook -> duplicate_values; error output contains counts only.
  • Existing OA unique-field or binding conflict -> conflict; no automatic merge.
  • --apply without confirmation env or active actor -> refuse before sync.
  • Confirmation env exported only on the Compose host -> apply_confirmation_required; no sync/write. Add the name-only -e inherit flag and rerun the same approved input.
  • Promise-based stdin fd read returns ERR_INVALID_ARG_TYPE on the deployed Node runtime -> no sync/write; use the fd-compatible reader and rerun dry-run before requesting apply approval.
  • Migration rollback with any phone IS NULL row -> stop; do not fill fake values merely to restore NOT NULL.

5. Good/Base/Bad Cases

  • Good: five members have unique phones, one member has no phone, all six have unique accounts and enterprise emails; dry-run plans six records and reports only aggregate counts.
  • Base: a later export still omits a member phone; the member's existing OA phone is preserved.
  • Base: the local and production Node versions differ; the same binary stdin workbook parses through fs.readFileSync(0) without a temporary file.
  • Base: Compose v1 inherits the already-set non-secret apply confirmation only when exec -e WECOM_DIRECTORY_EXPORT_APPLY is present.
  • Bad: write an empty string or shared placeholder, use personal email in place of the required enterprise email for bulk identity, or auto-grant a business role during import.

6. Tests Required

  • Prisma validation plus a migration review proving only DROP NOT NULL.
  • Unit tests for instruction rows/Chinese headers, five-present/one-missing, malformed phone, missing enterprise email and duplicate account/phone/email.
  • Unit regression asserts the CLI stdin helper calls fs.readFileSync(0); production dry-run must prove aggregate parsing/sync counts before apply.
  • Apply acceptance must include a negative preflight without the container env gate and a successful approved run with the name-only Compose -e flag; both outputs remain count/category only.
  • Route/service tests for create/update/import null, non-empty unique checks, OAuth missing phone and empty-source preservation of an existing phone.
  • Frontend typecheck/build and displays that render a clear empty-phone label.
  • Full backend regression, Trellis governance, sensitive-content scan and git diff --check; a real workbook may be parsed only into aggregate counts and remains Git-ignored.

7. Wrong vs Correct

Wrong

phone: source.phone || "00000000000"

Correct

phone: source.phone || null
// Existing employee updates omit `phone` entirely when the source is empty.

Wrong

const workbook = await fs.promises.readFile(0);

Correct

const workbook = fs.readFileSync(0);