Skip to content
WeSmile

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.

  1. 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 the error constant and never compares message — copy changes, and it changes with the language.

  2. 02401 has three states

    TOKEN_EXPIRED triggers a single-flight refresh and replays the request. TOKEN_MISSING and TOKEN_INVALID drop to signed-out. Collapse the three and one expiry kicks the user back to the login page.

  3. 03Cursor pagination

    {start, limit} returns {elements, total, hasMore, nextStart}. start is an opaque cursor: the next page can only use the nextStart you were given. Computing start + limit yourself interleaves pages.

  4. 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.

  5. 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:123 reference — 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

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: bindtap pointing at a method that doesn't exist does nothing when tapped; a wx:if on 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-muted when the icon table only has the icon-close-muted family. 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/message and 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.

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.