doc_id: DEV-202606-003 feature_id: FEAT-202606-001-unified-entry-client type: dev-progress title: Unified Entry Registration Demo Implementation Plan status: implemented owner: 医梦研发团队 created_at: 2026-06-02 updated_at: 2026-06-02 reviewers: [] related_docs:
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a next-week demonstrable outpatient registration loop from unified Web client → AI platform → MCP tool service → SQLite Mock HIS, with card confirmation, idempotency, mock payment, and reproducible acceptance scripts.
Architecture: Keep production-facing backend capabilities inside the existing AI platform modules. Add a separate mock-his-service demo service using SQLite; do not add it to the root Maven reactor and do not use MySQL. emoon-openplatform remains the only terminal-facing API, emoon-ai-agent owns routing/task orchestration, emoon-ai-card owns card state/actions, and emoon-ai-mcp owns the HIS tool facade and adapter boundary.
Tech Stack: Java 17, Spring Boot 3, Maven, SQLite for Mock HIS, existing RuoYi-Vue-Plus backend modules, MyBatis-Plus where already used, OkHttp/RestTemplate-style HTTP adapter according to existing project conventions, JUnit 5, Maven module tests, curl-based acceptance scripts.
This plan implements one frozen demo flow:
patient message
→ POST /api/v1/agent/chat/stream
→ AgentRouter creates/continues REGISTRATION task
→ Dify or deterministic demo response suggests department card
→ card actions select department / doctor / time slot / confirm appointment
→ MCP tool facade calls SQLite Mock HIS
→ Mock HIS locks slot, creates mock payment order, marks mock paid, creates appointment
→ appointment-success card returns with mock label and traceId
Do not implement real HIS, real payment,医保,退号退费,三端完整硬件接入,舌诊,报告解读, or a public /tasks API in this plan.
The repo already contains useful skeletons and tests:
emoon-openplatform/src/main/java/com/emoon/openplatform/controller/v1/AgentChatController.javaemoon-openplatform/src/main/java/com/emoon/openplatform/controller/v1/CardActionController.javaemoon-openplatform/src/main/java/com/emoon/openplatform/controller/v1/DeviceController.javaemoon-openplatform/src/main/java/com/emoon/openplatform/auth/HmacAuthInterceptor.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/application/AgentRouterService.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/application/AgentActionOrchestrator.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-card/src/main/java/com/emoon/ai/card/application/CardActionService.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/ai/mcp/application/McpToolService.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/client/MockHisClient.javaemoon-openplatform/src/test/java/com/emoon/openplatform/acceptance/TerminalMvpAcceptanceTest.javaTreat these as the starting point. Do not recreate modules that already exist.
Files:
mock-his-service/pom.xmlmock-his-service/src/main/java/com/emoon/mockhis/MockHisApplication.javamock-his-service/src/main/java/com/emoon/mockhis/controller/PatientController.javamock-his-service/src/main/java/com/emoon/mockhis/controller/CatalogController.javamock-his-service/src/main/java/com/emoon/mockhis/controller/SlotController.javamock-his-service/src/main/java/com/emoon/mockhis/controller/PaymentController.javamock-his-service/src/main/java/com/emoon/mockhis/controller/AppointmentController.javamock-his-service/src/main/java/com/emoon/mockhis/service/RegistrationFlowService.javamock-his-service/src/main/java/com/emoon/mockhis/domain/*.javamock-his-service/src/main/resources/application.ymlmock-his-service/src/main/resources/schema.sqlmock-his-service/src/main/resources/seed.sqlmock-his-service/src/test/java/com/emoon/mockhis/RegistrationFlowServiceTest.javaCreate: mock-his-service/src/test/java/com/emoon/mockhis/MockHisApiTest.java
[ ] Step 1: Write failing service tests for real HIS semantics
Add tests covering:
@Test
void lockSlotDecreasesRemainingCountAndExpiresAfterFiveMinutes() {
LockSlotResult first = service.lockSlot("slot_neuro_001", "patient_demo_001", "idem-lock-001");
assertThat(first.lockId()).isNotBlank();
assertThat(first.expiresAt()).isAfter(Instant.now());
assertThat(service.getSlot("slot_neuro_001").remainingCount()).isEqualTo(2);
LockSlotResult replay = service.lockSlot("slot_neuro_001", "patient_demo_001", "idem-lock-001");
assertThat(replay.lockId()).isEqualTo(first.lockId());
assertThat(service.getSlot("slot_neuro_001").remainingCount()).isEqualTo(2);
}
@Test
void createAppointmentRequiresPaidMockOrderAndIsIdempotent() {
LockSlotResult lock = service.lockSlot("slot_neuro_001", "patient_demo_001", "idem-lock-002");
PaymentOrder order = service.createPaymentOrder(lock.lockId(), "idem-pay-001");
assertThatThrownBy(() -> service.createAppointment(lock.lockId(), order.orderId(), "idem-appt-001"))
.hasMessageContaining("PAYMENT_NOT_PAID");
service.markMockPaid(order.orderId());
Appointment first = service.createAppointment(lock.lockId(), order.orderId(), "idem-appt-001");
Appointment replay = service.createAppointment(lock.lockId(), order.orderId(), "idem-appt-001");
assertThat(replay.appointmentId()).isEqualTo(first.appointmentId());
assertThat(first.mock()).isTrue();
}
Run:
mvn -f mock-his-service/pom.xml -DskipTests=false test
Expected: compilation fails until the service, schema, and DTOs are implemented.
schema.sql must include these tables:
create table if not exists his_patient (
patient_id text primary key,
name text not null,
identity_no text,
phone text,
created_at text not null
);
create unique index if not exists uk_his_patient_identity on his_patient(identity_no) where identity_no is not null;
create unique index if not exists uk_his_patient_phone on his_patient(phone) where phone is not null;
create table if not exists his_department (
department_id text primary key,
department_code text not null unique,
name text not null,
floor text not null
);
create table if not exists his_doctor (
doctor_id text primary key,
department_id text not null,
name text not null,
title text not null,
specialties text not null,
room text not null
);
create table if not exists his_schedule (
schedule_id text primary key,
doctor_id text not null,
visit_date text not null,
session_name text not null,
amount_cent integer not null
);
create table if not exists his_slot (
slot_id text primary key,
schedule_id text not null,
time_period text not null,
total_count integer not null,
remaining_count integer not null,
version integer not null default 0
);
create table if not exists his_slot_lock (
lock_id text primary key,
slot_id text not null,
patient_id text not null,
status text not null,
expires_at text not null,
idempotency_key text not null unique,
created_at text not null
);
create table if not exists his_payment_order (
order_id text primary key,
lock_id text not null,
amount_cent integer not null,
status text not null,
qr_content text not null,
mock integer not null,
idempotency_key text not null unique,
created_at text not null
);
create table if not exists his_appointment (
appointment_id text primary key,
lock_id text not null,
order_id text not null,
appointment_no text not null,
status text not null,
mock integer not null,
idempotency_key text not null unique,
created_at text not null
);
Seed at least:
神经内科: 李明 主任医师, 王佳 副主任医师
普通内科: 陈涛 主治医师
tomorrow morning slots: 09:30-09:45, 09:45-10:00
patient_demo_001: 张三 / 13800000000
Implement exactly these demo endpoints:
POST /mock-his/patients/search
POST /mock-his/patients
GET /mock-his/departments
GET /mock-his/doctors?departmentId=
GET /mock-his/schedules?doctorId=&date=
POST /mock-his/slots/lock
POST /mock-his/slots/release
POST /mock-his/payments/orders
POST /mock-his/payments/mock-paid
GET /mock-his/payments/{orderId}
POST /mock-his/appointments
GET /mock-his/appointments/{appointmentId}
Run:
mvn -f mock-his-service/pom.xml -DskipTests=false test
Expected: all Mock HIS tests pass.
Commit: feat(mock-his): add sqlite registration demo service
Files:
emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/ai/mcp/application/McpToolService.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/client/MockHisClient.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/domain/HisSlotLockResult.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/domain/HisPaymentOrder.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/main/java/com/emoon/mcp/his/domain/HisAppointment.javaModify: emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp/src/test/java/com/emoon/ai/mcp/application/McpToolServiceTest.java
[ ] Step 1: Write failing MCP tests
Add tests that assert:
@Test
void lockSlotDelegatesToMockHisAndReturnsLockId() {
HisSlotLockResult result = service.lockSlot("slot_neuro_001", "patient_demo_001", "trace-001", "idem-lock-001");
assertThat(result.lockId()).startsWith("LOCK");
assertThat(result.expiresAt()).isNotNull();
}
@Test
void paymentAndAppointmentUseMockFlagAndIdempotencyKey() {
HisPaymentOrder order = service.createPaymentOrder("LOCK202606020001", "trace-001", "idem-pay-001");
assertThat(order.qrContent()).startsWith("demo-payment://orderId=");
assertThat(order.mock()).isTrue();
service.markMockPaid(order.orderId(), "trace-001");
HisAppointment appt = service.createAppointment("LOCK202606020001", order.orderId(), "trace-001", "idem-appt-001");
assertThat(appt.mock()).isTrue();
assertThat(appt.appointmentNo()).isNotBlank();
}
Run:
mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp -DskipTests=false test
Expected: tests fail until new methods and DTOs exist.
Add methods to McpToolService:
HisSlotLockResult lockSlot(String slotId, String patientId, String traceId, String idempotencyKey);
void releaseSlot(String lockId, String traceId, String idempotencyKey);
HisPaymentOrder createPaymentOrder(String lockId, String traceId, String idempotencyKey);
void markMockPaid(String orderId, String traceId);
HisPaymentOrder queryPaymentStatus(String orderId, String traceId);
HisAppointment createAppointment(String lockId, String orderId, String traceId, String idempotencyKey);
These methods must log tool name, traceId, risk level, mock flag, and result status. Query methods are allowed from Dify workflow; write/financial methods must be called only from Card Action orchestration.
Run:
mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp -DskipTests=false test
Expected: pass.
Commit: feat(mcp): add registration mock his tools
Files:
emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/application/AgentActionOrchestrator.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-card/src/main/java/com/emoon/ai/card/application/CardActionService.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-card/src/main/java/com/emoon/ai/card/application/CardInstanceService.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/test/java/com/emoon/ai/agent/application/AgentActionOrchestratorTest.javaModify: emoon-infra/emoon-modules/emoon-ai/emoon-ai-card/src/test/java/com/emoon/ai/card/application/CardActionServiceTest.java
[ ] Step 1: Write failing orchestration test for lock → payment → appointment
Assert the exact card sequence:
select_department -> doctor-selection
select_doctor -> time-slot-selection
select_time_slot -> confirm-appointment with lockId and expiresAt
confirm_appointment -> payment-qrcode with mock qrContent
mock_payment_paid -> appointment-success
Test expectation:
assertThat(confirmCard.nextCard().get("cardKey")).isEqualTo("payment-qrcode");
assertThat(paymentCard.nextCard().get("cardKey")).isEqualTo("appointment-success");
assertThat(paymentCard.nextCardData().get("mock")).isEqualTo(true);
Run:
mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent,emoon-infra/emoon-modules/emoon-ai/emoon-ai-card -DskipTests=false test
Expected: fail until the current direct createAppointment shortcut is replaced.
Change AgentActionOrchestrator behavior:
select_time_slot:
read patientId from task context or use patient_demo_001 for demo
call mcpToolService.lockSlot(slotId, patientId, traceId, idempotencyKey)
patch task context with slotId, lockId, lockExpiresAt
create confirm-appointment card
confirm_appointment:
read lockId from task context
call mcpToolService.createPaymentOrder(lockId, traceId, idempotencyKey)
patch task context with orderId
create payment-qrcode card with mock:true
mock_payment_paid:
call mcpToolService.markMockPaid(orderId, traceId)
call mcpToolService.createAppointment(lockId, orderId, traceId, idempotencyKey)
complete task
create appointment-success card with mock:true
Keep Card Runtime from directly calling MCP. The call chain must remain:
CardActionController -> CardActionService -> AgentActionOrchestrator -> McpToolService -> Mock HIS
CardActionService.submit() must return the first result for repeated idempotencyKey on the same card/action. It must reject:
expired card
completed card with a new idempotencyKey
unknown actionName
high-risk action without confirm:true
Run:
mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent,emoon-infra/emoon-modules/emoon-ai/emoon-ai-card -DskipTests=false test
Expected: pass.
Commit: feat(agent): complete registration card action flow
Files:
emoon-openplatform/src/main/java/com/emoon/openplatform/controller/v1/AgentChatController.javaemoon-openplatform/src/main/java/com/emoon/openplatform/service/impl/AgentChatApplicationServiceImpl.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/application/AgentRouterService.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/application/TaskStateService.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/application/TerminalReplyTemplateService.javaModify: emoon-openplatform/src/test/java/com/emoon/openplatform/acceptance/TerminalMvpAcceptanceTest.java
[ ] Step 1: Add failing acceptance tests
Cover:
POST /agent/chat/stream with "我头疼三天想挂号"
emits task_updated REGISTRATION
emits message_delta
emits card_created department-selection
emits completed with conversationId and traceId
POST /agent/chat/stream with "下午" while WAIT_SELECT_TIME_SLOT
does not call DeepSeek
routes to current REGISTRATION task
guide_screen attempts registration
emits blocked message and no registration card
Run:
mvn -pl emoon-openplatform -DskipTests=false -Dtest=TerminalMvpAcceptanceTest test
Expected: fail until SSE events and task/card behavior match the frozen contract.
The stream must emit:
event: task_updated
event: message_delta
event: message_completed
event: card_created
event: completed
For errors:
event: error
event: completed
Do not expose Dify raw event names to the frontend.
Enforce:
1. device policy
2. activeTask unless explicit cancel/switch
3. waitingCard
4. deterministic registration/payment/cancel rules
5. DeepSeek JSON classification
6. clarification
Run:
mvn -pl emoon-openplatform -DskipTests=false -Dtest=TerminalMvpAcceptanceTest test
Expected: pass.
Commit: feat(openplatform): stabilize registration sse entry
Files:
emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/mcp/engine/impl/DifyAgentEngine.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/main/java/com/emoon/ai/agent/infrastructure/normalizer/DifyOutputNormalizer.javaemoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/test/java/com/emoon/ai/agent/infrastructure/normalizer/DifyOutputNormalizerTest.javaModify: docs/initiatives/FEAT-202606-001-unified-entry-client/需求文档.md only if implementation finds a contract mismatch.
[ ] Step 1: Write failing normalizer tests
Add tests:
@Test
void validDepartmentCardOutputNormalizesToPlatformCard() {
NormalizedAgentResponse result = normalizer.normalize("""
{"schemaVersion":"1.0","answer":"建议优先看神经内科",
"taskUpdate":{"taskType":"REGISTRATION","currentStep":"TRIAGE_RECOMMEND_DEPARTMENT"},
"cards":[{"cardKey":"department-selection","cardData":{"departments":[{"departmentId":"D010","name":"神经内科","reason":"头痛伴恶心"}]}}],
"safety":{"riskLevel":"LOW","needEmergencyWarning":false}}
""");
assertThat(result.cards()).hasSize(1);
assertThat(result.cards().get(0).cardKey()).isEqualTo("department-selection");
}
@Test
void invalidJsonFallsBackToTextWithTraceId() {
NormalizedAgentResponse result = normalizer.normalize("not json");
assertThat(result.cards()).isEmpty();
assertThat(result.degraded()).isTrue();
}
Run:
mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent -DskipTests=false -Dtest=DifyOutputNormalizerTest test
Expected: fail until the normalizer exists.
Normalizer rules:
invalid JSON -> text-only degraded response
unknown cardKey -> no card, warning
cardData schema mismatch -> no card, error message with traceId
sensitive identity fields in Dify output -> reject card
high risk output -> force confirm card or manual warning
Use one Dify app:
outpatient-registration-agent
Branches inside the app:
registration_intake
triage_recommendation
registration_reply
Dify must not directly call write/financial HIS tools. Dify can suggest a card and explanation; Card Action performs writes.
Run:
mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent -DskipTests=false test
Expected: pass.
Commit: feat(agent): normalize dify registration output
Files:
scripts/demo-registration/README.mdscripts/demo-registration/start-demo.shscripts/demo-registration/reset-mock-his.shscripts/demo-registration/registration-happy-path.shscripts/demo-registration/registration-edge-cases.shCreate: scripts/demo-registration/assertions.sh
[ ] Step 1: Write executable happy-path script
registration-happy-path.sh must perform:
1. health check Mock HIS
2. health check AI platform
3. call /agent/chat/stream with "我头疼三天,还有点恶心,想挂号"
4. extract department cardInstanceId
5. POST select_department
6. POST select_doctor
7. POST select_time_slot
8. POST confirm_appointment
9. POST mock_payment_paid
10. assert appointment-success card contains mock:true and appointmentNo
registration-edge-cases.sh must cover:
duplicate confirm returns same result
unpaid order cannot create appointment
expired lock forces reselect time slot
guide screen cannot register
Mock HIS timeout returns error card with traceId
Dify invalid JSON degrades to text and no card
Run:
bash scripts/demo-registration/reset-mock-his.sh
bash scripts/demo-registration/registration-happy-path.sh
bash scripts/demo-registration/registration-edge-cases.sh
Expected: all scripts exit 0 and print the final traceId, appointmentId, appointmentNo, and mock:true.
Commit: test(demo): add registration acceptance scripts
Files:
docs/initiatives/FEAT-202606-001-unified-entry-client/需求文档.mdUpdate only if needed: docs/initiatives/FEAT-202606-001-unified-entry-client/MVP接口契约.md
[ ] Step 1: Run module tests
Run:
mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-mcp -DskipTests=false test
mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent -DskipTests=false test
mvn -pl emoon-infra/emoon-modules/emoon-ai/emoon-ai-card -DskipTests=false test
mvn -pl emoon-openplatform -DskipTests=false test
Expected: all pass.
Run:
mvn -pl emoon-openplatform -am -DskipTests compile
Expected: compile succeeds.
Run:
mvn -pl emoon-admin -DskipTests=false -Dprofiles.active= -Dtest=AiPlatformArchitectureTest test
Expected: architecture test passes. No Controller imports Mapper. No API module contains implementation classes. Card module does not call MCP directly.
Collect:
Mock HIS sqlite db path
seed data version
conversationId
taskId
cardInstanceId sequence
traceId
appointmentId
appointmentNo
mock:true screenshot or API response
Commit: chore(demo): verify registration demo loop
Spec coverage:
Placeholder scan:
Type consistency:
lockId, orderId, appointmentId, appointmentNo, traceId, idempotencyKey, and mock:true are used consistently across Mock HIS, MCP, Card Action, and scripts.Plan complete and saved to docs/initiatives/FEAT-202606-001-unified-entry-client/dev-progress/统一入口挂号演示实施计划.md.
Two execution options:
Recommended for this repo: Subagent-Driven, because Mock HIS, MCP/Card, OpenPlatform SSE, and Dify normalizer are separate review surfaces.