Architecture
How four surfaces stay one product
There's only one answer: every rule lives on the server, and the four surfaces are its displays. Here's what that means in practice.
The stack
- Server
- Spring Boot 4 · Spring Data JPA · Redis · MySQL 8 · JDK 25
- Web & console
- Next.js 16 App Router · shadcn/ui · Tailwind CSS 4 · TanStack Query
- Mini program
- Native WeChat framework (not uni-app or Taro) · TypeScript logic layer
- Storage
- MySQL 8 · Redis · object storage with direct client upload
Sliced by business domain
Not by controller / service / repository — that puts a hundred files in one directory and makes a single change span four of them. Now, changing the tree hole means opening one directory.
- admin126
the entire console API: review, placements, orders, permissions
- user69
profiles, following, privacy, check-in, reports, roles
- message65
system messages, DM quota, WebSocket, push
- ugc63
posts, topics, likes/saves/shares, search
- campus48
schools, faculties, campuses, places
- showcase39
placements, emoji, poems, carousels, shortcut tiles
- form35
questionnaires, questions, weekly matching
- student34
verification by email, photo or quiz
- activity27
events, sessions, sign-ups, event comments
- score23
points, top-ups, redemption, payments
- organization22
clubs and club membership
- course21
courses, teachers, rooms, reviews
- treehole16
the anonymous tree hole
- tracking14
launch events and behavioural logging
- attachment14
attachments — shared by many domains, so its own
- wechat9
mini-program QR sign-in and internal callbacks
- idle11
the second-hand marketplace
- ai11
AI providers and content pre-screening
- comment7
comments — split out of ugc, shared by four content types
The number is how many files live in that domain; bars scale against the largest one. Only genuinely cross-domain code goes in shared.
The two surfaces that live in a browser
"Students can use it in a browser" and "a campus admin can run it themselves" — neither claim is one that text alone can carry.
Web app
Same backend, same accounts and same content as the mini program.
Feed: three columns on desktop, range switcher top right A post and its threaded comments Messages: a one-to-one thread with a classmate Mini-program QR confirmation: the current account is shown before web sign-in
Admin console
Review, placements, quiz banks, matchmaking and orders all live here.
Dashboard: a campus admin sees their own school's numbers Verification review: the AI's reasoning sits in plain view, a human makes the call (ID photo blurred here) Moderation: deletes are soft — tick "include deleted" to look back and restore in one click Matchmaking desk: pending and paired at a glance — a person decides Targeting: one carousel can run for a single school, or even a single campus Generic orders: what was bought is a field, not a hard-coded "top-up"
The API contract
These five are fixed. Four independently written clients only work because they don't move.
01HTTP status codes carry meaning
Success is a 2xx whose body is the data itself, with no envelope. Failure is the matching status code with a body of
{error, message}. Branching reads theerrorconstant and never comparesmessage— copy changes, and it changes with the language.02401 has three states
TOKEN_EXPIREDtriggers a single-flight refresh and replays the request.TOKEN_MISSINGandTOKEN_INVALIDdrop to signed-out. Collapse the three and one expiry kicks the user back to the login page.03Cursor pagination
{start, limit}returns{elements, total, hasMore, nextStart}.startis an opaque cursor: the next page can only use thenextStartyou were given. Computingstart + limityourself interleaves pages.04Null fields are omitted
The server never sends null. So before reading any nullable field, answer one question out loud: what does it mean when this key is absent? Write the answer in a comment — it's more reliable than remembering it.
05Error copy is localised
The server picks the language of an error message from
Accept-Language. Clients therefore send the language of their own interface rather than forwarding the browser's — otherwise English strings surface inside a Chinese UI.
Tokens and sessions
Student side
Self-issued opaque tokens held in Redis. The access token lasts four hours and renews on every request; the refresh token lasts seven days and is single-use. Logging in again revokes the previous session. Redis holds only a
User:123reference — the user record is re-read on every lookup, so a penalty or a change of verification status takes effect immediately rather than at token expiry, and no serialised user object sits in the cache.Two ways in
The mini program uses WeChat code login. The web app displays a mini-program code that opens a confirmation page. The signed-in mini-program user explicitly confirms, and the browser polls for a one-time web token. Identity comes directly from the mini-program session.
Admin side
A completely separate account system; tokens are not interchangeable. The access token is a 30-minute JWT whose id is registered server-side so it can be revoked instantly; the refresh token is opaque, lasts seven days and burns on use — which makes single-flight refresh in the client a requirement, not an optimisation.
What each surface had to give up
The web must proxy through its own server
CORS is registered only for the admin endpoints; the student-facing API sends no cross-origin headers at all — a mini program isn't a browser, and opening CORS for the student API would only widen the attack surface. Direct browser calls are blocked by same-origin, so every request goes through a Next route handler.
WebSocket bypasses the proxy
Route handlers don't forward protocol upgrades, so the socket connects to the server directly. Browsers can't set custom headers on a WebSocket handshake either, so the token travels as a query parameter — a path the server's token filter supports. With no socket configured the whole thing is skipped; the list already polls as a fallback.
Images go straight to storage
The server only signs a short-lived upload form; the file goes from the client to the bucket and never touches the application server. That saves bandwidth and keeps one large upload from tying up a connection.
The mini program splits into bundles
Only high-traffic screens sit in the main bundle; courses, matching, photography, the tree hole, maps, events, the marketplace and points each get their own — so the first open downloads the main bundle alone.
These checks were paid for with outages
The most common failure on this project has one thing in common. It doesn't raise an error: no exception, no red log, the request still returns 200 — the thing simply didn't happen. Review can't catch it (the code looks fine) and unit tests can't either (test what? that one string equals another?). So each class of it gets its own guard. Every entry below is something that actually bit us.
A template bound to something that isn't there
Mini programs fail silently on dangling bindings:
bindtappointing at a method that doesn't exist does nothing when tapped; awx:ifon a field that doesn't exist is permanently false, so that block never appears. Renaming one side and not the other looks exactly like this — a blank area and not one line in the console.One wrong word in an icon class name
Icons are pure CSS background images, so a wrong class name leaves the spot simply empty — and "empty" looks identical to "there was never an icon here". Rewriting the web login confirmation page introduced
icon-info-mutedwhen the icon table only has theicon-close-mutedfamily. The circle in the result state was blank. It took reading the icon table line by line to find; the page never said a word.Push template ids hard-coded in the client
A dozen call sites each hard-coded a template id string, while the server's quota accounting keyed off a different table. Neither matched the other. The permission sheet still appeared, users still tapped allow, the endpoint still returned 200 — the pushes just never arrived. That was the root cause of a real outage here.
A missing translation shipped verbatim to the user
The server returns message keys and sends them through untranslated when the table has no entry. A student withdrawing a request that no longer exists saw a dialog reading
entity.school_application. It only surfaces at the moment that particular 404 really happens — endpoint fine, feature fine, everything normal until then.A blocklist that matches on strings
The restricted-actions list holds path strings with no compile-time link to the controller annotations. Rename
/dm/messageand that entry stops matching — from that moment a muted user can send DMs, with no test going red and no log making a sound. A stale allowlist entry merely fails to permit one thing; a stale blocklist entry permits one thing.A whole entity dropped into the response
Putting a JPA entity straight into a VO ships whatever the entity carries. The seller field on a marketplace listing carried openid, unionId, phone, student email and address that way — a list page returns a full page of them at once. The irony: the eight privacy toggles guard QQ, WeChat id and birthday, far lighter data, and those have a unit test watching them.
A ratchet, not a checklist
What isn't cleaned up yet — server-side Chinese strings still hard-coded, say — doesn't get written down as a "to migrate" list. A list is a second source of truth: it goes stale, and nobody reconciles it. Instead the test itself names the remaining files. Add one and the build goes red on the spot; convert one and you delete it from the list. The list only ever gets shorter.
A check that cries wolf is worse than no check
First it gets ignored, then it gets worked around, and finally the real problems it catches go unread too. So every check narrows its criteria deliberately and reports only certain errors: nested property access goes unchecked (that would need type information), the wxs linter only bans forms that really did fail to compile on a device, and the nav-bar check compares resolved tokens rather than class names — matching class names would flag seven or eight perfectly correct pages.
- 121
- server test classes
- 11
- mini-program checks
What deploying needs
The software is self-hosted. The real prerequisites are these external accounts — and two of them take longer to obtain than the code takes to write.
A server
Runs the app, MySQL 8 and Redis. JDK 25.
Object storage
Images and video, with CORS rules and a serving domain configured.
A mini-program account
The student entry point.
A merchant account
Only if you want top-ups.
A sending mailbox
Where email verification codes come from.
Every credential is injected from the environment. If one is missing the app fails at startup and names it — it never runs with an unresolved placeholder.