Skip to content

Data model ​

Data tables are auto-created by JPA from the entities (ddl-auto: update), and all primary keys are auto-incrementing id. Every entity carries the audit fields create_time / update_time (maintained automatically by JPA Auditing; card_adjusts has only create_time); there are no foreign-key constraints between entities — they are associated via business id fields at the service layer.

Scope note: the detailed tables below cover the core business tables; tables such as acupoints, ad_schedules, ad_screens, dispense_logs, formula_items, formulas, herb_stock_logs, herbs, prescription_items, prescription_template_items, prescription_templates, recharges, review_customers, review_staffs, therapy_templates are authoritative in their entities and in api (TokenEntity is dead code and is not counted).

customers — patient ​

FieldTypeMeaning
idLongPrimary key
nameStringFull name (not null)
ageIntegerAge
levelIntegerMember level
phoneStringPhone number (unique, mainland China numbering)
openidStringMini program openid (unique, nullable); the phone card lookup's openid path binds and looks up cards by it — bare-phone-number card lookup is not done (2026-10-14 ruling)
genderIntegerGender (0 female, 1 male)
addressStringAddress
birthdayDateBirthday
beauty_consentIntegerSeparate consent for beauty sensitive info: 1 granted / 0 not (default 0; beauty phase d, PIPL)
beauty_consent_timeDateConsent time (written by the consent endpoint)
beauty_consent_staff_idLongHandling staff id (written by the consent endpoint)

Indexes: name, phone number, level, birthday, openid.

customer_histories — patient medical history (allergy / past history / care plan) ​

FieldTypeMeaning
idLongPrimary key
customer_idLongPatient id (not null, business foreign key, indexed)
typeInteger0 allergy history / 1 past history / 2 care plan (beauty phase d archive extension, not null)
contentStringContent (not null, free text, ≤200 characters)

Records are appended one by one (add/delete only, no editing), and queries return them in reverse id order. The demo seed adds allergy/past-history records for each of the two demo patients — see the patients group in api; the allergy cross-check reads type=0 only, type=2 holds beauty care-plan text.

customer_tags — customer tags (beauty phase d) ​

FieldTypeMeaning
idLongPrimary key
customer_idLongPatient id (not null, indexed)
tagStringTag (not null, ≤20 characters, e.g. 「肤质·油性」 oily skin, 「偏好·力度大」 prefers strong pressure)
create_time / update_timeDateAudit times

Unique constraint (customer_id, tag): one tag per customer. Lightweight skin-type/preference tags — allergies are not tags (history type 0) and care plans ride history type 2, never a parallel archive; PIPL separate consent is recorded on customers.beauty_consent. See the patients group in api.

treats — treatment record (core business table) ​

TCM treatment records; one patient can have multiple:

FieldTypeMeaning
idLongPrimary key
cidLongPatient id (not null, business foreign key)
staff_idLongTreating physician id (nullable, staffs.id; the signature semantics = which physician performed this treatment; historical records may be unsigned, and the name is echoed back from staffs on read)
cdescTEXTChief complaint
inquiryTEXTInquiry
observationTEXTInspection
palpationTEXTPalpation
pulseStringPulse presentation
pulse_left / pulse_rightTEXTLeft / right hand pulse presentation
pulse_infoTEXTPulse interpretation
five_ben_zi_* / five_zi_ben_*StringFive-phase · mother/child (generation) relations (left/right hand)
five_ben_ke_* / five_ke_ben_*StringFive-phase · restraint relations (left/right hand)
five_nan_jin_* (large/small, left/right)StringNan Jing (Classic of Difficulties) imagery
five_rate_left / five_rate_rightTEXTFive-phase ratios
five_aux_left / five_aux_rightTEXTFive-phase auxiliaries
acupoint_left / acupoint_rightTEXTPoint selection (left/right hand)
acu_method / manipulationStringAcupuncture prescription: needle technique (filiform needle / electroacupuncture / warm needle…), manipulation (reinforcing / reducing / even reinforcing-reducing…), ≤30 characters, blanks treated as empty
moxa_methodStringAcupuncture prescription: moxibustion method (mild moxibustion / sparrow-pecking moxibustion / indirect moxibustion…), ≤60 characters, blanks treated as empty
retention_minutesIntegerAcupuncture prescription: needle retention minutes (1-240, nullable)
acu_courseTEXTAcupuncture prescription: course / frequency (free text, ≤100 characters)
diagnoseTEXTDiagnosis
planTEXTConditioning plan
dietTEXTDietary advice
conditioningTEXTConditioning process
reviewTEXTFollow-up record

Indexes: patient id, creation time.

Treatment-timeline convention (2026-10-12 ruling; the "treatment records" list on the patient record): each prototype entry = item name + course tag + summary (physician · duration · pattern differentiation, treatment method, point selection). Physician comes from the staff_id signature (echoed as staffName on read; unsigned records carry no label); duration comes from retention_minutes needle-retention minutes (only acupuncture treatments have a value, and no value means no label — visit duration is never fabricated); course tag comes from acu_course; item name is not shown — treatment records have no association fields with items, service cards, or orders anywhere (treats has no item_id, orders has no treat_id, card_usages has no treat_id), and where there is no association nothing is fabricated; the item-name dimension is covered by the "cards held" panel on the same page and the service-card course markers (see the course convention). To attribute "treatment ↔ settlement", a new orders.treat_id plus defined attribution semantics would be required — a separate requirement.

acupoints (acupoint dictionary): name (acupoint name, unique, 1–10 characters), pinyin (full-pinyin lowercase search code, not null), meridian (meridian affiliation, not null), location (body-surface location), indication (indications), audit times. This is reference data for meridians and acupoints — the point-selection fields on the treatment record remain free text, and the dictionary only assists point selection (search by acupoint name/pinyin, append on selection); it participates in no other business validation.

items — service card ​

FieldTypeMeaning
idLongPrimary key
nameStringName (not null, unique)
priceIntegerPrice
descriptionTEXTDescription
enabledInteger1 on sale / 0 off sale (the Kiosk shows on-sale items only; existing rows default to 1)
coverStringCover image relative URL (nullable, hosted under /media)
duration_minutesIntegerDuration (minutes, nullable, for display)
categoryIntegerCategory: 0 clinic service (default; existing rows get 0 when the column is added) / 1 skin care / 2 body care / 3 TCM beauty (beauty extension item a, docs/design/salon-extension.md)
sortIntegerDisplay order, lower values first

reviews / review_staffs / review_customers — feedback and retrospectives ​

TableKey fieldsNotes
reviews (daily summary)day (unique, normalized to midnight of that day), good, improvementClinic-wide daily summary; saving on the same day overwrites
review_staffs (staff daily retrospective)staff_id, name, cost (cost incurred), done, advice, dayRecorded per staff member per day
review_customers (patient follow-up)customer_id, name, last (last visit time), day (follow-up day), adviceOnce day is due, the entry appears in the daily report reminders

orders / recharges ​

TableKey fieldsCurrent state
orders (orders)user_id (patient), item_id (service card), staff_id, status, priceWired up: Kiosk orders and front-desk orders both land as status=0 and snapshot the service card's current price (front-desk orders also record staff_id); status semantics: 0 ordered → 1 confirmed → 2 completed, 9 cancelled (0/1→9, only 9 can be deleted); admin-console paging and transitions in the orders group of api; settlement info lives in the settlements table (one settlement per order), and order paging joins out the payment method
recharges (stored-value transactions)user_id, money, note (nullable, ≤100 characters; refunds write back a note like "refund for order #settlement id")Wired up: top-ups are positive and stored-value payment deductions are negative, so balance = sum of the transactions; stored-value refunds return on the original path as a positive transaction (excluded from top-ups in daily reconciliation); endpoints in the stored-value group of api
order_item (order line items)order_id, item_id, name, price, qty, subtotalMulti-item orders write one line per item (name/price are snapshots at order time, qty≥1, subtotal = unit price × quantity); order endpoints always join out items[], and for legacy orders without line rows they fall back to a single row with quantity 1 based on itemId+price

settlements — settlement (billing) ​

FieldTypeMeaning
idLongPrimary key (settlement number, displayed in reverse chronological order)
order_idLongOrder id (not null; the original unique index was removed along with the refund convention — after a refund, re-billing one order can produce multiple settlements, and duplicate-charge prevention is now backstopped by a "most recent un-refunded" check)
user_idLongPatient id (not null, denormalized from the order for per-patient reconciliation)
pay_typeIntegerPayment method: 1 stored value / 2 WeChat / 3 Alipay / 4 cash / 5 visit-card write-off
moneyIntegerAmount actually received (CNY, taken from the order price snapshot; visit-card write-off records 0)
create_time / update_timeDateAudit times

Indexes: user_id (the order_id unique index was removed along with the refund convention; a startup migration DROPs legacy indexes left in old databases). The settlement action moves the order 0/1 → 2 completed; stored-value payment also writes one negative transaction to recharges; visit-card write-off deducts 1 from customer_cards remaining visits. WeChat/Alipay are demo-convention (the method is only recorded, with no real payment channel) — see the billing group of api.

customer_cards — patient card holdings (visit cards) ​

FieldTypeMeaning
idLongPrimary key
customer_idLongPatient id (not null, indexed, customers.id)
item_idLongService card id (not null, items.id)
total_times / remaining_timesIntegerTotal visits / remaining visits (issuing a card grants the full amount; write-off deducts from the remaining count)
statusInteger1 active / 0 suspended (column default 1; suspended cards do not participate in write-off)
source_order_idLongSource order of the card issue (nullable; empty for cards issued manually at the front desk)
valid_untilDateValid until (nullable = no expiry); expired cards do not participate in write-off
create_time / update_timeDateAudit times

Indexes: patient. Write-off picks the earliest card matching "same patient + same service card, active, unexpired, with remaining visits" and deducts 1 visit. Endpoints in the visit-card group of api.

Course convention (2026-10-12 ruling): a course = a service card — the Nth card of the same item (by create_time purchase order) is the Nth course, naturally derived from the card-purchase transactions; no "patient × service card completed-visits" ledger table is created; without a service card no course is tagged (item-level visit history does not exist, so first visit / follow-up is never fabricated). Scheduling cells and the patient strip on treatment records display per this convention; batch card retrieval goes through GET /api/v1/card/customers.

refund_logs — refund transactions (refunds) ​

FieldTypeMeaning
idLongPrimary key
settlement_idLongThe refunded settlement (not null, unique index — one settlement can be refunded only once)
order_idLongOrder id (not null, indexed, used to roll back the order status)
moneyIntegerRefund amount (CNY, = the original settlement's amount actually received; 0 for visit cards)
pay_typeIntegerRedundant copy of the original payment method: 1 stored value / 2 WeChat / 3 Alipay / 4 cash / 5 visit-card write-off
staff_idLongOperator staff id (nullable)
reasonStringRefund reason (≤100 characters, not null — a refund must be able to answer "why")
create_time / update_timeDateAudit times

Refund = the whole order returned on the original path, with no soft delete (demo convention leaves no trace); the positive transaction for a stored-value refund is naturally recorded in recharges (note "refund for order #settlement id", excluded from top-ups in daily reconciliation); a visit-card refund voids that order's card_usages and restores the remaining visits on customer_cards; after a refund the order goes 2 → 1 and can be re-billed. Convention documented in reconciliation.md.

card_usages — visit-card write-off transactions ​

FieldTypeMeaning
idLongPrimary key
card_idLongCard-holding id (not null, indexed, customer_cards.id)
order_idLongOrder that triggered the write-off (nullable — backfilled legacy cards / historical migrations have no order)
staff_idLongServing / operating staff (nullable, taken from the order's staffId)
times_usedIntegerWhich visit number this consumption is (1-based = total visits − remaining after the deduction)
create_time / update_timeDateAudit times

Append-only: every settlement write-off (payType=5) writes one row in the same transaction and rolls back with the settlement; there is no manual deduction. Seed data backfilled historical write-offs (with no order number) for the demo cards. Endpoints in the visit-card group of api.

card_adjusts — card-holding adjustment transactions (treatment-course card marketing: bonus visits / transfer card) ​

FieldTypeMeaning
idLongPrimary key
card_idLongCard-holding id (not null, indexed, customer_cards.id)
typeInteger1 bonus visits / 2 transfer card
timesIntegerVisits added (1-99); fixed 0 for transfer card
customer_idLongTarget patient id for the card transfer (required for type=2; empty for bonus visits)
reasonStringAdjustment reason (not null, ≤100 characters, shown in the front end as a trail)
staff_idLongOperating staff (nullable; the server takes the staff matching the current logged-in account)
create_timeDateAudit time

Append-only: bonus visits add N to both total/remaining (in the same transaction); transfer card only swaps customer_id (remaining visits and validity stay as-is); suspended cards cannot be operated on (expiry does not block — you can adjust the validity first and then use it). Endpoints in the visit-card group of api.

appointments — appointments (front desk / clinic manager creates bookings; mini program self-service slot booking → check-in on arrival → convert to treatment) ​

FieldTypeMeaning
idLongPrimary key
customer_idLongPatient id (not null, customers.id)
item_idLongBooked service card id (nullable = decided on arrival)
staff_idLongCheck-in staff id (nullable = assigned on arrival)
room_idLongWorkstation id (nullable = no workstation axis; beauty bookings occupy one, rooms.id)
start_timeDateStart time of the booked slot (not null)
durationIntegerDuration (minutes; self-service slot booking is fixed at 60)
statusInteger0 not yet arrived → 1 checked in (converted to treatment); 0→9 cancelled; only 9 can be deleted
remarkStringRemarks (symptoms / needs); self-service slot booking records the soft marker "mini program self-service" — there is no source column
create_time / update_timeDateAudit times

Indexes: patient, service card, workstation, time slot. Endpoints in the appointments group of api; same-slot conflict check: a booking is rejected when the same physician already has a status 0/1 appointment on the same day and hour (one booking per slot; the empty-staffId group is mutually exclusive on its own); a non-empty roomId is mutually exclusive on the workstation axis too (technician + workstation two dimensions, beauty phase b, locked by AppointmentRoomConflictHttpTest).

commission_rules / commission_records — commission (beauty phase c) ​

TableFieldsNotes
commission_rules (rules)item_id (not null), title (position, exact match on staffs.title), ratio (integer percent 0–100)P0 fixed ratio "card × position", one ratio per position (unique constraint item_id+title); physicians and technicians share one table, never a parallel one; rule engine is P1
commission_records (records)order_id, settlement_id, order_item_id (all not null), staff_id (not null), item_name/subtotal/ratio (snapshots), amount (yuan, negative on refund reversal), remarkGenerated automatically when a settlement completes (hooked in SettlementServiceImpl): attribution = orders.staff_id, booked per order_items line subtotal × ratio (card-deduction settlements with 0 paid still book; integer yuan rounded down); insert-only, refunds write equal negative reversals (remark 「退单冲销 #结算id」) netting to zero; legacy orders without item lines are not booked

Indexes: rule item_id + unique (item_id, title); record order_id, settlement_id, staff_id. Endpoints in the commission group of api.

rooms — workstations / rooms (beauty phase b) ​

FieldTypeMeaning
idLongPrimary key
nameStringWorkstation name (not null, unique)
enabledInteger1 in use, 0 disabled (existing rows default to 1; the booking form only lists workstations in use)
create_time / update_timeDateAudit times

Index: name. The resource axis of appointments — beauty bookings occupy a workstation, clinic bookings do not (appointments.room_id is nullable); unrelated to the queue display text queue_calls.room (a display field, not a bookable resource). See the workstations group of api.

prescriptions / prescription_items — herbal prescriptions ​

FieldTypeMeaning
idLongPrimary key (prescription number)
treat_idLongTreatment record id (nullable, treats.id)
customer_idLongPatient id (not null, customers.id)
staff_idLongPrescribing physician id (nullable, staffs.id)
dosesIntegerDoses (number of packets, not null, default 7)
decoction_statusIntegerDecoction service status: 0 no decoction service needed / 1 pending decoction / 2 ready for pickup / 3 picked up (checking decoction service when writing the prescription sets 1; the flow pending → ready for pickup → picked up advances one-way only)
decoction_bagsIntegerNumber of decoction-service bags (= doses; empty when no decoction service is needed)
prescription_typeIntegerPrescription type: 0 decoction (default) / 1 paste
paste_statusIntegerPaste pickup status: 0 not a paste / 1 pending preparation / 2 ready for pickup / 3 picked up (writing a paste prescription sets 1; the flow pending preparation → ready for pickup → picked up advances one-way only)
craftStringFinishing method (pastes only: refined honey / clear paste / sugar paste / ejiao finishing, etc., nullable; always empty for decoctions)
dispense_statusIntegerDispensing status: 0 pending dispensing (default) / 1 dispensed (writing the prescription sets 0, one-way, no rollback; advancing decoction service / paste to "picked up" automatically sets 1)
dispense_timeDateDispensing time (empty while pending dispensing)
dispense_byLongDispensing operator staff_id (left empty on the auto-populated path)
review_staff_idLongPharmacist reviewer staff_id (nullable)
check_staff_idLongDispensing / double-check staff_id (nullable)
delivery_typeIntegerPickup method: 0 self pickup (default) / 1 same-city delivery (available when decoction service is checked, nullable)
usage / take_methodStringDecoction method (decoct in water, one dose per day, etc.) / administration (warm in divided morning/evening doses, etc., nullable)
remarkStringRemarks
create_time / update_timeDateAudit times

prescription_items (herbs): prescription_id (not null, indexed), herb (herb name, not null, free text), weight (grams per dose, Double, not null), special (special decoction method, nullable), process (processing, nullable), sort (order, not null).

herbs (herb dictionary): name (unique; price comparison requires an exact same-name match with the prescription herb), price (price per gram in fen, int, >0), stock_min/stock_max (stock lower/upper limits, grams, nullable = no alerting — the balance endpoint flags warnStock when below the lower limit or above the upper limit; stockroom lower/upper-limit alert convention), dose_max (grams-per-dose upper limit, g/dose, nullable = no alerting — the prescription-time dose review POST /prescription/dose-check compares grams per dose against this, and weight must strictly exceed the limit to trigger; entered by the clinic itself, same convention as the stock limits; there is no authoritative Pharmacopoeia dose data source in the repo, so nothing is hardcoded), audit times. Prescription pricing is a live estimate against the dictionary (not persisted, no snapshot; herb names not in the dictionary are not billed).

dispense_logs (dispensing transactions, one-way append): prescription_id (not null, indexed) + action (1 dispensing — this path has only one action type) + staff_id (operator, left empty when auto-populated) + note (≤100 characters; the auto-populated path writes the source, e.g. "decoction-service pickup auto-dispensed") + audit times. Duplicate dispensing returns 400 and cannot be undone; dispensing does not deduct stock and writes no amount snapshot — see pharmacy and dispensing flow.

herb_stock_logs (processed-herb stock-in/stock-out transactions): herb_id (not null, indexed) + type (1 stock-in / 0 stock-out) + quantity (grams, positive integer) + expiry (batch expiry, may be attached on stock-in, always empty on stock-out) + supplier (≤50 characters) + note (≤100 characters) + audit times. Stock = stock-in total − stock-out total (no balance column; recomputed from the transactions); stock-out consumes batches first-expiry-first-out by expiry (FEFO), and the balance endpoint reports "earliest unconsumed batch expiry + expiry alert (default 30 days)" on that basis; ledger convention — no procurement documents or settlement involved; see the processed-herb inventory group of api.

prescription_templates / prescription_template_items (syndrome templates, structure same as below): the template side adds suggested values doses/decoction/usage/remark and an enabled listing flag. formulas / formula_items (formula library): formulas has name (formula name, unique, 1–20 characters), pinyin (full-pinyin lowercase search code, not null), source (source), indication (functions and indications); formula_items has formula_id (not null, indexed) + the four herb fields herb/weight/special/sort. Both are reference data for writing prescriptions — templates are applied by syndrome, formulas are searched by formula name/pinyin and pull up the complete formula; after being pulled up they can be freely added to or removed from the prescription and are never written back; formulas follow the literature's common-dose convention and do not participate in pricing or prescription review.

therapy_templates (external-therapy prescription templates): name (template name, unique, 1–20 characters, indexed) + method (external-therapy method, not null, ≤10 characters, indexed) + target (suggested point selection / body area, ≤100 characters, nullable) + course (suggested course / frequency, ≤100 characters, nullable) + note (precautions, ≤100 characters, nullable) + enabled (0 disabled / 1 enabled) + audit times. A single table with no line items (an external-therapy combination is free text of "method + body area + course", unlike formulas which have per-herb dosages). The treatment page's "apply external-therapy template" takes only the three suggested values method/target/course and fills them into the acupuncture-prescription area and the point-selection text; afterwards they are freely editable with the treatment record and never written back to the template.

Indexes: prescriptions patient, treatment record; prescription_items prescription; herb_stock_logs herb, type. Decoction-service pickup and paste pickup flows are implemented (two independent one-way chains: pending decoction → ready for pickup → picked up; pending preparation → ready for pickup → picked up); pharmacy dispensing is implemented (dispense_status 0→1 one-way + dispense_logs transactions, auto-populated when decoction service / paste reaches "picked up"); processed-herb stock-in/stock-out transactions are implemented (balance recomputed from the transactions); compatibility review (the eighteen incompatibilities / nineteen antagonisms) is a static rule comparison (not persisted as a table) — see the prescriptions group of api.

staffs — staff (login identities; clinic operations) ​

FieldTypeMeaning
idLongPrimary key
nameStringFull name (not null)
accountStringLogin account (unique)
passwordStringPassword (BCrypt)
phoneStringPhone number (unique)
avatarStringAvatar URL
ageIntegerAge
genderIntegerGender (0 female, 1 male)
addressStringAddress
birthdayDateBirthday
roleIntegerRole: role == 0 returns the admin permission set, 1-9 is management (the MANAGER permission set), >= 10 is ordinary staff (USER, default 99); method-level @PreAuthorize decides on this basis
titleStringJob title (free text, nullable): the title semantics and roster data source beyond the permission tier role, e.g. physician / acupuncture physician / front desk; the scheduling page's "physician roster" = employed (status=1) with a title containing "医师"
famous_levelIntegerFamous-doctor level: 0 ordinary (default), 1-3 famous doctor; affects only list sorting (descending, famous doctors first) and the marker badge — no tiered / time-slot differential pricing
statusIntegerStatus: 0 resigned, 1 employed, 2 on leave (<= 0 cannot log in)

Indexes: name, account, phone number.

signs — attendance clock-ins (clinic operations) ​

FieldTypeMeaning
idLongPrimary key
staff_idLongStaff id
typeInteger1 clock-in / 0 clock-out
create_timeDateClock time

Attendance statistics are computed live at query time (no scheduled job): on the same day the earliest clock-in and the latest clock-out are taken, and the duration is counted in whole hours.

staff_leaves — staff leave (clinic operations) ​

FieldTypeMeaning
idLongPrimary key
staff_idLongStaff id (staffs.id)
leave_typeIntegerType: 0 sick leave, 1 personal leave
reasonStringReason (not null)
start_time / end_timeDateStart/end time (end not earlier than start)
create_time / update_timeDateAudit times

Indexes: staff id. Convention: leave is record-only — it does not change the staff login status and has no approval flow (demo convention).

staff_shifts — staff recurring shift table (clinic operations) ​

FieldTypeMeaning
idLongPrimary key
staff_idLongStaff id (staffs.id)
weekdayIntegerDay of week (1 Monday … 7 Sunday)
start_time / end_timeStringHH:mm slot (start < end)
create_time / update_timeDateAudit times

Indexes: staff id, day of week. Convention: one shift per staff member per weekday, repeating weekly; the shift table is only a scheduling reference — attendance clock-ins still defer to signs (clock-in records are never written back).

ad_materials / ad_screens / ad_schedules — advertising screens (tablet display) ​

These back the Kiosk side of tablet/; see the tablet display design. The files themselves are not stored in the database: material uploads land on disk under data/ads/yyyyMM/ (configurable via sinomed.ads.upload-dir), and the tables store relative URLs.

ad_materials — materials: name, type (1 image, 2 video), url, duration_ms (carousel dwell time in milliseconds), enabled, sort (order, lower values first); overlay copy fields category (category), kicker (eyebrow label), title (headline), copy (body text), price (price, all nullable).

ad_screens — screens: code (unique; the tablet opens it with ?screen=code), name, location, enabled, last_seen_at (a heartbeat piggybacked when the tablet pulls its playlist; the admin console shows an online badge based on a 2-minute threshold).

ad_schedules — schedules: screen_id / material_id (business foreign keys), weekdays (1=Monday…7=Sunday, comma-separated, empty = every day), start_time / end_time (HH:mm, empty = all day), enabled.

queue_calls — queue calling records (tablet display) ​

FieldTypeMeaning
idLongPrimary key (also serves as the tablet pull cursor since)
screen_idLongTargeted screen (business foreign key; empty = broadcast to all screens)
numberStringNumber (e.g. 08)
roomStringTreatment room name
patient_maskedStringMasked patient name (e.g. 张*, privacy by default)
statusInteger0 pending call / 1 called (current writes are always 1)
called_atDateQueue calling time

card_lookup_logs — phone self-service lookup audit (card lookup + appointment lookup + openid binding) ​

The table name carries over from the card-lookup audit; /api/v1/kiosk/my-appointments (cross-device lookup of one's own not-yet-arrived appointments), /api/v1/kiosk/appointment-cancel (self-service cancellation of one's own not-yet-arrived appointments, decided on the user's behalf 2026-10-16) and /api/v1/kiosk/openid-bind (openid self-service binding) share this same table with card lookup, under the same retention convention — patient self-service lookups after identity verification never leave gaps.

FieldTypeMeaning
idLongPrimary key
phoneStringPhone number of the patient whose cards were looked up (backfilled after an openid-path hit; empty on a miss)
operatorStringIdentity of the person performing the lookup (who did it): openid path and openid binding = openid; SMS path = phone number
modeStringEffective mode (off / openid_only / sms_verified)
customer_idLongMatched patient id (empty on a miss)
resultStringResult: ok / disabled / missing_openid / no_binding / missing_code / bad_code / expired_code / used_code / no_customer / bind_ok / bind_conflict (the last two are for the openid binding path)
create_timeDateTime

Indexes: phone, operator. Convention: every attempt (including rejections) writes one row; the card lookup / appointment lookup / openid binding paths share the same convention; see kiosk.md.

sms_codes — SMS verification codes (SMS path for phone card lookup) ​

FieldTypeMeaning
idLongPrimary key
phoneStringPhone number (not null)
codeString6-digit numeric code
expires_atDateExpiry time (5 minutes after the code is issued)
usedInteger0 unused / 1 used (one-time, integer default 0)
attemptsIntegerWrong-attempt counter (integer default 0); 5 consecutive wrong attempts burn the code (used=1, must re-issue) — see api
create_timeDateCode issue time

There is no real SMS gateway: code issuance is a server-side stub, and the code only lands in the database and the log (SMS_CODE_DEMO phone=… code=…); tests fetch the code directly from SmsCodeRepository.

wx_* — WeChat official account (module tables; design design/wechat-official-account.md, behavior per the wx group in api) ​

Seven tables back the official-account module (columns listed verbatim from the entities; all carry create_time audit, wx_message_logs and wx_binding_logs are append-only and carry no update_time, the remaining five also carry update_time):

TableColumnsNotes
wx_accountsid, app_id (unique), app_secret, token, aes_key, account_name, account_type (service/subscription), verified, encrypt_mode (plaintext/compatible/safe), enabled, remarkConfiguration entry (ADMIN writes); app_secret/aes_key are AES-encrypted at rest (WX_OA_CREDENTIAL_KEY); token is a plaintext column; config changes evict the per-account SDK/router cache
wx_fansid, account_id, openid, unionid, nickname, head_img_url, subscribe, subscribe_time, unsubscribe_time, customer_id, remarkFan ↔ customer binding (customer_id blank = unbound); unsubscribe keeps the row and never auto-unbinds
wx_auto_repliesid, account_id, rule_type (subscribe/keyword/fallback), keyword, match_type (exact/contains), reply_type (text), content, priority, enabledAt most one enabled subscribe/fallback per account (service-layer constraint, no DB unique); lower priority number wins
wx_menusid, account_id, parent_id (two levels), name, menu_type (9 protocol values), url, media_id, miniprogram_appid, miniprogram_pagepath, event_key, sort, enabledLocal drafts; POST /wx/menus/push/{accountId} sends enabled ones to WeChat (credential-gated); ≤3 top × ≤5 sub per account
wx_materialsid, account_id, media_id, material_type (temp/image/video), name, url, local_path, size, mime_typeBlank media_id = local governance row (fan-image capture or local upload); remote upload writes back the WeChat media_id/url; unique (account_id, media_id) via the startup index uk_wx_material_account_media
wx_message_logsid, account_id, direction (in/out/reject), msg_type, from_user, to_user, content, msg_id, dedupe_key, resultEvery inbound/outbound/rejected push; result covers ok/disabled/account_missing/bad_signature/duplicate/…; reject audits from the disabled period stay queryable
wx_binding_logsid, account_id, fan_openid, phone, customer_id, resultFan-binding audit trail; result covers ok/missing_code/bad_code/used_code/expired_code/no_customer/bound_other/conflict

Last updated: